Scrubbing in the video tab set currentTime on every pointer move. Each new seek aborts the running one. With a large video a seek takes longer than the gap between two moves, so none finished and the picture froze. The composition tab never had this, because its players run through the VideoLibrary, which only keeps the newest wish while a seek runs. - The pending-seek logic moves into a shared helper, src/viewport/videoSeek.ts. The VideoLibrary uses it unchanged. - The video tab seeks through the same helper and uploads the picture again after each finished seek. - Play drops a pending scrub wish. Otherwise the video would jump back right after it starts. - Scrubbing before the first picture no longer jumps back to the time the tab opened with. The video stays at the last chosen time. Tests: gate on melektaus-atx green, 303 files, 4681 tests (2 new in video-seek). e2e 66 ALL PASSED, 30 checks. Without the helper the new unit test is red ([0.1, 0.2, 0.3, 0.4, 0.5] instead of [0.1]). There is no browser check for the freeze itself, the test video seeks too fast to show it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
80 lines
2.5 KiB
TypeScript
80 lines
2.5 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: () => void) => frames.push(callback),
|
|
};
|
|
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;
|
|
chaseSeeks(
|
|
video,
|
|
chase,
|
|
() => false,
|
|
() => (landed += 1),
|
|
);
|
|
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]);
|
|
});
|
|
});
|