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:
adminandClaude Opus 5.5 committed 2026-10-12 06:36:30 +02:00
1 parent 597367c804
commit 73e675835e
14 files changed
+1031 -12

No files matched your search

+13 -1
View File
@@ -16,6 +16,7 @@ import type { AssetType } from '../model/AssetTypes';
import { embeddedIn } from '../music/embedded';
import { defaultTracks } from '../music/song';
import { heldSongGraph } from '../music/routing';
import { newCompositionTrack } from '../model/composition';
import { linearCurve } from '../model/curve';
import { isFontFile } from '../model/font';
import { DEFAULT_PALETTE, PIXEL_CANVAS_DEFAULT } from '../model/pixelart';
@@ -126,6 +127,15 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
return { ...base, type: 'bus' };
case 'particles':
return { ...base, type: 'particles', graph: createEmptyGraph() };
case 'composition':
return {
...base,
type: 'composition',
objects: [],
tracks: [newCompositionTrack(1)],
items: [],
clips: [],
};
}
}
@@ -221,13 +231,15 @@ function copyOf(original: Asset, id: string, name: string): Asset {
if (copy.type === 'texture' || copy.type === 'pixelart')
for (const frame of copy.frames) frame.id = newId();
renewClipIds(copy, new Map());
if (copy.type === 'scene' || copy.type === 'container') {
if (copy.type === 'scene' || copy.type === 'container' || copy.type === 'composition') {
const ids = new Map(copy.objects.map((o) => [o.id, newId()]));
for (const object of copy.objects) {
object.id = ids.get(object.id)!;
object.parentId = ids.get(object.parentId) ?? object.parentId;
}
swapReferences(copy.objects, (ref) => renamedReference(ref, ids), new Recorder());
// An item spawns the object of its own id.
if (copy.type === 'composition') for (const item of copy.items) item.id = ids.get(item.id) ?? item.id;
}
return copy;
}