From beb95c17a7aad157f9d68d73afa503ab9d82079b Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Fri, 30 Oct 2026 23:33:59 +0100 Subject: [PATCH] Let free videos wait while a run waits for its driven videos (#437) A run that waits for its driven videos does not tick its world, so the clock of every free video sprite stands. Its player played on anyway. The picture ran ahead of the clock by the length of the wait, so On video finished came late and a Pause video sent the picture back. - VideoLibrary.setHeld holds the free players next to the toolbar pause. PlaySession and Player switch it with the wait at the start, after a Load scene and while a recording sets up its encoders. - The hold starts in waitForVideos, not on the next tick. Otherwise a free video started at Play would be a frame ahead. - A free player without its first picture plays on until it has one. Held at once, it would never get one. - startRun and endRun clear the hold, so a run that ends while it waits leaves no held preview behind. Found on the way: - Resuming called play() on a clip without Loop that already stood at its end, and play() starts such a video over. This happened after the toolbar pause already and would have happened after every waiting Load scene. Such a clip now stays at its end. Tests: - Unit video-library 14/14, 2 of them new. 7 deliberate breaks, all red. - e2e 66 (atx): ALL PASSED, 40 checks. New 66t: the driven video came 1.5 s late, the wait took 1686 ms. The free video stayed red all along, blue came at 0.983 s, white at 1.483 s and On video finished at 2.017 s. Without the hold the free video was white in the first frame and only 66t was red. - Gate (atx): CHECK EXIT 0, 339 files, 5253 tests. The guard for the clip end came in while the gate ran. It is checked with tsc, lint and its test file with 3 deliberate breaks. Co-Authored-By: Claude Opus 5.5 --- src/editor/PlaySession.ts | 12 ++-- src/player/Player.ts | 4 ++ src/viewport/VideoLibrary.ts | 49 ++++++++++++-- tests/viewport/video-library.test.ts | 96 ++++++++++++++++++++++++++++ tools/e2e/66-video.mjs | 90 +++++++++++++++++++++++++- 5 files changed, 236 insertions(+), 15 deletions(-) diff --git a/src/editor/PlaySession.ts b/src/editor/PlaySession.ts index fbefd24e..6236a54b 100644 --- a/src/editor/PlaySession.ts +++ b/src/editor/PlaySession.ts @@ -317,6 +317,8 @@ export class PlaySession { /** The Game tab has drawn the new world once by now, which opened the players it shows. */ private waitForVideos(): void { this.videoWait = this.host.gameView.videosLoading() ? performance.now() + VIDEO_WAIT_MS : null; + // From now, not from the next tick: a free video started at Play would be a frame ahead. + videoLibrary.setHeld(this.videoWait !== null); } /** Whether the run still waits for its videos; the frame it stops waiting, the notice says so. */ @@ -395,12 +397,10 @@ export class PlaySession { if (!run) return; const world = run.world; this.tellPace(); - if ( - (this.state.paused && !this.stepping) || - this.waitsForVideos() || - this.recorder.isStarting || - this.recordStep === 0 - ) { + // The world does not tick while it waits, and the free videos wait with it (#437). + const waits = this.waitsForVideos() || this.recorder.isStarting; + videoLibrary.setHeld(waits); + if ((this.state.paused && !this.stepping) || waits || this.recordStep === 0) { // Keys and clicks that happen while paused are dropped instead of piling up – otherwise // resuming would deliver every one of them in a single frame. this.input.endFrame(); diff --git a/src/player/Player.ts b/src/player/Player.ts index bc3c5e10..6ed21144 100644 --- a/src/player/Player.ts +++ b/src/player/Player.ts @@ -112,6 +112,8 @@ export class Player { // A recording that sets up its encoders holds the run too: its frames meanwhile would be lost. // One not in real time (#423) moves it by one of its frames, or holds it until it has taken one. const waits = this.waitsForVideos() || this.recorder.isStarting; + // The world does not tick while it waits, and the free videos wait with it (#437). + videoLibrary.setHeld(waits); const recorded = waits ? null : this.recorder.step(); if (waits || recorded === 0) { this.input.endFrame(); @@ -143,6 +145,8 @@ export class Player { /** The screen has drawn the new world once by now, which opened the players it shows. */ private waitForVideos(): void { this.videoWait = this.screen.videosLoading() ? performance.now() + VIDEO_WAIT_MS : null; + // From now, not from the next tick: a free video started at Play would be a frame ahead. + videoLibrary.setHeld(this.videoWait !== null); } private waitsForVideos(): boolean { diff --git a/src/viewport/VideoLibrary.ts b/src/viewport/VideoLibrary.ts index 09fb3aec..a86794b5 100644 --- a/src/viewport/VideoLibrary.ts +++ b/src/viewport/VideoLibrary.ts @@ -4,7 +4,8 @@ * same clip share a player and show the same picture. * * In the editor a video plays without sound, as a preview. A run starts every player over at its - * clip's start and lets it be heard along the master routing of its video; a pause holds them, and + * clip's start and lets it be heard along the master routing of its video; a pause holds them, as + * does the run waiting for its driven videos or its recording (see `setHeld`), and * the end of the run makes them quiet previews again. * * A sprite whose Video clock is driven (#404) has a player of its own instead, which shows the @@ -118,6 +119,8 @@ export class VideoLibrary { private readonly listeners = new Set<() => void>(); private heard = false; private paused = false; + /** The free players wait with a run that waits for its driven videos or its recording (#437). */ + private held = false; /** Driven players hold at every picture asked rather than play: a recording takes each (#423). */ private stepped = false; private sweeper: ReturnType | null = null; @@ -201,6 +204,7 @@ export class VideoLibrary { startRun(): void { this.heard = true; this.paused = false; + this.held = false; for (const player of this.freePlayers()) { if (!player.hushed) this.hear(player); this.rewind(player); @@ -211,6 +215,7 @@ export class VideoLibrary { endRun(): void { this.heard = false; this.paused = false; + this.held = false; for (const player of this.freePlayers()) { player.element.muted = true; this.rewind(player); @@ -219,9 +224,34 @@ export class VideoLibrary { setPaused(paused: boolean): void { this.paused = paused; + this.settle(); + } + + /** + * While a run waits at its start or after a Load scene (#437), its world does not tick, and the + * clock of every free sprite stands. Its player holds too, so the picture keeps to that clock: + * else On video finished came late by the wait, and a Pause video sent the picture back. + */ + setHeld(held: boolean): void { + if (this.held === held) return; + this.held = held; + this.settle(); + } + + /** Whether the free players stand: paused by the toolbar or held by a waiting run. */ + private get still(): boolean { + return this.paused || this.held; + } + + /** + * Holds or plays the free players as `still` says; one without its first picture plays on to get + * it. One held at the end of a clip without Loop stays there: play() would start it over. + */ + private settle(): void { for (const player of this.freePlayers()) - if (paused) player.element.pause(); - else if (!player.hushed) void player.element.play().catch(() => {}); + if (this.still) { + if (player.texture) player.element.pause(); + } else if (!player.hushed && !atEnd(player)) void player.element.play().catch(() => {}); } private freePlayers(): Player[] { @@ -320,7 +350,7 @@ export class VideoLibrary { private unhush(player: Player): void { player.hushed = false; if (this.heard) this.hear(player); - if (!this.paused) void player.element.play().catch(() => {}); + if (!this.still) void player.element.play().catch(() => {}); } private stopSweeping(): void { @@ -371,8 +401,8 @@ export class VideoLibrary { if (player.gone) return texture.destroy(true); player.texture = texture; if (drive) this.follow(player, drive); - // Started even in a paused run or when nothing wears it, and held once there is a picture. - else if (this.paused || player.hushed) player.element.pause(); + // Started even in a paused or waiting run or when nothing wears it, and held once there is a picture. + else if (this.still || player.hushed) player.element.pause(); this.notify(); }), texture: null, @@ -449,7 +479,7 @@ export class VideoLibrary { private rewind(player: Player): void { player.element.currentTime = player.play.start; - if (!this.paused && !player.hushed) void player.element.play().catch(() => {}); + if (!this.still && !player.hushed) void player.element.play().catch(() => {}); } private drop(player: Player): void { @@ -482,6 +512,11 @@ export class VideoLibrary { export const videoLibrary = new VideoLibrary(); +/** Whether a free player stands at the end of a clip without Loop, as `watch` holds it. */ +function atEnd({ element, play }: Player): boolean { + return !play.loop && (element.ended || element.currentTime >= play.end - END_SLACK); +} + /** * A `