/** * The Composition tab (#417). A sprite dragged in from the Assets panel becomes an item, 3 s long * and picked, so the inspector shows its TIMING; a second one dropped onto the first lands right * after it. A dragged end stops at the next item. An animation dragged in becomes a clip that moves * the item it overlaps in the picture. The hierarchy lists what is alive at the playhead, Delete * takes an item with its object, and Play runs the playhead to the end. The video of an item about * to begin is opened ahead, so the cut to it shows its picture at once. * * Everything it adds carries a `ct69` id and goes again at the end. */ import { readFileSync } from 'node:fs'; import { uploadVideo } from '../lib/projects.mjs'; const CUT = 'ct69-cut'; const BOX = 'ct69-box'; const SLIDE = 'ct69-slide'; const VIDEO = 'ct69-video'; const SCREEN = 'ct69-screen'; export async function compositionTab(t) { const { check, sleep, json, evaluate, mutate, mouse, drag, press, showTab } = t; /** Drags a row of the Assets panel to a point step by step: the drop only exists while the button is down. */ const dragAsset = async (assetId, to) => { // A press on the row of the drag before, this soon, would read as a double-click that opens it. await sleep(450); const from = await json(`__gm.assetRow(${JSON.stringify(assetId)})`); await mouse('mouseMoved', from.x, from.y); await mouse('mousePressed', from.x, from.y); for (let i = 1; i <= 8; i++) { await mouse( 'mouseMoved', from.x + ((to.x - from.x) * i) / 8, from.y + ((to.y - from.y) * i) / 8, 'left', { buttons: 1, }, ); await sleep(16); } const drop = await json('__gm.composition.drop()'); await mouse('mouseReleased', to.x, to.y); await sleep(200); return drop; }; const model = () => json(`(() => { const c = __gm.state.project.assets.find((a) => a.id === '${CUT}'); return { objects: c.objects.map((o) => o.id), items: c.items, clips: c.clips }; })()`); await press('Escape'); await showTab('scene'); // Ten frames a second keeps the numbers small: an item of 3 s is 30 frames, the slide 5. await mutate(` const p = __gm.state.project; p.assets.push( { id: '${CUT}', type: 'composition', name: 'Cut 69', props: { fps: 10, loop: false }, objects: [], groups: [], tracks: [{ id: 'ct69-t1', name: 'Front' }, { id: 'ct69-t2', name: 'Back' }], items: [], clips: [] }, { id: '${BOX}', type: 'sprite', name: 'Box 69', props: { width: 100, height: 100, color: 0xff0000, texture: '' } }, { id: '${SLIDE}', type: 'animation', name: 'Slide 69', props: { fps: 10, playback: 'once' }, root: '${BOX}', tracks: [{ id: 'ct69-row', target: '', property: 'x', keys: [{ frame: 0, value: 0 }, { frame: 5, value: 100 }] }] }, );`); await evaluate(`__gm.state.openAsset('${CUT}')`); await sleep(300); const opened = await json(`{ tab: __gm.tab(), current: __gm.composition.current() }`); check( 'opening a composition brings up its tab', opened.tab === 'composition' && opened.current === CUT, JSON.stringify(opened), ); // 69a. A sprite dropped on the first track at frame 5 is an item there, 3 s long, picked. const first = await dragAsset(BOX, await json(`__gm.composition.laneAt('ct69-t1', 5)`)); let m = await model(); const a = m.items[0]; const picked = await json('__gm.state.selection'); check( 'a sprite dragged onto a track becomes an item where it is let go, 3 s long and picked', first?.what === 'item' && first.rejection === '' && m.items.length === 1 && a.start === 5 && a.length === 30 && a.fitMedia === true && JSON.stringify(m.objects) === JSON.stringify([a.id]) && picked?.kind === 'object' && picked.holderId === CUT && picked.objectId === a.id, JSON.stringify({ first, m, picked }), ); const timing = await json(`__gm.section('TIMING')`); check('the inspector shows the picked item’s TIMING', timing !== null, JSON.stringify(timing)); // 69b. A second one let go over the first lands right after it. await dragAsset(BOX, await json(`__gm.composition.laneAt('ct69-t1', 20)`)); m = await model(); const b = m.items.find((i) => i.id !== a.id); check( 'one dropped onto an item lands right after it', m.items.length === 2 && b?.start === 35 && b.length === 30, JSON.stringify(m.items), ); // 69c. The first item's end dragged past the second stops where the second begins. const box = await json(`__gm.composition.box('${a.id}')`); const far = await json(`__gm.composition.laneAt('ct69-t1', 60)`); await drag(box.x + box.w - 2, box.y + box.h / 2, far.x, box.y + box.h / 2); await sleep(150); m = await model(); const resized = m.items.find((i) => i.id === a.id); check( 'a dragged end stops where the next item begins, and the length is the item’s own then', resized?.length === 30 && resized.fitMedia === undefined, JSON.stringify(resized), ); // 69d. The animation dropped at frame 5 is a clip one pass long that moves the item in the picture. const clipDrop = await dragAsset(SLIDE, await json(`__gm.composition.laneAt('ct69-t1', 5)`)); m = await model(); const x = async (frame) => { await evaluate(`__gm.composition.scrub(${frame})`); await sleep(60); return (await json('__gm.composition.shown()')).find((o) => o.id === a.id)?.x ?? null; }; const xs = [await x(5), await x(8), await x(20)]; check( 'an animation dragged onto a track becomes a clip, and moves the item it overlaps in the picture', clipDrop?.what === 'clip' && m.clips.length === 1 && m.clips[0].start === 5 && m.clips[0].animation === SLIDE && xs[0] === 0 && xs[1] === 60 && xs[2] === 100, JSON.stringify({ clipDrop, clips: m.clips, xs }), ); // 69e. The hierarchy lists what is alive at the playhead – read off the rows it shows. const rowsAt = async (frame) => { await evaluate(`__gm.composition.scrub(${frame})`); await sleep(80); const shown = await json(`[${m.items.map((i) => `__gm.hierarchyRow('${i.id}')`).join(',')}]`); return m.items.filter((_, n) => shown[n] !== null).map((i) => i.id); }; const rows = [await rowsAt(2), await rowsAt(10), await rowsAt(40)]; check( 'the hierarchy lists the items alive at the playhead', rows[0].length === 0 && JSON.stringify(rows[1]) === JSON.stringify([a.id]) && JSON.stringify(rows[2]) === JSON.stringify([b.id]), JSON.stringify(rows), ); // 69f. Delete takes the picked item with its object; undo brings both back. await evaluate(`__gm.state.select({ kind: 'object', holderId: '${CUT}', objectId: '${b.id}' })`); await press('Delete'); await sleep(150); const gone = await model(); await press('z', { ctrl: true }); await sleep(150); const back = await model(); check( 'Delete takes an item with its object, undo brings both back', !gone.items.some((i) => i.id === b.id) && !gone.objects.includes(b.id) && back.items.some((i) => i.id === b.id) && back.objects.includes(b.id), JSON.stringify({ gone, back }), ); // 69g. Play runs the playhead from the start to the end of the last item, and stops there. await evaluate(`__gm.composition.scrub(0)`); const playAt = await json('__gm.composition.playAt()'); await mouse('mouseMoved', playAt.x, playAt.y); await mouse('mousePressed', playAt.x, playAt.y); await mouse('mouseReleased', playAt.x, playAt.y); await sleep(500); const running = await json('__gm.composition.view()'); let ended = running; for (let i = 0; i < 40 && ended.playing; i++) { await sleep(150); ended = await json('__gm.composition.view()'); } check( 'Play runs the playhead to the end and stops there', running.playing && running.playhead > 0 && !ended.playing && ended.playhead === 65, JSON.stringify({ running, ended }), ); // 69h. Two video items back to back: the second one's video is opened a second ahead, so the cut // draws its picture in the very redraw it begins – loaded only then, it was left out until it came. 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: '${VIDEO}', type: 'video', name: 'Colors 69', props: {}, file: ${JSON.stringify(uploaded.file)}, width: 320, height: 180, duration: 2, fps: 30, audio: true }, { id: '${SCREEN}', type: 'sprite', name: 'Screen 69', props: { width: 160, height: 90, color: 0xffffff, texture: '${VIDEO}' } }, ); const c = p.assets.find((a) => a.id === '${CUT}'); const object = (id) => ({ ...structuredClone(c.objects[0]), id, assetId: '${SCREEN}', overrides: {}, instanceEdits: {} }); c.objects = [object('ct69-v1'), object('ct69-v2')]; c.items = [ { id: 'ct69-v1', track: 'ct69-t1', start: 0, length: 20, offset: 0 }, { id: 'ct69-v2', track: 'ct69-t1', start: 20, length: 10, offset: 1 }, ]; c.clips = [];`); const second = `__gm.videos().find((v) => v.key.endsWith(':ct69-v2')) ?? null`; await evaluate(`__gm.composition.scrub(5)`); await sleep(300); const early = await json(second); await evaluate(`__gm.composition.scrub(12)`); let ahead = null; // Settled: a seek still landing would hold the view a frame, which is no picture missing. for (let i = 0; i < 40 && !(ahead?.ready && !ahead.seeking && !ahead.landing); i++) { await sleep(150); ahead = await json(second); } const cut = await json( `(() => { __gm.composition.scrub(20); return __gm.composition.drawn('ct69-v2'); })()`, ); check( 'a coming video item is opened a second ahead, at its offset, and drawn in the redraw it begins', early === null && ahead?.ready && ahead.paused && Math.abs(ahead.time - 1) < 0.05 && cut !== null, JSON.stringify({ early, ahead, cut }), ); await showTab('scene'); await mutate(` const p = __gm.state.project; p.assets = p.assets.filter((a) => !a.id.startsWith('ct69-'));`); }