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; 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; /** 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(); 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): 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); } } }