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>
597 lines
20 KiB
TypeScript
597 lines
20 KiB
TypeScript
/**
|
||
* Compositions (#417): objects along a time axis. What lives at a frame, which clips play on it and
|
||
* where its videos stand is `compositionAt`; `compositionHolder` writes it out for a view to draw.
|
||
*/
|
||
import { describe, expect, it } from 'vitest';
|
||
import {
|
||
AddAnimationTrackCommand,
|
||
SetAnimationKeyCommand,
|
||
SetAnimationRootCommand,
|
||
SetTrackRelativeCommand,
|
||
} from '../../src/core/commands/animationCommands';
|
||
import {
|
||
CreateAssetCommand,
|
||
CreateVideoCommand,
|
||
DeleteAssetCommand,
|
||
DuplicateAssetCommand,
|
||
} from '../../src/core/commands/assetCommands';
|
||
import { AddClipCommand } from '../../src/core/commands/clipCommands';
|
||
import type {
|
||
AnimationAsset,
|
||
CompositionAsset,
|
||
CompositionItem,
|
||
ContainerAsset,
|
||
} from '../../src/core/model/Asset';
|
||
import {
|
||
clipLength,
|
||
compositionAt,
|
||
compositionFps,
|
||
compositionHolder,
|
||
compositionLength,
|
||
compositionLoops,
|
||
fittedLength,
|
||
itemVideo,
|
||
} from '../../src/core/model/composition';
|
||
import { instanceChildId, resolveObjects } from '../../src/core/model/instance';
|
||
import { findAssetOf, objectHolders } from '../../src/core/model/Project';
|
||
import type { PropertyBag } from '../../src/core/model/Properties';
|
||
import { createSceneObject } from '../../src/core/model/SceneObject';
|
||
import {
|
||
VIDEO_CLOCK_KEY,
|
||
VIDEO_END_KEY,
|
||
VIDEO_START_KEY,
|
||
VIDEO_TIME_KEY,
|
||
} from '../../src/core/properties/videos';
|
||
import { makeProject, type ProjectFixture, roundTrip } from '../helpers';
|
||
|
||
const FILE = `a1b2c3d4e5f6/${'c7'.repeat(32)}.mp4`;
|
||
|
||
interface Cut extends ProjectFixture {
|
||
compId: string;
|
||
spriteId: string;
|
||
comp(): CompositionAsset;
|
||
/** Adds a track and returns its id. */
|
||
track(name: string): string;
|
||
/** Places `assetId` as an item on a track; returns the item's id, which is its object's. */
|
||
item(
|
||
track: string,
|
||
assetId: string,
|
||
start: number,
|
||
length: number,
|
||
extra?: Partial<CompositionItem>,
|
||
): string;
|
||
animation(
|
||
root: string,
|
||
rows: [property: string, keys: [number, number][], target?: string][],
|
||
fps?: number,
|
||
): string;
|
||
clip(track: string, animation: string, start: number): string;
|
||
}
|
||
|
||
function makeCut(): Cut {
|
||
const f = makeProject();
|
||
const comp = new CreateAssetCommand('composition', 'Cut');
|
||
const sprite = new CreateAssetCommand('sprite', 'Box');
|
||
f.stack.execute(comp);
|
||
f.stack.execute(sprite);
|
||
const current = () => findAssetOf(f.project, comp.asset.id, 'composition')!;
|
||
let next = 0;
|
||
return {
|
||
...f,
|
||
compId: comp.asset.id,
|
||
spriteId: sprite.asset.id,
|
||
comp: current,
|
||
track(name) {
|
||
const id = `t${++next}`;
|
||
current().tracks.push({ id, name });
|
||
return id;
|
||
},
|
||
item(track, assetId, start, length, extra = {}) {
|
||
const id = `i${++next}`;
|
||
current().objects.push(createSceneObject(id, assetId, { x: 0, y: 0 }));
|
||
current().items.push({ id, track, start, length, offset: 0, ...extra });
|
||
return id;
|
||
},
|
||
animation(root, rows, fps = 10) {
|
||
const create = new CreateAssetCommand('animation', 'Move', { fps, playback: 'loop' });
|
||
f.stack.execute(create);
|
||
f.stack.execute(new SetAnimationRootCommand(create.asset.id, root));
|
||
for (const [property, keys, target = ''] of rows) {
|
||
const row = new AddAnimationTrackCommand(create.asset.id, target, property);
|
||
f.stack.execute(row);
|
||
for (const [frame, value] of keys)
|
||
f.stack.execute(new SetAnimationKeyCommand(create.asset.id, row.trackId, frame, value));
|
||
}
|
||
return create.asset.id;
|
||
},
|
||
clip(track, animation, start) {
|
||
const id = `c${++next}`;
|
||
current().clips.push({ id, track, animation, start });
|
||
return id;
|
||
},
|
||
};
|
||
}
|
||
|
||
const firstTrack = (cut: Cut) => cut.comp().tracks[0]!.id;
|
||
const liveIds = (cut: Cut, frame: number) =>
|
||
compositionAt(cut.project, cut.comp(), frame).map((l) => l.object.id);
|
||
|
||
function addVideo(cut: Cut, duration = 10): string {
|
||
const video = new CreateVideoCommand('Intro', FILE, {
|
||
width: 320,
|
||
height: 180,
|
||
duration,
|
||
fps: 30,
|
||
audio: false,
|
||
});
|
||
cut.stack.execute(video);
|
||
return video.asset.id;
|
||
}
|
||
|
||
function videoSprite(cut: Cut, texture: string): string {
|
||
const sprite = new CreateAssetCommand('sprite', 'Screen', { texture });
|
||
cut.stack.execute(sprite);
|
||
return sprite.asset.id;
|
||
}
|
||
|
||
/** A container "Rig" holding a sprite "Arm", returned with the template id of the arm. */
|
||
function rig(cut: Cut, name = 'Rig', armProps: PropertyBag = {}): { id: string; arm: string } {
|
||
const create = new CreateAssetCommand('container', name);
|
||
cut.stack.execute(create);
|
||
const arm = new CreateAssetCommand('sprite', 'Arm', armProps);
|
||
cut.stack.execute(arm);
|
||
const template = findAssetOf(cut.project, create.asset.id, 'container') as ContainerAsset;
|
||
const armId = `arm-of-${name}`;
|
||
template.objects.push(createSceneObject(armId, arm.asset.id, { x: 5, y: 0 }));
|
||
return { id: create.asset.id, arm: armId };
|
||
}
|
||
|
||
describe('a new composition', () => {
|
||
it('starts with one track, nothing on it, at 60 fps and not looping', () => {
|
||
const cut = makeCut();
|
||
const comp = cut.comp();
|
||
expect(comp.tracks).toHaveLength(1);
|
||
expect(comp.tracks[0]!.name).toBe('Track 1');
|
||
expect([comp.objects, comp.items, comp.clips]).toEqual([[], [], []]);
|
||
expect(compositionFps(comp)).toBe(60);
|
||
expect(compositionLoops(comp)).toBe(false);
|
||
});
|
||
|
||
it('is an object holder, so deleting an asset takes the object it placed – and undo brings it back', () => {
|
||
const cut = makeCut();
|
||
const item = cut.item(firstTrack(cut), cut.spriteId, 0, 60);
|
||
expect(objectHolders(cut.project).map((h) => h.id)).toContain(cut.compId);
|
||
cut.stack.execute(new DeleteAssetCommand(cut.spriteId));
|
||
expect(cut.comp().objects).toEqual([]);
|
||
expect(liveIds(cut, 10)).toEqual([]);
|
||
cut.stack.undo();
|
||
expect(liveIds(cut, 10)).toEqual([item]);
|
||
});
|
||
|
||
it('a duplicate gets objects of its own, and its items follow them', () => {
|
||
const cut = makeCut();
|
||
cut.item(firstTrack(cut), cut.spriteId, 0, 60);
|
||
const duplicate = new DuplicateAssetCommand(cut.compId);
|
||
cut.stack.execute(duplicate);
|
||
const copy = findAssetOf(cut.project, duplicate.copyId, 'composition')!;
|
||
expect(copy.objects[0]!.id).not.toBe(cut.comp().objects[0]!.id);
|
||
expect(copy.items[0]!.id).toBe(copy.objects[0]!.id);
|
||
expect(compositionAt(cut.project, copy, 10).map((l) => l.object.id)).toEqual([copy.objects[0]!.id]);
|
||
});
|
||
});
|
||
|
||
describe('what lives at a frame', () => {
|
||
it('an item from its start up to, not including, its end', () => {
|
||
const cut = makeCut();
|
||
const item = cut.item(firstTrack(cut), cut.spriteId, 30, 60);
|
||
expect(liveIds(cut, 29)).toEqual([]);
|
||
expect(liveIds(cut, 30)).toEqual([item]);
|
||
expect(liveIds(cut, 89)).toEqual([item]);
|
||
expect(liveIds(cut, 90)).toEqual([]);
|
||
});
|
||
|
||
it('counts its time in seconds since its start, and its video time from its offset', () => {
|
||
const cut = makeCut();
|
||
cut.item(firstTrack(cut), cut.spriteId, 30, 120, { offset: 2 });
|
||
const [live] = compositionAt(cut.project, cut.comp(), 60);
|
||
expect(live!.time).toBe(0.5);
|
||
expect(live!.videoTime).toBe(2.5);
|
||
});
|
||
|
||
it('nothing of an item whose object or track is gone', () => {
|
||
const cut = makeCut();
|
||
const kept = cut.item(firstTrack(cut), cut.spriteId, 0, 60);
|
||
const orphan = cut.item(firstTrack(cut), cut.spriteId, 0, 60);
|
||
cut.comp().objects = cut.comp().objects.filter((o) => o.id !== orphan);
|
||
cut.item('no-such-track', cut.spriteId, 0, 60);
|
||
expect(liveIds(cut, 10)).toEqual([kept]);
|
||
});
|
||
|
||
it('draws the last track first, so the first lies in front; a track by start', () => {
|
||
const cut = makeCut();
|
||
const top = firstTrack(cut);
|
||
const bottom = cut.track('Background');
|
||
const front = cut.item(top, cut.spriteId, 0, 60);
|
||
const backLate = cut.item(bottom, cut.spriteId, 10, 60);
|
||
const backEarly = cut.item(bottom, cut.spriteId, 0, 30);
|
||
expect(liveIds(cut, 20)).toEqual([backEarly, backLate, front]);
|
||
expect(
|
||
resolveObjects(cut.project, compositionHolder(cut.project, cut.comp(), 20)).map((o) => o.id),
|
||
).toEqual([backEarly, backLate, front]);
|
||
});
|
||
|
||
it('lasts until the last item or clip ends', () => {
|
||
const cut = makeCut();
|
||
cut.item(firstTrack(cut), cut.spriteId, 0, 60);
|
||
expect(compositionLength(cut.project, cut.comp())).toBe(60);
|
||
cut.clip(
|
||
firstTrack(cut),
|
||
cut.animation(cut.spriteId, [
|
||
[
|
||
'x',
|
||
[
|
||
[0, 0],
|
||
[9, 90],
|
||
],
|
||
],
|
||
]),
|
||
40,
|
||
);
|
||
expect(compositionLength(cut.project, cut.comp())).toBe(100);
|
||
});
|
||
});
|
||
|
||
describe('animation clips', () => {
|
||
it('last one pass of their animation, counted in the frames of the composition', () => {
|
||
const cut = makeCut();
|
||
// Keys on 0…9 at 10 fps: ten frames, one second – 60 frames at 60 fps.
|
||
const move = cut.animation(cut.spriteId, [
|
||
[
|
||
'x',
|
||
[
|
||
[0, 0],
|
||
[9, 90],
|
||
],
|
||
],
|
||
]);
|
||
const clip = cut.clip(firstTrack(cut), move, 0);
|
||
expect(
|
||
clipLength(
|
||
cut.project,
|
||
cut.comp(),
|
||
cut.comp().clips.find((c) => c.id === clip)!,
|
||
),
|
||
).toBe(60);
|
||
});
|
||
|
||
it('play on the item of their track they overlap, from where the clip stands', () => {
|
||
const cut = makeCut();
|
||
const item = cut.item(firstTrack(cut), cut.spriteId, 0, 120);
|
||
cut.clip(
|
||
firstTrack(cut),
|
||
cut.animation(cut.spriteId, [
|
||
[
|
||
'x',
|
||
[
|
||
[0, 0],
|
||
[9, 90],
|
||
],
|
||
],
|
||
]),
|
||
30,
|
||
);
|
||
const x = (frame: number) =>
|
||
compositionHolder(cut.project, cut.comp(), frame).objects.find((o) => o.id === item)!.position.x;
|
||
expect(x(29)).toBe(0);
|
||
expect(x(30)).toBe(0);
|
||
// 30 frames at 60 fps after its start: 0.5 s, frame 5 of the animation.
|
||
expect(x(60)).toBe(50);
|
||
expect(compositionAt(cut.project, cut.comp(), 60)[0]!.clips[0]!.frame).toBe(5);
|
||
});
|
||
|
||
it('hold their last frame once through, for as long as the item lasts', () => {
|
||
const cut = makeCut();
|
||
const item = cut.item(firstTrack(cut), cut.spriteId, 0, 300);
|
||
cut.clip(
|
||
firstTrack(cut),
|
||
cut.animation(cut.spriteId, [
|
||
[
|
||
'x',
|
||
[
|
||
[0, 0],
|
||
[9, 90],
|
||
],
|
||
],
|
||
]),
|
||
0,
|
||
);
|
||
const holder = compositionHolder(cut.project, cut.comp(), 200);
|
||
expect(holder.objects.find((o) => o.id === item)!.position.x).toBe(90);
|
||
});
|
||
|
||
it('play on neither an item of another track nor one that starts after they ended', () => {
|
||
const cut = makeCut();
|
||
const other = cut.track('Other');
|
||
const elsewhere = cut.item(other, cut.spriteId, 0, 300);
|
||
const later = cut.item(firstTrack(cut), cut.spriteId, 100, 60);
|
||
cut.clip(
|
||
firstTrack(cut),
|
||
cut.animation(cut.spriteId, [
|
||
[
|
||
'x',
|
||
[
|
||
[0, 10],
|
||
[9, 90],
|
||
],
|
||
],
|
||
]),
|
||
0,
|
||
);
|
||
const at = compositionAt(cut.project, cut.comp(), 120);
|
||
expect(at.find((l) => l.object.id === elsewhere)!.clips).toEqual([]);
|
||
expect(at.find((l) => l.object.id === later)!.clips).toEqual([]);
|
||
});
|
||
|
||
it('running over two items, play on each in turn', () => {
|
||
const cut = makeCut();
|
||
const track = firstTrack(cut);
|
||
const first = cut.item(track, cut.spriteId, 0, 30);
|
||
const second = cut.item(track, cut.spriteId, 30, 60);
|
||
cut.clip(
|
||
track,
|
||
cut.animation(cut.spriteId, [
|
||
[
|
||
'x',
|
||
[
|
||
[0, 0],
|
||
[9, 90],
|
||
],
|
||
],
|
||
]),
|
||
0,
|
||
);
|
||
const x = (frame: number, id: string) =>
|
||
compositionHolder(cut.project, cut.comp(), frame).objects.find((o) => o.id === id)!.position.x;
|
||
expect(x(12, first)).toBe(20);
|
||
expect(x(42, second)).toBe(70);
|
||
});
|
||
|
||
it('add a relative row to the value the item has', () => {
|
||
const cut = makeCut();
|
||
const item = cut.item(firstTrack(cut), cut.spriteId, 0, 120);
|
||
cut.comp().objects[0]!.position.x = 100;
|
||
const move = cut.animation(cut.spriteId, [
|
||
[
|
||
'x',
|
||
[
|
||
[0, 0],
|
||
[9, 90],
|
||
],
|
||
],
|
||
]);
|
||
const animation = findAssetOf(cut.project, move, 'animation') as AnimationAsset;
|
||
cut.stack.execute(new SetTrackRelativeCommand(move, animation.tracks[0]!.id, true));
|
||
cut.clip(firstTrack(cut), move, 0);
|
||
const holder = compositionHolder(cut.project, cut.comp(), 30);
|
||
expect(holder.objects.find((o) => o.id === item)!.position.x).toBe(150);
|
||
});
|
||
|
||
it('reach into a container item by template path, and by names when built over another', () => {
|
||
const cut = makeCut();
|
||
const own = rig(cut);
|
||
const lookalike = rig(cut, 'Other rig');
|
||
const item = cut.item(firstTrack(cut), lookalike.id, 0, 120);
|
||
// Built over "Rig": its row names Rig's arm, which "Other rig" has by name only.
|
||
cut.clip(
|
||
firstTrack(cut),
|
||
cut.animation(own.id, [
|
||
[
|
||
'rotation',
|
||
[
|
||
[0, 0],
|
||
[9, 90],
|
||
],
|
||
own.arm,
|
||
],
|
||
]),
|
||
0,
|
||
);
|
||
const holder = compositionHolder(cut.project, cut.comp(), 30);
|
||
const arm = resolveObjects(cut.project, holder).find(
|
||
(o) => o.id === instanceChildId(item, lookalike.arm),
|
||
)!;
|
||
expect(arm.overrides.rotation).toBe(50);
|
||
|
||
const direct = makeCut();
|
||
const same = rig(direct);
|
||
const placed = direct.item(firstTrack(direct), same.id, 0, 120);
|
||
direct.clip(
|
||
firstTrack(direct),
|
||
direct.animation(same.id, [
|
||
[
|
||
'rotation',
|
||
[
|
||
[0, 0],
|
||
[9, 90],
|
||
],
|
||
same.arm,
|
||
],
|
||
]),
|
||
0,
|
||
);
|
||
const drawn = resolveObjects(direct.project, compositionHolder(direct.project, direct.comp(), 30));
|
||
expect(drawn.find((o) => o.id === instanceChildId(placed, same.arm))!.overrides.rotation).toBe(50);
|
||
});
|
||
|
||
it('of an animation over a scene play nothing, and one gone neither', () => {
|
||
const cut = makeCut();
|
||
const scene = new CreateAssetCommand('scene', 'Level');
|
||
cut.stack.execute(scene);
|
||
cut.item(firstTrack(cut), cut.spriteId, 0, 120);
|
||
const overScene = cut.animation(scene.asset.id, [
|
||
[
|
||
'gravityY',
|
||
[
|
||
[0, 0],
|
||
[9, 90],
|
||
],
|
||
],
|
||
]);
|
||
cut.clip(firstTrack(cut), overScene, 0);
|
||
cut.clip(firstTrack(cut), 'gone', 0);
|
||
expect(cut.comp().clips.map((c) => clipLength(cut.project, cut.comp(), c))).toEqual([0, 0]);
|
||
expect(compositionAt(cut.project, cut.comp(), 10)[0]!.clips).toEqual([]);
|
||
});
|
||
|
||
it('leave the document alone', () => {
|
||
const cut = makeCut();
|
||
cut.item(firstTrack(cut), cut.spriteId, 0, 120);
|
||
cut.clip(
|
||
firstTrack(cut),
|
||
cut.animation(cut.spriteId, [
|
||
[
|
||
'x',
|
||
[
|
||
[0, 0],
|
||
[9, 90],
|
||
],
|
||
],
|
||
]),
|
||
0,
|
||
);
|
||
const before = structuredClone(cut.comp());
|
||
compositionHolder(cut.project, cut.comp(), 30);
|
||
expect(cut.comp()).toEqual(before);
|
||
});
|
||
});
|
||
|
||
describe('items wearing a video', () => {
|
||
it('drive it at the item’s video time', () => {
|
||
const cut = makeCut();
|
||
const screen = videoSprite(cut, addVideo(cut));
|
||
const item = cut.item(firstTrack(cut), screen, 60, 600, { offset: 1.5 });
|
||
const object = compositionHolder(cut.project, cut.comp(), 90).objects.find((o) => o.id === item)!;
|
||
expect(object.overrides[VIDEO_CLOCK_KEY]).toBe('driven');
|
||
expect(object.overrides[VIDEO_TIME_KEY]).toBe(2);
|
||
});
|
||
|
||
it('drive one worn inside a container item too – unless that one is driven by hand', () => {
|
||
const cut = makeCut();
|
||
const video = addVideo(cut);
|
||
const free = rig(cut, 'Free', { texture: video });
|
||
const byHand = rig(cut, 'Hand', { texture: video, [VIDEO_CLOCK_KEY]: 'driven', [VIDEO_TIME_KEY]: 7 });
|
||
const a = cut.item(firstTrack(cut), free.id, 0, 120);
|
||
const b = cut.item(cut.track('B'), byHand.id, 0, 120);
|
||
const drawn = resolveObjects(cut.project, compositionHolder(cut.project, cut.comp(), 60));
|
||
const arm = (item: string, template: string) =>
|
||
drawn.find((o) => o.id === instanceChildId(item, template))!;
|
||
expect(arm(a, free.arm).overrides[VIDEO_TIME_KEY]).toBe(1);
|
||
expect(arm(a, free.arm).overrides[VIDEO_CLOCK_KEY]).toBe('driven');
|
||
// Its own 7 s stay: nothing is written over the sprite's driven time.
|
||
expect(arm(b, byHand.arm).overrides).toEqual({});
|
||
});
|
||
|
||
it('take the length of the video, or of the clip, less the offset when fitted', () => {
|
||
const cut = makeCut();
|
||
const video = addVideo(cut, 10);
|
||
const clip = new AddClipCommand(video, { [VIDEO_START_KEY]: 0.5, [VIDEO_END_KEY]: 2 });
|
||
cut.stack.execute(clip);
|
||
const track = firstTrack(cut);
|
||
const whole = cut.item(track, videoSprite(cut, video), 0, 1, { fitMedia: true });
|
||
const trimmed = cut.item(track, videoSprite(cut, video), 0, 1, { offset: 2 });
|
||
const short = cut.item(track, videoSprite(cut, clip.clipId), 0, 1);
|
||
const plain = cut.item(track, cut.spriteId, 0, 1);
|
||
const fitted = (id: string) =>
|
||
fittedLength(
|
||
cut.project,
|
||
cut.comp(),
|
||
cut.comp().items.find((i) => i.id === id)!,
|
||
);
|
||
expect(fitted(whole)).toBe(600);
|
||
expect(fitted(trimmed)).toBe(480);
|
||
expect(fitted(short)).toBe(90);
|
||
expect(fitted(plain)).toBe(180);
|
||
expect(
|
||
itemVideo(
|
||
cut.project,
|
||
cut.comp(),
|
||
cut.comp().items.find((i) => i.id === plain)!,
|
||
),
|
||
).toBeNull();
|
||
});
|
||
|
||
it('take the default length while the video’s length is not known yet', () => {
|
||
const cut = makeCut();
|
||
const item = cut.item(firstTrack(cut), videoSprite(cut, addVideo(cut, 0)), 0, 1);
|
||
expect(
|
||
fittedLength(
|
||
cut.project,
|
||
cut.comp(),
|
||
cut.comp().items.find((i) => i.id === item)!,
|
||
),
|
||
).toBe(180);
|
||
});
|
||
|
||
it('follow the texture an override gives the item', () => {
|
||
const cut = makeCut();
|
||
const item = cut.item(firstTrack(cut), cut.spriteId, 0, 1);
|
||
cut.comp().objects[0]!.overrides.texture = addVideo(cut, 4);
|
||
expect(
|
||
fittedLength(
|
||
cut.project,
|
||
cut.comp(),
|
||
cut.comp().items.find((i) => i.id === item)!,
|
||
),
|
||
).toBe(240);
|
||
});
|
||
});
|
||
|
||
describe('saving and loading a composition', () => {
|
||
it('keeps tracks, items, clips and the objects', () => {
|
||
const cut = makeCut();
|
||
const track = cut.track('Second');
|
||
cut.item(track, cut.spriteId, 12, 34, { offset: 1.25, fitMedia: true });
|
||
cut.clip(track, 'gone', 5);
|
||
const loaded = findAssetOf(roundTrip(cut.project), cut.compId, 'composition')!;
|
||
expect(loaded.tracks).toEqual(cut.comp().tracks);
|
||
expect(loaded.items).toEqual(cut.comp().items);
|
||
expect(loaded.clips).toEqual(cut.comp().clips);
|
||
expect(loaded.objects).toEqual(cut.comp().objects);
|
||
});
|
||
|
||
it('drops what no item spawns, an item without its object or track, and what is no time', () => {
|
||
const cut = makeCut();
|
||
const track = firstTrack(cut);
|
||
const kept = cut.item(track, cut.spriteId, 0, 10);
|
||
cut.comp().objects.push(createSceneObject('stray', cut.spriteId, { x: 0, y: 0 }));
|
||
cut.comp().items.push({ id: 'no-object', track, start: 0, length: 10, offset: 0 });
|
||
cut.item('no-track', cut.spriteId, 0, 10);
|
||
cut.item(track, cut.spriteId, -5, 10);
|
||
cut.item(track, cut.spriteId, 0, 0);
|
||
cut.clip('no-track', 'x', 0);
|
||
cut.clip(track, 'y', -1);
|
||
const loaded = findAssetOf(roundTrip(cut.project), cut.compId, 'composition')!;
|
||
expect(loaded.items.map((i) => i.id)).toEqual([kept]);
|
||
expect(loaded.objects.map((o) => o.id)).toEqual([kept]);
|
||
expect(loaded.clips).toEqual([]);
|
||
});
|
||
|
||
it('keeps every object at the top level, a negative offset at 0 and at least one track', () => {
|
||
const cut = makeCut();
|
||
const track = firstTrack(cut);
|
||
const parent = cut.item(track, cut.spriteId, 0, 10);
|
||
const child = cut.item(track, cut.spriteId, 10, 10, { offset: -3 });
|
||
cut.comp().objects.find((o) => o.id === child)!.parentId = parent;
|
||
const raw = structuredClone(cut.project);
|
||
findAssetOf(raw, cut.compId, 'composition')!.tracks = [];
|
||
const loaded = findAssetOf(roundTrip(raw), cut.compId, 'composition')!;
|
||
expect(loaded.tracks).toHaveLength(1);
|
||
// The one track left is a new one, so the items on the old one fall away.
|
||
expect(loaded.items).toEqual([]);
|
||
const again = findAssetOf(roundTrip(cut.project), cut.compId, 'composition')!;
|
||
expect(again.objects.map((o) => o.parentId)).toEqual(['', '']);
|
||
expect(again.items.find((i) => i.id === child)!.offset).toBe(0);
|
||
expect(again.items.some((i) => 'fitMedia' in i)).toBe(false);
|
||
});
|
||
});
|