Files
feuerwerk/tools/e2e/68-composition.mjs
T
adminandClaude Opus 5.5 77ac9d1aaf
feuerwerk / gate (push) Successful in 4m31s
feuerwerk / suite (push) Successful in 18m21s
Play sounds, patterns and songs on the tracks of a composition (#418)
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>
2026-10-13 21:28:36 +02:00

431 lines
19 KiB
JavaScript
Raw 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.
import { readFileSync } from 'node:fs';
import { uploadVideo } from '../lib/projects.mjs';
import { MAKE_OBJECT } from './lib/scaffold.mjs';
/**
* A composition placed in the scene (#417). In the editor the placement shows the composition's
* first frame, and a click on that picture picks the placement. Played, a behavior below it starts
* it: each item becomes an object below the placement while it lasts, the first track in front, a
* clip moves the item it overlaps, and `On composition finished` hears the end, by which time no
* item is left. Nothing of the run stays in the scene. An item's video is waited for: the clock
* stands until the Game tab has its first picture, for a clip that starts later the picture there,
* and then the video plays at once. A recording that runs meanwhile takes no frame. When the item
* ends, its video stops and goes quiet with it. A sound item is planned once the clock runs, timed
* to the cut.
*
* Everything it adds carries a `cp68` id and goes again at the end.
*/
const PLACE = 'cp68-place';
const DONE = 'cut 68 done';
const node = (id, type, x, y, params = {}) => ({ id: `cp68-${id}`, type, x, y, params });
const link = (id, from, fromPort, to, toPort) => ({
id: `cp68-${id}`,
from: { node: `cp68-${from}`, port: fromPort },
to: { node: `cp68-${to}`, port: toPort },
});
export async function composition(t) {
const { check, sleep, json, evaluate, mutate, press, play, stop, showTab, waitUntil } = t;
await showTab('scene');
const graph = {
nodes: [
node('start', 'OnStart', 0, 0),
node('owner', 'Owner', 0, 150),
node('play', 'PlayComposition', 300, 0),
node('finished', 'OnCompositionFinished', 0, 300, { composition: 'cp68-cut' }),
node('log', 'Log', 300, 300, { message: DONE }),
],
edges: [
link('e1', 'start', 'exec', 'play', 'exec'),
link('e2', 'owner', 'object', 'play', 'object'),
link('e3', 'finished', 'exec', 'log', 'exec'),
],
};
// Red from the start, a larger blue behind it from the next frame – built after red, it still goes
// behind – and green after them, moved by a clip; ten frames a second, so the cut lasts 1.2 s. Placed where the camera looks, in front of what is there.
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
const cam = scene.objects.find((o) => o.id === 'o3').position;
${MAKE_OBJECT}
const sprite = p.assets.find((a) => a.type === 'sprite');
const look = (o, size, color) => { o.overrides = { width: size, height: size, color, texture: '' }; return o; };
const red = look(make('cp68-red', sprite.id, '', 0, 'Red 68'), 200, 0xff0000);
const blue = look(make('cp68-blue', sprite.id, '', 0, 'Blue 68'), 300, 0x0000ff);
const green = look(make('cp68-green', sprite.id, '', 0, 'Green 68'), 200, 0x00ff00);
for (const o of [red, blue, green]) delete o.groupId;
p.assets.push(
{
id: 'cp68-cut',
type: 'composition',
name: 'Cut 68',
props: { fps: 10, loop: false },
objects: [red, blue, green],
groups: [],
tracks: [{ id: 'cp68-front', name: 'Front' }, { id: 'cp68-back', name: 'Back' }],
items: [
{ id: 'cp68-red', track: 'cp68-front', start: 0, length: 6, offset: 0 },
{ id: 'cp68-blue', track: 'cp68-back', start: 1, length: 5, offset: 0 },
{ id: 'cp68-green', track: 'cp68-front', start: 6, length: 6, offset: 0 },
],
clips: [{ id: 'cp68-clip', track: 'cp68-front', animation: 'cp68-move', start: 6 }],
},
{
id: 'cp68-move',
type: 'animation',
name: 'Slide 68',
props: { fps: 10, playback: 'once' },
root: sprite.id,
tracks: [{ id: 'cp68-row', target: '', property: 'x', keys: [{ frame: 0, value: 0 }, { frame: 5, value: 100 }] }],
},
{ id: 'cp68-director', type: 'behavior', name: 'Director 68', props: { enabled: true, runInEditor: false }, variables: [], graph: ${JSON.stringify(graph)} },
);
const place = make('${PLACE}', 'cp68-cut', '', 0, 'Cut 68');
Object.assign(place.position, { x: cam.x, y: cam.y, z: 2000 });
scene.objects.push(place, make('cp68-logic', 'cp68-director', '${PLACE}', 0, 'Director 68'));
__gm.lookAt(cam.x, cam.y);
`);
const id = (item) => `${PLACE}~cp68-${item}`;
// 68a. In the editor the placement shows its first frame – red alone – and a click on the picture
// picks the placement, since what it shows is edited in the composition.
const order = await json('__gm.drawOrder()');
// Off the centre, where the placement's own symbol sits.
const rect = await json(`__gm.objectRect(${JSON.stringify(id('red'))})`);
const stack = rect
? await json(`__gm.sceneStack(${rect.x + rect.w * 0.35}, ${rect.y + rect.h * 0.35})`)
: null;
check(
'a placed composition shows its first frame, and a click on it picks the placement',
order.includes(id('red')) &&
!order.includes(id('blue')) &&
!order.includes(id('green')) &&
JSON.stringify(stack) === JSON.stringify([PLACE]),
JSON.stringify({ order: order.filter((o) => o.startsWith('cp68')), stack }),
);
// 68b. Played: the items come and go below the placement, sampled every frame of the editor.
await play();
const seen = await evaluate(`new Promise((resolve) => {
const frames = [];
const sample = () => {
const world = __gm.world();
if (!world) return resolve(JSON.stringify(frames));
const items = world.entities.filter((e) => e.objectId.startsWith('${PLACE}~'));
const green = items.find((e) => e.objectId === '${id('green')}');
frames.push({
ids: items.map((e) => e.objectId.slice('${PLACE}~cp68-'.length)),
parents: [...new Set(items.map((e) => e.parent))],
greenX: green ? green.x : null,
done: world.log.includes('${DONE}'),
});
if (frames.length > 400 || frames.at(-1).done) return resolve(JSON.stringify(frames));
requestAnimationFrame(sample);
};
sample();
})`);
const frames = JSON.parse(seen);
const both = frames.find((f) => f.ids.length === 2);
const greenXs = frames.map((f) => f.greenX).filter((x) => x !== null);
check(
'Play composition builds the items below the placement, the first track in front, one after the other',
both !== undefined &&
JSON.stringify(both.ids) === JSON.stringify(['blue', 'red']) &&
both.parents.length === 1 &&
both.parents[0] === PLACE &&
!frames.some((f) => f.ids.join('+') === 'red+blue') &&
frames.some((f) => JSON.stringify(f.ids) === JSON.stringify(['green'])) &&
!frames.some((f) => f.ids.includes('green') && f.ids.includes('red')),
JSON.stringify(frames.filter((_, i) => i % 5 === 0).map((f) => f.ids.join('+'))),
);
check(
'a clip moves the item it overlaps, from its value on',
greenXs.length >= 3 &&
greenXs[0] < 40 &&
greenXs.at(-1) > greenXs[0] &&
greenXs.every((x, i) => i === 0 || x >= greenXs[i - 1]),
JSON.stringify(greenXs),
);
const last = frames.at(-1);
check(
'On composition finished hears the end, by which time no item is left',
last?.done === true && last.ids.length === 0,
JSON.stringify(last),
);
await sleep(300);
await stop();
// 68c. The run's objects stay in the run: the scene holds the placement and its behavior only.
const stored = await json(
`__gm.state.activeScene.objects.filter((o) => o.id.startsWith('cp68')).map((o) => o.id).sort()`,
);
check(
'nothing of a played composition stays in the scene',
JSON.stringify(stored) === JSON.stringify(['cp68-logic', PLACE].sort()),
JSON.stringify(stored),
);
// 68d. A video item at frame 0, played from On start: the composition's clock stands while the Game
// tab loads the video, so its first frames are seen – it ran on without them before.
const uploaded = await uploadVideo(
t.baseUrl,
readFileSync(new URL('./fixtures/colors.webm', import.meta.url)),
'colors.webm',
);
await mutate(`
const p = __gm.state.project;
p.assets.push(
{ id: 'cp68-video', type: 'video', name: 'Colors 68', props: {}, file: ${JSON.stringify(uploaded.file)},
width: 320, height: 180, duration: 2, fps: 30, audio: true },
{ id: 'cp68-screen', type: 'sprite', name: 'Screen 68', props: { width: 160, height: 90, color: 0xffffff, texture: 'cp68-video' } },
);
const c = p.assets.find((a) => a.id === 'cp68-cut');
// Sixty frames a second: in steps of a tenth, a clock that ran on for a few frames still read 0.
c.props.fps = 60;
c.objects = [{ ...structuredClone(c.objects[0]), id: 'cp68-v', assetId: 'cp68-screen', name: 'Video 68', overrides: {} }];
c.items = [{ id: 'cp68-v', track: 'cp68-front', start: 0, length: 120, offset: 0 }];
c.clips = [];`);
// The video time the composition writes into its item every frame.
const clock = `__gm.world()?.entities.find((e) => e.objectId === '${id('v')}')?.props.videoTime ?? null`;
// Sampled from before the click on Play: play() settles after the start, and a video that loads
// quickly is ready by then, with no wait left to see (CI task 669).
await evaluate(`{
const loading = [];
const until = performance.now() + 20000;
window.cp68Wait = new Promise((done) => {
const step = () => {
const playing = __gm.state.mode === 'play';
const time = playing ? ${clock} : null;
const player = playing ? __gm.gameVideo('${id('v')}') : null;
if (player?.ready) return done(JSON.stringify({ loading, ready: time }));
if (player) loading.push(time);
if (performance.now() > until) return done(JSON.stringify({ loading, ready: null }));
requestAnimationFrame(step);
};
step();
});
// Not the promise as the block's value: evaluate would wait for it, and Play would come too late.
null;
}`);
await play();
const wait = JSON.parse(await evaluate('window.cp68Wait'));
await sleep(500);
const after = await json(clock);
check(
'a played composition waits for the first picture of an item’s video, then runs on',
wait.loading.length > 0 &&
wait.loading.every((time) => time === 0) &&
wait.ready !== null &&
wait.ready === 0 &&
after > 0.2,
JSON.stringify({
frames: wait.loading.length,
loading: wait.loading.slice(0, 8),
ready: wait.ready,
after,
}),
);
await stop();
// 68e. The item wears a clip that starts at 0.8 s: its player loads, then seeks back to 0.8. The
// clock stands until the Game tab draws that picture, and from its first step on the video
// plays – it was held and sought frame by frame for 0.1 s before, its first pictures stuck.
await mutate(`
const p = __gm.state.project;
p.assets.find((a) => a.id === 'cp68-video').clips = [{ id: 'cp68-late', name: 'Late', props: { start: 0.8, end: 0 } }];
p.assets.find((a) => a.id === 'cp68-screen').props.texture = 'cp68-late';`);
await play();
const rows = JSON.parse(
await evaluate(`new Promise((done) => {
const rows = [];
const step = () => {
const player = __gm.gameVideo('${id('v')}');
rows.push({ time: ${clock}, drawn: __gm.gameDrawnRect('${id('v')}') !== null,
at: player?.time ?? null, paused: player?.paused ?? null });
if (rows.length >= 240 || !__gm.world()) return done(JSON.stringify(rows));
requestAnimationFrame(step);
};
step();
})`),
);
const first = rows.findIndex((row) => row.drawn);
const moving = rows.filter((row) => row.drawn && row.time > 0).slice(0, 3);
check(
'a clip that starts later is waited for, then plays from its first step on',
first >= 0 &&
rows.slice(0, first).every((row) => row.time === 0) &&
Math.abs(rows[first].at - 0.8) < 0.02 &&
moving.length === 3 &&
moving.every((row) => row.paused === false),
JSON.stringify({ first, shown: rows[first], moving }),
);
await stop();
// 68f. A recording runs when Play composition starts the cut: no frame is taken while the Game
// tab leaves the item's video out – it recorded the empty frames of the wait before. A clip
// that starts elsewhere, so a new player loads: the one of 68e is still there, at its picture.
await mutate(`
const video = __gm.state.project.assets.find((a) => a.id === 'cp68-video');
video.clips.push({ id: 'cp68-later', name: 'Later', props: { start: 1.2, end: 0 } });
__gm.state.project.assets.find((a) => a.id === 'cp68-screen').props.texture = 'cp68-later';`);
const flow = structuredClone(graph);
flow.nodes.push(
node('record', 'StartRecording', 300, -150, { name: 'cp68 take', resolution: 'view', fps: '60' }),
node('key', 'OnKeyDown', 0, 450, { key: 'p' }),
);
flow.edges = flow.edges.filter((e) => e.id !== 'cp68-e1');
flow.edges.push(link('e4', 'start', 'exec', 'record', 'exec'), link('e5', 'key', 'exec', 'play', 'exec'));
await mutate(
`__gm.state.project.assets.find((a) => a.id === 'cp68-director').graph = ${JSON.stringify(flow)};`,
);
const { uploads, takes } = await json('__gm.recording()');
const take = `__gm.recording().takes[${takes.length}]`;
await play();
await waitUntil(`${take}?.state === 'recording' && ${take}.lastFrameAt !== null`, {
label: 'the recording takes frames',
});
// Sampled every frame in the page, from before the key on.
await evaluate(`{
const rows = (window.cp68Rows = []);
const step = () => {
const exists = (__gm.world()?.entities ?? []).some((e) => e.objectId === '${id('v')}');
rows.push({ exists, drawn: __gm.gameDrawnRect('${id('v')}') !== null, taken: ${take}?.lastFrameAt ?? null });
const drawn = rows.findIndex((row) => row.drawn);
if ((drawn < 0 || rows.length <= drawn + 20) && rows.length < 400) requestAnimationFrame(step);
};
requestAnimationFrame(step);
}`);
await press('p');
await waitUntil(
`window.cp68Rows.length >= 400 || (window.cp68Rows.some((row) => row.drawn) && window.cp68Rows.length > window.cp68Rows.findIndex((row) => row.drawn) + 20)`,
{ label: 'the item is drawn' },
);
const takeRows = await json('window.cp68Rows');
await stop();
// The frames in which the item was there and its video still left out.
const missing = takeRows.filter((row) => row.exists && !row.drawn);
const shown = takeRows.findIndex((row) => row.drawn);
check(
'a recording takes no frame while a composition waits for its video, and goes on after',
missing.length >= 2 &&
missing.every((row) => row.taken === missing[0].taken) &&
shown >= 0 &&
takeRows.at(-1).taken > missing.at(-1).taken,
JSON.stringify({
missing: missing.length,
takenWhileMissing: new Set(missing.map((row) => row.taken)).size - 1,
shown,
after:
takeRows.at(-1).taken !== null && missing.length > 0
? Math.round(takeRows.at(-1).taken - missing.at(-1).taken)
: null,
}),
);
await waitUntil(
`__gm.recording().uploads.length > ${uploads.length} && ['added', 'failed'].includes(__gm.recording().uploads.at(-1).state)`,
{ timeout: 60_000, label: 'the recording is in the project' },
);
const madeId = (await json('__gm.recording()')).uploads.at(-1).assetId ?? '';
// 68g. The item ends after half a second, its video of two seconds with sound in the middle: the
// item's player stops and goes quiet at once – it played on, heard, until it was let go 2-4 s
// later.
await mutate(`
const p = __gm.state.project;
p.assets.find((a) => a.id === 'cp68-director').graph = ${JSON.stringify(graph)};
p.assets.find((a) => a.id === 'cp68-screen').props.texture = 'cp68-video';
p.assets.find((a) => a.id === 'cp68-cut').items[0].length = 30;`);
await play();
const cut = JSON.parse(
await evaluate(`new Promise((done) => {
const rows = [];
let gone = -1;
const step = () => {
const exists = (__gm.world()?.entities ?? []).some((e) => e.objectId === '${id('v')}');
const player = __gm.gameVideo('${id('v')}');
rows.push({ exists, paused: player?.paused ?? null, muted: player?.muted ?? null });
if (gone < 0 && rows.some((row) => row.exists) && !exists) gone = rows.length - 1;
if ((gone >= 0 && rows.length > gone + 30) || rows.length >= 600 || !__gm.world())
return done(JSON.stringify({ gone, heard: rows.filter((row) => row.exists && row.paused === false && row.muted === false).length, after: rows.slice(gone + 12) }));
requestAnimationFrame(step);
};
step();
})`),
);
await stop();
check(
'the video of an item that ends stops and goes quiet with it',
cut.gone > 0 &&
cut.heard > 0 &&
cut.after.length > 0 &&
cut.after.every((row) => row.paused === true && row.muted === true),
JSON.stringify({
gone: cut.gone,
heard: cut.heard,
after: cut.after.slice(0, 3),
last: cut.after.at(-1),
}),
);
// 68h (#418). A sound item half a second in, beside the video item from 68d: the cut waits for the
// video, and its sound with it – planned once the clock runs, for the second of the audio
// clock the cut reaches the item at, and to its end.
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: 'cp68-blip', type: 'sound', name: 'Blip 68', props: {}, file: ${JSON.stringify(blip?.file ?? '')} });
p.assets.find((a) => a.id === 'cp68-screen').props.texture = 'cp68-video';
const c = p.assets.find((a) => a.id === 'cp68-cut');
c.items = [
{ id: 'cp68-v', track: 'cp68-front', start: 0, length: 120, offset: 0 },
{ id: 'cp68-tone', track: 'cp68-back', start: 30, length: 30, offset: 0,
audio: { kind: 'sound', asset: 'cp68-blip', volume: 0, bus: '' } },
];`);
await evaluate(`{
window.cp68Sound = new Promise((done) => {
const rows = [];
const until = performance.now() + 20000;
const step = () => {
if (__gm.state.mode === 'play') {
const time = ${clock};
const tone = __gm.sound.playbacks().find((p) => p.name === 'composition:cp68-cut');
rows.push({ time, clock: __gm.sound.clock(), planned: tone?.clips.length ?? 0, clip: tone?.clips[0] ?? null });
if (time !== null && time >= 0.6) return done(JSON.stringify(rows));
}
if (performance.now() > until) return done(JSON.stringify(rows));
requestAnimationFrame(step);
};
step();
});
null;
}`);
await play();
const toneRows = JSON.parse(await evaluate('window.cp68Sound'));
await stop();
const ran = toneRows.findIndex((row) => row.time > 0);
const reached = toneRows.find((row) => row.time >= 0.5);
const toneClip = toneRows.at(-1)?.clip;
const early = reached && toneClip ? reached.clock - toneClip.at : null;
check(
'a sound item is planned only once the cut’s clock runs, for when the cut reaches it, to its end',
ran > 0 &&
toneRows.slice(0, ran).every((row) => row.planned === 0) &&
toneClip?.sound === 'cp68-blip' &&
toneClip.offset === 0 &&
Math.abs(toneClip.duration - 0.5) < 0.002 &&
early !== null &&
early > -0.02 &&
early < 0.05,
JSON.stringify({ waited: ran, early, reached, toneClip }),
);
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => !a.id.startsWith('cp68-') && a.id !== ${JSON.stringify(madeId)});
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => !o.id.startsWith('cp68-'));`);
}