The e2e 68 check "a clip that starts later" was red in 3 of the last 8 suites (CI tasks 697, 703, 712). Two causes sat on top of each other. - The check measured too late. It started sampling after play(), and under CI load the clock was already at 0.05 to 0.22 s. It now starts before the click, like 68d does. - Measured early, a real bug showed: the video held for one frame at its first picture. The first picture triggers a redraw, and the game takes its first step in the same frame. readSpeed got both calls with the same frame time and divided by 1 ms. The speed came out at 16.7x, which counted as a jump, so the player paused. - readSpeed now measures a step without elapsed wall time against one frame at 60 fps. A real jump still counts as a jump. - e2e 34 "the pause holds the stream" also failed in task 712. Its cause is still open. The failure message now names the state before the pause, so the next red run shows where the samples got lost. Tests: - video-follow.test.ts 18/18, the new test is red without the fix - e2e 68 on melektaus-atx: ALL PASSED, 21 checks - Gate on melektaus-atx: CHECK EXIT 0, 314 files, 4853 tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
207 lines
9.0 KiB
TypeScript
207 lines
9.0 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
followVideo,
|
|
PICTURE_NUDGE,
|
|
pictureKept,
|
|
pictureLost,
|
|
pictureShown,
|
|
readSpeed,
|
|
SEEK_DRIFT,
|
|
showsPictureOf,
|
|
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('counts a first step in the same frame as the ask before it as a start', () => {
|
|
// The first picture redraws the view at once, then the game steps within the same frame.
|
|
let reading = readSpeed(null, 0.8, 0);
|
|
reading = readSpeed(reading, 0.8, 16);
|
|
reading = readSpeed(reading, 0.8 + 1 / 60, 16);
|
|
expect(reading.speed).toBe(1);
|
|
// Within one frame a jump is still a jump.
|
|
expect(readSpeed(readSpeed(null, 0.8, 0), 0.9, 0).speed).toBe(0);
|
|
});
|
|
|
|
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,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('the picture a held video shows (#423)', () => {
|
|
it('learns how long a picture lasts from the pictures Chrome hands over', () => {
|
|
// A video at 30 fps sought at 60: every other seek stays within the picture shown.
|
|
let shown = pictureShown(null, 0, 0.0005);
|
|
expect(shown).toEqual({ at: 0, reach: 0.0005, span: null });
|
|
shown = pictureShown(shown, 0, 1 / 60);
|
|
expect(shown).toEqual({ at: 0, reach: 1 / 60, span: null });
|
|
shown = pictureShown(shown, 1 / 30, 2 / 60);
|
|
expect(shown.span).toBeCloseTo(1 / 30, 12);
|
|
// A jump back or far on teaches nothing.
|
|
shown = pictureShown(shown, 1, 1.0005);
|
|
expect(shown).toEqual({ at: 1, reach: 1.0005, span: shown.span });
|
|
expect(shown.span).toBeCloseTo(1 / 30, 12);
|
|
});
|
|
|
|
it('learns nothing from a step two pictures long, which would hold the video for good', () => {
|
|
// Sought one frame of 60 on, but from a picture known only at its start: two pictures on came.
|
|
let shown = pictureShown(null, 0, 0.0005);
|
|
shown = pictureShown(shown, 2 / 30, 2 / 30 + 0.0005);
|
|
expect(shown.span).toBeNull();
|
|
// Learnt so, every seek within the two pictures was left out and none came to correct it.
|
|
expect(showsPictureOf(shown, 3 / 30)).toBe(false);
|
|
// A seek within the picture brings no frame in front of the others: it still shows, up to there.
|
|
shown = pictureKept(shown, 2 / 30 + 1 / 60 + 0.0005)!;
|
|
shown = pictureShown(shown, 3 / 30, 3 / 30 + 0.0005);
|
|
expect(shown.span).toBeCloseTo(1 / 30, 12);
|
|
});
|
|
|
|
it('forgets the picture shown behind another tab, and keeps how long one lasts', () => {
|
|
const learnt = { at: 0.5, reach: 0.51, span: 1 / 30 };
|
|
const lost = pictureLost(learnt)!;
|
|
expect(lost.span).toBe(1 / 30);
|
|
expect(showsPictureOf(lost, 0.51)).toBe(false);
|
|
expect(pictureKept(lost, 0.6)).toEqual(lost);
|
|
// The first picture handed over in front again is where it stands; the step to it teaches nothing.
|
|
const back = pictureShown(lost, 1.5, 1.5005);
|
|
expect(back).toEqual({ at: 1.5, reach: 1.5005, span: 1 / 30 });
|
|
// Known only behind a tab, the span is not learnt from a picture far from the one before.
|
|
expect(pictureShown(pictureLost({ at: 0.5, reach: 0.5, span: null }), 0.9, 0.9)).toEqual({
|
|
at: 0.9,
|
|
reach: 0.9,
|
|
span: null,
|
|
});
|
|
expect(pictureLost(null)).toBeNull();
|
|
});
|
|
|
|
it('knows a seek within the picture shown brings no other; where the next begins is the next', () => {
|
|
// As measured: frames of a video at 30 fps sought at 60.
|
|
const shown = { at: 1 / 30, reach: 1 / 30, span: 1 / 30 };
|
|
expect(showsPictureOf(shown, 1 / 30)).toBe(true);
|
|
expect(showsPictureOf(shown, 1.5 / 30)).toBe(true);
|
|
expect(showsPictureOf(shown, 2 / 30 - 0.0005)).toBe(true);
|
|
expect(showsPictureOf(shown, 4 / 60)).toBe(false);
|
|
expect(showsPictureOf(shown, 5 / 60)).toBe(false);
|
|
expect(showsPictureOf(shown, 0.02)).toBe(false);
|
|
expect(showsPictureOf({ at: 1 / 30, reach: 1 / 30, span: null }, 1.5 / 30)).toBe(false);
|
|
expect(showsPictureOf(null, 0)).toBe(false);
|
|
// A seek half a millisecond on stays within its picture, the shortest a video has (60 fps).
|
|
expect(showsPictureOf({ at: 0.5, reach: 0.5, span: 1 / 60 }, 0.5 + PICTURE_NUDGE)).toBe(true);
|
|
});
|
|
});
|