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>
215 lines
8.4 KiB
TypeScript
215 lines
8.4 KiB
TypeScript
import { isHandleEnabled } from '../../core/graph';
|
||
import { trackWriteAt } from '../../core/model/animation';
|
||
import {
|
||
compositionAt,
|
||
compositionFrameAt,
|
||
compositionFps,
|
||
compositionLength,
|
||
compositionLoops,
|
||
type LiveItem,
|
||
} from '../../core/model/composition';
|
||
import type { CompositionAsset } from '../../core/model/Asset';
|
||
import { curveLookup } from '../../core/model/curve';
|
||
import { contentId, placedWithContent } from '../../core/model/instance';
|
||
import { findAssetOf } from '../../core/model/Project';
|
||
import type { PropertyValue } from '../../core/model/Properties';
|
||
import { findVideo, VIDEO_WAIT_MS } from '../../core/model/video';
|
||
import { VIDEO_CLOCK_KEY, VIDEO_TIME_KEY, videoDrivenOf } from '../../core/properties/videos';
|
||
import { CompositionSound } from '../compositionSound';
|
||
import type { Entity } from '../Entity';
|
||
import type { World } from '../World';
|
||
import { bind } from './AnimationSystem';
|
||
import { type AudioSink, SILENT_AUDIO } from './Audio';
|
||
import type { System } from './System';
|
||
|
||
export interface CompositionFinish {
|
||
/** The placed composition. */
|
||
entity: Entity;
|
||
compositionId: string;
|
||
startedBy: readonly object[];
|
||
}
|
||
|
||
/** An item's object in the run, with what its clips started from and the sprites whose video it drives. */
|
||
interface Spawned {
|
||
entity: Entity;
|
||
bases: Map<string, PropertyValue>;
|
||
videos: Entity[];
|
||
}
|
||
|
||
interface Run {
|
||
id: number;
|
||
/** By item id. An item a behavior destroyed stays here until it ends, so it is not built again. */
|
||
live: Map<string, Spawned>;
|
||
/** Seconds the clock has stood so far for a video without its picture, see `waits`. */
|
||
waited: number;
|
||
/** Its audio items (#418), heard while its clock runs. */
|
||
sound: CompositionSound;
|
||
}
|
||
|
||
/** What the view that shows a run knows of its driven videos (#404). */
|
||
export interface VideoPictures {
|
||
/** Whether it did not show the sprite `objectId`, its video still without the picture asked for. */
|
||
missing(objectId: string): boolean;
|
||
}
|
||
|
||
/**
|
||
* Plays what `Play composition` started (#417): builds each item's object below the placed composition
|
||
* when the item begins and destroys it when it ends, writes the clips over them and drives their
|
||
* videos. Runs before the animations and the behaviors, so a behavior writes last and hears this
|
||
* frame's `finished`. The editor run plays none: it writes into the saved scene, where the objects of
|
||
* a cut would stay behind.
|
||
*
|
||
* While the view still waits for the picture of a video an item brings in, the composition's
|
||
* clock stands, so the video starts at its first frame and not where the clock has got to meanwhile.
|
||
* Its audio items are heard only while the clock runs: they stop while it stands – for a video, a
|
||
* placement switched off, a paused game – and start again where it stands.
|
||
*/
|
||
export class CompositionSystem implements System {
|
||
/** A paused frame stops the sound of every composition; nothing else moves. */
|
||
readonly runsWhilePaused = true;
|
||
finished: CompositionFinish[] = [];
|
||
private readonly runs = new Map<Entity, Run>();
|
||
|
||
constructor(
|
||
private readonly plays = true,
|
||
private readonly pictures: VideoPictures | null = null,
|
||
private readonly audio: AudioSink = SILENT_AUDIO,
|
||
) {}
|
||
|
||
update(world: World, dt: number): void {
|
||
this.finished = [];
|
||
for (const [placement, run] of this.runs)
|
||
if (!world.entities.includes(placement)) {
|
||
run.sound.stop();
|
||
this.runs.delete(placement);
|
||
}
|
||
if (world.frozen) {
|
||
for (const run of this.runs.values()) run.sound.stop();
|
||
return;
|
||
}
|
||
const started = world.entities.filter((e) => e.type === 'composition' && e.composition);
|
||
for (const placement of started) this.advance(world, placement, dt);
|
||
}
|
||
|
||
stop(): void {
|
||
this.finished = [];
|
||
for (const run of this.runs.values()) run.sound.stop();
|
||
this.runs.clear();
|
||
}
|
||
|
||
private advance(world: World, placement: Entity, dt: number): void {
|
||
const player = placement.composition!;
|
||
if (!this.plays) {
|
||
world.log.push('Play composition is ignored in the editor run');
|
||
delete placement.composition;
|
||
return;
|
||
}
|
||
let run = this.runs.get(placement);
|
||
if (run && run.id !== player.run) this.end(world, placement);
|
||
const comp = findAssetOf(world.project, placement.assetId, 'composition');
|
||
if (!comp || player.stopped) {
|
||
this.end(world, placement);
|
||
delete placement.composition;
|
||
return;
|
||
}
|
||
run = this.runs.get(placement);
|
||
if (!run)
|
||
this.runs.set(
|
||
placement,
|
||
(run = { id: player.run, live: new Map(), waited: 0, sound: new CompositionSound(this.audio) }),
|
||
);
|
||
const runs = player.shown && isHandleEnabled(placement) && !this.waits(run, dt);
|
||
if (runs) player.time += dt;
|
||
player.shown = true;
|
||
const length = compositionLength(world.project, comp);
|
||
let frame = compositionFrameAt(comp, player.time);
|
||
if (frame >= length && compositionLoops(comp) && length > 0) {
|
||
player.time %= length / compositionFps(comp);
|
||
frame = Math.min(compositionFrameAt(comp, player.time), length - 1);
|
||
} else if (frame >= length) {
|
||
this.end(world, placement);
|
||
delete placement.composition;
|
||
this.finished.push({ entity: placement, compositionId: comp.id, startedBy: player.startedBy });
|
||
return;
|
||
}
|
||
this.show(world, comp, placement, run, frame);
|
||
if (runs) run.sound.start(world.project, comp, player.time);
|
||
else run.sound.stop();
|
||
}
|
||
|
||
/** Whether a video of a live item still has no picture; at most `VIDEO_WAIT_MS` at a stretch. */
|
||
private waits(run: Run, dt: number): boolean {
|
||
const pictures = this.pictures;
|
||
const missing =
|
||
pictures !== null &&
|
||
[...run.live.values()].some(({ videos }) => videos.some((v) => pictures.missing(v.objectId)));
|
||
if (!missing) {
|
||
run.waited = 0;
|
||
return false;
|
||
}
|
||
run.waited += dt;
|
||
return run.waited * 1000 < VIDEO_WAIT_MS;
|
||
}
|
||
|
||
/** Takes every item of the placement's run away. */
|
||
private end(world: World, placement: Entity): void {
|
||
const run = this.runs.get(placement);
|
||
if (!run) return;
|
||
run.sound.stop();
|
||
for (const { entity } of run.live.values()) if (world.entities.includes(entity)) world.remove(entity);
|
||
this.runs.delete(placement);
|
||
}
|
||
|
||
private show(world: World, comp: CompositionAsset, placement: Entity, run: Run, frame: number): void {
|
||
const live = compositionAt(world.project, comp, frame);
|
||
const alive = new Set(live.map((l) => l.item.id));
|
||
for (const [id, { entity }] of run.live) {
|
||
if (alive.has(id)) continue;
|
||
if (world.entities.includes(entity)) world.remove(entity);
|
||
run.live.delete(id);
|
||
}
|
||
let born = false;
|
||
for (const item of live) {
|
||
if (run.live.has(item.item.id)) continue;
|
||
const spawned = spawn(world, placement, item);
|
||
if (!spawned) continue;
|
||
run.live.set(item.item.id, spawned);
|
||
born = true;
|
||
}
|
||
// In the order the tracks lay them, whenever they began.
|
||
if (born) world.arrange(live.flatMap((l) => run.live.get(l.item.id)?.entity ?? []));
|
||
const curves = curveLookup(world.project);
|
||
for (const item of live) {
|
||
const spawned = run.live.get(item.item.id);
|
||
if (spawned && world.entities.includes(spawned.entity)) play(world, spawned, item, curves);
|
||
}
|
||
}
|
||
}
|
||
|
||
function spawn(world: World, placement: Entity, item: LiveItem): Spawned | null {
|
||
const id = contentId(placement.objectId, item.object.id);
|
||
const entities = world.place(placedWithContent(world.project, item.object, id, placement.objectId));
|
||
const entity = entities.find((e) => e.objectId === id);
|
||
if (!entity) return null;
|
||
// Below the item, a sprite set to be driven by hand keeps its own time.
|
||
const videos = entities.filter((e) => e.type === 'sprite' && (e === entity || !videoDrivenOf(e.props)));
|
||
return { entity, bases: new Map(), videos };
|
||
}
|
||
|
||
function play(world: World, spawned: Spawned, item: LiveItem, curves: ReturnType<typeof curveLookup>): void {
|
||
for (const sprite of spawned.videos) {
|
||
const texture = sprite.props.texture;
|
||
if (typeof texture !== 'string' || !findVideo(world.project, texture)) continue;
|
||
sprite.props[VIDEO_CLOCK_KEY] = 'driven';
|
||
sprite.props[VIDEO_TIME_KEY] = item.videoTime;
|
||
}
|
||
for (const clip of item.clips) {
|
||
for (const b of bind(world, clip.animation, spawned.entity)) {
|
||
let base = spawned.bases.get(b.base);
|
||
if (base === undefined) spawned.bases.set(b.base, (base = b.read()));
|
||
const value = trackWriteAt(b.track, b.def, clip.frame, base, false, curves);
|
||
if (value !== undefined) b.write(value);
|
||
}
|
||
}
|
||
}
|