Files
feuerwerk/tools/e2e/69-composition-tab.mjs
adminandClaude Opus 5.5 2e4c0a09ea
feuerwerk / gate (push) Successful in 4m58s
feuerwerk / suite (push) Failing after 19m37s
Open clips and frames from the asset panel by double click (#430)
A double click on a clip row did not open its tab. The first click of
the double click had already selected the clip for the inspector, so
the second click selected the same thing again and the editor saw no
change. Frames of textures and pixel art had no rows at all.

- A double click now asks for the tab with openSelection, even when
  the selection is already right.
- Textures with frames and pixel art with more than one frame get a
  chevron and one row per frame, like sounds and videos. Which rows an
  asset has is decided in one place, src/core/model/assetParts.ts,
  used by the panel and the inspector link.
- A texture frame shows in the inspector on click and opens the
  texture with the frame selected on double click.
- A pixel art frame selects the pixel art on click, so an open tab
  paints on that frame. A double click opens the tab on the frame.
- A frame can be dragged onto a composition track like a video clip.
  The item carries the sprite of the whole image, the frame becomes
  the texture of its object, and it is 3 s long. The agent can do the
  same with add_composition_item.
- A texture label that names a frame now points at the frame row.

Found on the way: the e2e check from #425 only double clicked the
texture label, where the first click is a ping, so the bug never
showed there.

Tests:
- Unit: asset-parts (new, 6), composition-sprites and references
  extended, related tests 303/303, agent tests 21/21
- 5 mutations, all red in the matching test
- e2e 69 (atx): ALL PASSED, 26 checks, 69t new
- Gate (atx): CHECK EXIT 0, 325 files, 5017 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 19:30:29 +02:00

901 lines
41 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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. Clips of a sound and a video have rows in the Assets panel, are dragged onto a
* track like an asset, and the Texture label links the clip.
*
* 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 }),
);
// 69s (#425). The clips of a sound and of a video have rows of their own under it in the Assets
// panel, folded until its chevron opens them. Each dragged onto a track is an item as long as the
// clip: the sound's an audio item playing it, the video's wearing the sprite of its video with the
// clip as texture, its picture from the clip's start. Its Texture label links the clip: a click
// pings its row, a double-click opens the video with the clip picked.
// The drone of the seed lasts 0.75 s, so its clip of 0.5 s is told apart from it.
const drone = sounds.find((item) => item.bytes === 12044);
await mutate(`
const p = __gm.state.project;
p.assets.find((a) => a.id === '${VIDEO}').clips = [{ id: 'ct69-vclip', name: 'Middle', props: { start: 0.5, end: 1.5 } }];
p.assets.push({ id: 'ct69-drone', type: 'sound', name: 'Drone 69', props: {}, file: ${JSON.stringify(drone?.file ?? '')},
clips: [{ id: 'ct69-sclip', name: 'Bit', props: { start: 0.2, end: 0.7 } }] });
const c = p.assets.find((a) => a.id === '${CUT}');
c.objects = [];
c.items = [];
c.clips = [];`);
await evaluate('__gm.composition.scrub(0)');
await json(`__gm.assetRow('${VIDEO}')`);
const folded = await json(`__gm.assetRowShown('ct69-vclip')`);
const chevron = await json(`__gm.assetChevron('${VIDEO}')`);
if (chevron) await t.click(chevron.x, chevron.y);
const unfolded = await json(`__gm.assetRowShown('ct69-vclip')`);
/** Like `dragAsset`, but held over the lane until the drop is as long as `frames`: a cut decodes first. */
const dragClip = async (id, to, frames) => {
await sleep(450);
const from = await json(`__gm.assetRow(${JSON.stringify(id)})`);
// No row, nothing to drag: the check says so rather than the module throwing.
if (!from) return null;
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);
}
let drop = await json('__gm.composition.drop()');
for (let i = 0; i < 20 && drop?.length !== frames; i++) {
await sleep(50);
await mouse('mouseMoved', to.x + (i % 2), to.y, 'left', { buttons: 1 });
drop = await json('__gm.composition.drop()');
}
await mouse('mouseReleased', to.x, to.y);
await sleep(200);
return drop;
};
const videoClipDrop = await dragClip(
'ct69-vclip',
await json(`__gm.composition.laneAt('ct69-t1', 40)`),
10,
);
const soundClipDrop = await dragClip('ct69-sclip', await json(`__gm.composition.laneAt('ct69-t2', 10)`), 5);
const placed = await json(`(() => {
const p = __gm.state.project;
const c = p.assets.find((a) => a.id === '${CUT}');
const pictureItem = c.items.find((i) => !i.audio);
const object = c.objects.find((o) => o.id === pictureItem?.id);
const sprite = object && p.assets.find((a) => a.id === object.assetId);
return { pictureItem, audioItem: c.items.find((i) => i.audio) ?? null, overrides: object?.overrides ?? null,
sprite: sprite ? { owner: sprite.owner, texture: sprite.props.texture } : null };
})()`);
const clipPlayer = `__gm.videos().find((v) => v.key.includes('#0.5~1.5~') && v.key.endsWith(':${placed.pictureItem?.id}')) ?? null`;
await evaluate('__gm.composition.scrub(43)');
let middle = null;
for (let i = 0; i < 40 && !(middle?.ready && !middle.seeking && !middle.landing); i++) {
await sleep(150);
middle = await json(clipPlayer);
}
const middleDrawn = await json(`__gm.composition.drawn('${placed.pictureItem?.id}')`);
check(
'clips have folded rows of their own, and a clip of a video and of a sound lands as long as the clip',
folded === null &&
chevron !== null &&
unfolded !== null &&
videoClipDrop?.what === 'item' &&
videoClipDrop.rejection === '' &&
placed.pictureItem?.start === 40 &&
placed.pictureItem.length === 10 &&
placed.pictureItem.fitMedia === true &&
placed.overrides?.texture === 'ct69-vclip' &&
placed.sprite?.owner === CUT &&
placed.sprite.texture === VIDEO &&
drone !== undefined &&
soundClipDrop?.what === 'item' &&
placed.audioItem?.audio?.asset === 'ct69-sclip' &&
placed.audioItem.start === 10 &&
placed.audioItem.length === 5 &&
Math.abs((middle?.time ?? 0) - 0.8) < 0.05 &&
middleDrawn !== null,
JSON.stringify({ folded, chevron, unfolded, videoClipDrop, soundClipDrop, placed, middle, middleDrawn }),
);
await evaluate(
`__gm.state.select({ kind: 'object', holderId: '${CUT}', objectId: '${placed.pictureItem?.id}' })`,
);
await sleep(200);
// Folded again, so the ping has to unfold the clips itself.
const again = await json(`__gm.assetChevron('${VIDEO}')`);
if (again) await t.click(again.x, again.y);
const refolded = await json(`__gm.assetRowShown('ct69-vclip')`);
const textureLink = await json(`__gm.inspectorLink('Texture')`);
if (textureLink) await t.click(textureLink.x, textureLink.y);
const clipPing = { ping: await json('__gm.assetPing()'), selection: await json('__gm.state.selection') };
await sleep(1200);
const relink = await json(`__gm.inspectorLink('Texture')`);
if (relink) await t.doubleClick(relink.x, relink.y);
await sleep(200);
const clipOpened = { selection: await json('__gm.state.selection'), docs: await json('__gm.documents()') };
check(
'the Texture label links the clip: a click pings its row, a double-click opens the video with the clip picked',
refolded === null &&
textureLink?.underlined === true &&
textureLink.pressable === true &&
clipPing.ping?.id === 'ct69-vclip' &&
clipPing.selection?.kind === 'object' &&
clipOpened.selection?.kind === 'clip' &&
clipOpened.selection.clipId === 'ct69-vclip' &&
clipOpened.docs.active === `asset:${VIDEO}`,
JSON.stringify({ refolded, textureLink, clipPing, clipOpened }),
);
// 69t (#430). A double-click on a clip's row opens its video, though its first press has picked the
// clip already. The frames of a texture, and of pixel art with more than one, have rows too: a
// double-click opens the texture with the frame picked, or the pixel art painting on that frame, and
// one click there switches the frame. A frame dragged onto a track is an item wearing the texture's
// sprite with the frame as its texture.
await mutate(`
const p = __gm.state.project;
p.assets.push({ id: 'ct69-sheet', type: 'texture', name: 'Sheet 69', props: {}, file: '', width: 64, height: 32,
frames: [{ id: 'ct69-f1', name: 'Run', x: 16, y: 0, w: 16, h: 16 }] });
p.assets.push({ id: 'ct69-art', type: 'pixelart', name: 'Art 69', props: {}, width: 8, height: 8,
layers: [{ id: 'ct69-layer', name: 'Layer 1', opacity: 1 }],
frames: [{ id: 'ct69-p1', cels: {} }, { id: 'ct69-p2', cels: {} }] });`);
/** Double-clicks a part's row from the scene tab, its asset unfolded first as a user would. */
const openRow = async (id) => {
await showTab('scene');
await sleep(200);
const row = await json(`__gm.assetRow(${JSON.stringify(id)})`);
if (row) await t.doubleClick(row.x, row.y);
await sleep(300);
return { selection: await json('__gm.state.selection'), active: (await json('__gm.documents()')).active };
};
const clipRow = await openRow('ct69-vclip');
const frameRow = await openRow('ct69-f1');
const pixelRow = await openRow('ct69-p2');
const paintedOn = await json('__gm.pixelArt.frameId()');
const firstRow = await json(`__gm.assetRow('ct69-p1')`);
if (firstRow) await t.click(firstRow.x, firstRow.y);
await sleep(300);
const switched = {
frame: await json('__gm.pixelArt.frameId()'),
active: (await json('__gm.documents()')).active,
};
await evaluate(`__gm.state.openAsset('${CUT}')`);
await sleep(300);
const frameDrop = await dragClip('ct69-f1', await json(`__gm.composition.laneAt('ct69-t1', 100)`));
const framed = await json(`(() => {
const c = __gm.state.project.assets.find((a) => a.id === '${CUT}');
const item = c.items.find((i) => i.start === 100);
const object = c.objects.find((o) => o.id === item?.id);
const sprite = object && __gm.state.project.assets.find((a) => a.id === object.assetId);
return { overrides: object?.overrides ?? null, texture: sprite?.props.texture ?? null };
})()`);
check(
'a double-click on a clip or frame row opens its asset with it picked, and a frame lands on a track',
clipRow.selection?.kind === 'clip' &&
clipRow.selection.clipId === 'ct69-vclip' &&
clipRow.active === `asset:${VIDEO}` &&
frameRow.selection?.kind === 'frame' &&
frameRow.selection.frameId === 'ct69-f1' &&
frameRow.active === 'asset:ct69-sheet' &&
pixelRow.selection?.kind === 'asset' &&
pixelRow.selection.assetId === 'ct69-art' &&
pixelRow.active === 'asset:ct69-art' &&
paintedOn === 'ct69-p2' &&
switched.frame === 'ct69-p1' &&
switched.active === 'asset:ct69-art' &&
frameDrop?.what === 'item' &&
frameDrop.rejection === '' &&
framed.overrides?.texture === 'ct69-f1' &&
framed.texture === 'ct69-sheet',
JSON.stringify({ clipRow, frameRow, pixelRow, paintedOn, switched, frameDrop, framed }),
);
await showTab('scene');
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => !a.id.startsWith('ct69-') && !a.owner?.startsWith('ct69-'));`);
}