import { readFileSync } from 'node:fs'; import { uploadVideo } from '../lib/projects.mjs'; import { MAKE_OBJECT } from './lib/scaffold.mjs'; /** * A composition placed in the scene (#417). In the editor the placement shows the composition's * first frame, and a click on that picture picks the placement. Played, a behavior below it starts * it: each item becomes an object below the placement while it lasts, the first track in front, a * clip moves the item it overlaps, and `On composition finished` hears the end, by which time no * item is left. Nothing of the run stays in the scene. An item's video is waited for: the clock * stands until the Game tab has its first picture. * * Everything it adds carries a `cp68` id and goes again at the end. */ const PLACE = 'cp68-place'; const DONE = 'cut 68 done'; const node = (id, type, x, y, params = {}) => ({ id: `cp68-${id}`, type, x, y, params }); const link = (id, from, fromPort, to, toPort) => ({ id: `cp68-${id}`, from: { node: `cp68-${from}`, port: fromPort }, to: { node: `cp68-${to}`, port: toPort }, }); export async function composition(t) { const { check, sleep, json, evaluate, mutate, play, stop, showTab } = t; await showTab('scene'); const graph = { nodes: [ node('start', 'OnStart', 0, 0), node('owner', 'Owner', 0, 150), node('play', 'PlayComposition', 300, 0), node('finished', 'OnCompositionFinished', 0, 300, { composition: 'cp68-cut' }), node('log', 'Log', 300, 300, { message: DONE }), ], edges: [ link('e1', 'start', 'exec', 'play', 'exec'), link('e2', 'owner', 'object', 'play', 'object'), link('e3', 'finished', 'exec', 'log', 'exec'), ], }; // Red from the start, a larger blue behind it from the next frame – built after red, it still goes // behind – and green after them, moved by a clip; ten frames a second, so the cut lasts 1.2 s. Placed where the camera looks, in front of what is there. await mutate(` const p = __gm.state.project; const scene = __gm.state.activeScene; const cam = scene.objects.find((o) => o.id === 'o3').position; ${MAKE_OBJECT} const sprite = p.assets.find((a) => a.type === 'sprite'); const look = (o, size, color) => { o.overrides = { width: size, height: size, color, texture: '' }; return o; }; const red = look(make('cp68-red', sprite.id, '', 0, 'Red 68'), 200, 0xff0000); const blue = look(make('cp68-blue', sprite.id, '', 0, 'Blue 68'), 300, 0x0000ff); const green = look(make('cp68-green', sprite.id, '', 0, 'Green 68'), 200, 0x00ff00); for (const o of [red, blue, green]) delete o.groupId; p.assets.push( { id: 'cp68-cut', type: 'composition', name: 'Cut 68', props: { fps: 10, loop: false }, objects: [red, blue, green], groups: [], tracks: [{ id: 'cp68-front', name: 'Front' }, { id: 'cp68-back', name: 'Back' }], items: [ { id: 'cp68-red', track: 'cp68-front', start: 0, length: 6, offset: 0 }, { id: 'cp68-blue', track: 'cp68-back', start: 1, length: 5, offset: 0 }, { id: 'cp68-green', track: 'cp68-front', start: 6, length: 6, offset: 0 }, ], clips: [{ id: 'cp68-clip', track: 'cp68-front', animation: 'cp68-move', start: 6 }], }, { id: 'cp68-move', type: 'animation', name: 'Slide 68', props: { fps: 10, playback: 'once' }, root: sprite.id, tracks: [{ id: 'cp68-row', target: '', property: 'x', keys: [{ frame: 0, value: 0 }, { frame: 5, value: 100 }] }], }, { id: 'cp68-director', type: 'behavior', name: 'Director 68', props: { enabled: true, runInEditor: false }, variables: [], graph: ${JSON.stringify(graph)} }, ); const place = make('${PLACE}', 'cp68-cut', '', 0, 'Cut 68'); Object.assign(place.position, { x: cam.x, y: cam.y, z: 2000 }); scene.objects.push(place, make('cp68-logic', 'cp68-director', '${PLACE}', 0, 'Director 68')); __gm.lookAt(cam.x, cam.y); `); const id = (item) => `${PLACE}~cp68-${item}`; // 68a. In the editor the placement shows its first frame – red alone – and a click on the picture // picks the placement, since what it shows is edited in the composition. const order = await json('__gm.drawOrder()'); // Off the centre, where the placement's own symbol sits. const rect = await json(`__gm.objectRect(${JSON.stringify(id('red'))})`); const stack = rect ? await json(`__gm.sceneStack(${rect.x + rect.w * 0.35}, ${rect.y + rect.h * 0.35})`) : null; check( 'a placed composition shows its first frame, and a click on it picks the placement', order.includes(id('red')) && !order.includes(id('blue')) && !order.includes(id('green')) && JSON.stringify(stack) === JSON.stringify([PLACE]), JSON.stringify({ order: order.filter((o) => o.startsWith('cp68')), stack }), ); // 68b. Played: the items come and go below the placement, sampled every frame of the editor. await play(); const seen = await evaluate(`new Promise((resolve) => { const frames = []; const sample = () => { const world = __gm.world(); if (!world) return resolve(JSON.stringify(frames)); const items = world.entities.filter((e) => e.objectId.startsWith('${PLACE}~')); const green = items.find((e) => e.objectId === '${id('green')}'); frames.push({ ids: items.map((e) => e.objectId.slice('${PLACE}~cp68-'.length)), parents: [...new Set(items.map((e) => e.parent))], greenX: green ? green.x : null, done: world.log.includes('${DONE}'), }); if (frames.length > 400 || frames.at(-1).done) return resolve(JSON.stringify(frames)); requestAnimationFrame(sample); }; sample(); })`); const frames = JSON.parse(seen); const both = frames.find((f) => f.ids.length === 2); const greenXs = frames.map((f) => f.greenX).filter((x) => x !== null); check( 'Play composition builds the items below the placement, the first track in front, one after the other', both !== undefined && JSON.stringify(both.ids) === JSON.stringify(['blue', 'red']) && both.parents.length === 1 && both.parents[0] === PLACE && !frames.some((f) => f.ids.join('+') === 'red+blue') && frames.some((f) => JSON.stringify(f.ids) === JSON.stringify(['green'])) && !frames.some((f) => f.ids.includes('green') && f.ids.includes('red')), JSON.stringify(frames.filter((_, i) => i % 5 === 0).map((f) => f.ids.join('+'))), ); check( 'a clip moves the item it overlaps, from its value on', greenXs.length >= 3 && greenXs[0] < 40 && greenXs.at(-1) > greenXs[0] && greenXs.every((x, i) => i === 0 || x >= greenXs[i - 1]), JSON.stringify(greenXs), ); const last = frames.at(-1); check( 'On composition finished hears the end, by which time no item is left', last?.done === true && last.ids.length === 0, JSON.stringify(last), ); await sleep(300); await stop(); // 68c. The run's objects stay in the run: the scene holds the placement and its behavior only. const stored = await json( `__gm.state.activeScene.objects.filter((o) => o.id.startsWith('cp68')).map((o) => o.id).sort()`, ); check( 'nothing of a played composition stays in the scene', JSON.stringify(stored) === JSON.stringify(['cp68-logic', PLACE].sort()), JSON.stringify(stored), ); // 68d. A video item at frame 0, played from On start: the composition's clock stands while the Game // tab loads the video, so its first frames are seen – it ran on without them before. const uploaded = await uploadVideo( t.baseUrl, readFileSync(new URL('./fixtures/colors.webm', import.meta.url)), 'colors.webm', ); await mutate(` const p = __gm.state.project; p.assets.push( { id: 'cp68-video', type: 'video', name: 'Colors 68', props: {}, file: ${JSON.stringify(uploaded.file)}, width: 320, height: 180, duration: 2, fps: 30, audio: true }, { id: 'cp68-screen', type: 'sprite', name: 'Screen 68', props: { width: 160, height: 90, color: 0xffffff, texture: 'cp68-video' } }, ); const c = p.assets.find((a) => a.id === 'cp68-cut'); // Sixty frames a second: in steps of a tenth, a clock that ran on for a few frames still read 0. c.props.fps = 60; c.objects = [{ ...structuredClone(c.objects[0]), id: 'cp68-v', assetId: 'cp68-screen', name: 'Video 68', overrides: {} }]; c.items = [{ id: 'cp68-v', track: 'cp68-front', start: 0, length: 120, offset: 0 }]; c.clips = [];`); await play(); // The video time the composition writes into its item every frame. const clock = `__gm.world()?.entities.find((e) => e.objectId === '${id('v')}')?.props.videoTime ?? null`; const wait = JSON.parse( await evaluate(`new Promise((done) => { const loading = []; const step = () => { const time = ${clock}; const player = __gm.videos().find((v) => v.key.endsWith(':${id('v')}')); if (player?.ready) return done(JSON.stringify({ loading, ready: time })); if (player) loading.push(time); if (!__gm.world() || loading.length > 300) return done(JSON.stringify({ loading, ready: null })); requestAnimationFrame(step); }; step(); })`), ); await sleep(500); const after = await json(clock); check( 'a played composition waits for the first picture of an item’s video, then runs on', wait.loading.length > 0 && wait.loading.every((time) => time === 0) && wait.ready !== null && wait.ready === 0 && after > 0.2, JSON.stringify({ frames: wait.loading.length, loading: wait.loading.slice(0, 8), ready: wait.ready, after, }), ); await stop(); await mutate(` const p = __gm.state.project; p.assets = p.assets.filter((a) => !a.id.startsWith('cp68-')); const scene = __gm.state.activeScene; scene.objects = scene.objects.filter((o) => !o.id.startsWith('cp68-'));`); }