Timeline keys can be copied and pasted at the playhead, duplicated with Ctrl+drag, and frames inserted or removed from a right-click menu (#102)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
195fc94e08
commit
bf02c9e8b3
11 files changed
+666
-23
No files matched your search
@@ -285,7 +285,10 @@ export class RemoveAnimationKeysCommand extends TimelineCommand {
|
||||
}
|
||||
}
|
||||
|
||||
/** Shifts the keys by `delta` frames together; one landing on a key that stays behind replaces it. */
|
||||
/**
|
||||
* 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;
|
||||
|
||||
@@ -293,13 +296,16 @@ export class MoveAnimationKeysCommand extends TimelineCommand {
|
||||
assetId: string,
|
||||
private readonly keys: readonly KeyRef[],
|
||||
private readonly delta: number,
|
||||
private readonly copy = false,
|
||||
) {
|
||||
super(assetId);
|
||||
this.label = keys.length === 1 ? 'Move key' : `Move ${keys.length} keys`;
|
||||
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);
|
||||
@@ -310,7 +316,7 @@ export class MoveAnimationKeysCommand extends TimelineCommand {
|
||||
}
|
||||
for (const [trackId, frames] of byTrack) {
|
||||
const track = requireTrack(asset, trackId);
|
||||
const staying = track.keys.filter((k) => !frames.has(k.frame));
|
||||
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 }));
|
||||
@@ -319,3 +325,162 @@ export class MoveAnimationKeysCommand extends TimelineCommand {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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`);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user