Add the composition asset with its tracks, items and clips (#417)
A composition is a timeline of scene objects for cuts and cutscenes. This first step brings the model and the pure evaluation behind it. The tab and placing it in a scene follow in the next steps. - CompositionAsset holds objects, tracks, items and clips. Each item owns exactly one object with overrides and container edits. - The composition is a third object holder next to scene and container. Delete cascade, undo, usages and references work with it. - Duplicating gives the copy new object ids and moves the item ids along with them. - compositionAt() tells which items live at a frame. The top track is drawn in front. - A clip acts on the item of its own track that it overlaps. After its end it holds the last value until the item ends. The animation's play mode is ignored, a clip is one pass. - Clip targets are found by template path, else by name path, like the runtime's animation system. - The root sprite of an item plays its video driven at offset plus time. Children follow unless they are already driven by hand. - fittedLength() gives the video or clip length minus the offset, or 3 s without media. - fps (60) and loop live in their own properties file. Keeping them in PropertySchema would close an import cycle. - The type is listed under Visuals with its own icon. It is not placeable yet. Tests: the gate passed with 300 files and 4625 tests. That is 25 new tests in tests/core/composition.test.ts and 2 icon checks that run for every icon. Four counter-checks (track order, overlap rule, child videos, ids on duplicate) each turned exactly their test red. e2e 04 passed with 67 checks and now counts 33 asset types. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
597367c804
commit
73e675835e
14 files changed
+1031
-12
No files matched your search
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* Compositions (#417): objects along a time axis, counted in frames of the composition's own `fps`.
|
||||
* What runs at a frame is one pure function, `compositionAt`, so the tab's preview and the running
|
||||
* game read the same cut; `compositionHolder` writes it out as objects for a view to draw.
|
||||
*/
|
||||
import { newId } from '../ids';
|
||||
import {
|
||||
COMPOSITION_FPS_KEY,
|
||||
COMPOSITION_LOOP_KEY,
|
||||
DEFAULT_COMPOSITION_FPS,
|
||||
} from '../properties/compositions';
|
||||
import { resolveSceneObjectProps } from '../properties/resolve';
|
||||
import { VIDEO_CLOCK_KEY, VIDEO_TIME_KEY, videoDrivenOf } from '../properties/videos';
|
||||
import { animationDuration, fpsOf, frameCount, targetNamePath } from './animation';
|
||||
import { type ReadAt, writeObjectRow } from './animationPreview';
|
||||
import type {
|
||||
AnimationAsset,
|
||||
AnimationTrack,
|
||||
CompositionAsset,
|
||||
CompositionClip,
|
||||
CompositionItem,
|
||||
CompositionTrack,
|
||||
} from './Asset';
|
||||
import { curveLookup } from './curve';
|
||||
import { instanceChildId, objectRef, resolveObjects, splitInstanceId } from './instance';
|
||||
import { findAsset, findAssetOf, type Project } from './Project';
|
||||
import { booleanOf, type PropertyBag } from './Properties';
|
||||
import { objectName, type SceneObject } from './SceneObject';
|
||||
import { findVideo, videoLengthOf, type VideoRef } from './video';
|
||||
|
||||
/** How long an item lasts while it has no video to take its length from. */
|
||||
export const DEFAULT_ITEM_SECONDS = 3;
|
||||
|
||||
export function compositionFps(comp: CompositionAsset): number {
|
||||
const fps = Number(comp.props[COMPOSITION_FPS_KEY]);
|
||||
return Number.isFinite(fps) && fps > 0 ? fps : DEFAULT_COMPOSITION_FPS;
|
||||
}
|
||||
|
||||
export function compositionLoops(comp: CompositionAsset): boolean {
|
||||
return booleanOf(comp.props[COMPOSITION_LOOP_KEY]);
|
||||
}
|
||||
|
||||
export function newCompositionTrack(index: number): CompositionTrack {
|
||||
return { id: newId(), name: `Track ${index}` };
|
||||
}
|
||||
|
||||
export function itemEnd(item: CompositionItem): number {
|
||||
return item.start + item.length;
|
||||
}
|
||||
|
||||
/** The object an item spawns; undefined once it is gone, and then the item does nothing. */
|
||||
export function itemObject(comp: CompositionAsset, item: CompositionItem): SceneObject | undefined {
|
||||
return comp.objects.find((o) => o.id === item.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* The animation a clip plays, unless it is gone or built over a scene: a scene's rows aim at that
|
||||
* scene's objects, nothing an item holds.
|
||||
*/
|
||||
function clipAnimation(project: Project, clip: CompositionClip): AnimationAsset | undefined {
|
||||
const animation = findAssetOf(project, clip.animation, 'animation');
|
||||
return animation && findAsset(project, animation.root)?.type !== 'scene' ? animation : undefined;
|
||||
}
|
||||
|
||||
/** Frames one pass of the clip's animation takes in the composition; 0 when it plays nothing. */
|
||||
export function clipLength(project: Project, comp: CompositionAsset, clip: CompositionClip): number {
|
||||
const animation = clipAnimation(project, clip);
|
||||
const seconds = animation ? animationDuration(animation) : 0;
|
||||
return seconds > 0 ? Math.max(1, Math.ceil(seconds * compositionFps(comp) - 1e-6)) : 0;
|
||||
}
|
||||
|
||||
/** The frame after the last item or clip ends: how long one run of the composition is. */
|
||||
export function compositionLength(project: Project, comp: CompositionAsset): number {
|
||||
let end = 0;
|
||||
for (const item of comp.items) if (itemObject(comp, item)) end = Math.max(end, itemEnd(item));
|
||||
for (const clip of comp.clips) end = Math.max(end, clip.start + clipLength(project, comp, clip));
|
||||
return end;
|
||||
}
|
||||
|
||||
/**
|
||||
* The video the item's own object wears through its texture – the override's, else its sprite's. A
|
||||
* video worn further down a container item runs with the item but gives it no length.
|
||||
*/
|
||||
export function itemVideo(project: Project, comp: CompositionAsset, item: CompositionItem): VideoRef | null {
|
||||
const object = itemObject(comp, item);
|
||||
const asset = object && findAsset(project, object.assetId);
|
||||
if (!object || asset?.type !== 'sprite') return null;
|
||||
return videoWorn(resolveSceneObjectProps(project, asset, object), project);
|
||||
}
|
||||
|
||||
function videoWorn(props: PropertyBag, project: Project): VideoRef | null {
|
||||
const texture = props.texture;
|
||||
return typeof texture === 'string' ? findVideo(project, texture) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The length an item with `fitMedia` takes: what is left of its video after `offset`, or the default
|
||||
* while it wears none or the video's length is not known yet.
|
||||
*/
|
||||
export function fittedLength(project: Project, comp: CompositionAsset, item: CompositionItem): number {
|
||||
const fps = compositionFps(comp);
|
||||
const video = itemVideo(project, comp, item);
|
||||
const seconds = video ? videoLengthOf(video) - item.offset : 0;
|
||||
return seconds > 0 ? Math.max(1, Math.round(seconds * fps)) : Math.round(DEFAULT_ITEM_SECONDS * fps);
|
||||
}
|
||||
|
||||
/** A clip playing on an item, at `frame` of its animation – held on the last one once it is through. */
|
||||
export interface ClipAt {
|
||||
clip: CompositionClip;
|
||||
animation: AnimationAsset;
|
||||
frame: number;
|
||||
}
|
||||
|
||||
/** An item alive at a frame of the composition. */
|
||||
export interface LiveItem {
|
||||
item: CompositionItem;
|
||||
object: SceneObject;
|
||||
/** The index of its track, 0 the first. */
|
||||
track: number;
|
||||
/** Seconds since the item began. */
|
||||
time: number;
|
||||
/** Seconds into what its videos play: `time` past the item's offset. */
|
||||
videoTime: number;
|
||||
/** In the order they started; a later one writes over an earlier one. */
|
||||
clips: ClipAt[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything alive at `frame`, in drawing order: the last track first, so the first lies in front –
|
||||
* the order the items take below the composition, which decides between equal `z`. An item lives
|
||||
* from its start up to, not including, its end; one on a track that is gone, or whose object is gone,
|
||||
* does not. A clip plays on an item of its track it overlaps once it has started, and holds its last
|
||||
* frame for as long as that item lasts.
|
||||
*/
|
||||
export function compositionAt(project: Project, comp: CompositionAsset, frame: number): LiveItem[] {
|
||||
const fps = compositionFps(comp);
|
||||
const tracks = new Map(comp.tracks.map((t, index) => [t.id, index]));
|
||||
const clips = comp.clips
|
||||
.map((clip, order) => ({ clip, order, animation: clipAnimation(project, clip) }))
|
||||
.filter((c) => c.animation !== undefined && c.clip.start <= frame)
|
||||
.sort((a, b) => a.clip.start - b.clip.start || a.order - b.order);
|
||||
const live: { item: LiveItem; order: number }[] = [];
|
||||
comp.items.forEach((item, order) => {
|
||||
const track = tracks.get(item.track);
|
||||
const object = itemObject(comp, item);
|
||||
if (track === undefined || !object || frame < item.start || frame >= itemEnd(item)) return;
|
||||
const time = (frame - item.start) / fps;
|
||||
const playing: ClipAt[] = [];
|
||||
for (const { clip, animation } of clips) {
|
||||
const end = clip.start + clipLength(project, comp, clip);
|
||||
if (clip.track !== item.track || clip.start >= itemEnd(item) || end <= item.start) continue;
|
||||
const at = ((frame - clip.start) / fps) * fpsOf(animation!);
|
||||
playing.push({ clip, animation: animation!, frame: Math.min(at, frameCount(animation!) - 1) });
|
||||
}
|
||||
live.push({ item: { item, object, track, time, videoTime: item.offset + time, clips: playing }, order });
|
||||
});
|
||||
live.sort(
|
||||
(a, b) => b.item.track - a.item.track || a.item.item.start - b.item.item.start || a.order - b.order,
|
||||
);
|
||||
return live.map((l) => l.item);
|
||||
}
|
||||
|
||||
/**
|
||||
* The composition at `frame` as objects a view draws: a copy holding only what is alive, in drawing
|
||||
* order, each with its clips' rows and its video time written over it. Every sprite of an item that
|
||||
* wears a video and is not driven by hand is driven at the item's video time. Nothing here touches
|
||||
* the document.
|
||||
*/
|
||||
export function compositionHolder(project: Project, comp: CompositionAsset, frame: number): CompositionAsset {
|
||||
const live = compositionAt(project, comp, frame);
|
||||
const holder: CompositionAsset = {
|
||||
...comp,
|
||||
props: { ...comp.props },
|
||||
objects: live.map((l) => copyOf(l.object)),
|
||||
};
|
||||
const curves = curveLookup(project);
|
||||
for (const item of live) {
|
||||
driveVideos(project, holder, item);
|
||||
for (const clip of item.clips) {
|
||||
const at: ReadAt = { frame: clip.frame, reverse: false, curves };
|
||||
for (const track of clip.animation.tracks) {
|
||||
const id = rowTarget(project, holder, item.object.id, clip.animation, track);
|
||||
if (id) writeObjectRow(project, holder, id, track, at);
|
||||
}
|
||||
}
|
||||
}
|
||||
return holder;
|
||||
}
|
||||
|
||||
const record = (value: unknown): Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {};
|
||||
const text = (value: unknown) => (typeof value === 'string' ? value : '');
|
||||
const finite = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) ? value : NaN);
|
||||
const list = (raw: unknown): Record<string, unknown>[] => (Array.isArray(raw) ? raw.map(record) : []);
|
||||
|
||||
/** Held to what a track may be: ids no other has, a name, and at least one track. */
|
||||
export function heldCompositionTracks(raw: unknown): CompositionTrack[] {
|
||||
const tracks: CompositionTrack[] = [];
|
||||
for (const t of list(raw)) {
|
||||
const id = text(t.id);
|
||||
if (!id || tracks.some((other) => other.id === id)) continue;
|
||||
tracks.push({ id, name: text(t.name) || `Track ${tracks.length + 1}` });
|
||||
}
|
||||
return tracks.length > 0 ? tracks : [newCompositionTrack(1)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Held to what an item may be: the id of an object it spawns and no other item does, a track of
|
||||
* the composition, a start in whole frames from 0, at least a frame long, an offset from 0.
|
||||
*/
|
||||
export function heldCompositionItems(
|
||||
raw: unknown,
|
||||
tracks: readonly CompositionTrack[],
|
||||
objects: readonly SceneObject[],
|
||||
): CompositionItem[] {
|
||||
const known = new Set(tracks.map((t) => t.id));
|
||||
const placed = new Set(objects.map((o) => o.id));
|
||||
const items: CompositionItem[] = [];
|
||||
for (const i of list(raw)) {
|
||||
const id = text(i.id);
|
||||
const start = Math.round(finite(i.start));
|
||||
const length = Math.round(finite(i.length));
|
||||
if (!placed.has(id) || items.some((other) => other.id === id) || !known.has(text(i.track))) continue;
|
||||
if (!(start >= 0) || !(length >= 1)) continue;
|
||||
const offset = finite(i.offset);
|
||||
const item: CompositionItem = {
|
||||
id,
|
||||
track: text(i.track),
|
||||
start,
|
||||
length,
|
||||
offset: offset > 0 ? offset : 0,
|
||||
};
|
||||
if (i.fitMedia === true) item.fitMedia = true;
|
||||
items.push(item);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/** Held like an item; an animation that is gone is kept, as an animation keeps a root that is gone. */
|
||||
export function heldCompositionClips(raw: unknown, tracks: readonly CompositionTrack[]): CompositionClip[] {
|
||||
const known = new Set(tracks.map((t) => t.id));
|
||||
const clips: CompositionClip[] = [];
|
||||
for (const c of list(raw)) {
|
||||
const id = text(c.id);
|
||||
const start = Math.round(finite(c.start));
|
||||
if (!id || clips.some((other) => other.id === id) || !known.has(text(c.track)) || !(start >= 0)) continue;
|
||||
clips.push({ id, track: text(c.track), animation: text(c.animation), start });
|
||||
}
|
||||
return clips;
|
||||
}
|
||||
|
||||
function copyOf(object: SceneObject): SceneObject {
|
||||
return {
|
||||
...object,
|
||||
position: { ...object.position },
|
||||
overrides: { ...object.overrides },
|
||||
instanceEdits: structuredClone(object.instanceEdits),
|
||||
};
|
||||
}
|
||||
|
||||
function driveVideos(project: Project, holder: CompositionAsset, live: LiveItem): void {
|
||||
const below = resolveObjects(project, holder).filter(
|
||||
(o) => splitInstanceId(o.id)?.instanceId === live.object.id,
|
||||
);
|
||||
for (const object of [holder.objects.find((o) => o.id === live.object.id)!, ...below]) {
|
||||
const asset = findAsset(project, object.assetId);
|
||||
if (asset?.type !== 'sprite') continue;
|
||||
const props = resolveSceneObjectProps(project, asset, object);
|
||||
const own = object.id === live.object.id;
|
||||
if (!videoWorn(props, project) || (!own && videoDrivenOf(props))) continue;
|
||||
const ref = objectRef(project, holder, object.id);
|
||||
ref?.setOverride(VIDEO_CLOCK_KEY, 'driven');
|
||||
ref?.setOverride(VIDEO_TIME_KEY, live.videoTime);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The object of the holder a row of a clip aims at on the item `rootId`: the item itself, the object
|
||||
* at the row's template path below it, or else the one its names lead to – the way `Play animation`
|
||||
* finds a target on an object built from another container.
|
||||
*/
|
||||
function rowTarget(
|
||||
project: Project,
|
||||
holder: CompositionAsset,
|
||||
rootId: string,
|
||||
animation: AnimationAsset,
|
||||
track: AnimationTrack,
|
||||
): string | undefined {
|
||||
if (track.target === '') return rootId;
|
||||
const objects = resolveObjects(project, holder);
|
||||
const byPath = instanceChildId(rootId, track.target);
|
||||
if (objects.some((o) => o.id === byPath)) return byPath;
|
||||
const names = targetNamePath(project, findAsset(project, animation.root), track.target);
|
||||
let at: string | undefined = rootId;
|
||||
for (const name of names ?? []) {
|
||||
const found = objects.filter(
|
||||
(o) => o.parentId === at && objectName(o, findAsset(project, o.assetId)) === name,
|
||||
);
|
||||
at = found.length === 1 ? found[0]!.id : undefined;
|
||||
if (!at) return undefined;
|
||||
}
|
||||
return names ? at : undefined;
|
||||
}
|
||||
Reference in new issue
Block a user