Keep composition sound in step and give compositions a bus of their own
A composition's sound ran on the audio clock while its picture ran on the frames. A frame counts at most 0.1 s, so after a hitch the sound stayed ahead of the picture until the next stop. Songs in a trailer drifted off the cut. The items of a composition also had no bus of their own, and a video sprite could not pick where its sound goes. - CompositionSound.follow compares the audio clock with the picture each frame. More than 40 ms apart, it starts the sound again where the picture is. This holds in the tab and in a run. - A time scale other than 1 now restarts the sound again and again. It stays in time but comes in pieces. - The sprite of a video item shows "Driven (by composition)" as a fixed Video clock row. Video time is hidden there, because the composition sets it every frame. - Every composition has a bus in the mix and a node in the master routing. A new one goes to the master unless other compositions are linked elsewhere, apart from where the sounds go. - Audio items pick From asset, To composition, Master or a bus. New items start with To composition. Items saved before keep From asset, so music already sent through a bus does not bypass it. - Video sprites have a new row Video bus: From asset by default, the master or a bus. In a composition the default is the composition's bus, also for video sprites inside a container item. - The chosen bus is part of the key of the video player, so a sprite with another bus gets a player of its own. Found along the way: - Six tests with full lists of sprite defaults expected the old rows and now list Video bus too. Tests: - tsc, eslint, Prettier clean - Unit: 971 green in the 28 files touched - npm run check on melektaus-atx: green, 309 files, 4804 tests - e2e 68 (melektaus-atx): 13 checks green, 68i new. The sound item and the video item go into mix:cp68-cut, which goes to the master. - e2e 69 (melektaus-atx): ALL PASSED, 23 checks, 69q and 69r new. After a hitch of 500 ms the sound restarted twice and then lay 0.5 to 5 ms off the playhead. - Counter-checks in unit tests: without the comparison the hitch test fails, without the bus in the preload the run test fails. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
875c76026a
commit
4d0a696327
37 files changed
+657
-83
No files matched your search
@@ -382,7 +382,7 @@ export async function composition(t) {
|
||||
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: '' } },
|
||||
audio: { kind: 'sound', asset: 'cp68-blip', volume: 0, bus: '#composition' } },
|
||||
];`);
|
||||
await evaluate(`{
|
||||
window.cp68Sound = new Promise((done) => {
|
||||
@@ -393,7 +393,15 @@ export async function composition(t) {
|
||||
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 (time !== null && time >= 0.6) {
|
||||
const buses = __gm.routing.buses();
|
||||
rows.at(-1).routes = {
|
||||
tone: buses.find((b) => b.name === 'play:' + tone?.id)?.into ?? null,
|
||||
cut: buses.find((b) => b.name === 'mix:cp68-cut')?.into ?? null,
|
||||
videos: __gm.routing.media().filter((m) => m.routed).map((m) => m.bus),
|
||||
};
|
||||
return done(JSON.stringify(rows));
|
||||
}
|
||||
}
|
||||
if (performance.now() > until) return done(JSON.stringify(rows));
|
||||
requestAnimationFrame(step);
|
||||
@@ -421,6 +429,17 @@ export async function composition(t) {
|
||||
early < 0.05,
|
||||
JSON.stringify({ waited: ran, early, reached, toneClip }),
|
||||
);
|
||||
// 68i. The sound item picked the composition's bus, the video item picked none: both go into the
|
||||
// composition's bus, and that one into the master.
|
||||
const routes = toneRows.at(-1)?.routes;
|
||||
check(
|
||||
'a sound item and a video item go into the composition’s bus, and that one into the master',
|
||||
routes?.tone === 'mix:cp68-cut' &&
|
||||
routes.cut === 'mix' &&
|
||||
routes.videos.length > 0 &&
|
||||
routes.videos.every((bus) => bus === 'mix:cp68-cut'),
|
||||
JSON.stringify(routes),
|
||||
);
|
||||
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
* 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.
|
||||
* the handle of its fade in drags out a fade its bus rises along. A video item's sprite reads as
|
||||
* driven by the composition and goes into its bus, which has a node in the master routing; after a
|
||||
* hitch the sound is started again where the playhead is.
|
||||
*
|
||||
* Everything it adds carries a `ct69` id and goes again at the end.
|
||||
*/
|
||||
@@ -533,6 +535,7 @@ export async function compositionTab(t) {
|
||||
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 &&
|
||||
@@ -634,6 +637,70 @@ export async function compositionTab(t) {
|
||||
JSON.stringify({ fadeBox, perFrame, fadeHover, faded, levels }),
|
||||
);
|
||||
|
||||
// 69q. A video dragged in: its sprite's Video clock reads as driven by the composition, its Video
|
||||
// time – the composition's – is not shown, its Video bus goes into the composition by default. The
|
||||
// composition has a node of its own in the master routing, linked to the master.
|
||||
await mutate(`
|
||||
const c = __gm.state.project.assets.find((a) => a.id === '${CUT}');
|
||||
c.objects = [];
|
||||
c.items = [];`);
|
||||
await dragAsset(SCREEN, await json(`__gm.composition.laneAt('ct69-t1', 0)`));
|
||||
const screenItem = (await model()).items[0];
|
||||
// A row is looked for once it is scrolled into view: the inspector is taller than the window.
|
||||
const videoRows = await json(`({
|
||||
driven: __gm.inspectorField('Video clock') !== null && __gm.locate('Driven (by composition)') !== null,
|
||||
time: __gm.inspectorField('Video time'),
|
||||
volume: __gm.inspectorField('Video volume') !== null,
|
||||
bus: __gm.inspectorField('Video bus') !== null && __gm.locate('To composition') !== null,
|
||||
stored: __gm.state.project.assets.find((a) => a.id === '${CUT}').objects[0]?.overrides ?? null,
|
||||
})`);
|
||||
const routing = await json('__gm.routing.graph()');
|
||||
const node = routing.nodes.find((n) => n.id === CUT);
|
||||
const links = routing.edges.filter((e) => e.from.node === CUT).map((e) => e.to.node);
|
||||
check(
|
||||
'a video item reads as driven by the composition, goes into its bus, and the composition is routed',
|
||||
screenItem !== undefined &&
|
||||
videoRows.driven &&
|
||||
videoRows.time === null &&
|
||||
videoRows.volume &&
|
||||
videoRows.bus &&
|
||||
videoRows.stored?.videoClock === undefined &&
|
||||
node?.params.kind === 'composition' &&
|
||||
JSON.stringify(links) === JSON.stringify(['#master']),
|
||||
JSON.stringify({ screenItem, videoRows, node, links }),
|
||||
);
|
||||
|
||||
// 69r. A hitch of half a second while the tab plays: the playhead counts a tenth of it at most, the
|
||||
// sound ran on – it is started again where the playhead is, and the two keep together after.
|
||||
await mutate(`
|
||||
const c = __gm.state.project.assets.find((a) => a.id === '${CUT}');
|
||||
c.objects = [];
|
||||
c.items = [{ id: 'ct69-long', track: 'ct69-t2', start: 0, length: 100, offset: 0,
|
||||
audio: { kind: 'sound', asset: 'ct69-sound', volume: 0, bus: '' } }];`);
|
||||
await evaluate('__gm.composition.scrub(0)');
|
||||
const longAt = await json('__gm.composition.playAt()');
|
||||
await t.click(longAt.x, longAt.y);
|
||||
let hitchBefore = null;
|
||||
for (let i = 0; i < 40 && !(hitchBefore?.playing && hitchBefore.ahead !== null); i++) {
|
||||
await sleep(50);
|
||||
hitchBefore = await json('__gm.composition.sound()');
|
||||
}
|
||||
await sleep(300);
|
||||
hitchBefore = await json('__gm.composition.sound()');
|
||||
await evaluate(`{ const until = performance.now() + 500; while (performance.now() < until); null; }`);
|
||||
await sleep(400);
|
||||
const hitchAfter = await json('__gm.composition.sound()');
|
||||
await t.click(longAt.x, longAt.y);
|
||||
check(
|
||||
'after a hitch the sound of the tab is started again where the playhead is',
|
||||
hitchBefore?.playing &&
|
||||
Math.abs(hitchBefore.ahead) < 0.06 &&
|
||||
hitchAfter.resyncs > hitchBefore.resyncs &&
|
||||
hitchAfter.playing &&
|
||||
Math.abs(hitchAfter.ahead) < 0.06,
|
||||
JSON.stringify({ hitchBefore, hitchAfter }),
|
||||
);
|
||||
|
||||
await showTab('scene');
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
|
||||
Reference in new issue
Block a user