A recording made while switching between tabs had stretches without the video item and without its sound, and in between the video stood for several frames. Two things went wrong behind another tab. - A player opened there got no first picture. It waited for requestVideoFrameCallback, which Chrome never calls behind a tab. A recording not in real time then waited 3 s per frame and took it without the video. Now the player takes its first picture as soon as it has data while the tab is hidden. - The span of a picture was learnt from steps that skipped pictures. After a time behind the tab it learnt 0.133 s instead of 1/30 s, left out seeks within that span and never corrected it. Now it learns only from steps where the time sought moved at most half a step, so no picture can lie between them. - Behind the tab a seek forgets which picture shows but keeps the span. A landing without a frame counts as the same picture only once the tab has been in front for 500 ms. Found in the user's recording 7ea9146b: 5363 frames, all 1/60 s apart, with the video missing at 10.63 s (8 frames), 43.17 s (5 frames) and 46.93 s (28 frames), and the sound gone with it. Tests: - Unit tests/viewport: 377 green, 4 new or rebuilt. With the old rule and the first picture only by requestVideoFrameCallback both new tests go red. - e2e 68 on melektaus-atx: ALL PASSED, 21 checks. New 68l: a video item that begins behind another tab has its colours exactly, and a tab switched every 350 ms gives colour changes at 0/30/60/90. - One run in five with switches had a change one frame late, so the check allows one frame either way. - Gate on melektaus-atx: green, 311 files, 4824 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
197 lines
8.5 KiB
TypeScript
197 lines
8.5 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('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);
|
|
});
|
|
});
|