Files
feuerwerk/tests/viewport/video-follow.test.ts
T
adminandClaude Opus 5.5 b2b4cbad28
game-maker / gate (push) Successful in 4m21s
game-maker / suite (push) Failing after 17m16s
Keep a video's loading out of recordings and first frames (#417)
A Start recording right after Play composition still began with
empty frames. The composition's clock waited for its video, but the
recording went on and took every frame while the video loaded. A
clip that starts later in the video also slipped through, because
its player already had one picture while it still sought to the
clip's start.

- The view now counts every video sprite it leaves out as missing,
  not only one without any first picture.
- GameRecorder takes no frame while the game view misses a video,
  for at most VIDEO_WAIT_MS (3 s) at a time. The recorded time
  stands and the sound of that time is dropped, so it stays in sync.
- RecordingClock.start forgets a hold from before the first frame.
  Without that, the first real frames all got the time 0.
- A followed player starts at 1x until the first speed reading.
  Before, it paused and sought every frame for about 100 ms, so the
  first pictures after a wait stuck and then jumped. A step faster
  than 4x and a time running backwards do not count as a start.
- Test hooks: __gm.gameVideo(id) reads the game view's own player,
  TakeInfo.lastFrameAt shows when the last frame was taken.

Tests:
- Unit recording (clock after an early hold), video-follow (2 new,
  1 adapted): 449 green in tests/core/recording, tests/viewport and
  tests/core/composition*
- e2e 68e (a clip from 0.8 s shows that picture first and then
  plays), 68f (a recording that already runs takes no frame while
  the composition's video loads)
- 68e is red without the start at 1x, 68f without the hold
- README: 2038 editor checks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:38:37 +02:00

130 lines
5.2 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
followVideo,
readSpeed,
SEEK_DRIFT,
SPEED_SPAN_MS,
SPEED_WINDOW_MS,
} from '../../src/viewport/videoFollow';
/**
* How a driven video keeps up with the time its wearer asks for (#404): it plays along while that
* time runs forward at a playable speed, and holds and seeks when it stands, jumps or runs back.
*/
describe('the speed of the asked time', () => {
it('is measured over a window, not from frame to frame', () => {
let reading = readSpeed(null, 0, 0);
// A game steps its animation at its own rate: here a frame without a step, then two in one.
for (const [time, now] of [
[0.017, 17],
[0.033, 33],
[0.05, 50],
[0.05, 66],
[0.083, 83],
] as const)
reading = readSpeed(reading, time, now);
expect(reading.samples[0]).toEqual({ time: 0, at: 0 });
reading = readSpeed(reading, 0.1, SPEED_WINDOW_MS);
expect(reading.speed).toBeCloseTo(1, 5);
});
it('counts a start from standing as 1x until the window has its reading', () => {
let reading = readSpeed(null, 0.8, 0);
reading = readSpeed(reading, 0.8, 16);
expect(reading.speed).toBe(0);
// The first step of 1/60 s after the wait: held and sought instead, the first pictures stuck.
reading = readSpeed(reading, 0.8 + 1 / 60, 33);
expect(reading.speed).toBe(1);
// Two steps in one frame are still a start.
expect(readSpeed(readSpeed(null, 0.8, 0), 0.8 + 2 / 60, 16).speed).toBe(1);
});
it('does not count a jump faster than a video plays, or a time running back, as a start', () => {
expect(readSpeed(readSpeed(null, 0.8, 0), 0.9, 16).speed).toBe(0);
expect(readSpeed(readSpeed(null, 0.8, 0), 0.8 - 1 / 60, 16).speed).toBe(0);
});
it('reads steps of 1/60 s on uneven frames as 1x within 5 % once the window has grown', () => {
let reading = readSpeed(null, 0, 0);
const read: number[] = [];
let now = 0;
let seed = 7;
while (now < 3000) {
// Frames 8 to 25 ms apart, in no order a window could line up with.
seed = (seed * 48271) % 2147483647;
now += 8 + (seed % 18);
reading = readSpeed(reading, Math.floor((now * 60) / 1000) / 60, now);
if (now > SPEED_SPAN_MS) read.push(reading.speed);
}
expect(read.length).toBeGreaterThan(100);
expect(Math.min(...read)).toBeGreaterThan(0.95);
expect(Math.max(...read)).toBeLessThan(1.05);
});
it('measures a start from where the time began to move, not across the wait before it', () => {
let reading = readSpeed(null, 0.5, 0);
const start = 16 * 187;
for (let now = 16; now <= start; now += 16) reading = readSpeed(reading, 0.5, now);
for (let now = start + 16; now <= start + 208; now += 16)
reading = readSpeed(reading, 0.5 + (now - start) / 1000, now);
expect(reading.speed).toBeCloseTo(1, 5);
});
it('keeps the speed through a jump and measures again from there', () => {
const playing = {
samples: [
{ time: 3.6, at: 600 },
{ time: 3.98, at: 980 },
],
speed: 1,
};
const turned = readSpeed(playing, 0.01, 990);
expect(turned).toEqual({ samples: [{ time: 0.01, at: 990 }], speed: 1 });
});
it('reads a still time as standing', () => {
const playing = { samples: [{ time: 2, at: 0 }], speed: 1 };
expect(readSpeed(playing, 2, SPEED_WINDOW_MS / 2).speed).toBe(1);
expect(readSpeed(playing, 2, SPEED_WINDOW_MS).speed).toBe(0);
});
});
describe('following the asked time', () => {
const at = { target: 1, speed: 1, current: 1, end: Infinity };
it('plays along at the speed of the asked time', () => {
expect(followVideo(at)).toEqual({ kind: 'play', rate: 1, seek: null, heard: true });
expect(followVideo({ ...at, speed: 0.5 })).toEqual({ kind: 'play', rate: 0.5, seek: null, heard: false });
expect(followVideo({ ...at, speed: 4 })).toMatchObject({ kind: 'play', rate: 4 });
});
it('nudges a small lag away by a few percent, never more than a tenth', () => {
const behind = followVideo({ ...at, current: 0.9 });
expect(behind).toMatchObject({ kind: 'play', seek: null });
expect(behind.kind === 'play' && behind.rate).toBeCloseTo(1.05, 5);
const ahead = followVideo({ ...at, current: 1 + SEEK_DRIFT });
expect(ahead.kind === 'play' && ahead.rate).toBeCloseTo(0.9, 5);
});
it('seeks once it is further off than the drift allows', () => {
expect(followVideo({ ...at, current: 0.7 })).toEqual({ kind: 'play', rate: 1, seek: 1, heard: true });
});
it('holds and seeks for a still picture, a time running back or too fast', () => {
expect(followVideo({ ...at, speed: 0, current: 0.2 })).toEqual({ kind: 'hold', seek: 1 });
expect(followVideo({ ...at, speed: -1, current: 1.2 })).toEqual({ kind: 'hold', seek: 1 });
expect(followVideo({ ...at, speed: 8, current: 0.2 })).toEqual({ kind: 'hold', seek: 1 });
});
it('does not seek a held video that shows the asked picture already', () => {
expect(followVideo({ ...at, speed: 0, current: 1.004 })).toEqual({ kind: 'hold', seek: null });
});
it('waits at the asked picture once it played past the end of its stretch', () => {
expect(followVideo({ target: 2.999, speed: 1, current: 3.02, end: 3 })).toEqual({
kind: 'hold',
seek: 2.999,
});
});
});