Two sprites with videos back to back left a blank frame at the cut. The video player only came into being once the item began, and a sprite is not drawn until its video has a first frame. So the second item was missing until its video had loaded. - compositionAhead lists the items that begin within the next second. Near the end of a looping composition it also lists the items at its start. - An item that is alive already is not loaded ahead. Two times would otherwise pull at the same player. - The frame carries these videos hidden and paused on their first picture at the item's offset. Their players get the id the item will have, so the cut reuses them instead of opening a second one. - This works the same in the tab's preview and in the game. - One second of lead (AHEAD_SECONDS) is an assumption. It can go up if large videos still flash. Tests: - Unit composition: 27 green, 2 new. Unit composition-play: 14 green, 1 new for the path in the game. - e2e 69: ALL PASSED, 12 checks. The new 69h puts two video items back to back and checks the second player is open and paused at its offset before the cut, and drawn in the same redraw as the cut. - Without loading ahead in the preview 69h fails with cut: null. Bending two spots in the model turned their unit tests red. - Gate on melektaus-atx: CHECK EXIT 0, 302 files, 4665 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
405 lines
16 KiB
TypeScript
405 lines
16 KiB
TypeScript
/**
|
||
* 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 {
|
||
contentId,
|
||
instanceChildId,
|
||
objectRef,
|
||
placedWithContent,
|
||
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 composition placed in a running game that `Play composition` started; kept on its entity, out of
|
||
* `props`. Its items exist only while it plays.
|
||
*/
|
||
export interface CompositionPlayer {
|
||
/** Tells a start apart from the one before, so a restart builds every item anew. */
|
||
run: number;
|
||
/** Seconds since its first frame. */
|
||
time: number;
|
||
/** Whether the first frame was shown: the clock runs from the tick after the start. */
|
||
shown: boolean;
|
||
/** Set by `Stop composition`: the next tick takes its items away. */
|
||
stopped: boolean;
|
||
/** The behaviors that played it; they hear `On composition finished` wherever it stands. */
|
||
startedBy: object[];
|
||
}
|
||
|
||
let runCount = 0;
|
||
|
||
/**
|
||
* Starts the composition placed as `holder` from its first frame. One already playing plays on, unless
|
||
* `restart` – or a `Stop composition` this tick – asks to start over.
|
||
*/
|
||
export function playComposition(
|
||
holder: { composition?: CompositionPlayer },
|
||
restart = false,
|
||
startedBy?: object,
|
||
): void {
|
||
const playing = holder.composition;
|
||
if (playing && !playing.stopped && !restart) {
|
||
if (startedBy && !playing.startedBy.includes(startedBy)) playing.startedBy.push(startedBy);
|
||
return;
|
||
}
|
||
holder.composition = {
|
||
run: ++runCount,
|
||
time: 0,
|
||
shown: false,
|
||
stopped: false,
|
||
startedBy: startedBy ? [startedBy] : [],
|
||
};
|
||
}
|
||
|
||
export function stopComposition(holder: { composition?: CompositionPlayer }): void {
|
||
if (holder.composition) holder.composition.stopped = true;
|
||
}
|
||
|
||
/** The frame a player's time stands on; the small guard keeps a time of exactly `n / fps` on frame n. */
|
||
export function compositionFrameAt(comp: CompositionAsset, time: number): number {
|
||
return Math.floor(time * compositionFps(comp) + 1e-6);
|
||
}
|
||
|
||
/** 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 {
|
||
return holderOf(project, comp, compositionAt(project, comp, frame));
|
||
}
|
||
|
||
/** How far ahead of where a composition stands the items about to begin are written out, in seconds. */
|
||
export const AHEAD_SECONDS = 1;
|
||
|
||
/**
|
||
* The items that begin within `seconds` after `frame` – once it loops, those at the start too when
|
||
* the end is that close – each written out as at its first frame, like `compositionHolder` does. A
|
||
* view opens their videos ahead of the cut: opened when its item began, a video had no picture yet,
|
||
* and the cut showed nothing until it loaded. One alive at `frame` is left out, it wears its own time.
|
||
*/
|
||
export function compositionAhead(
|
||
project: Project,
|
||
comp: CompositionAsset,
|
||
frame: number,
|
||
seconds = AHEAD_SECONDS,
|
||
): CompositionAsset {
|
||
const until = frame + Math.ceil(seconds * compositionFps(comp));
|
||
const length = compositionLength(project, comp);
|
||
const wrapped = compositionLoops(comp) && until >= length ? until - length : -1;
|
||
const coming = comp.items.filter(
|
||
(item) => (item.start > frame && item.start <= until) || item.start <= wrapped,
|
||
);
|
||
if (coming.length === 0) return holderOf(project, comp, []);
|
||
const alive = new Set(compositionAt(project, comp, frame).map((l) => l.item.id));
|
||
const live = coming
|
||
.filter((item) => !alive.has(item.id))
|
||
.flatMap((item) => compositionAt(project, comp, item.start).filter((l) => l.item.id === item.id));
|
||
return holderOf(project, comp, live);
|
||
}
|
||
|
||
function holderOf(project: Project, comp: CompositionAsset, live: LiveItem[]): CompositionAsset {
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* What a composition placed as `placementId` shows in the editor: its first frame, as objects below
|
||
* the placement with the ids its items take in a run. Stored nowhere – a placement is sealed, and
|
||
* its items are edited in the composition only.
|
||
*/
|
||
export function placedPicture(project: Project, comp: CompositionAsset, placementId: string): SceneObject[] {
|
||
return compositionHolder(project, comp, 0).objects.flatMap((o) =>
|
||
placedWithContent(project, o, contentId(placementId, o.id), placementId),
|
||
);
|
||
}
|
||
|
||
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;
|
||
}
|