Key a video to its end at 1x and zoom the timeline further out (#407)
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>
This commit is contained in:
1 parent
c9e8a3f6ac
commit
774207208c
9 files changed
+291
-8
No files matched your search
@@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
|
|||||||
The browser suite and the three example games need a machine with a GPU:
|
The browser suite and the three example games need a machine with a GPU:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1999 checks (GM_STEPS=20 for one step)
|
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2000 checks (GM_STEPS=20 for one step)
|
||||||
# every tool signs in first – see the Accounts page
|
# every tool signs in first – see the Accounts page
|
||||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||||
|
|||||||
@@ -243,6 +243,44 @@ export class SetAnimationKeyCommand extends TimelineCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
* 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.
|
* that cannot blend is left as it is – it jumps whatever it says – and only such keys is refused.
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { instanceChildId, resolveObjects } from './instance';
|
|||||||
import { findAsset, type Project } from './Project';
|
import { findAsset, type Project } from './Project';
|
||||||
import { colorOf, type PropertyBag, type PropertyValue, textOf } from './Properties';
|
import { colorOf, type PropertyBag, type PropertyValue, textOf } from './Properties';
|
||||||
import { objectName, type SceneObject } from './SceneObject';
|
import { objectName, type SceneObject } from './SceneObject';
|
||||||
|
import { findVideo, type VideoRef } from './video';
|
||||||
import { mixColor } from '../util/color';
|
import { mixColor } from '../util/color';
|
||||||
|
|
||||||
/** `once` holds the last value when it ends, `once-reset` puts back what was there before it started. */
|
/** `once` holds the last value when it ends, `once-reset` puts back what was there before it started. */
|
||||||
@@ -522,6 +523,26 @@ export function trackDef(
|
|||||||
return targetDefs(project, animation, track.target)?.find((d) => d.key === track.property);
|
return targetDefs(project, animation, track.target)?.find((d) => d.key === track.property);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The video the object a row aims at wears at `frame`, for a row shown only with one (#407): what
|
||||||
|
* the texture row the def names holds there – keyed in this animation, or else on the object.
|
||||||
|
*/
|
||||||
|
export function rowVideoAt(
|
||||||
|
project: Project,
|
||||||
|
animation: AnimationAsset,
|
||||||
|
track: AnimationTrack,
|
||||||
|
frame: number,
|
||||||
|
): VideoRef | null {
|
||||||
|
const aimed = aimedAt(project, animation, track.target);
|
||||||
|
const def =
|
||||||
|
aimed && animatableDefsOf(project, aimed.asset, aimed.bag).find((d) => d.key === track.property);
|
||||||
|
const textureKey = def && 'shownForVideo' in def ? def.shownForVideo : undefined;
|
||||||
|
if (!aimed || !textureKey) return null;
|
||||||
|
const keyed = animation.tracks.find((t) => t.target === track.target && t.property === textureKey);
|
||||||
|
const value = (keyed && trackValueAt(keyed.keys, 'texture', frame)) ?? aimed.bag[textureKey];
|
||||||
|
return findVideo(project, textOf(value ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The rows the dialog offers for the object `target` names: what it can animate, less a behavior's
|
* The rows the dialog offers for the object `target` names: what it can animate, less a behavior's
|
||||||
* private variables (#270) and what another row animates already. A row on a private variable keeps
|
* private variables (#270) and what another row animates already. A row on a private variable keeps
|
||||||
|
|||||||
@@ -72,6 +72,31 @@ export function videoPlayOf(ref: VideoRef): VideoPlay {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Seconds a video or clip lasts; 0 while the video's length is not known yet. */
|
||||||
|
export function videoLengthOf(ref: VideoRef): number {
|
||||||
|
const play = videoPlayOf(ref);
|
||||||
|
const end = Number.isFinite(play.end) ? play.end : ref.asset.duration;
|
||||||
|
return end > play.start ? end - play.start : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A key of a `Video time` row: seconds into the clip on a frame of the animation. */
|
||||||
|
export interface FrameValue {
|
||||||
|
frame: number;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a driven video playing at 1× from `value` seconds on `frame` reaches the end of a clip
|
||||||
|
* `length` seconds long (#407): the last whole frame at `fps` before it, and the clip's time there.
|
||||||
|
* Null when not one frame fits.
|
||||||
|
*/
|
||||||
|
export function realTimeEnd(frame: number, value: number, length: number, fps: number): FrameValue | null {
|
||||||
|
if (!(length > 0) || !(fps > 0) || !Number.isFinite(value)) return null;
|
||||||
|
const frames = Math.floor((length - value) * fps + 1e-6);
|
||||||
|
if (frames < 1) return null;
|
||||||
|
return { frame: frame + frames, value: Math.round((value + frames / fps) * 1e6) / 1e6 };
|
||||||
|
}
|
||||||
|
|
||||||
const PREFIX = 'video:';
|
const PREFIX = 'video:';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
clipboardKeyCount,
|
clipboardKeyCount,
|
||||||
copyAnimationKeys,
|
copyAnimationKeys,
|
||||||
|
KeyStraightRunCommand,
|
||||||
MoveAnimationKeysCommand,
|
MoveAnimationKeysCommand,
|
||||||
PasteAnimationKeysCommand,
|
PasteAnimationKeysCommand,
|
||||||
RemoveAnimationTracksCommand,
|
RemoveAnimationTracksCommand,
|
||||||
@@ -23,16 +24,21 @@ import {
|
|||||||
hasKey,
|
hasKey,
|
||||||
interpolationOf,
|
interpolationOf,
|
||||||
type KeyRef,
|
type KeyRef,
|
||||||
|
rowVideoAt,
|
||||||
sameKey,
|
sameKey,
|
||||||
trackDef,
|
trackDef,
|
||||||
trackLabel,
|
trackLabel,
|
||||||
|
trackValueAt,
|
||||||
} from '../../core/model/animation';
|
} from '../../core/model/animation';
|
||||||
import type { AnimationAsset, AnimationTrack, KeyInterpolation } from '../../core/model/Asset';
|
import type { AnimationAsset, AnimationTrack, KeyInterpolation } from '../../core/model/Asset';
|
||||||
import type { Vec2 } from '../../core/model/Properties';
|
import type { Vec2 } from '../../core/model/Properties';
|
||||||
import { findAssetOf } from '../../core/model/Project';
|
import { findAssetOf } from '../../core/model/Project';
|
||||||
|
import { type FrameValue, realTimeEnd, videoLengthOf } from '../../core/model/video';
|
||||||
|
import { VIDEO_TIME_KEY } from '../../core/properties/videos';
|
||||||
import {
|
import {
|
||||||
drawBand,
|
drawBand,
|
||||||
Menu,
|
Menu,
|
||||||
|
type MenuEntry,
|
||||||
pressCount,
|
pressCount,
|
||||||
type Size,
|
type Size,
|
||||||
Theme,
|
Theme,
|
||||||
@@ -306,6 +312,42 @@ export class TimelineCanvas extends Widget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- a driven video at 1× (#407) -----------------------------------------------------------
|
||||||
|
|
||||||
|
/** The two keys that play the row's video at 1× from the playhead to the clip's end, or null. */
|
||||||
|
videoRun(trackId: string): { from: FrameValue; to: FrameValue; length: number } | null {
|
||||||
|
const asset = this.asset();
|
||||||
|
const track = asset?.tracks.find((t) => t.id === trackId);
|
||||||
|
if (!asset || !track || track.relative || trackDef(this.state.project, asset, track)?.kind !== 'number')
|
||||||
|
return null;
|
||||||
|
const at = this.playheadFrame;
|
||||||
|
const video = rowVideoAt(this.state.project, asset, track, at);
|
||||||
|
if (!video) return null;
|
||||||
|
const value = Number(trackValueAt(track.keys, 'number', at) ?? 0);
|
||||||
|
const length = videoLengthOf(video);
|
||||||
|
const to = realTimeEnd(at, value, length, fpsOf(asset));
|
||||||
|
return to && { from: { frame: at, value }, to, length };
|
||||||
|
}
|
||||||
|
|
||||||
|
keyVideoRun(trackId: string): boolean {
|
||||||
|
const asset = this.asset();
|
||||||
|
const run = this.videoRun(trackId);
|
||||||
|
if (!asset || !run) return false;
|
||||||
|
const command = new KeyStraightRunCommand(asset.id, trackId, run.from, run.to);
|
||||||
|
if (!this.state.execute(command)) return false;
|
||||||
|
this.state.select(trackSelection(asset.id, [{ trackId, frame: run.to.frame }], trackId));
|
||||||
|
const seconds = (s: number) => `${Number(s.toFixed(3))} s`;
|
||||||
|
const gone =
|
||||||
|
command.removed === 0
|
||||||
|
? ''
|
||||||
|
: `, ${command.removed} ${command.removed === 1 ? 'key' : 'keys'} between removed`;
|
||||||
|
this.state.setNotice(
|
||||||
|
`Video at 1×: frame ${run.to.frame} shows ${seconds(run.to.value)} of ${seconds(run.length)}${gone}.`,
|
||||||
|
'quiet',
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A right-click on a key picks it first, the way a node's menu does, so Copy speaks about it; one
|
* A right-click on a key picks it first, the way a node's menu does, so Copy speaks about it; one
|
||||||
* on a row's header picks the row. Delete row always means the row under the pointer.
|
* on a row's header picks the row. Delete row always means the row under the pointer.
|
||||||
@@ -322,6 +364,18 @@ export class TimelineCanvas extends Widget {
|
|||||||
this.state.select(trackSelection(asset.id, [], track.id));
|
this.state.select(trackSelection(asset.id, [], track.id));
|
||||||
const picked = this.state.selectedKeys.length;
|
const picked = this.state.selectedKeys.length;
|
||||||
const at = this.playheadFrame;
|
const at = this.playheadFrame;
|
||||||
|
const timeRow = track ?? asset.tracks.find((t) => t.id === this.state.selectedTrackId);
|
||||||
|
const videoRun: MenuEntry[] =
|
||||||
|
timeRow?.property === VIDEO_TIME_KEY
|
||||||
|
? [
|
||||||
|
{ separator: true },
|
||||||
|
{
|
||||||
|
label: `Video at 1× to its end from frame ${at}`,
|
||||||
|
disabled: this.videoRun(timeRow.id) === null,
|
||||||
|
onSelect: () => this.keyVideoRun(timeRow.id),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
new Menu([
|
new Menu([
|
||||||
{
|
{
|
||||||
label: picked > 1 ? `Copy ${picked} keys` : 'Copy key',
|
label: picked > 1 ? `Copy ${picked} keys` : 'Copy key',
|
||||||
@@ -336,6 +390,7 @@ export class TimelineCanvas extends Widget {
|
|||||||
{ separator: true },
|
{ separator: true },
|
||||||
{ label: `Insert frames at ${at}…`, onSelect: () => this.openFramesDialog('insert') },
|
{ label: `Insert frames at ${at}…`, onSelect: () => this.openFramesDialog('insert') },
|
||||||
{ label: `Remove frames from ${at}…`, danger: true, onSelect: () => this.openFramesDialog('remove') },
|
{ label: `Remove frames from ${at}…`, danger: true, onSelect: () => this.openFramesDialog('remove') },
|
||||||
|
...videoRun,
|
||||||
{ separator: true },
|
{ separator: true },
|
||||||
{
|
{
|
||||||
label: 'Delete row',
|
label: 'Delete row',
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ export const HEADER_WIDTH = 220;
|
|||||||
/** Height of the frame ruler above the rows. */
|
/** Height of the frame ruler above the rows. */
|
||||||
export const RULER_HEIGHT = 22;
|
export const RULER_HEIGHT = 22;
|
||||||
export const ROW_HEIGHT = 26;
|
export const ROW_HEIGHT = 26;
|
||||||
export const MIN_CELL_WIDTH = 4;
|
/** Far enough out that an hour of video at 30 fps fits a screen (#407): cells then share a pixel. */
|
||||||
|
export const MIN_CELL_WIDTH = 0.02;
|
||||||
export const MAX_CELL_WIDTH = 48;
|
export const MAX_CELL_WIDTH = 48;
|
||||||
const DEFAULT_CELL_WIDTH = 16;
|
const DEFAULT_CELL_WIDTH = 16;
|
||||||
/** Side of a key's diamond, in pixels – constant, so it stays grabbable when the cells are narrow. */
|
/** Side of a key's diamond, in pixels – constant, so it stays grabbable when the cells are narrow. */
|
||||||
@@ -80,10 +81,12 @@ export function clampPan(view: TimelineView): TimelineView {
|
|||||||
return view.panX > 0 ? { ...view, panX: 0 } : view;
|
return view.panX > 0 ? { ...view, panX: 0 } : view;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Every `n`-th frame gets a number on the ruler; a round step so the numbers read like a scale. */
|
/** Every `n`-th frame gets a number on the ruler; a round step (1, 2, 5, 10, …) so the numbers read like a scale. */
|
||||||
export function rulerStep(view: TimelineView): number {
|
export function rulerStep(view: TimelineView): number {
|
||||||
const steps = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000];
|
if (!(view.cellWidth > 0)) return 1;
|
||||||
return steps.find((step) => step * view.cellWidth >= RULER_LABEL_MIN_PX) ?? 1000;
|
for (let decade = 1; ; decade *= 10)
|
||||||
|
for (const step of [decade, 2 * decade, 5 * decade])
|
||||||
|
if (step * view.cellWidth >= RULER_LABEL_MIN_PX) return step;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,10 +3,27 @@
|
|||||||
* at its Video time, a number an animation keys like any other.
|
* at its Video time, a number an animation keys like any other.
|
||||||
*/
|
*/
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { CreateVideoCommand } from '../../src/core/commands/assetCommands';
|
import {
|
||||||
|
AddAnimationTrackCommand,
|
||||||
|
KeyStraightRunCommand,
|
||||||
|
SetAnimationKeyCommand,
|
||||||
|
SetAnimationRootCommand,
|
||||||
|
SetKeyInterpolationCommand,
|
||||||
|
} from '../../src/core/commands/animationCommands';
|
||||||
|
import { CreateAssetCommand, CreateVideoCommand } from '../../src/core/commands/assetCommands';
|
||||||
import { AddClipCommand } from '../../src/core/commands/clipCommands';
|
import { AddClipCommand } from '../../src/core/commands/clipCommands';
|
||||||
import { animatableDefs } from '../../src/core/model/animation';
|
import { CommandError } from '../../src/core/commands/Command';
|
||||||
import { drivenTimeOf, drivenVideoKey, isDrivenVideoKey, parseVideoKey } from '../../src/core/model/video';
|
import { animatableDefs, rowVideoAt } from '../../src/core/model/animation';
|
||||||
|
import { findAssetOf } from '../../src/core/model/Project';
|
||||||
|
import {
|
||||||
|
drivenTimeOf,
|
||||||
|
drivenVideoKey,
|
||||||
|
findVideo,
|
||||||
|
isDrivenVideoKey,
|
||||||
|
parseVideoKey,
|
||||||
|
realTimeEnd,
|
||||||
|
videoLengthOf,
|
||||||
|
} from '../../src/core/model/video';
|
||||||
import { isRowShown, schemaFor } from '../../src/core/properties/PropertySchema';
|
import { isRowShown, schemaFor } from '../../src/core/properties/PropertySchema';
|
||||||
import {
|
import {
|
||||||
VIDEO_CLOCK_KEY,
|
VIDEO_CLOCK_KEY,
|
||||||
@@ -92,3 +109,92 @@ describe('the Video clock and Video time rows', () => {
|
|||||||
expect(keys).toContain(VIDEO_CLOCK_KEY);
|
expect(keys).toContain(VIDEO_CLOCK_KEY);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('keying a driven video at 1× to its end (#407)', () => {
|
||||||
|
it('ends on the last whole frame before the clip does', () => {
|
||||||
|
expect(realTimeEnd(0, 0, 57.27, 10)).toEqual({ frame: 572, value: 57.2 });
|
||||||
|
expect(realTimeEnd(5, 0.5, 2, 10)).toEqual({ frame: 20, value: 2 });
|
||||||
|
expect(realTimeEnd(0, 1.2, 2, 30)).toEqual({ frame: 24, value: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has nothing to key when not one frame fits or nothing is known', () => {
|
||||||
|
expect(realTimeEnd(0, 1.95, 2, 10)).toBeNull();
|
||||||
|
expect(realTimeEnd(0, 0, 0, 10)).toBeNull();
|
||||||
|
expect(realTimeEnd(0, 0, 2, 0)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
const f = makeProject();
|
||||||
|
const video = new CreateVideoCommand('Intro', FILE, {
|
||||||
|
width: 320,
|
||||||
|
height: 180,
|
||||||
|
duration: 4,
|
||||||
|
fps: 30,
|
||||||
|
audio: false,
|
||||||
|
});
|
||||||
|
f.stack.execute(video);
|
||||||
|
const clip = new AddClipCommand(video.asset.id, { [VIDEO_START_KEY]: 0.5, [VIDEO_END_KEY]: 2 });
|
||||||
|
f.stack.execute(clip);
|
||||||
|
const sprite = new CreateAssetCommand('sprite', 'Shot', {
|
||||||
|
texture: clip.clipId,
|
||||||
|
[VIDEO_CLOCK_KEY]: 'driven',
|
||||||
|
});
|
||||||
|
f.stack.execute(sprite);
|
||||||
|
const cut = new CreateAssetCommand('animation', 'Cut', { fps: 10 });
|
||||||
|
f.stack.execute(cut);
|
||||||
|
f.stack.execute(new SetAnimationRootCommand(cut.asset.id, sprite.asset.id));
|
||||||
|
const row = new AddAnimationTrackCommand(cut.asset.id, '', VIDEO_TIME_KEY);
|
||||||
|
f.stack.execute(row);
|
||||||
|
const animation = () => findAssetOf(f.project, cut.asset.id, 'animation')!;
|
||||||
|
return { ...f, videoId: video.asset.id, clipId: clip.clipId, animation, rowId: row.trackId };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('measures a clip by its stretch and the whole video by its file', () => {
|
||||||
|
const { project, videoId, clipId } = setup();
|
||||||
|
expect(videoLengthOf(findVideo(project, clipId)!)).toBe(1.5);
|
||||||
|
expect(videoLengthOf(findVideo(project, videoId)!)).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds the video the row wears, also where the texture is keyed in the same animation', () => {
|
||||||
|
const { project, stack, animation, rowId, videoId, clipId } = setup();
|
||||||
|
const row = () => animation().tracks.find((t) => t.id === rowId)!;
|
||||||
|
expect(rowVideoAt(project, animation(), row(), 0)?.clip?.id).toBe(clipId);
|
||||||
|
const texture = new AddAnimationTrackCommand(animation().id, '', 'texture');
|
||||||
|
stack.execute(texture);
|
||||||
|
stack.execute(new SetAnimationKeyCommand(animation().id, texture.trackId, 0, videoId));
|
||||||
|
stack.execute(new SetAnimationKeyCommand(animation().id, texture.trackId, 10, clipId));
|
||||||
|
expect(rowVideoAt(project, animation(), row(), 5)?.clip).toBeNull();
|
||||||
|
expect(rowVideoAt(project, animation(), row(), 12)?.clip?.id).toBe(clipId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets both keys, makes the first linear and takes the keys between out, in one undo step', () => {
|
||||||
|
const { stack, animation, rowId } = setup();
|
||||||
|
const id = animation().id;
|
||||||
|
stack.execute(new SetAnimationKeyCommand(id, rowId, 5, 0.3));
|
||||||
|
stack.execute(new SetKeyInterpolationCommand(id, [{ trackId: rowId, frame: 5 }], 'step'));
|
||||||
|
stack.execute(new SetAnimationKeyCommand(id, rowId, 9, 1));
|
||||||
|
stack.execute(new SetAnimationKeyCommand(id, rowId, 30, 9));
|
||||||
|
const before = structuredClone(animation().tracks);
|
||||||
|
const run = new KeyStraightRunCommand(id, rowId, { frame: 5, value: 0.3 }, { frame: 17, value: 1.5 });
|
||||||
|
stack.execute(run);
|
||||||
|
expect(run.removed).toBe(1);
|
||||||
|
expect(animation().tracks[0]!.keys).toEqual([
|
||||||
|
{ frame: 5, value: 0.3 },
|
||||||
|
{ frame: 17, value: 1.5 },
|
||||||
|
{ frame: 30, value: 9 },
|
||||||
|
]);
|
||||||
|
stack.undo();
|
||||||
|
expect(animation().tracks).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a run that does not go forward or a row that is no number', () => {
|
||||||
|
const { stack, animation, rowId } = setup();
|
||||||
|
const id = animation().id;
|
||||||
|
const clock = new AddAnimationTrackCommand(id, '', VIDEO_CLOCK_KEY);
|
||||||
|
stack.execute(clock);
|
||||||
|
const run = (track: string, to: number) =>
|
||||||
|
new KeyStraightRunCommand(id, track, { frame: 4, value: 0 }, { frame: to, value: 1 });
|
||||||
|
expect(() => stack.execute(run(rowId, 4))).toThrow(CommandError);
|
||||||
|
expect(() => stack.execute(run(clock.trackId, 10))).toThrow(CommandError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -70,6 +70,11 @@ describe('timeline geometry', () => {
|
|||||||
expect(zoomCells(DEFAULT_VIEW, 0.001, 0).cellWidth).toBe(MIN_CELL_WIDTH);
|
expect(zoomCells(DEFAULT_VIEW, 0.001, 0).cellWidth).toBe(MIN_CELL_WIDTH);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('zooms out far enough that an hour of video at 30 fps fits a screen (#407)', () => {
|
||||||
|
const out = zoomCells(DEFAULT_VIEW, 1e-6, 0);
|
||||||
|
expect(frameToX(out, 3600 * 30)).toBeLessThanOrEqual(2200);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not let frame 0 drift right of the left edge', () => {
|
it('does not let frame 0 drift right of the left edge', () => {
|
||||||
expect(clampPan({ panX: 30, cellWidth: 16 }).panX).toBe(0);
|
expect(clampPan({ panX: 30, cellWidth: 16 }).panX).toBe(0);
|
||||||
expect(clampPan({ panX: -30, cellWidth: 16 }).panX).toBe(-30);
|
expect(clampPan({ panX: -30, cellWidth: 16 }).panX).toBe(-30);
|
||||||
@@ -79,6 +84,8 @@ describe('timeline geometry', () => {
|
|||||||
expect(rulerStep({ panX: 0, cellWidth: 48 })).toBe(1);
|
expect(rulerStep({ panX: 0, cellWidth: 48 })).toBe(1);
|
||||||
expect(rulerStep({ panX: 0, cellWidth: 16 })).toBe(5);
|
expect(rulerStep({ panX: 0, cellWidth: 16 })).toBe(5);
|
||||||
expect(rulerStep({ panX: 0, cellWidth: 4 })).toBe(10);
|
expect(rulerStep({ panX: 0, cellWidth: 4 })).toBe(10);
|
||||||
|
expect(rulerStep({ panX: 0, cellWidth: 0.1 })).toBe(500);
|
||||||
|
expect(rulerStep({ panX: 0, cellWidth: MIN_CELL_WIDTH })).toBe(2000);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -353,6 +353,34 @@ export async function video(t) {
|
|||||||
along.every((p) => !p.paused && !p.muted && Math.abs(p.rate - 1) <= 0.1 && gap(p.time, p.asked) < 0.15),
|
along.every((p) => !p.paused && !p.muted && Math.abs(p.rate - 1) <= 0.1 && gap(p.time, p.asked) < 0.15),
|
||||||
JSON.stringify(along),
|
JSON.stringify(along),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 66l. Right-click → "Video at 1× to its end" (#407): from the playhead (frame 5, 0.5 s) the
|
||||||
|
// last whole frame before the clip ends, the key on 9 between them gone; one undo step.
|
||||||
|
const rowKeys = () =>
|
||||||
|
json(
|
||||||
|
`__gm.state.project.assets.find((a) => a.id === '${CUT}').tracks[0].keys.map((k) => [k.frame, k.value])`,
|
||||||
|
);
|
||||||
|
await mutate(`
|
||||||
|
__gm.state.project.assets.find((a) => a.id === '${CUT}').tracks[0].keys =
|
||||||
|
[{ frame: 0, value: 0 }, { frame: 5, value: 0.5 }, { frame: 9, value: 1.7 }];`);
|
||||||
|
const keyed = JSON.stringify(await rowKeys());
|
||||||
|
await t.evaluate('__gm.animation.scrub(5)');
|
||||||
|
const inRow = await json("__gm.animation.cellAt('vd66-row', 2)");
|
||||||
|
await click(inRow.x, inRow.y, 'right');
|
||||||
|
const runItem = await t.find('Video at 1× to its end from frame 5', true);
|
||||||
|
await click(runItem.x, runItem.y);
|
||||||
|
const steps = Math.floor((uploaded.duration - 0.5) * 10 + 1e-6);
|
||||||
|
const ran = await rowKeys();
|
||||||
|
await t.undo();
|
||||||
|
check(
|
||||||
|
'the menu keys the video at 1x from the playhead to the last frame before its end, in one undo step',
|
||||||
|
ran.length === 3 &&
|
||||||
|
JSON.stringify(ran.slice(0, 2)) === '[[0,0],[5,0.5]]' &&
|
||||||
|
ran[2][0] === 5 + steps &&
|
||||||
|
Math.abs(ran[2][1] - (0.5 + steps / 10)) < 1e-6 &&
|
||||||
|
JSON.stringify(await rowKeys()) === keyed,
|
||||||
|
JSON.stringify({ ran, steps, duration: uploaded.duration, undone: await rowKeys() }),
|
||||||
|
);
|
||||||
await showTab('scene');
|
await showTab('scene');
|
||||||
|
|
||||||
await mutate(`
|
await mutate(`
|
||||||
|
|||||||
Reference in new issue
Block a user