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>
713 lines
31 KiB
JavaScript
713 lines
31 KiB
JavaScript
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';
|
||
|
||
/**
|
||
* Videos (#402). A WebM goes up and the worker turns it into an MP4 (in CI beside the PHP server,
|
||
* on stein2 in the container feuerwerk-video). "+ → Video" offers it with what it is; Use makes
|
||
* the asset and opens its tab, which shows the picture. A drag in the clip strip spans a clip, one
|
||
* undo step. A sprite wearing the clip plays only that stretch, over and over, without sound in the
|
||
* editor; Play starts it over from the clip's start and lets it be heard, Esc makes it quiet again.
|
||
*
|
||
* The test video is 2 s at 30 fps, a quarter each red, green, blue and white, with a tone. Colours
|
||
* are read from the video the tab holds and from the screen.
|
||
*
|
||
* Everything it adds carries a `vd66` id or is the asset it made, and goes again at the end.
|
||
*/
|
||
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';
|
||
const CUT = 'vd66-cut';
|
||
const BUS = 'vd66-bus';
|
||
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, 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++) {
|
||
await sleep(150);
|
||
value = await read();
|
||
}
|
||
return value;
|
||
};
|
||
|
||
// 66a. Converted on the server: the asked size, length and frame rate, as an MP4 with a poster.
|
||
const uploaded = await uploadVideo(
|
||
t.baseUrl,
|
||
readFileSync(new URL('./fixtures/colors.webm', import.meta.url)),
|
||
'colors.webm',
|
||
);
|
||
check(
|
||
'an uploaded WebM is converted to an MP4 with its size, length, frame rate and sound',
|
||
uploaded.status === 'ready' &&
|
||
/^[a-f0-9]{12}\/[a-f0-9]{64}\.mp4$/.test(uploaded.file) &&
|
||
uploaded.width === 320 &&
|
||
uploaded.height === 180 &&
|
||
Math.abs(uploaded.duration - 2) < 0.05 &&
|
||
uploaded.fps === 30 &&
|
||
uploaded.audio === true &&
|
||
uploaded.poster === uploaded.file.replace(/\.mp4$/, '.jpg'),
|
||
JSON.stringify(uploaded),
|
||
);
|
||
|
||
// 66b. "+ → Video" offers it with what it is, Use makes the asset and opens the tab.
|
||
await showTab('scene');
|
||
await clickText('+');
|
||
await pickAssetType(t, 'Video');
|
||
const picker = await waitFor(
|
||
() => json('__gm.videoPicker()'),
|
||
(p) => p?.files.includes(uploaded.file),
|
||
);
|
||
check(
|
||
'the + menu opens the video picker on what the server holds',
|
||
Boolean(picker?.files.includes(uploaded.file)),
|
||
JSON.stringify(picker),
|
||
);
|
||
const row = await json(`__gm.videoPickerRow(${JSON.stringify(uploaded.file)})`);
|
||
await click(row.x, row.y);
|
||
const picked = await json('__gm.videoPicker()');
|
||
check(
|
||
'a ready video says its size, length, frame rate and sound',
|
||
picked.detail.includes('320 × 180 · 2 s · 30 fps · with sound'),
|
||
picked.detail,
|
||
);
|
||
await shot('66-video-picker');
|
||
await clickText('Use video');
|
||
const assetId = await waitFor(
|
||
() => json('__gm.videoTab.current()'),
|
||
(id) => Boolean(id),
|
||
);
|
||
const asset = await json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(assetId)})`);
|
||
check(
|
||
'Use makes a video asset of it and opens its tab',
|
||
asset?.type === 'video' &&
|
||
asset.file === uploaded.file &&
|
||
asset.width === 320 &&
|
||
asset.fps === 30 &&
|
||
asset.name === 'colors',
|
||
JSON.stringify(asset),
|
||
);
|
||
|
||
// 66c. The tab shows the picture: red at the start, read from the video and from the screen.
|
||
const preview = await waitFor(
|
||
() => json('__gm.videoTab.preview()'),
|
||
(p) => p?.shown && p.pixel?.[0] > 200,
|
||
);
|
||
const shownColor = (p) => json(`__gm.pixelAt(${Math.round(p.x + p.w / 2)}, ${Math.round(p.y + p.h / 2)})`);
|
||
check(
|
||
'the tab shows the video, its first frame red',
|
||
preview?.shown &&
|
||
preview.pixel[0] > 200 &&
|
||
preview.pixel[1] < 50 &&
|
||
(await shownColor(preview)) === COLORS.red,
|
||
JSON.stringify({ preview, screen: await shownColor(preview) }),
|
||
);
|
||
|
||
// 66d. A drag in the strip spans a clip from 0.6 s to 1.4 s, which is then picked; one undo step.
|
||
const from = await json('__gm.videoTab.stripAt(0.6)');
|
||
const to = await json('__gm.videoTab.stripAt(1.4)');
|
||
await drag(from.x, from.y, to.x, to.y);
|
||
await sleep(300);
|
||
const clips = await json('__gm.videoTab.clips()');
|
||
const clip = clips[0];
|
||
check(
|
||
'a drag in the strip spans a clip, which is picked',
|
||
clips.length === 1 &&
|
||
Math.abs(clip.props.start - 0.6) < 0.02 &&
|
||
Math.abs(clip.props.end - 1.4) < 0.02 &&
|
||
(await json('__gm.videoTab.pickedClip()')) === clip.id,
|
||
JSON.stringify({ clips, picked: await json('__gm.videoTab.pickedClip()') }),
|
||
);
|
||
const atClip = await waitFor(
|
||
() => json('__gm.videoTab.preview()'),
|
||
(p) => p?.pixel?.[1] > 200 && p.pixel[0] < 50,
|
||
);
|
||
check(
|
||
'the tab stands at the picked clip’s start, green',
|
||
atClip.pixel[1] > 200 && atClip.pixel[0] < 50,
|
||
JSON.stringify(atClip),
|
||
);
|
||
await t.undo();
|
||
const undone = await json('__gm.videoTab.clips()');
|
||
await t.redo();
|
||
check(
|
||
'the clip is one undo step',
|
||
undone.length === 0 && (await json('__gm.videoTab.clips()')).length === 1,
|
||
JSON.stringify(undone),
|
||
);
|
||
|
||
// 66e. The undo left the video picked; a click on the band picks the clip again. Play in the tab
|
||
// runs the clip and moves the playhead; Stop holds it.
|
||
const band = await json('__gm.videoTab.stripAt(1)');
|
||
await click(band.x, band.y);
|
||
check('a click on its band picks the clip', (await json('__gm.videoTab.pickedClip()')) === clip.id);
|
||
const playButton = await json('__gm.videoTab.playButton()');
|
||
await click(playButton.x, playButton.y);
|
||
await sleep(500);
|
||
const running = await json(
|
||
'({ playing: __gm.videoTab.playing(), head: __gm.videoTab.playhead(), button: __gm.videoTab.playButton().text })',
|
||
);
|
||
check(
|
||
'Play in the tab plays the picked clip from its start',
|
||
running.playing && running.head > 0.6 && running.head < 1.4 && running.button === 'Stop',
|
||
JSON.stringify(running),
|
||
);
|
||
await click(playButton.x, playButton.y);
|
||
check('Stop holds it', (await json('__gm.videoTab.playing()')) === false);
|
||
|
||
// 66e-i. A click in the ruler while it plays stops it, and the header's button says Play again.
|
||
// Play then goes on from that moment, not from the clip's start.
|
||
const tabState =
|
||
'({ playing: __gm.videoTab.playing(), head: __gm.videoTab.playhead(), button: __gm.videoTab.playButton().text })';
|
||
await click(playButton.x, playButton.y);
|
||
await sleep(150);
|
||
const ruler = await json('__gm.videoTab.rulerAt(1)');
|
||
await click(ruler.x, ruler.y);
|
||
await sleep(150);
|
||
const halted = await json(tabState);
|
||
await click(playButton.x, playButton.y);
|
||
await sleep(150);
|
||
const resumed = await json(tabState);
|
||
await click(playButton.x, playButton.y);
|
||
check(
|
||
'a click in the ruler stops the play and its button',
|
||
halted.playing === false && halted.button === 'Play' && Math.abs(halted.head - 1) < 0.05,
|
||
JSON.stringify(halted),
|
||
);
|
||
check(
|
||
'Play goes on from the playhead',
|
||
resumed.playing && resumed.button === 'Stop' && resumed.head >= 1 && resumed.head < 1.4,
|
||
JSON.stringify(resumed),
|
||
);
|
||
|
||
// 66e-iii. With sound the video has a node in the Master routing (#351), linked to the master.
|
||
// Sent into a bus there, the tab still plays it straight into the master, as every
|
||
// tab's preview does (#368): heard at the output, where a recording of the editor
|
||
// listens, and nothing on the bus. It used to play past the mix altogether.
|
||
const graphOf = (expr) => json(`((g) => ${expr})(__gm.routing.graph())`);
|
||
const routed = await graphOf(
|
||
`({ node: g.nodes.find((n) => n.id === ${JSON.stringify(assetId)}) ?? null,
|
||
links: g.edges.filter((e) => e.from.node === ${JSON.stringify(assetId)}).map((e) => e.to.node) })`,
|
||
);
|
||
check(
|
||
'a video with sound has a node of its own in the Master routing, linked to the master',
|
||
routed.node?.params.kind === 'video' &&
|
||
routed.node.params.name === 'colors' &&
|
||
JSON.stringify(routed.links) === '["#master"]',
|
||
JSON.stringify(routed),
|
||
);
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
p.assets.push({ id: '${BUS}', type: 'bus', name: 'vd66 bus', props: {} });
|
||
p.routing.nodes.push({ id: '${BUS}', type: 'RoutedAsset', x: 320, y: 0, params: { name: 'vd66 bus', kind: 'bus' } });
|
||
p.routing.edges.push({ id: '${BUS}-out', from: { node: '${BUS}', port: 'out' }, to: { node: '#master', port: 'in' } });
|
||
for (const e of p.routing.edges) if (e.from.node === ${JSON.stringify(assetId)}) e.to = { node: '${BUS}', port: 'in' };`);
|
||
const metered = JSON.stringify(['mix', `mix:${BUS}`]);
|
||
await json(`__gm.routing.peaks(${metered})`);
|
||
await click(playButton.x, playButton.y);
|
||
const tabPeaks = await waitFor(
|
||
() => json(`__gm.routing.peaks(${metered})`),
|
||
(peaks) => Math.max(...(peaks.mix ?? [0])) > 0.01,
|
||
);
|
||
const tabRoutes = await json('__gm.routing.media()');
|
||
await click(playButton.x, playButton.y);
|
||
check(
|
||
'the tab plays the video straight into the master, past its routing',
|
||
Math.max(...(tabPeaks.mix ?? [0])) > 0.01 &&
|
||
Math.max(...(tabPeaks[`mix:${BUS}`] ?? [0])) === 0 &&
|
||
tabRoutes.some((r) => r.file === uploaded.file && !r.routed && r.bus === 'mix'),
|
||
JSON.stringify({ tabPeaks, tabRoutes }),
|
||
);
|
||
|
||
// 66e-ii. With the clip picked (#403), a drag in the track off its handles or in the strip beside
|
||
// its band changes nothing; one on its band moves it whole, one undo step. A click in the
|
||
// track beside it picks the video, on it the clip again.
|
||
const track = (s) => json(`__gm.videoTab.trackAt(${s})`);
|
||
const strip = (s) => json(`__gm.videoTab.stripAt(${s})`);
|
||
const clipsNow = async () => JSON.stringify(await json('__gm.videoTab.clips()'));
|
||
const spanBefore = await clipsNow();
|
||
for (const [at, from, to] of [
|
||
[track, 1, 1.8],
|
||
[strip, 1.6, 1.9],
|
||
]) {
|
||
const a = await at(from);
|
||
const b = await at(to);
|
||
await drag(a.x, a.y, b.x, b.y);
|
||
}
|
||
const untouched = await clipsNow();
|
||
const bandFrom = await strip(1);
|
||
const bandTo = await strip(1.2);
|
||
await drag(bandFrom.x, bandFrom.y, bandTo.x, bandTo.y);
|
||
await sleep(150);
|
||
const moved = (await json('__gm.videoTab.clips()'))[0];
|
||
await t.undo();
|
||
const undoneMove = await clipsNow();
|
||
const besideTrack = await track(1.8);
|
||
await click(besideTrack.x, besideTrack.y);
|
||
const pickedBeside = await json('__gm.videoTab.pickedClip()');
|
||
const onTrack = await track(1);
|
||
await click(onTrack.x, onTrack.y);
|
||
check(
|
||
'with the clip picked a drag off its handles changes nothing, on its band it moves the clip whole; a click in the track picks',
|
||
untouched === spanBefore &&
|
||
Math.abs(moved?.props.start - 0.8) < 0.02 &&
|
||
Math.abs(moved?.props.end - 1.6) < 0.02 &&
|
||
undoneMove === spanBefore &&
|
||
pickedBeside === null &&
|
||
(await json('__gm.videoTab.pickedClip()')) === clip.id,
|
||
JSON.stringify({ spanBefore, untouched, moved, undoneMove, pickedBeside }),
|
||
);
|
||
await shot('66-video-tab');
|
||
// At 1000 ms a frame after the wheel the time's zoom is on its way, as in the Sound tab (#377).
|
||
const timeZoom = await sampleZoom(t, await strip(1), '__gm.videoTab.zoom()');
|
||
check('a zoom of the time is shown on its way and lands where the jump does', timeZoom.ok, timeZoom.detail);
|
||
|
||
// 66f. A sprite wearing the clip shows only its stretch, over and over: green and blue, never red
|
||
// or white, without sound in the editor.
|
||
await showTab('scene');
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
const scene = __gm.state.activeScene;
|
||
${MAKE_OBJECT}
|
||
p.assets.unshift({ id: '${SPRITE}', type: 'sprite', name: '${SPRITE}', props: { width: 320, height: 180, color: 0xffffff, texture: ${JSON.stringify(clip.id)}, shader: '' } });
|
||
scene.objects.push(make('${OBJECT}', '${SPRITE}', '', ${LANE}, 'video'));
|
||
__gm.state.select(null);
|
||
__gm.lookAt(0, ${LANE});`);
|
||
await waitFor(
|
||
() => json('__gm.videos()'),
|
||
(players) => players.some((p) => p.ready),
|
||
);
|
||
const seen = new Set();
|
||
for (let i = 0; i < 14; i++) {
|
||
const r = await json(`__gm.objectRect('${OBJECT}')`);
|
||
seen.add(await json(`__gm.pixelAt(${Math.round(r.x)}, ${Math.round(r.y)})`));
|
||
await sleep(120);
|
||
}
|
||
const players = await json('__gm.videos()');
|
||
check(
|
||
'a sprite wearing the clip plays only its stretch, round and round',
|
||
seen.has(COLORS.green) && seen.has(COLORS.blue) && !seen.has(COLORS.red) && !seen.has(COLORS.white),
|
||
JSON.stringify([...seen]),
|
||
);
|
||
check(
|
||
'the editor plays it without sound',
|
||
players.length === 1 && players[0].muted && !players[0].paused && players[0].key.includes('#0.6~1.4~1~1'),
|
||
JSON.stringify(players),
|
||
);
|
||
await shot('66-video-sprite');
|
||
|
||
// 66g. Play starts it over from the clip's start and lets it be heard, along the routing of its
|
||
// video into the bus from 66e-iii; Esc makes it quiet again.
|
||
await play();
|
||
await sleep(200);
|
||
const heard = (await json('__gm.videos()'))[0];
|
||
check(
|
||
'Play starts the clip over and lets it be heard',
|
||
heard && !heard.muted && !heard.paused && heard.time >= 0.6 && heard.time < 1.1,
|
||
JSON.stringify(heard),
|
||
);
|
||
const runPeaks = await waitFor(
|
||
() => json(`__gm.routing.peaks(${metered})`),
|
||
(peaks) => Math.max(...(peaks[`mix:${BUS}`] ?? [0])) > 0.01,
|
||
20,
|
||
);
|
||
const runRoutes = await json('__gm.routing.media()');
|
||
check(
|
||
'in the game the video is heard on the bus its routing sends it to',
|
||
Math.max(...(runPeaks[`mix:${BUS}`] ?? [0])) > 0.01 &&
|
||
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];
|
||
check('Esc makes it a quiet preview again', quiet && quiet.muted, JSON.stringify(quiet));
|
||
|
||
// 66h. The sprite picked but not in the scene: only the inspector's preview wears the video. It
|
||
// asks for it every frame, so the player is not let go after 2 s; a let-go texture used to
|
||
// 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}' && 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);
|
||
const inspected = await json('__gm.inspectorSpritePreview()');
|
||
const thrown = t.consoleLog.slice(logFrom).filter((line) => /EXCEPTION|alphaMode/.test(line));
|
||
const kept = await json('__gm.videos()');
|
||
const inPreview = inspected && (await shownColor(inspected));
|
||
check(
|
||
'a sprite shown only in the inspector keeps its video playing there',
|
||
thrown.length === 0 &&
|
||
inspected?.animates === true &&
|
||
kept.length === 1 &&
|
||
kept[0].ready &&
|
||
(inPreview === COLORS.green || inPreview === COLORS.blue),
|
||
JSON.stringify({ thrown, inspected, kept, inPreview }),
|
||
);
|
||
|
||
// 66i. Driven by its Video time (#404): two sprites wearing the same video stand still in the
|
||
// scene, each at its own time (0.2 s red, 0.7 s green), each on a player of its own.
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
const scene = __gm.state.activeScene;
|
||
${MAKE_OBJECT}
|
||
p.assets.unshift({ id: '${DRIVEN}', type: 'sprite', name: '${DRIVEN}', props: { width: 320, height: 180, color: 0xffffff, texture: ${JSON.stringify(assetId)}, shader: '', videoClock: 'driven', videoTime: 0 } });
|
||
const early = make('${EARLY}', '${DRIVEN}', '', ${LANE}, 'at 0.2');
|
||
const late = make('${LATE}', '${DRIVEN}', '', ${LANE}, 'at 0.7');
|
||
early.overrides = { videoTime: 0.2 };
|
||
late.position.x = 400;
|
||
late.overrides = { videoTime: 0.7 };
|
||
scene.objects.push(early, late);
|
||
__gm.state.select(null);
|
||
__gm.lookAt(200, ${LANE});`);
|
||
const colorAt = (r) => r && json(`__gm.pixelAt(${Math.round(r.x)}, ${Math.round(r.y)})`);
|
||
const pair = async () => [
|
||
await colorAt(await json(`__gm.objectRect('${EARLY}')`)),
|
||
await colorAt(await json(`__gm.objectRect('${LATE}')`)),
|
||
];
|
||
const stills = await waitFor(pair, ([a, b]) => a === COLORS.red && b === COLORS.green);
|
||
await sleep(700);
|
||
const later = await pair();
|
||
const drivenPlayers = (await json('__gm.videos()')).filter((v) => v.key.includes('@'));
|
||
check(
|
||
'driven sprites stand still at their Video time, two of the same video on players of their own',
|
||
stills[0] === COLORS.red &&
|
||
stills[1] === COLORS.green &&
|
||
later[0] === COLORS.red &&
|
||
later[1] === COLORS.green &&
|
||
drivenPlayers.length === 2 &&
|
||
drivenPlayers.every((v) => v.paused),
|
||
JSON.stringify({ stills, later, drivenPlayers }),
|
||
);
|
||
await shot('66-video-driven');
|
||
|
||
// 66j. Keyed in an animation, the preview shows the picture of the time at the playhead, back and
|
||
// forth: frame 2 = 0.2 s red, 7 = green, 12 = blue, and back to 3 = red.
|
||
await mutate(`
|
||
__gm.state.project.assets.push({ id: '${CUT}', type: 'animation', name: '${CUT}', props: { fps: 10, playback: 'loop' },
|
||
root: '${DRIVEN}',
|
||
tracks: [{ id: 'vd66-row', target: '', property: 'videoTime', keys: [{ frame: 0, value: 0 }, { frame: 20, value: 2 }] }] });`);
|
||
await t.evaluate(`__gm.openDocument('asset:${CUT}')`);
|
||
await sleep(300);
|
||
const rootId = await json("__gm.animation.preview.objectId('')");
|
||
// Each picture must also stay: a video playing by itself shows every colour within 2 s.
|
||
const previewColor = async () =>
|
||
colorAt(await json(`__gm.animation.preview.rect(${JSON.stringify(rootId)})`));
|
||
const scrubbed = [];
|
||
const wanted = [COLORS.red, COLORS.green, COLORS.blue, COLORS.red];
|
||
for (const [i, frame] of [2, 7, 12, 3].entries()) {
|
||
await t.evaluate(`__gm.animation.scrub(${frame})`);
|
||
const first = await waitFor(previewColor, (color) => color === wanted[i], 10);
|
||
await sleep(700);
|
||
scrubbed.push([first, await previewColor()]);
|
||
}
|
||
check(
|
||
'scrubbing the animation shows the picture of the keyed Video time and holds it, back and forth',
|
||
scrubbed.every(([first, held], i) => first === wanted[i] && held === wanted[i]),
|
||
JSON.stringify(scrubbed),
|
||
);
|
||
await shot('66-video-scrubbed');
|
||
|
||
// 66k. Played in the tab, the video plays along at 1x, heard, at the time the playhead asks for
|
||
// (the clip and the animation both loop at 2 s, so the gap is measured round the loop).
|
||
const playCut = await json('__gm.animation.playAt()');
|
||
await click(playCut.x, playCut.y);
|
||
await sleep(600);
|
||
const along = [];
|
||
for (let i = 0; i < 5; i++) {
|
||
const asked = (await json(`__gm.animation.preview.item(${JSON.stringify(rootId)})`))?.props.videoTime;
|
||
const player = (await json('__gm.videos()')).find(
|
||
(v) => v.key.includes(`@`) && !v.key.includes(EARLY) && !v.key.includes(LATE),
|
||
);
|
||
along.push({ asked, ...player });
|
||
await sleep(200);
|
||
}
|
||
await click(playCut.x, playCut.y);
|
||
const gap = (a, b) => Math.min(Math.abs(a - b), 2 - Math.abs(a - b));
|
||
// The speed read is 1x; the rate is that, nudged by at most a tenth towards the asked time.
|
||
check(
|
||
'playing the animation plays the video along at 1x and lets it be heard',
|
||
along.every(
|
||
(p) =>
|
||
!p.paused &&
|
||
!p.muted &&
|
||
Math.abs(p.speed - 1) <= 0.1 &&
|
||
Math.abs(p.rate / p.speed - 1) <= 0.1 + 1e-9 &&
|
||
gap(p.time, p.asked) < 0.15,
|
||
),
|
||
JSON.stringify(along),
|
||
);
|
||
|
||
// 66l. Right-click → "Video at 1× to its end" (#407): from the playhead (frame 5, 0.5 s) the
|
||
// last whole frame before the clip ends, the key on 9 between them gone; one undo step.
|
||
const rowKeys = () =>
|
||
json(
|
||
`__gm.state.project.assets.find((a) => a.id === '${CUT}').tracks[0].keys.map((k) => [k.frame, k.value])`,
|
||
);
|
||
await mutate(`
|
||
__gm.state.project.assets.find((a) => a.id === '${CUT}').tracks[0].keys =
|
||
[{ frame: 0, value: 0 }, { frame: 5, value: 0.5 }, { frame: 9, value: 1.7 }];`);
|
||
const keyed = JSON.stringify(await rowKeys());
|
||
await t.evaluate('__gm.animation.scrub(5)');
|
||
const inRow = await json("__gm.animation.cellAt('vd66-row', 2)");
|
||
await click(inRow.x, inRow.y, 'right');
|
||
const runItem = await t.find('Video at 1× to its end from frame 5', true);
|
||
await click(runItem.x, runItem.y);
|
||
const steps = Math.floor((uploaded.duration - 0.5) * 10 + 1e-6);
|
||
const ran = await rowKeys();
|
||
await t.undo();
|
||
check(
|
||
'the menu keys the video at 1x from the playhead to the last frame before its end, in one undo step',
|
||
ran.length === 3 &&
|
||
JSON.stringify(ran.slice(0, 2)) === '[[0,0],[5,0.5]]' &&
|
||
ran[2][0] === 5 + steps &&
|
||
Math.abs(ran[2][1] - (0.5 + steps / 10)) < 1e-6 &&
|
||
JSON.stringify(await rowKeys()) === keyed,
|
||
JSON.stringify({ ran, steps, duration: uploaded.duration, undone: await rowKeys() }),
|
||
);
|
||
|
||
// 66o. Played once to 1.95 s, the video runs on past the end of its file while the time still
|
||
// reads as moving. play() on an ended video starts it over from its first picture, and red
|
||
// flashed two or three times before the white of the last frame stood. Every frame is read in
|
||
// the page.
|
||
await mutate(`
|
||
const cut = __gm.state.project.assets.find((a) => a.id === '${CUT}');
|
||
cut.props.playback = 'once';
|
||
cut.tracks[0].keys = [{ frame: 0, value: 0.05 }, { frame: 19, value: 1.95 }];`);
|
||
await t.evaluate('__gm.animation.scrub(0)');
|
||
await waitFor(previewColor, (color) => color === COLORS.red, 10);
|
||
await t.evaluate(`window.__vd66 = [];
|
||
(function read() {
|
||
const player = __gm.videos().find((v) => v.key.includes('@') && !v.key.includes('${EARLY}') && !v.key.includes('${LATE}'));
|
||
if (player) window.__vd66.push(Math.round(player.time * 1000) / 1000);
|
||
if (window.__vd66.length < 400) requestAnimationFrame(read);
|
||
})();`);
|
||
await click(playCut.x, playCut.y);
|
||
await sleep(2800);
|
||
const times = await json('window.__vd66.splice(0)');
|
||
const endColor = await previewColor();
|
||
if ((await json('__gm.animation.playAt()')).text !== 'Play') await click(playCut.x, playCut.y);
|
||
const reached = times.findIndex((time) => time >= 1.8);
|
||
const fellBack = reached < 0 ? [] : times.slice(reached).filter((time) => time < 1.5);
|
||
check(
|
||
'a video played once to its last frame stays there and never shows its first picture again',
|
||
reached >= 0 && fellBack.length === 0 && endColor === COLORS.white,
|
||
JSON.stringify({ reached, fellBack, last: times.slice(-5), frames: times.length, endColor }),
|
||
);
|
||
await showTab('scene');
|
||
|
||
// 66m. Switched off, a driven sprite keeps its player ready at its Video time, longer than a player
|
||
// nobody asks for lives: switched on, the first frame shows 1.2 s blue, not its plain colour.
|
||
// 4.5 s, as the sweeper looks only every 2 s and lets go of what is 2 s idle.
|
||
const switchLate = (on) =>
|
||
mutate(`
|
||
const late = __gm.state.activeScene.objects.find((o) => o.id === '${LATE}');
|
||
late.overrides = { ...late.overrides, videoTime: 1.2, enabled: ${on} };`);
|
||
await switchLate(false);
|
||
await sleep(4500);
|
||
const readied = (await json('__gm.videos()')).find((v) => v.key.includes(LATE));
|
||
await switchLate(true);
|
||
await t.evaluate('__gm.afterFrames(1)');
|
||
const firstShown = await colorAt(await json(`__gm.objectRect('${LATE}')`));
|
||
check(
|
||
'a driven sprite switched off keeps its video ready and shows its picture the frame it is switched on',
|
||
readied?.ready === true &&
|
||
readied.paused &&
|
||
readied.muted &&
|
||
Math.abs(readied.time - 1.2) < 0.05 &&
|
||
firstShown === COLORS.blue,
|
||
JSON.stringify({ readied, firstShown }),
|
||
);
|
||
|
||
// 66n. The same in a run: the world used to leave a switched-off object out of what the Game tab
|
||
// draws, so a cut to it loaded the video only then and showed white meanwhile. Nor did wearing
|
||
// the player count as asking for it: let go after 2 to 4 s, it was opened again.
|
||
await switchLate(false);
|
||
const sceneKeys = new Set((await json('__gm.videos()')).map((v) => v.key));
|
||
await play();
|
||
await sleep(4500);
|
||
const inRun = (await json('__gm.videos()')).filter((v) => v.key.includes(LATE) && !sceneKeys.has(v.key));
|
||
await stop();
|
||
await switchLate(true);
|
||
check(
|
||
'in a run, a driven sprite switched off keeps its video ready too',
|
||
inRun.length === 1 &&
|
||
inRun[0].ready &&
|
||
inRun[0].paused &&
|
||
inRun[0].muted &&
|
||
Math.abs(inRun[0].time - 1.2) < 0.05,
|
||
JSON.stringify(inRun),
|
||
);
|
||
|
||
// 66p. Play opened the Game tab's players only then: unless that tab had been shown just before,
|
||
// a driven sprite showed its plain white for the first frames of a run. Now the run waits
|
||
// for their first pictures, the clock held, and a sprite without one is not drawn. LATE goes
|
||
// in front of the camera (and of what earlier steps left there); every frame of the run is
|
||
// read in the page. 4.5 s first, so the players 66n opened are let go.
|
||
await mutate(`
|
||
const scene = __gm.state.activeScene;
|
||
const cam = scene.objects.find((o) => o.id === 'o3').position;
|
||
Object.assign(scene.objects.find((o) => o.id === '${LATE}').position, { x: cam.x + 120, y: cam.y + 80, z: 1000 });`);
|
||
await sleep(4500);
|
||
await t.evaluate(`window.__vd66p = [];
|
||
(function read() {
|
||
const info = __gm.playInfo();
|
||
const rect = info.playing && __gm.gameRect('${LATE}');
|
||
if (rect) window.__vd66p.push({ color: __gm.pixelAt(Math.round(rect.x), Math.round(rect.y)), waiting: info.waiting, time: info.time });
|
||
if (window.__vd66p.length < 90) requestAnimationFrame(read);
|
||
})();`);
|
||
await play();
|
||
await sleep(1500);
|
||
await stop();
|
||
const runFrames = await json('window.__vd66p.splice(0)');
|
||
const waited = runFrames.filter((f) => f.waiting);
|
||
check(
|
||
'a run waits for the pictures of its driven videos and never shows a driven sprite white',
|
||
runFrames.length >= 20 &&
|
||
waited.length > 0 &&
|
||
waited.every((f) => f.time === 0) &&
|
||
runFrames.every((f) => f.color !== COLORS.white) &&
|
||
runFrames.at(-1).color === COLORS.blue,
|
||
JSON.stringify({
|
||
waited: waited.length,
|
||
frames: runFrames.map((f) => `${f.color}${f.waiting ? '*' : ''}`),
|
||
}),
|
||
);
|
||
|
||
// 66q. Switched on at another picture than the one it was held at, a cut into the middle of a
|
||
// clip, a driven sprite showed the held one until its seek was done: red before blue. Every
|
||
// frame is read in the page.
|
||
await showTab('scene');
|
||
const cutLate = (on, time) =>
|
||
mutate(`
|
||
const late = __gm.state.activeScene.objects.find((o) => o.id === '${LATE}');
|
||
late.overrides = { ...late.overrides, videoTime: ${time}, enabled: ${on} };
|
||
__gm.lookAt(late.position.x, late.position.y);`);
|
||
await cutLate(true, 1.2);
|
||
const lateRect = await waitFor(
|
||
() => json(`__gm.objectRect('${LATE}')`),
|
||
(r) => r !== null,
|
||
);
|
||
await waitFor(
|
||
() => colorAt(lateRect),
|
||
(color) => color === COLORS.blue,
|
||
);
|
||
await cutLate(false, 0.2);
|
||
await sleep(800);
|
||
await t.evaluate(`window.__vd66q = [];
|
||
(function read() {
|
||
window.__vd66q.push(__gm.pixelAt(${Math.round(lateRect.x)}, ${Math.round(lateRect.y)}));
|
||
if (window.__vd66q.length < 60) requestAnimationFrame(read);
|
||
})();`);
|
||
await sleep(100);
|
||
await cutLate(true, 1.2);
|
||
await sleep(1200);
|
||
const cutFrames = await json('window.__vd66q.splice(0)');
|
||
check(
|
||
'a driven sprite switched on at another picture never shows the one it was held at',
|
||
cutFrames.length >= 30 && !cutFrames.includes(COLORS.red) && cutFrames.at(-1) === COLORS.blue,
|
||
JSON.stringify(cutFrames),
|
||
);
|
||
|
||
// 66r. Cut in and out fast, at times a hair apart: a seek that brought no new frame of the video
|
||
// left its player waiting for one for good, and the sprite stayed empty until the next jump.
|
||
const cuts = [0.7, 1.31, 0.05, 1.62, 0.4, 1.9, 1.05, 0.25, 1.48, 0.9];
|
||
for (const [i, time] of cuts.entries()) {
|
||
await cutLate(false, 0.2);
|
||
await sleep([0, 30, 90][i % 3]);
|
||
await cutLate(true, time);
|
||
await sleep([60, 0, 140][i % 3]);
|
||
await cutLate(true, time + 0.01);
|
||
await sleep([20, 120, 0][i % 3]);
|
||
}
|
||
await cutLate(true, 1.2);
|
||
await sleep(40);
|
||
await cutLate(true, 1.21);
|
||
await sleep(1500);
|
||
const settled = (await json('__gm.videos()')).filter((v) => v.key.includes(LATE));
|
||
const settledColor = await colorAt(lateRect);
|
||
check(
|
||
'cut in and out fast, a driven sprite still ends on its picture and no player waits on',
|
||
settledColor === COLORS.blue && settled.length > 0 && settled.every((v) => !v.landing),
|
||
JSON.stringify({ settledColor, settled }),
|
||
);
|
||
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
const gone = ['${OBJECT}', '${EARLY}', '${LATE}'];
|
||
for (const holder of p.assets) if (holder.objects) holder.objects = holder.objects.filter((o) => !gone.includes(o.id));
|
||
p.assets = p.assets.filter((a) => ![ '${SPRITE}', '${DRIVEN}', '${CUT}', '${BUS}', ${JSON.stringify(assetId)} ].includes(a.id));
|
||
__gm.state.select(null);
|
||
__gm.lookAt(0, 0);`);
|
||
}
|