diff --git a/README.md b/README.md index bad24b13..711d703e 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2060 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2061 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/viewport/VideoLibrary.ts b/src/viewport/VideoLibrary.ts index 8b54a96d..ec4bc89a 100644 --- a/src/viewport/VideoLibrary.ts +++ b/src/viewport/VideoLibrary.ts @@ -37,6 +37,12 @@ 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; +/** + * 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. + */ +const HUSH_MS = 100; /** * When the frame being drawn began, in milliseconds like `performance.now()`. The ticker moves every @@ -85,6 +91,7 @@ export class VideoLibrary { private heard = false; private paused = false; private sweeper: ReturnType | null = null; + private husher: ReturnType | null = null; /** When the page was last shown, by `performance.now()`; see `sweep`. */ private shown = 0; @@ -230,9 +237,24 @@ export class VideoLibrary { if (this.players.size === 0) this.stopSweeping(); } + /** + * 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`). + */ + 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; + player.element.muted = true; + if (!player.element.paused) player.element.pause(); + } + } + private stopSweeping(): void { if (this.sweeper !== null) clearInterval(this.sweeper); + if (this.husher !== null) clearInterval(this.husher); this.sweeper = null; + this.husher = null; } /** What each player is doing (test hook). */ @@ -288,6 +310,7 @@ export class VideoLibrary { }; this.players.set(key, player); this.sweeper ??= setInterval(() => this.sweep(), IDLE_MS); + if (drive) this.husher ??= setInterval(() => this.hush(), HUSH_MS); if (drive) chaseSeeks( player.element, diff --git a/tests/viewport/video-library.test.ts b/tests/viewport/video-library.test.ts index 3ebd9618..4b5b2e5d 100644 --- a/tests/viewport/video-library.test.ts +++ b/tests/viewport/video-library.test.ts @@ -213,6 +213,51 @@ describe('VideoLibrary driven players (#404)', () => { expect(videos.showsAsked(key)).toBe(true); }); + it('holds a driven player quiet as soon as its wearer is gone, as an ended composition item', async () => { + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval', 'performance'] }); + const events = new Map void>(); + let frames: (() => void)[] = []; + const element = { + ...fakeVideo(), + ended: false, + duration: 10, + playbackRate: 1, + seeking: false, + readyState: 4, + videoWidth: 320, + videoHeight: 180, + play() { + this.paused = false; + return Promise.resolve(); + }, + pause() { + this.paused = true; + }, + addEventListener: (type: string, listener: () => void) => events.set(type, listener), + requestVideoFrameCallback: (callback: () => void) => frames.push(callback), + }; + vi.stubGlobal('document', { hidden: false, addEventListener: () => {}, createElement: () => element }); + const { soundLibrary } = await import('../../src/viewport/SoundLibrary'); + vi.spyOn(soundLibrary, 'mediaInput').mockReturnValue(null); + const videos = await freshLibrary(); + const props = { videoClock: 'driven', videoTime: 0 }; + videos.wear(REF, props, 'view1:a', 'game'); + events.get('loadeddata')!(); + [frames] = [[], ...frames.map((frame) => frame())]; + // Worn at 1x for a second, heard. + for (let ms = 0; ms <= 1000; ms += 1000 / 60) { + element.currentTime = ms / 1000; + videos.wear(REF, { ...props, videoTime: ms / 1000 }, 'view1:a', 'game'); + vi.advanceTimersByTime(1000 / 60); + } + expect([element.paused, element.muted]).toEqual([false, false]); + // The item ends: nothing wears the player any more. Let go only after 2 s, it played on heard. + vi.advanceTimersByTime(200); + expect([element.paused, element.muted]).toEqual([true, true]); + expect(videos.debugInfo()).toHaveLength(1); + vi.useRealTimers(); + }); + it('tells a view whether one of its driven players still waits for its first picture', async () => { const videos = await freshLibrary(); videos.get(KEY); diff --git a/tools/e2e/68-composition.mjs b/tools/e2e/68-composition.mjs index bbb9f7d3..ec75fdea 100644 --- a/tools/e2e/68-composition.mjs +++ b/tools/e2e/68-composition.mjs @@ -9,7 +9,8 @@ import { MAKE_OBJECT } from './lib/scaffold.mjs'; * clip moves the item it overlaps, and `On composition finished` hears the end, by which time no * item is left. Nothing of the run stays in the scene. An item's video is waited for: the clock * stands until the Game tab has its first picture, for a clip that starts later the picture there, - * and then the video plays at once. A recording that runs meanwhile takes no frame. + * and then the video plays at once. A recording that runs meanwhile takes no frame. When the item + * ends, its video stops and goes quiet with it. * * Everything it adds carries a `cp68` id and goes again at the end. */ @@ -327,6 +328,46 @@ export async function composition(t) { ); const madeId = (await json('__gm.recording()')).uploads.at(-1).assetId ?? ''; + // 68g. The item ends after half a second, its video of two seconds with sound in the middle: the + // item's player stops and goes quiet at once – it played on, heard, until it was let go 2-4 s + // later. + await mutate(` + const p = __gm.state.project; + p.assets.find((a) => a.id === 'cp68-director').graph = ${JSON.stringify(graph)}; + p.assets.find((a) => a.id === 'cp68-screen').props.texture = 'cp68-video'; + p.assets.find((a) => a.id === 'cp68-cut').items[0].length = 30;`); + await play(); + const cut = JSON.parse( + await evaluate(`new Promise((done) => { + const rows = []; + let gone = -1; + const step = () => { + const exists = (__gm.world()?.entities ?? []).some((e) => e.objectId === '${id('v')}'); + const player = __gm.gameVideo('${id('v')}'); + rows.push({ exists, paused: player?.paused ?? null, muted: player?.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.filter((row) => row.exists && row.paused === false && row.muted === false).length, after: rows.slice(gone + 12) })); + requestAnimationFrame(step); + }; + step(); + })`), + ); + await stop(); + check( + 'the video of an item that ends stops and goes quiet with it', + cut.gone > 0 && + cut.heard > 0 && + cut.after.length > 0 && + cut.after.every((row) => row.paused === true && row.muted === true), + JSON.stringify({ + gone: cut.gone, + heard: cut.heard, + after: cut.after.slice(0, 3), + last: cut.after.at(-1), + }), + ); + await mutate(` const p = __gm.state.project; p.assets = p.assets.filter((a) => !a.id.startsWith('cp68-') && a.id !== ${JSON.stringify(madeId)});