Files
feuerwerk/src/core/commands/animationCommands.ts
T
2026-09-17 12:53:56 +02:00

487 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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`);
}
}