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>
87 lines
2.8 KiB
TypeScript
87 lines
2.8 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { chaseSeeks, seekChase, seekVideo } from '../../src/viewport/videoSeek';
|
|
|
|
/**
|
|
* Scrubbing a video (Video tab): each seek set while one ran aborted it, and a video that took
|
|
* longer to seek than the mouse took to move finished none and hung. Only the newest wish follows.
|
|
*/
|
|
function fakeVideo() {
|
|
const events = new Map<string, (() => void)[]>();
|
|
const frames: (() => void)[] = [];
|
|
const element = {
|
|
seeking: false,
|
|
at: 0,
|
|
/** Every time a seek was set, in order. */
|
|
seeks: [] as number[],
|
|
get currentTime() {
|
|
return this.at;
|
|
},
|
|
set currentTime(time: number) {
|
|
this.at = time;
|
|
this.seeking = true;
|
|
this.seeks.push(time);
|
|
},
|
|
addEventListener: (type: string, listener: () => void) =>
|
|
events.set(type, [...(events.get(type) ?? []), listener]),
|
|
requestVideoFrameCallback: (callback: (now: number, frame: { mediaTime: number }) => void) =>
|
|
frames.push(() => callback(0, { mediaTime: element.currentTime })),
|
|
};
|
|
const seekDone = () => {
|
|
element.seeking = false;
|
|
events.get('seeked')?.forEach((listener) => listener());
|
|
};
|
|
return { element, seekDone, frames };
|
|
}
|
|
|
|
describe('seeking while scrubbing', () => {
|
|
afterEach(() => vi.useRealTimers());
|
|
|
|
it('lets only the newest of the seeks asked for during one follow it', () => {
|
|
const { element, seekDone } = fakeVideo();
|
|
const chase = seekChase();
|
|
const video = element as unknown as HTMLVideoElement;
|
|
chaseSeeks(
|
|
video,
|
|
chase,
|
|
() => false,
|
|
() => {},
|
|
);
|
|
for (const time of [0.1, 0.2, 0.3, 0.4, 0.5]) seekVideo(video, chase, time);
|
|
expect(element.seeks).toEqual([0.1]);
|
|
seekDone();
|
|
expect(element.seeks).toEqual([0.1, 0.5]);
|
|
seekDone();
|
|
expect(element.seeks).toEqual([0.1, 0.5]);
|
|
});
|
|
|
|
it('loads the picture again with the frame after the last seek, or without one after 150 ms', () => {
|
|
vi.useFakeTimers({ toFake: ['setTimeout'] });
|
|
const { element, seekDone, frames } = fakeVideo();
|
|
const chase = seekChase();
|
|
const video = element as unknown as HTMLVideoElement;
|
|
let landed = 0;
|
|
const shown: (number | null)[] = [];
|
|
chaseSeeks(
|
|
video,
|
|
chase,
|
|
() => false,
|
|
(at) => {
|
|
landed += 1;
|
|
shown.push(at);
|
|
},
|
|
);
|
|
seekVideo(video, chase, 0.4);
|
|
seekDone();
|
|
expect([chase.landing, landed]).toEqual([true, 0]);
|
|
frames.shift()!();
|
|
expect([chase.landing, landed]).toEqual([false, 1]);
|
|
// No frame for this one.
|
|
seekVideo(video, chase, 0.8);
|
|
seekDone();
|
|
vi.advanceTimersByTime(200);
|
|
expect([chase.landing, landed]).toEqual([false, 2]);
|
|
// Told the time of the picture Chrome handed over, none where it handed over none.
|
|
expect(shown).toEqual([0.4, null]);
|
|
});
|
|
});
|