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 `