import { readFileSync } from 'node:fs'; import { uploadVideo } from '../lib/projects.mjs'; import { MAKE_OBJECT, pickAssetType } from './lib/scaffold.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 game-maker-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 COLORS = { red: '#fe0000', green: '#00ff01', blue: '#0000ff', white: '#ffffff' }; export async function video(t) { const { check, sleep, json, mutate, click, clickText, drag, shot, showTab, play, stop } = t; const 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); await shot('66-video-tab'); // 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}', '', 0, 'video')); __gm.state.select(null); __gm.lookAt(0, 0);`); 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; 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), ); 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}'); __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 }), ); 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}'); p.assets = p.assets.filter((a) => a.id !== '${SPRITE}' && a.id !== ${JSON.stringify(assetId)}); __gm.state.select(null);`); }