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

+66 -2
View File
@@ -1,4 +1,5 @@
import { readFileSync } from 'node:fs';
import { edge, node } from '../lib/builders.mjs';
import { uploadVideo } from '../lib/projects.mjs';
import { LANES, MAKE_OBJECT, pickAssetType } from './lib/scaffold.mjs';
import { sampleZoom } from './lib/zoomTransition.mjs';
@@ -17,6 +18,8 @@ import { sampleZoom } from './lib/zoomTransition.mjs';
*/
const SPRITE = 'vd66-sprite';
const OBJECT = 'vd66-object';
const LOGIC = 'vd66-logic';
const BRAIN = 'vd66-brain';
const DRIVEN = 'vd66-driven';
const EARLY = 'vd66-early';
const LATE = 'vd66-late';
@@ -26,7 +29,7 @@ const LANE = LANES.video;
const COLORS = { red: '#fe0000', green: '#00ff01', blue: '#0000ff', white: '#ffffff' };
export async function video(t) {
const { check, sleep, json, mutate, click, clickText, drag, shot, showTab, play, stop } = t;
const { check, sleep, json, mutate, click, clickText, drag, press, shot, showTab, play, stop } = t;
const waitFor = async (read, done, tries = 40) => {
let value = await read();
for (let i = 0; i < tries && !done(value); i++) {
@@ -323,6 +326,66 @@ export async function video(t) {
runRoutes.some((r) => r.file === uploaded.file && r.routed && r.bus === `mix:${BUS}`),
JSON.stringify({ runPeaks, runRoutes }),
);
// 66g-ii (#420). A behavior destroys the sprite: its free video stops and goes quiet with it. It
// played on, heard, until it was let go 2-4 s later. Every frame is read in the page.
await stop();
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
const logic = structuredClone(p.assets.find((a) => a.id === 'mover'));
Object.assign(logic, {
id: '${LOGIC}',
name: '${LOGIC}',
props: { enabled: true, runInEditor: false },
variables: [],
graph: ${JSON.stringify({
nodes: [
node('vd66-key', 'OnKeyDown', 0, 0, { key: 'd' }),
node('vd66-owner', 'Owner', 0, 180),
node('vd66-destroy', 'Destroy', 260, 0),
],
edges: [
edge('vd66-e1', 'vd66-key', 'exec', 'vd66-destroy', 'exec'),
edge('vd66-e2', 'vd66-owner', 'object', 'vd66-destroy', 'object'),
],
})},
});
p.assets.unshift(logic);
${MAKE_OBJECT}
scene.objects.push(make('${BRAIN}', '${LOGIC}', '${OBJECT}', 0, '${BRAIN}'));`);
await play();
await t.evaluate(`window.__vd66g = new Promise((done) => {
const rows = [];
let gone = -1;
const step = () => {
const exists = (__gm.world()?.entities ?? []).some((e) => e.objectId === '${OBJECT}');
const players = __gm.videos().filter((v) => !v.key.includes('@'));
rows.push({ exists, players: players.length, paused: players[0]?.paused ?? null, muted: players[0]?.muted ?? null });
if (gone < 0 && rows.some((row) => row.exists) && !exists) gone = rows.length - 1;
if ((gone >= 0 && rows.length > gone + 30) || rows.length >= 600 || !__gm.world())
return done(JSON.stringify({ gone, heard: rows.slice(0, Math.max(gone, 0)).filter((row) => row.paused === false && row.muted === false).length, after: rows.slice(gone + 12) }));
requestAnimationFrame(step);
};
step();
});
0;`);
await sleep(500);
await press('d');
const destroyed = JSON.parse(await t.evaluate('window.__vd66g'));
check(
'a free video stops and goes quiet as soon as its sprite is destroyed',
destroyed.gone > 0 &&
destroyed.heard > 0 &&
destroyed.after.length > 0 &&
destroyed.after.every((row) => row.players === 1 && row.paused === true && row.muted === true),
JSON.stringify({
gone: destroyed.gone,
heard: destroyed.heard,
after: destroyed.after.slice(0, 3),
last: destroyed.after.at(-1),
}),
);
await stop();
await sleep(200);
const quiet = (await json('__gm.videos()'))[0];
@@ -333,7 +396,8 @@ export async function video(t) {
// leave the preview drawing a destroyed one, and the batcher threw on every frame.
await mutate(`
const p = __gm.state.project;
for (const holder of p.assets) if (holder.objects) holder.objects = holder.objects.filter((o) => o.id !== '${OBJECT}');
for (const holder of p.assets) if (holder.objects) holder.objects = holder.objects.filter((o) => o.id !== '${OBJECT}' && o.id !== '${BRAIN}');
p.assets = p.assets.filter((a) => a.id !== '${LOGIC}');
__gm.state.select({ kind: 'asset', assetId: '${SPRITE}' });`);
const logFrom = t.consoleLog.length;
await sleep(4500);