/** * 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 there, * over it. The end of the first, covered by the second, lights up under the pointer and is dragged * past it. 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, and Play waits for a video whose player is still loading. A copied * item is pasted under the pointer or at the playhead. A video dragged in wears a sprite the * composition keeps to itself. An item dragged in the picture follows the pointer while the button * is down. A band picks items, with Alt clips, and copied clips are pasted where they fit. A sound * dragged in is an audio item, heard while the playhead runs; the handle of its fade in drags out a * fade its bus rises along. A video item's sprite reads as driven by the composition and goes into * its bus, which has a node in the master routing; after a hitch the sound is started again where * the playhead is. * * 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 where it is let go, over the first. 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 where it is let go, over it', m.items.length === 2 && b?.start === 20 && b.length === 30, JSON.stringify(m.items), ); // 69c. The first item's end, under the second, lights up with a resize cursor and is dragged past it. const box = await json(`__gm.composition.box('${a.id}')`); const end = { x: box.x + box.w - 2, y: box.y + box.h / 2 }; await mouse('mouseMoved', end.x - 20, end.y); await mouse('mouseMoved', end.x, end.y); await sleep(80); const hovered = await json('__gm.composition.view()'); const far = await json(`__gm.composition.laneAt('ct69-t1', 60)`); await drag(end.x, end.y, far.x, end.y); await sleep(150); m = await model(); const resized = m.items.find((i) => i.id === a.id); check( 'the covered end of an item lights up with a resize cursor, and is dragged past the item over it', hovered.hover?.id === a.id && hovered.hover.part === 'end' && hovered.cursor === 'ew-resize' && resized?.length === 55 && resized.fitMedia === undefined, JSON.stringify({ hover: hovered.hover, cursor: hovered.cursor, 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([a.id, 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 === 60, 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 }), ); // 69i. Play at the end starts over at 0, where the item's video was never opened: the playhead // waits for its first picture, so its first frames are seen – it ran on without them before. await mutate(` const c = __gm.state.project.assets.find((a) => a.id === '${CUT}'); c.objects = [{ ...structuredClone(c.objects[0]), id: 'ct69-v3' }]; c.items = [{ id: 'ct69-v3', track: 'ct69-t1', start: 0, length: 20, offset: 0 }];`); await evaluate(`__gm.composition.scrub(20)`); await sleep(200); await mouse('mousePressed', playAt.x, playAt.y); await mouse('mouseReleased', playAt.x, playAt.y); const wait = JSON.parse( await evaluate(`new Promise((done) => { const loading = []; const step = () => { const player = __gm.videos().find((v) => v.key.endsWith(':ct69-v3')); const { playhead } = __gm.composition.view(); if (player?.ready) return done(JSON.stringify({ loading, ready: playhead })); if (player) loading.push(playhead); if (loading.length > 300) return done(JSON.stringify({ loading, ready: null })); requestAnimationFrame(step); }; step(); })`), ); await sleep(500); const after = await json('__gm.composition.view()'); check( 'Play waits at a video item whose player is still loading, then runs on', wait.loading.length > 0 && wait.loading.every((at) => at === 0) && wait.ready !== null && wait.ready < 0.5 && after.playhead > 2, JSON.stringify({ frames: wait.loading.length, ready: wait.ready, after: after.playhead }), ); // 69j. Ctrl+C copies the picked item. Ctrl+V pastes it under the pointer while that is over the // lanes, on the track there; with the pointer off the lanes at the playhead, on the picked track. for (let i = 0; i < 30 && (await json('__gm.composition.view()')).playing; i++) await sleep(150); await mutate(` const c = __gm.state.project.assets.find((a) => a.id === '${CUT}'); c.objects = [{ ...structuredClone(c.objects[0]), id: 'ct69-p1', assetId: '${BOX}' }]; c.items = [{ id: 'ct69-p1', track: 'ct69-t1', start: 0, length: 30, offset: 0 }];`); const original = await json(`__gm.composition.box('ct69-p1')`); await t.click(original.x + original.w / 2, original.y + original.h / 2); await press('c', { ctrl: true }); const lane = await json(`__gm.composition.laneAt('ct69-t2', 50)`); await mouse('mouseMoved', lane.x, lane.y); await press('v', { ctrl: true }); await evaluate(`__gm.composition.scrub(12)`); await mouse('mouseMoved', playAt.x, playAt.y); await press('v', { ctrl: true }); m = await model(); const copies = m.items.filter((i) => i.id !== 'ct69-p1'); const pickedNow = await json('__gm.state.selection'); check( 'Ctrl+V pastes a copied item under the pointer, else at the playhead on the picked track', m.items.length === 3 && m.objects.length === 3 && copies[0]?.track === 'ct69-t2' && copies[0].start === 50 && copies[0].length === 30 && copies[1]?.track === 'ct69-t2' && copies[1].start === 12 && pickedNow?.objectIds?.[0] === copies[1].id, JSON.stringify({ copies, picked: pickedNow?.objectIds }), ); // 69k. A video dragged straight onto a track (#419) is an item as long as the video, wearing a sprite // the composition keeps and the Assets panel leaves out; a second drop shares it, and undoing both // takes the sprite along. const kept = (id) => json(`(() => { const p = __gm.state.project; const c = p.assets.find((a) => a.id === '${CUT}'); const object = c.objects.find((o) => o.id === ${JSON.stringify(id)}); const sprite = object && p.assets.find((a) => a.id === object.assetId); return { item: c.items.find((i) => i.id === ${JSON.stringify(id)}) ?? null, sprite: sprite ? { id: sprite.id, type: sprite.type, owner: sprite.owner, texture: sprite.props.texture } : null, owned: p.assets.filter((a) => a.owner === '${CUT}').length }; })()`); const videoDrop = await dragAsset(VIDEO, await json(`__gm.composition.laneAt('ct69-t2', 70)`)); const one = await kept((await json('__gm.state.selection'))?.objectIds?.[0] ?? ''); const row = one.sprite ? await json(`__gm.assetRow('${one.sprite.id}')`) : 'none'; await dragAsset(VIDEO, await json(`__gm.composition.laneAt('ct69-t2', 100)`)); const two = await kept((await json('__gm.state.selection'))?.objectIds?.[0] ?? ''); await evaluate('__gm.state.undo()'); await evaluate('__gm.state.undo()'); const undone = await kept(''); check( 'a video dropped on a track wears a sprite the composition keeps, shared by a second drop', videoDrop?.what === 'item' && videoDrop.rejection === '' && one.item?.track === 'ct69-t2' && one.item.start === 70 && one.item.length === 20 && one.sprite?.type === 'sprite' && one.sprite.owner === CUT && one.sprite.texture === VIDEO && row === null && two.item?.start === 100 && two.sprite?.id === one.sprite.id && two.owned === 1 && undone.owned === 0, JSON.stringify({ videoDrop, one, row, two, undone }), ); // 69l. An item dragged in the picture follows the pointer while the button is down, not only when // it is let go: the drag's preview each frame does not build the picture again from the document. await evaluate('__gm.composition.scrub(15)'); await sleep(100); const front = (await json('__gm.composition.shown()')).at(-1)?.id ?? ''; const startAt = await json(`__gm.composition.objectScreen('${front}')`); const objectX = () => json( `__gm.state.project.assets.find((a) => a.id === '${CUT}').objects.find((o) => o.id === '${front}').position.x`, ); const xBefore = await objectX(); await mouse('mouseMoved', startAt.x, startAt.y); await mouse('mousePressed', startAt.x, startAt.y); for (let i = 1; i <= 6; i++) { await mouse('mouseMoved', startAt.x + i * 10, startAt.y, 'left', { buttons: 1 }); await sleep(16); } await sleep(100); const during = await json(`__gm.composition.objectScreen('${front}')`); await mouse('mouseReleased', startAt.x + 60, startAt.y); await sleep(150); const xAfter = await objectX(); check( 'an item dragged in the picture follows the pointer while dragged and keeps the place let go', Math.abs(during.x - startAt.x - 60) < 2 && xAfter > xBefore, JSON.stringify({ front, startAt, during, xBefore, xAfter }), ); // 69m. A band from an empty spot picks the items it touches, with Alt – from anywhere, an item too – // the clips. Ctrl+C and Ctrl+V take the clips under the pointer; a second paste onto the first // moves on until the clips fit, as clips of a track never overlap. await mutate(` const c = __gm.state.project.assets.find((a) => a.id === '${CUT}'); const like = structuredClone(c.objects[0]); c.objects = ['ct69-b1', 'ct69-b2', 'ct69-b3'].map((id) => ({ ...like, id, assetId: '${BOX}' })); c.items = [ { id: 'ct69-b1', track: 'ct69-t1', start: 0, length: 30, offset: 0 }, { id: 'ct69-b2', track: 'ct69-t1', start: 40, length: 30, offset: 0 }, { id: 'ct69-b3', track: 'ct69-t2', start: 0, length: 30, offset: 0 }, ]; c.clips = [ { id: 'ct69-k1', track: 'ct69-t1', animation: '${SLIDE}', start: 0 }, { id: 'ct69-k2', track: 'ct69-t2', animation: '${SLIDE}', start: 10 }, ];`); await sleep(100); const empty = await json(`__gm.composition.laneAt('ct69-t2', 90)`); const into = await json(`__gm.composition.laneAt('ct69-t1', 25)`); await drag(empty.x, empty.y, into.x, into.y); const banded = await json('__gm.state.selection'); const k1 = await json(`__gm.composition.box('ct69-k1')`); const k2 = await json(`__gm.composition.box('ct69-k2')`); await drag(k1.x + 1, k1.y - 3, k2.x + k2.w + 3, k2.y + k2.h - 1, { modifiers: { alt: true } }); const altBanded = await json('__gm.state.selection'); await press('c', { ctrl: true }); const pasteAt = await json(`__gm.composition.laneAt('ct69-t1', 50)`); await mouse('mouseMoved', pasteAt.x, pasteAt.y); await press('v', { ctrl: true }); const firstPaste = (await model()).clips.slice(2); await press('v', { ctrl: true }); m = await model(); const secondPaste = m.clips.slice(4); const pastedPick = await json('__gm.state.selection'); check( 'a band picks the items it touches, with Alt the clips, and copied clips paste where they fit', JSON.stringify([...(banded?.objectIds ?? [])].sort()) === JSON.stringify(['ct69-b1', 'ct69-b2', 'ct69-b3']) && altBanded?.kind === 'composition' && JSON.stringify([...altBanded.clipIds].sort()) === JSON.stringify(['ct69-k1', 'ct69-k2']) && firstPaste.length === 2 && firstPaste[0].track === 'ct69-t1' && firstPaste[0].start === 50 && firstPaste[1].track === 'ct69-t2' && firstPaste[1].start === 60 && secondPaste.length === 2 && secondPaste[0].start - firstPaste[0].start >= 5 && secondPaste[0].start - firstPaste[0].start <= 6 && secondPaste[1].start - secondPaste[0].start === 10 && JSON.stringify(pastedPick?.clipIds) === JSON.stringify(secondPaste.map((c) => c.id)), JSON.stringify({ banded: banded?.objectIds, altBanded, firstPaste, secondPaste, pastedPick }), ); // 69n. A dragged item takes the clips that begin on it along; with Alt pressed once the drag runs // it goes alone. The clip of the item next to it stays where it is. await mutate(` const c = __gm.state.project.assets.find((a) => a.id === '${CUT}'); c.items = [ { id: 'ct69-b1', track: 'ct69-t1', start: 0, length: 30, offset: 0 }, { id: 'ct69-b2', track: 'ct69-t1', start: 70, length: 30, offset: 0 }, { id: 'ct69-b3', track: 'ct69-t2', start: 0, length: 30, offset: 0 }, ]; c.clips = [ { id: 'ct69-k1', track: 'ct69-t1', animation: '${SLIDE}', start: 5 }, { id: 'ct69-k2', track: 'ct69-t1', animation: '${SLIDE}', start: 75 }, ];`); await sleep(100); const frameWidth = ((await json(`__gm.composition.laneAt('ct69-t1', 100)`)).x - (await json(`__gm.composition.laneAt('ct69-t1', 0)`)).x) / 100; /** Drags the item by `frames` from its middle – where no clip lies –, Alt held only while it moves. */ const dragItem = async (id, frames, alt) => { const box = await json(`__gm.composition.box('${id}')`); const from = { x: box.x + box.w / 2, y: box.y + box.h / 2 }; const to = from.x + frames * frameWidth; await mouse('mouseMoved', from.x, from.y); await mouse('mousePressed', from.x, from.y); for (let i = 1; i <= 12; i++) { await mouse('mouseMoved', from.x + ((to - from.x) * i) / 12, from.y, 'left', { buttons: 1, modifiers: alt ? 1 : 0, }); await sleep(16); } await mouse('mouseReleased', to, from.y); await sleep(150); const now = await model(); return { from, picked: (await json('__gm.state.selection'))?.objectIds, item: now.items.find((i) => i.id === id)?.start, clips: Object.fromEntries(now.clips.map((c) => [c.id, c.start])), }; }; const along = await dragItem('ct69-b1', 20, false); const alone = await dragItem('ct69-b1', -15, true); check( 'a dragged item takes the clips that begin on it along, with Alt it goes alone', along.item === 20 && along.clips['ct69-k1'] === 25 && along.clips['ct69-k2'] === 75 && alone.item === 5 && alone.clips['ct69-k1'] === 25 && alone.clips['ct69-k2'] === 75, JSON.stringify({ frameWidth, along, alone }), ); // 69o (#418). A sound dragged onto a track is an audio item as long as one pass of it, picked on // the composition, the inspector naming it. Played from the middle, the composition plans it as a // clip that far into the sound, on the item's own bus and to the item's end; Pause ends it, and a // scrub plays nothing. const sounds = await evaluate(`fetch('/feuerwerk/api/index.php?r=sounds').then((r) => r.json())`); const blip = sounds.find((item) => item.bytes === 4044); await mutate(` const p = __gm.state.project; p.assets.push({ id: 'ct69-sound', type: 'sound', name: 'Blip 69', props: {}, file: ${JSON.stringify(blip?.file ?? '')} }); const c = p.assets.find((a) => a.id === '${CUT}'); c.objects = []; c.items = []; c.clips = [];`); const seconds = await evaluate(`__gm.sound.load(${JSON.stringify(blip?.file ?? '')})`); const soundDrop = await dragAsset('ct69-sound', await json(`__gm.composition.laneAt('ct69-t2', 10)`)); m = await model(); const tone = m.items[0]; const tonePick = await json('__gm.state.selection'); const toneSection = await json(`__gm.section('SOUND ITEM')`); await mutate(` const c = __gm.state.project.assets.find((a) => a.id === '${CUT}'); c.items[0].length = 20;`); await evaluate('__gm.composition.scrub(15)'); await sleep(200); const scrubbed = (await json('__gm.sound.playbacks()')).filter((p) => p.name === `composition:${CUT}`); const toneAt = await json('__gm.composition.playAt()'); await mouse('mouseMoved', toneAt.x, toneAt.y); await t.click(toneAt.x, toneAt.y); let heard = []; for (let i = 0; i < 40 && !heard.some((p) => p.name === `composition:${CUT}` && p.clips.length > 0); i++) { await sleep(50); heard = await json('__gm.sound.playbacks()'); } const toneView = await json('__gm.composition.view()'); const toneSound = await json('__gm.composition.sound()'); const playing = heard.find((p) => p.name === `composition:${CUT}`); const toneClip = playing?.clips[0]; await t.click(toneAt.x, toneAt.y); await sleep(300); const paused = (await json('__gm.sound.playbacks()')).filter((p) => p.name === `composition:${CUT}`); check( 'a sound dragged onto a track is an audio item, heard from the playhead on its own bus until the item ends', seconds > 0 && soundDrop?.what === 'item' && tone?.audio?.kind === 'sound' && tone.audio.asset === 'ct69-sound' && tone.audio.bus === '#composition' && tone.start === 10 && tone.length === Math.max(1, Math.round(seconds * 10)) && m.objects.length === 0 && tonePick?.kind === 'composition' && JSON.stringify(tonePick.itemIds) === JSON.stringify([tone.id]) && toneSection !== null && scrubbed.length === 0 && toneClip?.sound === 'ct69-sound' && toneClip.bus === `composition:${CUT}:${tone.id}` && toneSound.playing && toneSound.playings.includes(playing.id) && toneClip.offset > 0.5 && toneClip.offset < 0.8 && Math.abs(toneClip.offset + toneClip.duration - 2) < 0.002 && paused.length === 0, JSON.stringify({ seconds, soundDrop, tone, tonePick, scrubbed, toneClip, paused: paused.length, toneView, heard, toneSection, toneSound, }), ); // 69o-ii. A pattern and a song leave the Assets panel as well and land as audio items as long as // their preview promised – the panel once let only sounds go. await mutate(` const p = __gm.state.project; p.assets.push( { id: 'ct69-pattern', type: 'pattern', name: 'Riff 69', props: { bars: 1 }, notes: [] }, { id: 'ct69-song', type: 'song', name: 'Song 69', props: {}, tracks: [], items: [], graph: { nodes: [], edges: [] } }, ); p.assets.find((a) => a.id === '${CUT}').items = [];`); const patternDrop = await dragAsset('ct69-pattern', await json(`__gm.composition.laneAt('ct69-t1', 10)`)); const songDrop = await dragAsset('ct69-song', await json(`__gm.composition.laneAt('ct69-t2', 10)`)); m = await model(); const kinds = m.items.map((i) => ({ kind: i.audio?.kind, asset: i.audio?.asset, start: i.start, length: i.length, })); const landed = (drop, kind) => drop?.what === 'item' && kinds.some( (i) => i.kind === kind && i.asset === drop.assetId && i.start === 10 && i.length === drop.length, ); check( 'a pattern and a song dragged onto a track are audio items as long as their preview', landed(patternDrop, 'pattern') && landed(songDrop, 'song') && m.items.length === 2, JSON.stringify({ patternDrop, songDrop, kinds }), ); // 69p. The handle on the top left corner of a sound item lights up under the pointer and drags // its fade in out to 10 frames; played from the item's start, its bus rises from silence over that // second and stays up. await mutate(` __gm.state.project.assets.find((a) => a.id === '${CUT}').items = [ { id: 'ct69-fade', track: 'ct69-t2', start: 10, length: 40, offset: 0, audio: { kind: 'sound', asset: 'ct69-sound', volume: 0, bus: '' } }, ];`); await evaluate('__gm.composition.scrub(0)'); await sleep(100); const fadeBox = await json(`__gm.composition.box('ct69-fade')`); const perFrame = (await json('__gm.composition.view()')).pxPerTick; await mouse('mouseMoved', fadeBox.x + 1, fadeBox.y + 3); await sleep(50); const fadeHover = (await json('__gm.composition.view()')).hover; await drag(fadeBox.x + 1, fadeBox.y + 3, fadeBox.x + 10 * perFrame, fadeBox.y + 3); const faded = (await model()).items[0]; await evaluate('__gm.composition.scrub(10)'); const fadeAt = await json('__gm.composition.playAt()'); await t.click(fadeAt.x, fadeAt.y); const levels = []; for (let i = 0; i < 16; i++) { await sleep(100); const bus = (await json('__gm.song.buses()')).find((b) => b.name.endsWith(`composition:${CUT}:ct69-fade`), ); levels.push(bus ? Number(bus.level.toFixed(3)) : null); } await t.click(fadeAt.x, fadeAt.y); const heardLevels = levels.filter((l) => l !== null); check( 'the fade handle of a sound item drags its fade in, and its bus rises from silence over it', fadeHover?.id === 'ct69-fade' && fadeHover.part === 'fadeIn' && faded?.fadeIn === 10 && faded.fadeOut === undefined && heardLevels[0] < 0.5 && heardLevels.some((l) => l > 0.1 && l < 0.9) && heardLevels.at(-1) > 0.99, JSON.stringify({ fadeBox, perFrame, fadeHover, faded, levels }), ); // 69q. A video dragged in: its sprite's Video clock reads as driven by the composition, its Video // time – the composition's – is not shown, its Video bus goes into the composition by default. The // composition has a node of its own in the master routing, linked to the master. await mutate(` const c = __gm.state.project.assets.find((a) => a.id === '${CUT}'); c.objects = []; c.items = [];`); await dragAsset(SCREEN, await json(`__gm.composition.laneAt('ct69-t1', 0)`)); const screenItem = (await model()).items[0]; // A row is looked for once it is scrolled into view: the inspector is taller than the window. const videoRows = await json(`({ driven: __gm.inspectorField('Video clock') !== null && __gm.locate('Driven (by composition)') !== null, time: __gm.inspectorField('Video time'), volume: __gm.inspectorField('Video volume') !== null, bus: __gm.inspectorField('Video bus') !== null && __gm.locate('To composition') !== null, stored: __gm.state.project.assets.find((a) => a.id === '${CUT}').objects[0]?.overrides ?? null, })`); const routing = await json('__gm.routing.graph()'); const node = routing.nodes.find((n) => n.id === CUT); const links = routing.edges.filter((e) => e.from.node === CUT).map((e) => e.to.node); check( 'a video item reads as driven by the composition, goes into its bus, and the composition is routed', screenItem !== undefined && videoRows.driven && videoRows.time === null && videoRows.volume && videoRows.bus && videoRows.stored?.videoClock === undefined && node?.params.kind === 'composition' && JSON.stringify(links) === JSON.stringify(['#master']), JSON.stringify({ screenItem, videoRows, node, links }), ); // 69r. A hitch of half a second while the tab plays: the playhead counts a tenth of it at most, the // sound ran on – it is started again where the playhead is, and the two keep together after. await mutate(` const c = __gm.state.project.assets.find((a) => a.id === '${CUT}'); c.objects = []; c.items = [{ id: 'ct69-long', track: 'ct69-t2', start: 0, length: 100, offset: 0, audio: { kind: 'sound', asset: 'ct69-sound', volume: 0, bus: '' } }];`); await evaluate('__gm.composition.scrub(0)'); const longAt = await json('__gm.composition.playAt()'); await t.click(longAt.x, longAt.y); let hitchBefore = null; for (let i = 0; i < 40 && !(hitchBefore?.playing && hitchBefore.ahead !== null); i++) { await sleep(50); hitchBefore = await json('__gm.composition.sound()'); } await sleep(300); hitchBefore = await json('__gm.composition.sound()'); await evaluate(`{ const until = performance.now() + 500; while (performance.now() < until); null; }`); await sleep(400); const hitchAfter = await json('__gm.composition.sound()'); await t.click(longAt.x, longAt.y); check( 'after a hitch the sound of the tab is started again where the playhead is', hitchBefore?.playing && Math.abs(hitchBefore.ahead) < 0.06 && hitchAfter.resyncs > hitchBefore.resyncs && hitchAfter.playing && Math.abs(hitchAfter.ahead) < 0.06, JSON.stringify({ hitchBefore, hitchAfter }), ); await showTab('scene'); await mutate(` const p = __gm.state.project; p.assets = p.assets.filter((a) => !a.id.startsWith('ct69-') && !a.owner?.startsWith('ct69-'));`); }