Files
feuerwerk/tests/core/video-steer.test.ts
T
adminandClaude Opus 5.5 b17a924157
feuerwerk / gate (push) Successful in 5m58s
feuerwerk / suite (push) Failing after 17m35s
Let a behavior play, pause and seek a video and hear it end (#410)
A game should be able to stop a video on a sprite, jump to a spot in
it and start it again, and react when it has run out. Until now a
video on a sprite either ran free on its own or followed a
composition, and no behavior could touch it.

- Five new nodes: Play video, Pause video and Seek video (time in s
  from the clip start), On video finished with a Video field, and the
  data node Video time with time, length and playing.
- Play, Pause and Seek act on the owner when object is not linked.
- On video finished with an empty field hears every video. A chosen
  video also hears its clips.
- A new VideoSystem keeps a clock for every sprite with a video. It
  counts from the moment the sprite appears, as long as it is on. For
  a Driven sprite the clock is its Video time. A paused game holds all
  clocks.
- A free sprite that one of the nodes touches gets the clock
  "steered" at run time. It then has its own player, and other
  sprites with the same clip play on. Pause of the game and sound go
  the existing Driven way.
- On a Driven sprite and in the editor run the nodes do nothing and
  write a line to the log.
- Until its own player has a frame, a steered sprite shows the frame
  of the shared player. Without that it would blink out on the first
  Pause video.
- Play at the end of a clip without loop starts it from the start.
  The issue does not say, this is my choice.

Found, not fixed: with a Driven sprite in the scene the run waits up
to 3 s for its frame at the start. The world does not tick then, but
the free videos already play. Their clocks lag behind the picture by
that wait, so On video finished comes that much late.

Tests:
- Unit: 23 new (video-steer 16, node catalog 7), affected groups
  4820/4820.
- Counter-probes: 16 bends, all red.
- e2e 66 on atx: ALL PASSED, 39 checks. New 66s: Pause at 0.15 s
  held its own player at 0.2 s, the picture stayed red for 1 s. Seek
  landed on 0.5 s exactly, green, held. Play showed green, blue,
  white, and white stayed at the end. On video finished came once.
  Without the clock "steered" all three 66s checks are red.
- Gate on atx: CHECK EXIT 0, 336 files, 5199 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 10:13:52 +01:00

311 lines
11 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.
/**
* The time of the video a sprite wears, as a run counts it (#410): `On video finished`, `Video time`
* and the nodes that play, pause and seek it.
*/
import { describe, expect, it } from 'vitest';
import { CreateVideoCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import {
advanceVideoClock,
clipTimeOf,
newVideoClock,
steerVideoClock,
} from '../../src/core/model/videoClock';
import { VIDEO_CLOCK_KEY, VIDEO_TIME_KEY } from '../../src/core/properties/videos';
import { addGameSystems } from '../../src/runtime/systems/BehaviorSystem';
import { VideoSystem } from '../../src/runtime/systems/VideoSystem';
import { Input } from '../../src/runtime/systems/Input';
import { World } from '../../src/runtime/World';
import { graphEditor, makeBehavior, makeSpriteScene, place, type SceneFixture } from '../helpers';
const FILE = `a1b2c3d4e5f6/${'c7'.repeat(32)}.mp4`;
describe('the clock of a video', () => {
it('goes round with Loop and holds at the end without, past the end and before the start', () => {
expect(clipTimeOf(2.5, 2, true)).toBeCloseTo(0.5, 9);
expect(clipTimeOf(2.5, 2, false)).toBe(2);
expect(clipTimeOf(-1, 2, true)).toBe(0);
expect(clipTimeOf(Number.NaN, 2, false)).toBe(0);
// A length not known yet bounds only at the start.
expect(clipTimeOf(7, 0, false)).toBe(7);
});
it('reaches the end of a clip without Loop once a pass and stops there', () => {
const clock = newVideoClock('v');
const ends = [];
for (let i = 0; i < 30; i++) ends.push(advanceVideoClock(clock, 0.1, 2, false));
expect(ends.filter(Boolean)).toHaveLength(1);
expect(ends.indexOf(true)).toBe(19);
expect(clock).toMatchObject({ time: 2, playing: false });
});
it('never ends with Loop: it goes round', () => {
const clock = newVideoClock('v');
const ends = [];
for (let i = 0; i < 50; i++) ends.push(advanceVideoClock(clock, 0.1, 2, true));
expect(ends.some(Boolean)).toBe(false);
expect(clock.time).toBeCloseTo(1, 6);
expect(clock.playing).toBe(true);
});
it('a held clock does not move and does not end', () => {
const clock = { ...newVideoClock('v'), time: 1.95 };
steerVideoClock(clock, { kind: 'pause' }, 2, false);
expect(advanceVideoClock(clock, 0.1, 2, false)).toBe(false);
expect(clock.time).toBe(1.95);
});
it('a seek past the end goes round with Loop and holds the last picture without', () => {
const looping = newVideoClock('v');
steerVideoClock(looping, { kind: 'seek', time: 4.5 }, 2, true);
expect(looping.time).toBeCloseTo(0.5, 9);
const once = newVideoClock('v');
steerVideoClock(once, { kind: 'seek', time: 4.5 }, 2, false);
expect(once.time).toBe(2);
// Still playing, it is at its end: the next step tells so, once.
expect(advanceVideoClock(once, 0.1, 2, false)).toBe(true);
expect(advanceVideoClock(once, 0.1, 2, false)).toBe(false);
});
it('Play at the end of a clip without Loop starts it over, and it ends again', () => {
const clock = newVideoClock('v');
for (let i = 0; i < 25; i++) advanceVideoClock(clock, 0.1, 2, false);
steerVideoClock(clock, { kind: 'play' }, 2, false);
expect(clock).toMatchObject({ time: 0, playing: true });
const ends = [];
for (let i = 0; i < 25; i++) ends.push(advanceVideoClock(clock, 0.1, 2, false));
expect(ends.filter(Boolean)).toHaveLength(1);
});
});
interface Stage extends SceneFixture {
videoId: string;
/** The placed sprite wearing the video, and a second one wearing the same. */
screen: string;
other: string;
}
/** A sprite "Screen" wearing a video two seconds long, placed twice; Loop as asked. */
function stage(loop: boolean): Stage {
const f = makeSpriteScene('Screen');
const video = new CreateVideoCommand('Intro', FILE, {
width: 320,
height: 180,
duration: 2,
fps: 30,
audio: false,
});
f.stack.execute(video);
f.stack.execute(new SetAssetPropertyCommand(video.asset.id, 'loop', loop));
f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'texture', video.asset.id));
return {
...f,
videoId: video.asset.id,
screen: place(f, f.spriteId, { x: 0, y: 0 }),
other: place(f, f.spriteId, { x: 400, y: 0 }),
};
}
/** A behavior below `parent`: `On video finished` (Video as given) logs "done". */
function listener(s: Stage, parent: string, video = ''): ReturnType<typeof graphEditor> {
const behavior = makeBehavior(s, 'Watch');
place(s, behavior.id, { x: 0, y: 0 }, parent);
const edit = graphEditor(s, behavior);
const finished = edit.addNode('OnVideoFinished', { video });
const log = edit.addNode('Log', { message: 'done' });
edit.connect(finished, 'exec', log, 'exec');
return edit;
}
function play(s: Stage, steersVideos = true) {
const world = World.instantiate(s.project, s.sceneId);
const input = new Input();
addGameSystems(world, s.project, input, { steersVideos });
return {
world,
input,
at: (objectId: string) => world.findByObjectId(objectId)!,
done: () => world.log.filter((line) => line === 'done').length,
frame(times = 1) {
for (let i = 0; i < times; i++) {
world.tick(0.1);
input.endFrame();
}
},
};
}
describe('a video played in a game', () => {
it('tells once when a free video without Loop reaches its end, counted from when its sprite appeared', () => {
const s = stage(false);
listener(s, s.screen);
const run = play(s);
run.frame(19);
expect(run.done()).toBe(0);
run.frame();
expect(run.done()).toBe(1);
run.frame(30);
expect(run.done()).toBe(1);
// Counted only: a free sprite stays free.
expect(run.at(s.screen).props[VIDEO_CLOCK_KEY]).not.toBe('steered');
});
it('never tells with Loop', () => {
const s = stage(true);
listener(s, s.screen);
const run = play(s);
run.frame(60);
expect(run.done()).toBe(0);
});
it('hears only the sprites below its owner, and the video it names', () => {
const s = stage(false);
listener(s, s.screen, 'some-other-video');
const run = play(s);
run.frame(25);
expect(run.done()).toBe(0);
const t = stage(false);
listener(t, t.screen, t.videoId);
const again = play(t);
again.frame(25);
// Two sprites wear it; the owner's is the one heard.
expect(again.done()).toBe(1);
});
it('a paused game holds the clock', () => {
const s = stage(false);
listener(s, s.screen);
const run = play(s);
run.frame(10);
run.world.paused = true;
run.frame(20);
expect(run.done()).toBe(0);
run.world.paused = false;
run.frame(10);
expect(run.done()).toBe(1);
});
it('pause, seek and play steer the owner’s video on a player of its own and leave the other one free', () => {
const s = stage(false);
const edit = listener(s, s.screen);
const pause = edit.addNode('PauseVideo');
const seek = edit.addNode('SeekVideo', { time: 0.5 });
const resume = edit.addNode('PlayVideo');
const p = edit.addNode('OnKeyDown', { key: 'p' });
const k = edit.addNode('OnKeyDown', { key: 'k' });
const g = edit.addNode('OnKeyDown', { key: 'g' });
edit.connect(p, 'exec', pause, 'exec');
edit.connect(k, 'exec', seek, 'exec');
edit.connect(g, 'exec', resume, 'exec');
const run = play(s);
run.frame(10);
run.input.press('p');
run.frame();
const screen = run.at(s.screen);
expect(screen.props[VIDEO_CLOCK_KEY]).toBe('steered');
const held = screen.props[VIDEO_TIME_KEY] as number;
// The clock moved on with this frame before the behavior held it.
expect(held).toBeCloseTo(1.1, 6);
run.input.release('p');
run.frame(30);
expect(screen.props[VIDEO_TIME_KEY]).toBe(held);
expect(run.done()).toBe(0);
expect(run.at(s.other).props[VIDEO_CLOCK_KEY]).not.toBe('steered');
run.input.press('k');
run.frame();
run.input.release('k');
expect(screen.props[VIDEO_TIME_KEY]).toBe(0.5);
run.input.press('g');
run.frame();
run.input.release('g');
run.frame(14);
expect(run.done()).toBe(0);
run.frame();
expect(run.done()).toBe(1);
expect(screen.props[VIDEO_TIME_KEY]).toBe(2);
});
it('a driven video is left alone, says so, and ends when its Video time passes the end', () => {
const s = stage(false);
s.stack.execute(new SetAssetPropertyCommand(s.spriteId, VIDEO_CLOCK_KEY, 'driven'));
const edit = listener(s, s.screen);
edit.connect(edit.addNode('OnStart'), 'exec', edit.addNode('PauseVideo'), 'exec');
const run = play(s);
run.frame();
expect(run.world.log.filter((l) => l.includes('driven Video clock'))).toHaveLength(1);
const screen = run.at(s.screen);
expect(screen.props[VIDEO_CLOCK_KEY]).toBe('driven');
screen.props[VIDEO_TIME_KEY] = 1.9;
run.frame();
expect(run.done()).toBe(0);
screen.props[VIDEO_TIME_KEY] = 2.05;
run.frame();
screen.props[VIDEO_TIME_KEY] = 2.3;
run.frame();
expect(run.done()).toBe(1);
});
it('a driven video already past its end when its sprite appears does not tell so', () => {
const s = stage(false);
s.stack.execute(new SetAssetPropertyCommand(s.spriteId, VIDEO_CLOCK_KEY, 'driven'));
s.stack.execute(new SetAssetPropertyCommand(s.spriteId, VIDEO_TIME_KEY, 5));
listener(s, s.screen);
const run = play(s);
run.frame(5);
expect(run.done()).toBe(0);
});
it('the editor run steers nothing and says so', () => {
const s = stage(false);
const edit = listener(s, s.screen);
edit.connect(edit.addNode('OnStart'), 'exec', edit.addNode('PauseVideo'), 'exec');
const run = play(s, false);
run.frame(3);
expect(run.world.log.filter((l) => l.includes('ignored in the editor run'))).toHaveLength(1);
expect(run.at(s.screen).props[VIDEO_CLOCK_KEY]).not.toBe('steered');
});
it('Video time reads the clock: where it stands in the clip, its length, whether it moves', () => {
const s = stage(true);
const world = World.instantiate(s.project, s.sceneId);
const videos = new VideoSystem();
world.addSystem(videos);
for (let i = 0; i < 25; i++) world.tick(0.1);
const screen = world.findByObjectId(s.screen)!;
const reading = videos.reading(world, screen)!;
expect(reading.time).toBeCloseTo(0.5, 6);
expect(reading).toMatchObject({ length: 2, playing: true });
videos.steer(world, screen, { kind: 'pause' }, {});
world.tick(0.1);
expect(videos.reading(world, screen)!.playing).toBe(false);
screen.props.texture = '';
expect(videos.reading(world, screen)).toBeNull();
});
it('another video on a steered sprite starts its clock over, still steered; none at all frees it', () => {
const s = stage(false);
const world = World.instantiate(s.project, s.sceneId);
const videos = new VideoSystem();
world.addSystem(videos);
const screen = world.findByObjectId(s.screen)!;
world.tick(0.1);
videos.steer(world, screen, { kind: 'seek', time: 1.5 }, {});
const second = new CreateVideoCommand('Outro', FILE, {
width: 320,
height: 180,
duration: 3,
fps: 30,
audio: false,
});
s.stack.execute(second);
screen.props.texture = second.asset.id;
world.tick(0.1);
expect(screen.video).toMatchObject({ ref: second.asset.id, steered: true, playing: true });
expect(screen.props[VIDEO_TIME_KEY]).toBeCloseTo(0.1, 9);
screen.props.texture = '';
world.tick(0.1);
expect(screen.video).toBeUndefined();
expect(screen.props[VIDEO_CLOCK_KEY]).toBe('free');
});
});