At 4K and 60 fps the user's computer reaches about 39 fps in real time.
Reading each frame back from the GPU takes longer than a frame, and
neither reading back later nor the canvas route helped. With Real time
off the run moves on by one frame of the recording at a time, however
long the computer takes, so the file has every frame.
- Start recording has a new check Real time, on by default. Only the
node records so, the editor recording stays in real time.
- SteadyPace in recording.ts is the pace as a pure class. A step is
1/fps once the frame before is taken and the sound up to it is made,
otherwise 0 and the run holds. A frame's time is its number.
- The sound is made apart, as decided in the issue. AudioSwitch routes
what the run plays to the live library, or to the offline library of
a SteadySound while such a recording lasts. That library has the same
mix, buses, FX and limiter and is rendered up to each next frame.
- A driven video holds at every picture asked for, and the frame waits
for it. Its sound is decoded from the MP4 and played in step through
its bus.
- Two Chrome quirks measured and worked around. A held video hands
over the picture of a seek before seeked, so the frame callback is
asked for when the seek begins. A seek right onto where a picture
begins shows that one or the one before, so such a seek lands half a
millisecond further. Together 30 s became 10.7 s for 2 s of video.
- The notice tells how far the recording has got, and the line at the
end how long it took.
Deviations and limits:
- Input is not ignored, it still reaches the game, which runs slower.
- At most ten minutes per recording. Chrome holds the whole buffer of
the OfflineAudioContext while rendering, 230 MB for ten minutes.
- A free video plays on in real time and does not fit. A sound playing
before the start stays at the speakers.
Found along the way:
- A check in e2e 60 was red since 77365f1. It expected one log line,
the start line of the recording came on top. CI was off, so nobody
saw it. The expectation is updated.
Tests:
- First gate run red: 1 test in node-catalog.test.ts still expected
the settings of Start recording without realTime. Updated.
- Second gate run on melektaus-atx green: 310 files,
4818 tests.
- e2e 66 + 68 all passed, 50 checks, 68j new with 3 checks. ffmpeg
reads all 120 frames 1/60 s apart, the colour changes at frames
30/60/90, the video's sound from the first frame, the blip 8 ms off.
- e2e 60 (20 checks) and 67 green.
- Counter-check without the pace: all three 68j checks red, 32 of 119
frames, colour changes at 33/62/96, no sound. AudioSwitch's unit test
goes red when the ids are mixed up.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
165 lines
6.8 KiB
TypeScript
165 lines
6.8 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
followVideo,
|
|
PICTURE_NUDGE,
|
|
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', () => {
|
|
let shown = pictureShown(null, 0);
|
|
expect(shown).toEqual({ at: 0, span: null });
|
|
shown = pictureShown(shown, 0);
|
|
expect(shown.span).toBeNull();
|
|
shown = pictureShown(shown, 2 / 30);
|
|
expect(shown.span).toBeCloseTo(2 / 30, 12);
|
|
shown = pictureShown(shown, 3 / 30);
|
|
expect(shown.span).toBeCloseTo(1 / 30, 12);
|
|
// A jump back or far on teaches nothing shorter.
|
|
shown = pictureShown(shown, 1);
|
|
expect(shown).toEqual({ at: 1, span: shown.span });
|
|
expect(shown.span).toBeCloseTo(1 / 30, 12);
|
|
});
|
|
|
|
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, 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, 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, span: 1 / 60 }, 0.5 + PICTURE_NUDGE)).toBe(true);
|
|
});
|
|
});
|