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

+22 -2
View File
@@ -36,6 +36,7 @@ import type {
AnimationTrack,
Asset,
BehaviorAsset,
CompositionAsset,
ContainerAsset,
PictureCut,
PixelArtAsset,
@@ -72,7 +73,8 @@ import { isSoundFile } from '../model/sound';
import { isVideoFile } from '../model/video';
import { emptyEdit, type InstanceEdit, isEmptyEdit } from '../model/instance';
import { type PropertyBag, type PropertyValue, tagOf, type Vec2 } from '../model/Properties';
import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project';
import { type ObjectHolder, PROJECT_FORMAT_VERSION, type Project } from '../model/Project';
import { heldCompositionClips, heldCompositionItems, heldCompositionTracks } from '../model/composition';
import { readScope, readSharedScope } from '../model/Scope';
import type { ObjectGroup, SceneObject } from '../model/SceneObject';
import {
@@ -205,6 +207,24 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
};
normalizeGroups(holder, a.groups);
assets.push(holder);
} else if (type === 'composition') {
// One object per item, all at the top level; an object no item spawns is dropped with it.
const objects = normalizeObjects(a.objects, schemaOf).map((o) => ({ ...o, parentId: '' }));
const tracks = heldCompositionTracks(a.tracks);
const items = heldCompositionItems(a.items, tracks, objects);
const spawned = new Set(items.map((i) => i.id));
const composition: CompositionAsset = {
id,
type,
name,
props,
objects: objects.filter((o) => spawned.has(o.id)),
tracks,
items,
clips: heldCompositionClips(a.clips, tracks),
};
normalizeGroups(composition, a.groups);
assets.push(composition);
} else if (type === 'behavior') {
const declared = variables.get(id) ?? [];
const behavior: BehaviorAsset = {
@@ -447,7 +467,7 @@ function normalizeObjects(
* Hierarchy folders (#241): only those with a member are kept, and a member is a top-level object
* naming one of them – anything else loses its `groupId`.
*/
function normalizeGroups(holder: SceneAsset | ContainerAsset, raw: unknown): void {
function normalizeGroups(holder: ObjectHolder, raw: unknown): void {
const groups = new Map<string, ObjectGroup>();
for (const item of Array.isArray(raw) ? raw : []) {
const g = asRecord(item);