487 lines
18 KiB
TypeScript
487 lines
18 KiB
TypeScript
import { newId } from '../ids';
|
||
import {
|
||
ANIMATION_ROOT_TYPES,
|
||
blends,
|
||
isKeyInterpolation,
|
||
normalizeKeys,
|
||
targetAssetOf,
|
||
targetDefs,
|
||
trackDef,
|
||
trackValueAt,
|
||
} from '../model/animation';
|
||
import type { AnimationAsset, AnimationKey, AnimationTrack, KeyInterpolation } from '../model/Asset';
|
||
import { findAsset, type Project } from '../model/Project';
|
||
import type { PropertyValue } from '../model/Properties';
|
||
import { coerceValue } from '../properties/PropertySchema';
|
||
import { type Command, CommandError } from './Command';
|
||
import { requireAsset } from './require';
|
||
|
||
/** A key on one row, as the timeline selects it. */
|
||
export interface KeyRef {
|
||
trackId: string;
|
||
frame: number;
|
||
}
|
||
|
||
function requireAnimation(project: Project, assetId: string): AnimationAsset {
|
||
const asset = requireAsset(project, assetId);
|
||
if (asset.type !== 'animation') throw new CommandError(`${asset.name} is not an animation`);
|
||
return asset;
|
||
}
|
||
|
||
function requireTrack(asset: AnimationAsset, trackId: string): AnimationTrack {
|
||
const track = asset.tracks.find((t) => t.id === trackId);
|
||
if (!track) throw new CommandError(`${asset.name} has no such row`);
|
||
return track;
|
||
}
|
||
|
||
/** The kind of `property` on the object `target` names, refusing what cannot be animated there. */
|
||
function requireAnimatable(
|
||
project: Project,
|
||
asset: AnimationAsset,
|
||
target: string,
|
||
property: string,
|
||
): string {
|
||
if (!findAsset(project, asset.root)) throw new CommandError(`${asset.name} has nothing to animate yet`);
|
||
const defs = targetDefs(project, asset, target);
|
||
if (!defs) throw new CommandError('That object is not part of the animated asset');
|
||
const kind = defs.find((d) => d.key === property)?.kind;
|
||
if (!kind)
|
||
throw new CommandError(
|
||
`${targetAssetOf(project, asset, target)?.name} has no property ${property} to animate`,
|
||
);
|
||
return kind;
|
||
}
|
||
|
||
function requireUnique(asset: AnimationAsset, target: string, property: string, except = ''): void {
|
||
if (asset.tracks.some((t) => t.id !== except && t.target === target && t.property === property))
|
||
throw new CommandError('That property already has a row');
|
||
}
|
||
|
||
/**
|
||
* Every edit of a timeline keeps the rows as they were and puts them back on undo – the rows of
|
||
* one asset are small, and a key move that swallows another key is exactly undone this way.
|
||
*/
|
||
abstract class TimelineCommand implements Command {
|
||
abstract readonly label: string;
|
||
private before: AnimationTrack[] = [];
|
||
private rootBefore = '';
|
||
|
||
constructor(protected readonly assetId: string) {}
|
||
|
||
execute(project: Project): void {
|
||
const asset = requireAnimation(project, this.assetId);
|
||
this.before = structuredClone(asset.tracks);
|
||
this.rootBefore = asset.root;
|
||
try {
|
||
this.apply(project, asset);
|
||
} catch (error) {
|
||
asset.tracks = this.before;
|
||
asset.root = this.rootBefore;
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
const asset = requireAnimation(project, this.assetId);
|
||
asset.tracks = structuredClone(this.before);
|
||
asset.root = this.rootBefore;
|
||
}
|
||
|
||
protected abstract apply(project: Project, asset: AnimationAsset): void;
|
||
}
|
||
|
||
/** The rows stay: a row whose object the new root lacks reads as missing and keeps its keys. */
|
||
export class SetAnimationRootCommand extends TimelineCommand {
|
||
readonly label = 'Set animated asset';
|
||
|
||
constructor(
|
||
assetId: string,
|
||
private readonly rootId: string,
|
||
) {
|
||
super(assetId);
|
||
}
|
||
|
||
protected apply(project: Project, asset: AnimationAsset): void {
|
||
if (this.rootId !== '') {
|
||
const root = requireAsset(project, this.rootId);
|
||
if (!ANIMATION_ROOT_TYPES.includes(root.type))
|
||
throw new CommandError(`A ${root.type} cannot be animated`);
|
||
}
|
||
asset.root = this.rootId;
|
||
}
|
||
}
|
||
|
||
export class AddAnimationTrackCommand extends TimelineCommand {
|
||
readonly label = 'Add row';
|
||
readonly trackId = newId();
|
||
|
||
constructor(
|
||
assetId: string,
|
||
private readonly target: string,
|
||
private readonly property: string,
|
||
) {
|
||
super(assetId);
|
||
}
|
||
|
||
protected apply(project: Project, asset: AnimationAsset): void {
|
||
requireAnimatable(project, asset, this.target, this.property);
|
||
requireUnique(asset, this.target, this.property);
|
||
asset.tracks.push({ id: this.trackId, target: this.target, property: this.property, keys: [] });
|
||
}
|
||
}
|
||
|
||
export class RemoveAnimationTracksCommand extends TimelineCommand {
|
||
readonly label: string;
|
||
|
||
constructor(
|
||
assetId: string,
|
||
private readonly trackIds: readonly string[],
|
||
) {
|
||
super(assetId);
|
||
this.label = trackIds.length === 1 ? 'Remove row' : `Remove ${trackIds.length} rows`;
|
||
}
|
||
|
||
protected apply(_project: Project, asset: AnimationAsset): void {
|
||
for (const id of this.trackIds) requireTrack(asset, id);
|
||
asset.tracks = asset.tracks.filter((t) => !this.trackIds.includes(t.id));
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Aims a row somewhere else and keeps its keys – only at a property of the same kind, since a
|
||
* color key would mean nothing on a switch. A row aiming at something gone may go anywhere.
|
||
*/
|
||
export class RetargetAnimationTrackCommand extends TimelineCommand {
|
||
readonly label = 'Change row';
|
||
|
||
constructor(
|
||
assetId: string,
|
||
private readonly trackId: string,
|
||
private readonly target: string,
|
||
private readonly property: string,
|
||
) {
|
||
super(assetId);
|
||
}
|
||
|
||
protected apply(project: Project, asset: AnimationAsset): void {
|
||
const track = requireTrack(asset, this.trackId);
|
||
const kind = requireAnimatable(project, asset, this.target, this.property);
|
||
const was = trackDef(project, asset, track)?.kind;
|
||
if (was !== undefined && was !== kind) throw new CommandError(`A ${was} row cannot become a ${kind} row`);
|
||
requireUnique(asset, this.target, this.property, track.id);
|
||
track.target = this.target;
|
||
track.property = this.property;
|
||
if (kind !== 'number') delete track.relative;
|
||
}
|
||
}
|
||
|
||
/** Only a number can be relative: an offset of a color or a picture means nothing. */
|
||
export class SetTrackRelativeCommand extends TimelineCommand {
|
||
readonly label: string;
|
||
|
||
constructor(
|
||
assetId: string,
|
||
private readonly trackId: string,
|
||
private readonly relative: boolean,
|
||
) {
|
||
super(assetId);
|
||
this.label = relative ? 'Make row relative' : 'Make row absolute';
|
||
}
|
||
|
||
protected apply(project: Project, asset: AnimationAsset): void {
|
||
const track = requireTrack(asset, this.trackId);
|
||
if (this.relative) {
|
||
if (requireAnimatable(project, asset, track.target, track.property) !== 'number')
|
||
throw new CommandError('Only a number row can be relative');
|
||
track.relative = true;
|
||
} else delete track.relative;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Sets the key at `frame`, replacing the value of one already there but keeping its interpolation.
|
||
* Without a value it takes the value the row has at that frame – what a double click in the timeline
|
||
* means – and on an empty row the property's default, which for a relative row is no offset at all.
|
||
*/
|
||
export class SetAnimationKeyCommand extends TimelineCommand {
|
||
readonly label = 'Set key';
|
||
|
||
constructor(
|
||
assetId: string,
|
||
private readonly trackId: string,
|
||
private readonly frame: number,
|
||
private readonly value?: PropertyValue,
|
||
) {
|
||
super(assetId);
|
||
}
|
||
|
||
protected apply(project: Project, asset: AnimationAsset): void {
|
||
const track = requireTrack(asset, this.trackId);
|
||
if (!Number.isInteger(this.frame) || this.frame < 0)
|
||
throw new CommandError('A key sits on a whole frame from 0');
|
||
const def = trackDef(project, asset, track);
|
||
if (!def) throw new CommandError('The row aims at something that is gone');
|
||
const current = trackValueAt(track.keys, def.kind, this.frame);
|
||
const raw = this.value ?? current ?? (track.relative ? 0 : def.default);
|
||
const key: AnimationKey = { frame: this.frame, value: coerceValue(def, raw) };
|
||
const interpolation = track.keys.find((k) => k.frame === this.frame)?.interpolation;
|
||
if (interpolation) key.interpolation = interpolation;
|
||
track.keys = normalizeKeys([...track.keys, key]);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Sets how each key blends into the next one. `linear` is stored as no field at all. A key on a row
|
||
* that cannot blend is left as it is – it jumps whatever it says – and only such keys is refused.
|
||
*/
|
||
export class SetKeyInterpolationCommand extends TimelineCommand {
|
||
readonly label: string;
|
||
|
||
constructor(
|
||
assetId: string,
|
||
private readonly keys: readonly KeyRef[],
|
||
private readonly interpolation: KeyInterpolation,
|
||
) {
|
||
super(assetId);
|
||
this.label = keys.length === 1 ? 'Set easing' : `Set easing of ${keys.length} keys`;
|
||
}
|
||
|
||
protected apply(project: Project, asset: AnimationAsset): void {
|
||
if (!isKeyInterpolation(this.interpolation)) throw new CommandError('No such easing');
|
||
let changed = 0;
|
||
for (const ref of this.keys) {
|
||
const track = requireTrack(asset, ref.trackId);
|
||
const key = track.keys.find((k) => k.frame === ref.frame);
|
||
if (!key) throw new CommandError(`No key on frame ${ref.frame}`);
|
||
const kind = trackDef(project, asset, track)?.kind;
|
||
if (kind !== undefined && !blends(kind)) continue;
|
||
if (this.interpolation === 'linear') delete key.interpolation;
|
||
else key.interpolation = this.interpolation;
|
||
changed += 1;
|
||
}
|
||
if (changed === 0) throw new CommandError('Only a number or colour key can ease');
|
||
}
|
||
}
|
||
|
||
export class RemoveAnimationKeysCommand extends TimelineCommand {
|
||
readonly label: string;
|
||
|
||
constructor(
|
||
assetId: string,
|
||
private readonly keys: readonly KeyRef[],
|
||
) {
|
||
super(assetId);
|
||
this.label = keys.length === 1 ? 'Remove key' : `Remove ${keys.length} keys`;
|
||
}
|
||
|
||
protected apply(_project: Project, asset: AnimationAsset): void {
|
||
if (this.keys.length === 0) throw new CommandError('No keys to remove');
|
||
for (const ref of this.keys) {
|
||
const track = requireTrack(asset, ref.trackId);
|
||
const at = track.keys.findIndex((k) => k.frame === ref.frame);
|
||
if (at < 0) throw new CommandError(`No key on frame ${ref.frame}`);
|
||
track.keys.splice(at, 1);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Shifts the keys by `delta` frames together; one landing on a key that stays behind replaces it.
|
||
* With `copy` the originals stay where they were and the copies travel – Ctrl+drag (#102).
|
||
*/
|
||
export class MoveAnimationKeysCommand extends TimelineCommand {
|
||
readonly label: string;
|
||
|
||
constructor(
|
||
assetId: string,
|
||
private readonly keys: readonly KeyRef[],
|
||
private readonly delta: number,
|
||
private readonly copy = false,
|
||
) {
|
||
super(assetId);
|
||
const noun = keys.length === 1 ? 'key' : `${keys.length} keys`;
|
||
this.label = `${copy ? 'Duplicate' : 'Move'} ${noun}`;
|
||
}
|
||
|
||
protected apply(_project: Project, asset: AnimationAsset): void {
|
||
if (!Number.isInteger(this.delta)) throw new CommandError('Keys move by whole frames');
|
||
if (this.copy && this.delta === 0) throw new CommandError('A duplicate has to land somewhere else');
|
||
const byTrack = new Map<string, Set<number>>();
|
||
for (const ref of this.keys) {
|
||
const track = requireTrack(asset, ref.trackId);
|
||
if (!track.keys.some((k) => k.frame === ref.frame))
|
||
throw new CommandError(`No key on frame ${ref.frame}`);
|
||
if (ref.frame + this.delta < 0) throw new CommandError('A key cannot move before frame 0');
|
||
byTrack.set(ref.trackId, (byTrack.get(ref.trackId) ?? new Set()).add(ref.frame));
|
||
}
|
||
for (const [trackId, frames] of byTrack) {
|
||
const track = requireTrack(asset, trackId);
|
||
const staying = this.copy ? track.keys : track.keys.filter((k) => !frames.has(k.frame));
|
||
const moved = track.keys
|
||
.filter((k) => frames.has(k.frame))
|
||
.map((k) => ({ ...k, frame: k.frame + this.delta }));
|
||
// Later wins in `normalizeKeys`, so the moved keys go last.
|
||
track.keys = normalizeKeys([...staying, ...moved]);
|
||
}
|
||
}
|
||
}
|
||
|
||
/** One row of copied keys: where it came from, its kind, and the keys counted from the first copied frame. */
|
||
export interface ClipboardRow {
|
||
trackId: string;
|
||
target: string;
|
||
property: string;
|
||
/** Unknown when the row aimed at something gone – such keys only go back onto that very row. */
|
||
kind: string | null;
|
||
keys: { offset: number; value: PropertyValue; interpolation?: KeyInterpolation }[];
|
||
}
|
||
|
||
/** What Ctrl+C on picked keys holds (#102). Frames are relative: the earliest copied key is offset 0. */
|
||
export interface KeyClipboard {
|
||
assetId: string;
|
||
rows: ClipboardRow[];
|
||
}
|
||
|
||
/** The picked keys as a clipboard, or null when none of them exists. */
|
||
export function copyAnimationKeys(
|
||
project: Project,
|
||
asset: AnimationAsset,
|
||
refs: readonly KeyRef[],
|
||
): KeyClipboard | null {
|
||
const picked = asset.tracks
|
||
.map((track) => ({
|
||
track,
|
||
keys: track.keys.filter((k) => refs.some((r) => r.trackId === track.id && r.frame === k.frame)),
|
||
}))
|
||
.filter((p) => p.keys.length > 0);
|
||
if (picked.length === 0) return null;
|
||
const first = Math.min(...picked.flatMap((p) => p.keys.map((k) => k.frame)));
|
||
return {
|
||
assetId: asset.id,
|
||
rows: picked.map(({ track, keys }) => ({
|
||
trackId: track.id,
|
||
target: track.target,
|
||
property: track.property,
|
||
kind: trackDef(project, asset, track)?.kind ?? null,
|
||
keys: keys.map((k) => ({
|
||
offset: k.frame - first,
|
||
value: structuredClone(k.value),
|
||
...(k.interpolation ? { interpolation: k.interpolation } : {}),
|
||
})),
|
||
})),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Pastes a clipboard with its first key on `frame`. Keys of one row go onto `intoTrackId` when given
|
||
* – the row picked in the timeline – as long as it has the same kind. Otherwise every row finds its
|
||
* own: the same row in the animation it came from, the row animating the same property elsewhere.
|
||
* Rows that find none are left out and counted in `skipped`; a pasted key replaces one already there.
|
||
*/
|
||
export class PasteAnimationKeysCommand extends TimelineCommand {
|
||
readonly label: string;
|
||
/** The keys as they landed, for the timeline to pick. */
|
||
pasted: KeyRef[] = [];
|
||
skipped = 0;
|
||
|
||
constructor(
|
||
assetId: string,
|
||
private readonly clipboard: KeyClipboard,
|
||
private readonly frame: number,
|
||
private readonly intoTrackId = '',
|
||
) {
|
||
super(assetId);
|
||
const count = clipboard.rows.reduce((n, r) => n + r.keys.length, 0);
|
||
this.label = count === 1 ? 'Paste key' : `Paste ${count} keys`;
|
||
}
|
||
|
||
protected apply(project: Project, asset: AnimationAsset): void {
|
||
if (!Number.isInteger(this.frame) || this.frame < 0)
|
||
throw new CommandError('Keys paste onto a whole frame from 0');
|
||
this.pasted = [];
|
||
this.skipped = 0;
|
||
const single = this.clipboard.rows.length === 1 && this.intoTrackId !== '';
|
||
for (const row of this.clipboard.rows) {
|
||
const track = single ? requireTrack(asset, this.intoTrackId) : this.matchingTrack(asset, row);
|
||
if (!track) {
|
||
this.skipped += 1;
|
||
continue;
|
||
}
|
||
const def = trackDef(project, asset, track);
|
||
if (row.kind === null ? track.id !== row.trackId : def?.kind !== row.kind) {
|
||
if (single)
|
||
throw new CommandError(
|
||
def
|
||
? `${row.kind ?? 'These'} keys cannot go on a ${def.kind} row`
|
||
: 'The row aims at something that is gone',
|
||
);
|
||
this.skipped += 1;
|
||
continue;
|
||
}
|
||
const keys = row.keys.map((k) => {
|
||
const key: AnimationKey = {
|
||
frame: this.frame + k.offset,
|
||
value: def ? coerceValue(def, k.value) : structuredClone(k.value),
|
||
};
|
||
if (k.interpolation && (!def || blends(def.kind))) key.interpolation = k.interpolation;
|
||
return key;
|
||
});
|
||
track.keys = normalizeKeys([...track.keys, ...keys]);
|
||
this.pasted.push(...keys.map((k) => ({ trackId: track.id, frame: k.frame })));
|
||
}
|
||
if (this.pasted.length === 0) throw new CommandError('None of the copied rows is in this animation');
|
||
}
|
||
|
||
private matchingTrack(asset: AnimationAsset, row: ClipboardRow): AnimationTrack | undefined {
|
||
return asset.id === this.clipboard.assetId
|
||
? asset.tracks.find((t) => t.id === row.trackId)
|
||
: asset.tracks.find((t) => t.target === row.target && t.property === row.property);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Where a key on `frame` ends up when `count` frames are inserted at `at` (count > 0) or removed from
|
||
* it (count < 0): keys from `at` on move, and removing drops the keys in the removed span (null).
|
||
*/
|
||
export function shiftedFrame(frame: number, at: number, count: number): number | null {
|
||
if (frame < at) return frame;
|
||
if (count < 0 && frame < at - count) return null;
|
||
return frame + count;
|
||
}
|
||
|
||
/**
|
||
* Inserts (`count` > 0) or removes (`count` < 0) frames at `at`, over every row – the way to make an
|
||
* animation longer or shorter in the middle (#102). A step that would change nothing is refused.
|
||
*/
|
||
export class ShiftAnimationFramesCommand extends TimelineCommand {
|
||
readonly label: string;
|
||
|
||
constructor(
|
||
assetId: string,
|
||
private readonly at: number,
|
||
private readonly count: number,
|
||
) {
|
||
super(assetId);
|
||
const frames = Math.abs(count) === 1 ? 'frame' : `${Math.abs(count)} frames`;
|
||
this.label = count > 0 ? `Insert ${frames}` : `Remove ${frames}`;
|
||
}
|
||
|
||
protected apply(_project: Project, asset: AnimationAsset): void {
|
||
if (!Number.isInteger(this.at) || this.at < 0)
|
||
throw new CommandError('Frames are inserted at a whole frame from 0');
|
||
if (!Number.isInteger(this.count) || this.count === 0)
|
||
throw new CommandError('Give a whole number of frames');
|
||
let touched = 0;
|
||
for (const track of asset.tracks) {
|
||
const keys: AnimationKey[] = [];
|
||
for (const key of track.keys) {
|
||
const frame = shiftedFrame(key.frame, this.at, this.count);
|
||
if (frame !== key.frame) touched += 1;
|
||
if (frame !== null) keys.push({ ...key, frame });
|
||
}
|
||
track.keys = keys;
|
||
}
|
||
if (touched === 0) throw new CommandError(`No keys from frame ${this.at} on`);
|
||
}
|
||
}
|