Silence a free video once its sprite is destroyed (#420)

A sprite whose Video clock is free played its video on after it was
destroyed, heard in a game, until the player was let go 2 to 4 s
later. A driven one was already held and quiet after 100 ms.

- The library holds and quiets a free player nothing asked for in
  HUSH_MS, as it did a driven one. That covers Destroy, a scene load,
  the end of a run and a sprite switched off.
- Asked for again before it is let go, it plays on from where it was
  held, heard only in a run. A hidden page leaves it alone, as the
  sweep does.
- A sprite only out of view keeps its free video playing and heard.
  Whether a video is heard should not hang on the camera. Before, it
  ran 2 to 4 s and was then let go. Now it plays on, which opens its
  player even where it was never in view.

Tests:
- Unit: video-library 1 new. A free player losing its wearer is held
  and quiet 200 ms later, stays so through a new run, plays on heard
  when asked again with the same element, and runs on on a hidden
  page.
- e2e 66 on melektaus-atx: ALL PASSED, 36 checks, 66g-ii new. Heard
  31 frames before Destroy, held and muted in every frame read after.
- Counter-probe without hushing free players: the unit test and only
  66g-ii fail, the player plays on heard (paused false, muted false).
- Gate on melektaus-atx: 309 files, 4797 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-14 09:49:12 +02:00
1 parent acd12326dc
commit 875c76026a
5 files changed
+166 -19

No files matched your search

+33 -14
View File
@@ -39,9 +39,9 @@ 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;
/**
* A driven player (#404) nothing has asked for in this long is held and quiet: its wearer is gone,
* like the sprite of a composition item that has ended, or no longer drawn. It runs on by itself
* otherwise, heard, until it is let go.
* A player nothing has asked for in this long is held and quiet: its wearer is gone, like a sprite
* destroyed or the sprite of a composition item that has ended, or no longer drawn. It runs on by
* itself otherwise, heard, until it is let go. A free one (#420) plays on from there once asked again.
*/
const HUSH_MS = 100;
/** Seconds a driven video's level takes to get most of the way to a new one, see `hear`. */
@@ -90,6 +90,8 @@ interface Player {
asked: number;
/** Set when its wearer sets the time rather than the video itself. */
drive: Drive | null;
/** Whether a free player is held and quiet because nothing wears it, see `hush`. */
hushed: boolean;
}
export class VideoLibrary {
@@ -120,6 +122,7 @@ export class VideoLibrary {
const player = this.players.get(key) ?? this.start(key);
if (!player) return null;
player.asked = performance.now();
if (player.hushed) this.unhush(player);
return player.texture;
}
@@ -170,12 +173,15 @@ export class VideoLibrary {
return false;
}
/** A run begins: every player from its start, heard. A driven one goes where its wearer says. */
/**
* A run begins: every player from its start, heard; a hushed one once it is worn again. A driven
* one goes where its wearer says.
*/
startRun(): void {
this.heard = true;
this.paused = false;
for (const player of this.freePlayers()) {
this.hear(player);
if (!player.hushed) this.hear(player);
this.rewind(player);
}
}
@@ -194,7 +200,7 @@ export class VideoLibrary {
this.paused = paused;
for (const player of this.freePlayers())
if (paused) player.element.pause();
else void player.element.play().catch(() => {});
else if (!player.hushed) void player.element.play().catch(() => {});
}
private freePlayers(): Player[] {
@@ -246,18 +252,30 @@ export class VideoLibrary {
}
/**
* Holds and quiets the driven players nobody asked for lately, see `HUSH_MS`. One still without
* its first picture plays on, without sound: it only gets one by playing (see `openVideo`).
* Holds and quiets the players nobody asked for lately, see `HUSH_MS`. One still without its first
* picture plays on, without sound: it only gets one by playing (see `openVideo`). A free one is
* left alone while the page is hidden, as by `sweep`.
*/
private hush(): void {
const now = performance.now();
for (const player of this.players.values()) {
if (!player.drive || !player.texture || now - player.asked <= HUSH_MS) continue;
if (player.drive) {
if (!player.texture || now - player.asked <= HUSH_MS) continue;
} else if (player.hushed || document.hidden || now - Math.max(player.asked, this.shown) <= HUSH_MS)
continue;
player.hushed = !player.drive;
player.element.muted = true;
if (!player.element.paused) player.element.pause();
if (player.texture && !player.element.paused) player.element.pause();
}
}
/** A free player worn again plays on from where it was held, heard in a run. */
private unhush(player: Player): void {
player.hushed = false;
if (this.heard) this.hear(player);
if (!this.paused) void player.element.play().catch(() => {});
}
private stopSweeping(): void {
if (this.sweeper !== null) clearInterval(this.sweeper);
if (this.husher !== null) clearInterval(this.husher);
@@ -306,8 +324,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, and held once there is a picture.
else if (this.paused) player.element.pause();
// 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();
this.notify();
}),
texture: null,
@@ -316,10 +334,11 @@ export class VideoLibrary {
gone: false,
asked: performance.now(),
drive,
hushed: false,
};
this.players.set(key, player);
this.sweeper ??= setInterval(() => this.sweep(), IDLE_MS);
if (drive) this.husher ??= setInterval(() => this.hush(), HUSH_MS);
this.husher ??= setInterval(() => this.hush(), HUSH_MS);
if (drive)
chaseSeeks(
player.element,
@@ -376,7 +395,7 @@ export class VideoLibrary {
private rewind(player: Player): void {
player.element.currentTime = player.play.start;
if (!this.paused) void player.element.play().catch(() => {});
if (!this.paused && !player.hushed) void player.element.play().catch(() => {});
}
private drop(player: Player): void {
+8 -2
View File
@@ -861,8 +861,14 @@ export abstract class WorldViewport<S extends ViewSource = ViewSource> extends W
this.ghostSprites.delete(item.id);
this.videosShown.delete(item.id);
// A driven video (#404) is kept ready at its Video time while its sprite is switched off or out
// of view: switched on, it showed its plain colour until the video had loaded.
if (item.type === 'sprite' && this.wearVideo(item, false) !== item) this.animated = true;
// of view: switched on, it showed its plain colour until the video had loaded. A free one plays
// on, heard in a run, while its sprite is only out of view; switched off, it is held (#420).
if (item.type !== 'sprite') return;
if (this.wearVideo(item, false) !== item) this.animated = true;
else if (item.enabled && item.texture && isVideoKey(item.texture.file)) {
videoLibrary.get(item.texture.file);
this.animated = true;
}
return;
}
if (!sprite) {