Files
feuerwerk/tests/core/video-clock.test.ts
adminandClaude Opus 5.5 774207208c 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>
2026-10-11 14:51:20 +02:00

201 lines
8.0 KiB
TypeScript
Raw Permalink 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.
/**
* A video that follows the animation (#404): a sprite whose Video clock is driven shows the picture
* at its Video time, a number an animation keys like any other.
*/
import { describe, expect, it } from 'vitest';
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 { CommandError } from '../../src/core/commands/Command';
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 {
VIDEO_CLOCK_KEY,
VIDEO_END_KEY,
VIDEO_START_KEY,
VIDEO_TIME_KEY,
} from '../../src/core/properties/videos';
import { makeProject } from '../helpers';
const FILE = `a1b2c3d4e5f6/${'c7'.repeat(32)}.mp4`;
const KEY = `video:${FILE}#2~4~1~1`;
describe('where a driven video stands', () => {
const clip = { start: 2, end: 4, loop: true };
it('counts from the start of its clip', () => {
expect(drivenTimeOf(clip, 0.5, 10)).toBe(2.5);
});
it('goes round past the end with Loop', () => {
expect(drivenTimeOf(clip, 5, 10)).toBe(3);
});
it('holds the last picture without Loop, and the first one before the start', () => {
expect(drivenTimeOf({ ...clip, loop: false }, 9, 10)).toBe(3.999);
expect(drivenTimeOf(clip, -1, 10)).toBe(2);
});
it('measures a clip running to the end of the video by the file', () => {
const tail = { start: 6, end: Infinity, loop: true };
expect(drivenTimeOf(tail, 5, 10)).toBe(7);
expect(drivenTimeOf({ ...tail, loop: false }, 5, 10)).toBe(9.999);
expect(drivenTimeOf(tail, 5, NaN)).toBe(11);
});
});
describe('the player a wearer drives', () => {
it('goes by the clip key and the name of its wearer', () => {
const key = drivenVideoKey(KEY, 'view3:obj1');
expect(key).toBe(`${KEY}@view3:obj1`);
expect(isDrivenVideoKey(key)).toBe(true);
expect(isDrivenVideoKey(KEY)).toBe(false);
expect(parseVideoKey(key)).toEqual(parseVideoKey(KEY));
});
});
describe('the Video clock and Video time rows', () => {
function setup() {
const f = makeProject();
const create = new CreateVideoCommand('Intro', FILE, {
width: 320,
height: 180,
duration: 2,
fps: 30,
audio: false,
});
f.stack.execute(create);
const clip = new AddClipCommand(create.asset.id, { [VIDEO_START_KEY]: 0.5, [VIDEO_END_KEY]: 1 });
f.stack.execute(clip);
return { project: f.project, videoId: create.asset.id, clipId: clip.clipId };
}
const row = (key: string) => schemaFor('sprite').find((d) => d.key === key)!;
it('are shown only while the texture is a video or a clip of one', () => {
const { project, videoId, clipId } = setup();
const driven = { [VIDEO_CLOCK_KEY]: 'driven' };
for (const texture of [videoId, clipId]) {
expect(isRowShown(row(VIDEO_CLOCK_KEY), { texture }, project)).toBe(true);
expect(isRowShown(row(VIDEO_TIME_KEY), { texture, ...driven }, project)).toBe(true);
}
expect(isRowShown(row(VIDEO_CLOCK_KEY), { texture: '' }, project)).toBe(false);
expect(isRowShown(row(VIDEO_TIME_KEY), { texture: '', ...driven }, project)).toBe(false);
});
it('show the time only while the clock is driven', () => {
const { project, videoId } = setup();
expect(isRowShown(row(VIDEO_TIME_KEY), { texture: videoId }, project)).toBe(false);
});
it('can be keyed by an animation', () => {
const keys = animatableDefs('sprite').map((d) => d.key);
expect(keys).toContain(VIDEO_TIME_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);
});
});