Read a driven video's speed on the frame clock so 1x stays 1x (#404)
game-maker / gate (push) Successful in 4m12s
game-maker / suite (push) Successful in 16m58s

A driven video measures how fast its wearer's time runs and sets its
playbackRate from that. The time moves on with the ticker, but the
reading took its clock from performance.now() while drawing. A frame
that had more to do before the draw shifted that clock against the
time, and a 1x animation read as up to 1.09. Outside 0.9 to 1.1 the
video goes mute, so the sound could drop out for a moment. In CI this
made e2e 66k red now and then.

- VideoLibrary reads the speed with the timestamp of the frame
  (document.timeline.currentTime), the same clock the time runs on.
  It falls back to performance.now() where there is no document.
- debugInfo() reports the measured speed of a driven player.
- e2e 66k checks the measured speed and the correction of the rate
  apart. The rate alone may differ from 1 by up to 10 percent on
  purpose, so checking it at +-0.1 was too strict.

Tests:
- Unit video-library: 8 tests green, 1 new. With performance.now()
  back in place the new test is red and reads 1.089.
- e2e 66 on melektaus-atx: all passed, 27 checks, speed 1.000 in
  all 5 readings.
- Gate on melektaus-atx: CHECK EXIT 0, 297 files, 4562 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-11 17:49:45 +02:00
1 parent 8679dfbe65
commit 3a1e420806
3 files changed
+54 -4

No files matched your search

+17 -2
View File
@@ -36,6 +36,18 @@ const SEEK_SLACK = 1 / 120;
/** How long a run waits at its start for the driven videos it shows (#404) before it goes anyway. */ /** How long a run waits at its start for the driven videos it shows (#404) before it goes anyway. */
export const VIDEO_WAIT_MS = 3000; export const VIDEO_WAIT_MS = 3000;
/**
* When the frame being drawn began, in milliseconds like `performance.now()`. The ticker moves every
* clock on by the step between these, so a driven video's time does too; `performance.now()` read
* while drawing lags it by whatever the frame did before. A busy page lags more in some frames
* than in others, and 45 ms more at one end of a window of 500 ms reads a 1x time as 1.09.
*/
function frameTime(): number {
const now =
typeof document !== 'undefined' && 'timeline' in document ? document.timeline.currentTime : null;
return typeof now === 'number' ? now : performance.now();
}
/** What the wearer of a driven player (#404) asks of it, see `wear`. */ /** What the wearer of a driven player (#404) asks of it, see `wear`. */
interface Drive { interface Drive {
/** Seconds into the clip. */ /** Seconds into the clip. */
@@ -108,7 +120,7 @@ export class VideoLibrary {
const drive = player.drive; const drive = player.drive;
drive.time = time; drive.time = time;
drive.heard = heard; drive.heard = heard;
drive.reading = readSpeed(drive.reading, time, performance.now()); drive.reading = readSpeed(drive.reading, time, frameTime());
if (player.texture) this.follow(player, drive); if (player.texture) this.follow(player, drive);
return { ...ref, file: key }; return { ...ref, file: key };
} }
@@ -223,8 +235,10 @@ export class VideoLibrary {
ready: boolean; ready: boolean;
rate: number; rate: number;
seeking: boolean; seeking: boolean;
/** How fast its wearer's time runs, as read for a driven player; 0 for a free one. */
speed: number;
}[] { }[] {
return [...this.players].map(([key, { element, texture }]) => ({ return [...this.players].map(([key, { element, texture, drive }]) => ({
key, key,
time: element.currentTime, time: element.currentTime,
paused: element.paused, paused: element.paused,
@@ -232,6 +246,7 @@ export class VideoLibrary {
ready: texture !== null, ready: texture !== null,
rate: element.playbackRate, rate: element.playbackRate,
seeking: element.seeking, seeking: element.seeking,
speed: drive?.reading?.speed ?? 0,
})); }));
} }
+28 -1
View File
@@ -107,7 +107,10 @@ describe('VideoLibrary driven players (#404)', () => {
vi.stubGlobal('document', { hidden: false, addEventListener: () => {}, createElement: fakeVideo }); vi.stubGlobal('document', { hidden: false, addEventListener: () => {}, createElement: fakeVideo });
}); });
afterEach(() => vi.unstubAllGlobals()); afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
const REF = { file: KEY, smooth: true, rect: null, sourceWidth: 320, sourceHeight: 180, slice: null }; const REF = { file: KEY, smooth: true, rect: null, sourceWidth: 320, sourceHeight: 180, slice: null };
@@ -128,6 +131,30 @@ describe('VideoLibrary driven players (#404)', () => {
]); ]);
}); });
it('reads the speed by the time of the frame, not by when the frame got round to it', async () => {
const frame = { at: 0 };
vi.stubGlobal('document', {
hidden: false,
addEventListener: () => {},
createElement: fakeVideo,
timeline: {
get currentTime() {
return frame.at;
},
},
});
// The first frames are drawn 45 ms late, as on a busy page; the time runs at 1x by the frames.
let late = 45;
vi.spyOn(performance, 'now').mockImplementation(() => frame.at + late);
const videos = await freshLibrary();
for (let i = 0; i <= 36; i++) {
frame.at = (i * 1000) / 60;
if (i === 7) late = 0;
videos.wear(REF, { videoClock: 'driven', videoTime: i / 60 }, 'view1:a', false);
}
expect(videos.debugInfo()[0]!.speed).toBeCloseTo(1, 6);
});
it('tells a view whether one of its driven players still waits for its first picture', async () => { it('tells a view whether one of its driven players still waits for its first picture', async () => {
const videos = await freshLibrary(); const videos = await freshLibrary();
videos.get(KEY); videos.get(KEY);
+9 -1
View File
@@ -348,9 +348,17 @@ export async function video(t) {
} }
await click(playCut.x, playCut.y); await click(playCut.x, playCut.y);
const gap = (a, b) => Math.min(Math.abs(a - b), 2 - Math.abs(a - b)); const gap = (a, b) => Math.min(Math.abs(a - b), 2 - Math.abs(a - b));
// The speed read is 1x; the rate is that, nudged by at most a tenth towards the asked time.
check( check(
'playing the animation plays the video along at 1x and lets it be heard', 'playing the animation plays the video along at 1x and lets it be heard',
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.speed - 1) <= 0.1 &&
Math.abs(p.rate / p.speed - 1) <= 0.1 + 1e-9 &&
gap(p.time, p.asked) < 0.15,
),
JSON.stringify(along), JSON.stringify(along),
); );