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 <noreply@anthropic.com>
921 lines
40 KiB
JavaScript
921 lines
40 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 STEER = 'vd66-steer';
|
||
const STEERED = 'vd66-steered';
|
||
const STEER_LOGIC = 'vd66-steer-logic';
|
||
const STEER_BRAIN = 'vd66-steer-brain';
|
||
const SLOW = 'vd66-slow';
|
||
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.
|
||
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: a switched-off object stays in what the Game tab draws, so a cut to it
|
||
// finds its video loaded and shows no white. Wearing the player counts as asking for it, so it
|
||
// is not let go after 2 to 4 s and 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 }),
|
||
);
|
||
|
||
// 66s. Steered by a behavior (#410): Pause video holds the picture on a player of its own, Seek
|
||
// video sends it to 0.5 s (green) and it stays held there, Play video plays on from there:
|
||
// blue, then white held at the end of a video without Loop, and On video finished is heard
|
||
// once. Read from the Game tab, the frames after Play in the page. No driven sprite is left
|
||
// in the scene: the run would wait for its picture, and keys pressed meanwhile are lost.
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
const scene = __gm.state.activeScene;
|
||
scene.objects = scene.objects.filter((o) => o.id !== '${LATE}' && o.id !== '${EARLY}');
|
||
p.assets.find((a) => a.id === ${JSON.stringify(assetId)}).props.loop = false;
|
||
const logic = structuredClone(p.assets.find((a) => a.id === 'mover'));
|
||
Object.assign(logic, {
|
||
id: '${STEER_LOGIC}',
|
||
name: '${STEER_LOGIC}',
|
||
props: { enabled: true, runInEditor: false },
|
||
variables: [],
|
||
graph: ${JSON.stringify({
|
||
nodes: [
|
||
node('vd66s-p', 'OnKeyDown', 0, 0, { key: 'p' }),
|
||
node('vd66s-pause', 'PauseVideo', 260, 0),
|
||
node('vd66s-s', 'OnKeyDown', 0, 160, { key: 's' }),
|
||
node('vd66s-seek', 'SeekVideo', 260, 160, { time: 0.5 }),
|
||
node('vd66s-g', 'OnKeyDown', 0, 320, { key: 'g' }),
|
||
node('vd66s-play', 'PlayVideo', 260, 320),
|
||
node('vd66s-end', 'OnVideoFinished', 0, 480),
|
||
node('vd66s-log', 'Log', 260, 480, { message: 'vd66 finished' }),
|
||
],
|
||
edges: [
|
||
edge('vd66s-e1', 'vd66s-p', 'exec', 'vd66s-pause', 'exec'),
|
||
edge('vd66s-e2', 'vd66s-s', 'exec', 'vd66s-seek', 'exec'),
|
||
edge('vd66s-e3', 'vd66s-g', 'exec', 'vd66s-play', 'exec'),
|
||
edge('vd66s-e4', 'vd66s-end', 'exec', 'vd66s-log', 'exec'),
|
||
],
|
||
})},
|
||
});
|
||
p.assets.unshift(logic);
|
||
p.assets.unshift({ id: '${STEER}', type: 'sprite', name: '${STEER}', props: { width: 320, height: 180, color: 0xffffff, texture: ${JSON.stringify(assetId)}, shader: '' } });
|
||
${MAKE_OBJECT}
|
||
const cam = scene.objects.find((o) => o.id === 'o3').position;
|
||
const screen = make('${STEERED}', '${STEER}', '', 0, '${STEERED}');
|
||
Object.assign(screen.position, { x: cam.x + 120, y: cam.y + 80, z: 1000 });
|
||
scene.objects.push(screen);
|
||
scene.objects.push(make('${STEER_BRAIN}', '${STEER_LOGIC}', '${STEERED}', 0, '${STEER_BRAIN}'));`);
|
||
const steeredColor = () =>
|
||
json(`(() => {
|
||
const r = __gm.gameRect('${STEERED}');
|
||
return r ? __gm.pixelAt(Math.round(r.x), Math.round(r.y)) : null;
|
||
})()`);
|
||
const steeredPlayers = async () =>
|
||
(await json('__gm.videos()')).filter((v) => v.key.includes('@') && v.key.includes(STEERED));
|
||
const finishedFrom = t.consoleLog.length;
|
||
await play();
|
||
const steerRun = await waitFor(
|
||
() => json('__gm.playInfo()'),
|
||
(info) => info.playing && !info.waiting && info.time > 0,
|
||
);
|
||
const startColor = await steeredColor();
|
||
await press('p');
|
||
await sleep(900);
|
||
const heldColors = [];
|
||
for (let i = 0; i < 6; i++) {
|
||
heldColors.push(await steeredColor());
|
||
await sleep(120);
|
||
}
|
||
const heldPlayers = await steeredPlayers();
|
||
check(
|
||
'Pause video holds the picture of a free video, on a player of its own',
|
||
!steerRun.waiting &&
|
||
startColor === COLORS.red &&
|
||
heldColors.every((color) => color === COLORS.red) &&
|
||
heldPlayers.length === 1 &&
|
||
heldPlayers[0].paused &&
|
||
heldPlayers[0].muted,
|
||
JSON.stringify({ steerRun, startColor, heldColors, heldPlayers }),
|
||
);
|
||
await press('s');
|
||
const sought = await waitFor(steeredColor, (color) => color === COLORS.green);
|
||
await sleep(700);
|
||
const soughtHeld = await steeredColor();
|
||
const soughtPlayer = (await steeredPlayers())[0];
|
||
check(
|
||
'Seek video sends it to 0.5 s, green, and it stays held there',
|
||
sought === COLORS.green &&
|
||
soughtHeld === COLORS.green &&
|
||
soughtPlayer?.paused === true &&
|
||
Math.abs(soughtPlayer.time - 0.5) < 0.05,
|
||
JSON.stringify({ sought, soughtHeld, soughtPlayer }),
|
||
);
|
||
// The reads end while the run still draws each frame: a read leaves the stage shifted to its pixel
|
||
// until the next drawing, and the editor, idle after Stop, would keep it so under every later
|
||
// click (e2e 67's Play missed).
|
||
await t.evaluate(`window.__vd66s = [];
|
||
window.__vd66sOn = true;
|
||
(function read() {
|
||
if (!window.__vd66sOn) return;
|
||
const r = __gm.gameRect('${STEERED}');
|
||
if (r) window.__vd66s.push(__gm.pixelAt(Math.round(r.x), Math.round(r.y)));
|
||
requestAnimationFrame(read);
|
||
})();`);
|
||
await press('g');
|
||
await sleep(2200);
|
||
await t.evaluate('window.__vd66sOn = false');
|
||
await sleep(100);
|
||
const playedFrames = await json('window.__vd66s.splice(0)');
|
||
const finished = t.consoleLog.slice(finishedFrom).filter((line) => line.includes('vd66 finished'));
|
||
await stop();
|
||
const firstBlue = playedFrames.indexOf(COLORS.blue);
|
||
const firstWhite = playedFrames.indexOf(COLORS.white);
|
||
check(
|
||
'Play video plays on from there to the last picture, and On video finished is heard once',
|
||
!playedFrames.includes(COLORS.red) &&
|
||
firstBlue > 0 &&
|
||
firstWhite > firstBlue &&
|
||
playedFrames.slice(firstWhite).every((color) => color === COLORS.white) &&
|
||
finished.length === 1,
|
||
JSON.stringify({ finished, frames: playedFrames.join(' ') }),
|
||
);
|
||
|
||
// 66t. A driven sprite whose video comes 1.5 s late holds the run at its start (#437). The free
|
||
// video beside it waits too, at its first picture: red all through the wait, then its colours
|
||
// at their game times, and On video finished (it logs its Time) with the end of its last
|
||
// picture, not 1.5 s after it. The video elements opened at Play get their file 1.5 s late.
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
p.assets.find((a) => a.id === '${STEER_LOGIC}').graph = ${JSON.stringify({
|
||
nodes: [
|
||
node('vd66t-end', 'OnVideoFinished', 0, 0),
|
||
node('vd66t-time', 'Time', 0, 160),
|
||
node('vd66t-log', 'Log', 260, 0),
|
||
],
|
||
edges: [
|
||
edge('vd66t-e1', 'vd66t-end', 'exec', 'vd66t-log', 'exec'),
|
||
edge('vd66t-e2', 'vd66t-time', 'time', 'vd66t-log', 'message'),
|
||
],
|
||
})};
|
||
${MAKE_OBJECT}
|
||
const scene = __gm.state.activeScene;
|
||
const cam = scene.objects.find((o) => o.id === 'o3').position;
|
||
const slow = make('${SLOW}', '${DRIVEN}', '', 0, '${SLOW}');
|
||
Object.assign(slow.position, { x: cam.x - 200, y: cam.y - 100, z: 900 });
|
||
scene.objects.push(slow);`);
|
||
await t.evaluate(`(() => {
|
||
const make = document.createElement;
|
||
const src = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'src');
|
||
document.createElement = function (tag, ...rest) {
|
||
const element = make.call(document, tag, ...rest);
|
||
if (String(tag).toLowerCase() === 'video')
|
||
Object.defineProperty(element, 'src', {
|
||
configurable: true,
|
||
get: () => src.get.call(element),
|
||
set: (url) => setTimeout(() => src.set.call(element, url), 1500),
|
||
});
|
||
return element;
|
||
};
|
||
setTimeout(() => (document.createElement = make), 1000);
|
||
window.__vd66t = [];
|
||
(function read() {
|
||
const info = __gm.playInfo();
|
||
const r = info.playing && __gm.gameRect('${STEERED}');
|
||
if (r) window.__vd66t.push({ color: __gm.pixelAt(Math.round(r.x), Math.round(r.y)), waiting: info.waiting, time: info.time, at: performance.now() });
|
||
if (window.__vd66t.length < 600 && !(info.time > 2.4)) requestAnimationFrame(read);
|
||
})();
|
||
})()`);
|
||
const loggedFrom = t.consoleLog.length;
|
||
await play();
|
||
await waitFor(
|
||
() => json('__gm.playInfo().time'),
|
||
(time) => time > 2.4,
|
||
40,
|
||
);
|
||
await sleep(200);
|
||
const slowFrames = await json('window.__vd66t.splice(0)');
|
||
const logged = t.consoleLog
|
||
.slice(loggedFrom)
|
||
.filter((line) => line.includes(`[behavior ${STEER_BRAIN}]`))
|
||
.map((line) => Number(line.split(' ').at(-1)));
|
||
await stop();
|
||
const slowWait = slowFrames.filter((f) => f.waiting);
|
||
const slowRun = slowFrames.filter((f) => !f.waiting && f.time > 0);
|
||
const firstAt = (color) => slowRun.find((f) => f.color === color)?.time ?? null;
|
||
const [blueAt, whiteAt] = [firstAt(COLORS.blue), firstAt(COLORS.white)];
|
||
check(
|
||
'a free video waits with a run that waits for a driven one, and On video finished comes with its last picture',
|
||
slowWait.length > 0 &&
|
||
slowWait.at(-1).at - slowWait[0].at > 1000 &&
|
||
slowWait.every((f) => f.color === COLORS.red) &&
|
||
slowRun[0]?.color === COLORS.red &&
|
||
blueAt !== null &&
|
||
Math.abs(blueAt - 1) < 0.15 &&
|
||
whiteAt !== null &&
|
||
Math.abs(whiteAt - 1.5) < 0.15 &&
|
||
logged.length === 1 &&
|
||
Math.abs(logged[0] - 2) < 0.15,
|
||
JSON.stringify({
|
||
waited: slowWait.length && Math.round(slowWait.at(-1).at - slowWait[0].at),
|
||
waitColors: [...new Set(slowWait.map((f) => f.color))],
|
||
blueAt,
|
||
whiteAt,
|
||
logged,
|
||
frames: slowRun.map((f) => `${f.color}@${f.time.toFixed(2)}`).join(' '),
|
||
}),
|
||
);
|
||
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
const gone = ['${OBJECT}', '${EARLY}', '${LATE}', '${STEERED}', '${STEER_BRAIN}', '${SLOW}'];
|
||
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}', '${STEER}', '${STEER_LOGIC}', ${JSON.stringify(assetId)} ].includes(a.id));
|
||
__gm.state.select(null);
|
||
__gm.lookAt(0, 0);`);
|
||
}
|