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);`); }