From c0ba2673bacf8ddbe0cbdb88bc478380bcefa2d7 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Mon, 12 Oct 2026 12:56:37 +0200 Subject: [PATCH] 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 --- src/editor/video/VideoCanvas.ts | 26 +++++++++- src/viewport/VideoLibrary.ts | 64 +++++-------------------- src/viewport/videoSeek.ts | 67 ++++++++++++++++++++++++++ tests/viewport/video-seek.test.ts | 79 +++++++++++++++++++++++++++++++ 4 files changed, 181 insertions(+), 55 deletions(-) create mode 100644 src/viewport/videoSeek.ts create mode 100644 tests/viewport/video-seek.test.ts diff --git a/src/editor/video/VideoCanvas.ts b/src/editor/video/VideoCanvas.ts index d348c25c..dbfb80e5 100644 --- a/src/editor/video/VideoCanvas.ts +++ b/src/editor/video/VideoCanvas.ts @@ -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 { diff --git a/src/viewport/VideoLibrary.ts b/src/viewport/VideoLibrary.ts index 19782369..5a78910a 100644 --- a/src/viewport/VideoLibrary.ts +++ b/src/viewport/VideoLibrary.ts @@ -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 diff --git a/src/viewport/videoSeek.ts b/src/viewport/videoSeek.ts new file mode 100644 index 00000000..c92572b8 --- /dev/null +++ b/src/viewport/videoSeek.ts @@ -0,0 +1,67 @@ +/** + * Seeking a `