import { newId } from '../ids'; import { ANIMATION_ROOT_TYPES, blends, findTrack, hasKey, isKeyInterpolation, type KeyRef, normalizeKeys, targetAssetOf, targetDefs, trackDef, trackValueAt, } from '../model/animation'; import type { AnimationAsset, AnimationKey, AnimationTrack, KeyInterpolation } from '../model/Asset'; import { curveLookup } from '../model/curve'; 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'; 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 = findTrack(asset, trackId); if (!track) throw new CommandError(`${asset.name} has no such row`); return track; } function requireKey(asset: AnimationAsset, ref: KeyRef): { track: AnimationTrack; key: AnimationKey } { 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}`); return { track, key }; } function requireFrame(frame: number, message: string): void { if (!Number.isInteger(frame) || frame < 0) throw new CommandError(message); } /** `Remove key`, `Remove 3 keys` – how a key command names itself. */ function keysLabel(verb: string, count: number): string { return count === 1 ? `${verb} key` : `${verb} ${count} keys`; } /** 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); requireFrame(this.frame, '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, false, curveLookup(project)); 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]); } } /** * Keys a number row straight from one value to another (#407): both keys set, the first one linear, * the keys between them gone – what a driven video needs to run at 1× without a bend. */ export class KeyStraightRunCommand extends TimelineCommand { readonly label = 'Key video at 1×'; /** How many keys between the two were taken out. */ removed = 0; constructor( assetId: string, private readonly trackId: string, private readonly from: AnimationKey, private readonly to: AnimationKey, ) { super(assetId); } protected apply(project: Project, asset: AnimationAsset): void { const track = requireTrack(asset, this.trackId); requireFrame(this.from.frame, 'A key sits on a whole frame from 0'); requireFrame(this.to.frame, 'A key sits on a whole frame from 0'); if (this.to.frame <= this.from.frame) throw new CommandError('The run has to end after it starts'); const def = trackDef(project, asset, track); if (def?.kind !== 'number') throw new CommandError('Only a number row can run straight'); const kept = track.keys.filter((k) => k.frame < this.from.frame || k.frame > this.to.frame); const end = track.keys.find((k) => k.frame === this.to.frame); this.removed = track.keys.filter((k) => k.frame > this.from.frame && k.frame < this.to.frame).length; const last: AnimationKey = { frame: this.to.frame, value: coerceValue(def, this.to.value) }; if (end?.interpolation) last.interpolation = end.interpolation; track.keys = normalizeKeys([ ...kept, { frame: this.from.frame, value: coerceValue(def, this.from.value) }, last, ]); } } /** * 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, key } = requireKey(asset, ref); 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 color key can ease'); } } export class RemoveAnimationKeysCommand extends TimelineCommand { readonly label: string; constructor( assetId: string, private readonly keys: readonly KeyRef[], ) { super(assetId); this.label = keysLabel('Remove', keys.length); } 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, key } = requireKey(asset, ref); track.keys.splice(track.keys.indexOf(key), 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); this.label = keysLabel(copy ? 'Duplicate' : 'Move', keys.length); } 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>(); for (const ref of this.keys) { requireKey(asset, ref); 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[]; } export function clipboardKeyCount(clipboard: KeyClipboard): number { return clipboard.rows.reduce((n, r) => n + r.keys.length, 0); } /** 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) => hasKey(refs, { trackId: track.id, 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); this.label = keysLabel('Paste', clipboardKeyCount(clipboard)); } protected apply(project: Project, asset: AnimationAsset): void { requireFrame(this.frame, '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 ? findTrack(asset, 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 { requireFrame(this.at, '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`); } }