Keep the video tab's picture following while scrubbing (#417)
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>
This commit is contained in:
1 parent
40b6290d24
commit
c0ba2673ba
4 files changed
+181
-55
No files matched your search
@@ -40,6 +40,7 @@ import {
|
||||
zoomWave,
|
||||
} from '../sound/waveView';
|
||||
import { openVideo } from '../../viewport/VideoLibrary';
|
||||
import { chaseSeeks, seekChase, type SeekChase, seekVideo } from '../../viewport/videoSeek';
|
||||
import type { EditorState } from '../state';
|
||||
import { containRect } from '../texture/imageGrid';
|
||||
import { axisTween } from '../viewTween';
|
||||
@@ -81,6 +82,10 @@ interface Preview {
|
||||
element: HTMLVideoElement;
|
||||
/** Made once the video has shown its first frame, see `openVideo`. */
|
||||
texture: Texture | null;
|
||||
/** Its seeks while scrubbing, see `videoSeek.ts`. */
|
||||
chase: SeekChase;
|
||||
/** The moment last asked for, where it stands once it has its first frame. */
|
||||
asked: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -206,6 +211,8 @@ export class VideoCanvas extends Widget {
|
||||
this.playing = span;
|
||||
element.muted = false;
|
||||
element.volume = videoVolumeOf(asset.props);
|
||||
// A moment still wanted from scrubbing would send it back after the start.
|
||||
if (this.preview) this.preview.chase.wanted = null;
|
||||
element.currentTime = span.start;
|
||||
void element.play().catch(() => {});
|
||||
this.invalidate();
|
||||
@@ -339,6 +346,8 @@ export class VideoCanvas extends Widget {
|
||||
const preview: Preview = {
|
||||
file: asset.file,
|
||||
texture: null,
|
||||
chase: seekChase(),
|
||||
asked: held,
|
||||
element: openVideo(`/game-maker/data/videos/${asset.file}`, held, (texture) => {
|
||||
if (this.preview !== preview) return texture.destroy(true);
|
||||
preview.texture = texture;
|
||||
@@ -346,11 +355,20 @@ export class VideoCanvas extends Widget {
|
||||
// It played for its first frame; until Play it stands at the moment the tab shows.
|
||||
if (!this.playing) {
|
||||
preview.element.pause();
|
||||
preview.element.currentTime = held;
|
||||
seekVideo(preview.element, preview.chase, preview.asked);
|
||||
}
|
||||
this.invalidate();
|
||||
}),
|
||||
};
|
||||
chaseSeeks(
|
||||
preview.element,
|
||||
preview.chase,
|
||||
() => this.preview !== preview,
|
||||
() => {
|
||||
preview.texture?.source.update();
|
||||
this.invalidate();
|
||||
},
|
||||
);
|
||||
preview.element.addEventListener('seeked', () => this.invalidate());
|
||||
this.preview = preview;
|
||||
return preview.element;
|
||||
@@ -368,10 +386,14 @@ export class VideoCanvas extends Widget {
|
||||
preview.texture?.destroy(true);
|
||||
}
|
||||
|
||||
/** Shows a moment; while scrubbing, only the newest of the moments asked for meanwhile follows. */
|
||||
private seekTo(seconds: number): void {
|
||||
const asset = this.asset();
|
||||
const element = asset && this.player(asset);
|
||||
if (element) element.currentTime = Math.max(0, Math.min(asset.duration, seconds));
|
||||
const preview = this.preview;
|
||||
if (!element || !preview) return;
|
||||
preview.asked = Math.max(0, Math.min(asset.duration, seconds));
|
||||
seekVideo(element, preview.chase, preview.asked);
|
||||
}
|
||||
|
||||
override onKeyDown(event: KeyboardEvent): boolean {
|
||||
|
||||
@@ -25,6 +25,7 @@ import { videoDrivenOf, videoTimeOf } from '../core/properties/videos';
|
||||
import { FileLibrary } from './FileLibrary';
|
||||
import { soundLibrary } from './SoundLibrary';
|
||||
import { followVideo, readSpeed, type SpeedReading } from './videoFollow';
|
||||
import { chaseSeeks, seekVideo, type SeekChase } from './videoSeek';
|
||||
|
||||
const VIDEO_BASE = '/game-maker/data/videos';
|
||||
|
||||
@@ -36,10 +37,6 @@ export function videoFileUrl(file: string): string {
|
||||
const END_SLACK = 1 / 60;
|
||||
/** A player nothing has asked for in this long is let go: no sprite wears it any more. */
|
||||
const IDLE_MS = 2000;
|
||||
/** Closer than this a seek that waited for the one before it is not worth another. */
|
||||
const SEEK_SLACK = 1 / 120;
|
||||
/** How long after `seeked` a seek counts as landed when Chrome hands over no frame for it. */
|
||||
const LAND_MS = 150;
|
||||
|
||||
/**
|
||||
* When the frame being drawn began, in milliseconds like `performance.now()`. The ticker moves every
|
||||
@@ -53,17 +50,13 @@ function frameTime(): number {
|
||||
return typeof now === 'number' ? now : performance.now();
|
||||
}
|
||||
|
||||
/** What the wearer of a driven player (#404) asks of it, see `wear`. */
|
||||
interface Drive {
|
||||
/** What the wearer of a driven player (#404) asks of it, see `wear`; its seeks, see `videoSeek.ts`. */
|
||||
interface Drive extends SeekChase {
|
||||
/** Seconds into the clip. */
|
||||
time: number;
|
||||
/** Whether the wearer lets it be heard, when it plays at about 1x. */
|
||||
heard: boolean;
|
||||
reading: SpeedReading | null;
|
||||
/** The newest seek asked for while one was still running: only that one follows. */
|
||||
wanted: number | null;
|
||||
/** Set from a seek until the picture it went to is in the texture, see `seek`. */
|
||||
landing: boolean;
|
||||
}
|
||||
|
||||
interface Player {
|
||||
@@ -196,7 +189,7 @@ export class VideoLibrary {
|
||||
// A video that ran to the end of its file starts over from its first picture on play().
|
||||
end: element.ended ? element.currentTime : Math.min(play.end, element.duration),
|
||||
});
|
||||
if (step.seek !== null) this.seek(player, drive, step.seek);
|
||||
if (step.seek !== null) seekVideo(element, drive, step.seek);
|
||||
if (step.kind === 'hold') {
|
||||
element.muted = true;
|
||||
if (!element.paused) element.pause();
|
||||
@@ -208,17 +201,6 @@ export class VideoLibrary {
|
||||
if (element.paused) void element.play().catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeks, or, while a seek is still running, remembers the newest wish for when it is done. It has
|
||||
* landed once the last of them is done, see `chase`.
|
||||
*/
|
||||
private seek(player: Player, drive: Drive, time: number): void {
|
||||
const { element } = player;
|
||||
if (element.seeking) drive.wanted = time;
|
||||
else element.currentTime = time;
|
||||
drive.landing = true;
|
||||
}
|
||||
|
||||
/** Lets every player go: a session that ends takes its videos along. */
|
||||
clear(): void {
|
||||
for (const player of this.players.values()) this.drop(player);
|
||||
@@ -300,7 +282,13 @@ export class VideoLibrary {
|
||||
};
|
||||
this.players.set(key, player);
|
||||
this.sweeper ??= setInterval(() => this.sweep(), IDLE_MS);
|
||||
if (drive) this.chase(player, drive);
|
||||
if (drive)
|
||||
chaseSeeks(
|
||||
player.element,
|
||||
drive,
|
||||
() => player.gone,
|
||||
() => player.texture?.source.update(),
|
||||
);
|
||||
else {
|
||||
this.watch(player);
|
||||
if (this.heard) this.hear(player);
|
||||
@@ -308,36 +296,6 @@ export class VideoLibrary {
|
||||
return player;
|
||||
}
|
||||
|
||||
/**
|
||||
* Once a seek is done, the newest one asked for meanwhile follows. When none does, the seek lands
|
||||
* with the next frame Chrome hands over: at `seeked` the picture was not there yet, and Pixi, which
|
||||
* loads a paused video's picture only then, kept the one before it for a frame. Chrome hands over
|
||||
* no frame for some seeks, though, and a player waiting for one stayed empty until the next jump;
|
||||
* after `LAND_MS` it lands without.
|
||||
*/
|
||||
private chase(player: Player, drive: Drive): void {
|
||||
const { element } = player;
|
||||
let round = 0;
|
||||
element.addEventListener('seeked', () => {
|
||||
if (player.gone) return;
|
||||
const wanted = drive.wanted;
|
||||
drive.wanted = null;
|
||||
if (wanted !== null && Math.abs(wanted - element.currentTime) > SEEK_SLACK) {
|
||||
element.currentTime = wanted;
|
||||
return;
|
||||
}
|
||||
if (!drive.landing) return;
|
||||
const now = ++round;
|
||||
const land = () => {
|
||||
if (player.gone || now !== round || element.seeking || !drive.landing) return;
|
||||
player.texture?.source.update();
|
||||
drive.landing = false;
|
||||
};
|
||||
element.requestVideoFrameCallback(land);
|
||||
setTimeout(land, LAND_MS);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a clip back to its start at its end, or holds it there when it does not repeat. A clip
|
||||
* that runs to the end of the video is sent back when the video ends; the whole video loops by
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Seeking a `<video>` that is asked for a new moment faster than it can decode one, as while
|
||||
* scrubbing. Every seek set while one still runs aborts it, and a 1080p video moved by the mouse
|
||||
* finished none until the mouse stood still; its picture hung. Only the newest wish waits instead,
|
||||
* and follows once the running seek is done.
|
||||
*/
|
||||
|
||||
/** Closer than this a seek that waited for the one before it is not worth another. */
|
||||
const SEEK_SLACK = 1 / 120;
|
||||
/** How long after `seeked` a seek counts as landed when Chrome hands over no frame for it. */
|
||||
const LAND_MS = 150;
|
||||
|
||||
/** One element's seeks, see `seekVideo` and `chaseSeeks`. */
|
||||
export interface SeekChase {
|
||||
/** The newest seek asked for while one was still running: only that one follows. */
|
||||
wanted: number | null;
|
||||
/** Set from a seek until the picture it went to is in the texture. */
|
||||
landing: boolean;
|
||||
}
|
||||
|
||||
export function seekChase(): SeekChase {
|
||||
return { wanted: null, landing: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeks, or, while a seek is still running, remembers the newest wish for when it is done. It has
|
||||
* landed once the last of them is done, see `chaseSeeks`.
|
||||
*/
|
||||
export function seekVideo(element: HTMLVideoElement, chase: SeekChase, time: number): void {
|
||||
if (element.seeking) chase.wanted = time;
|
||||
else element.currentTime = time;
|
||||
chase.landing = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Once a seek is done, the newest one asked for meanwhile follows. When none does, the seek lands
|
||||
* with the next frame Chrome hands over: at `seeked` the picture was not there yet, and Pixi, which
|
||||
* loads a paused video's picture only then, kept the one before it for a frame. `landed` is where
|
||||
* the caller loads it again. Chrome hands over no frame for some seeks, though, and a player waiting
|
||||
* for one stayed empty until the next jump; after `LAND_MS` it lands without.
|
||||
*/
|
||||
export function chaseSeeks(
|
||||
element: HTMLVideoElement,
|
||||
chase: SeekChase,
|
||||
gone: () => boolean,
|
||||
landed: () => void,
|
||||
): void {
|
||||
let round = 0;
|
||||
element.addEventListener('seeked', () => {
|
||||
if (gone()) return;
|
||||
const wanted = chase.wanted;
|
||||
chase.wanted = null;
|
||||
if (wanted !== null && Math.abs(wanted - element.currentTime) > SEEK_SLACK) {
|
||||
element.currentTime = wanted;
|
||||
return;
|
||||
}
|
||||
if (!chase.landing) return;
|
||||
const now = ++round;
|
||||
const land = () => {
|
||||
if (gone() || now !== round || element.seeking || !chase.landing) return;
|
||||
landed();
|
||||
chase.landing = false;
|
||||
};
|
||||
element.requestVideoFrameCallback(land);
|
||||
setTimeout(land, LAND_MS);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
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]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user