A trailer needs its music and effects on the same timeline as its pictures. Until now a composition could only place objects, so sound had to be started by hand from a behavior and drifted against the picture. - Audio items: a sound, a clip of a sound, a pattern or a song dragged from the asset panel onto a track becomes an item as long as one pass of it. It may share a track with object and video items and overlap them. - Editing works as for other items: move, resize, trim on the left (moves the offset), fit to media, delete, copy and paste, box selection. Sounds draw their waveform, the colour tells the kind. - The inspector shows what it plays, start, length, offset, volume (-60 to +12 dB) and bus. The bus is "From asset", the master or one of the buses, as asked for in the issue. - An item longer than its material plays in rounds. Sounds and patterns start over, a song runs on in its loop. Loop marks on the item follow the same rule, a song's first mark sits at its loop end. - compositionAudio.ts turns the items into sequences, each with its own tempo. CompositionSound plays them, in the tab and in a game. - In the tab the items are heard from the playhead on, straight into the master like every tab. A scrub is silent. Playing a song or pattern in its own tab stops the composition, and the other way round. - In a game they are planned on the audio clock for the moment the composition clock reaches them, with the scheduler's LEAD made up. While the clock stands (waiting for a video, paused, placement off) they are silent and resume at the right spot. Found along the way: - The asset panel never let a song out of its list, so a song could not be dragged anywhere. Song and scene tab reject it as they reject a pattern. - The asset panel scrolled to the selected asset on every rebuild of its list, so folding a group far below the selection jumped back up. Only a new selection scrolls now. - At the end of the list, folding a group pulled every row down by the rows that went away. ScrollContainer has a new opt-in holdOffset, set only in the asset panel, that keeps the offset until the user scrolls up. Limits: a time scale other than 1 or a frame longer than 0.1 s moves the picture and not the sound. A baked song plays its notes here, not the bake. Tests: - tsc, eslint, Prettier clean. Gate on melektaus-atx green, 309 files, 4788 tests (4772 plus 16 new). - Unit: composition-audio.test.ts 13 new, scroll-container 3 new. Counter-checks: without catching up on load time or without the silence on pause the unit tests go red. - e2e 68h: the sound is planned 1.2 ms after the composition reaches 0.5 s. Without the LEAD make-up it is 54 ms late and red. - e2e 04 and 69 ALL PASSED, 86 checks. 69o-ii drags a pattern and a song onto a track, 20 frames each. - e2e 04: folding Sound at the end of the list keeps its header at y 234. With the old panel it jumps to 984 and the check is red. - Not run in a browser: the loop marks of a song item, copy and paste of audio items, the bus choice. - README and wiki count 2072 checks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
599 lines
27 KiB
JavaScript
599 lines
27 KiB
JavaScript
/**
|
||
* 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.
|
||
*
|
||
* 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.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 }),
|
||
);
|
||
|
||
await showTab('scene');
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
p.assets = p.assets.filter((a) => !a.id.startsWith('ct69-') && !a.owner?.startsWith('ct69-'));`);
|
||
}
|