To play a clip at its real speed the user had to know its length, work out the last whole frame by hand and then scroll far to the right to set the end key. The timeline now does the sum, and it zooms out far enough to see a long clip in one piece. - The context menu of a Video time row gets "Video at 1x to its end from frame N". It keys the playhead with the row's value there and the last whole frame before the clip ends with the matching time. - The start key turns linear and keys in between are removed. The whole run is one undo step (KeyStraightRunCommand). - The clip is the one the sprite wears at the playhead, also when the texture is keyed in the same animation (rowVideoAt). - The entry is greyed out for relative rows, without a video, while the length is unknown, or when the playhead is already at the end. - The status line names the result, e.g. "frame 572 shows 57.2 s of 57.27 s". - The timeline zooms out to 0.02 px per frame instead of 4 px. The ruler labels follow a 1-2-5 step without an upper limit. Tests: - Unit video-clock + timeline-view: 27 tests green, 8 new - e2e 66 (atx, alone): ALL PASSED, 23 checks, 66l new - Gate (atx): CHECK EXIT 0, 297 files, 4553 tests - Counter-check: the command keeping the keys in between turns exactly the command test red Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
532 lines
19 KiB
TypeScript
532 lines
19 KiB
TypeScript
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<string, Set<number>>();
|
||
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`);
|
||
}
|
||
}
|