A composition lost the first frames of a video item. Its clock ran while the player was still loading, in the tab and in the game. The game only waited for videos once, when it started. A composition that a behavior starts later spawned its items after that wait. - The view now records which video sprites it left out for lack of a first picture, one entry per object. - The composition tab holds its playhead while the preview misses a video, for at most VIDEO_WAIT_MS (3 s) like the game. - CompositionSystem holds a composition's clock while one of its live items misses its video. The rest of the game keeps running. - The editor play and the published game pass their view to the run as RunServices.pictures. - VIDEO_WAIT_MS moves to core/model/video.ts, because the runtime does not import from viewport/. - Videos loaded ahead for coming items do not hold the clock. Items can now be copied and pasted inside the composition tab. - Ctrl+C keeps the timing of the copied items next to the objects. - Ctrl+V pastes objects and items in one undo step. The earliest item lands at the frame under the pointer, on the track there. - Without the pointer over the tracks it lands at the playhead, on the selected track or on the top track of the selected items. - Several items keep their distances and tracks to each other. - The same copy pastes into a scene as plain objects. Objects copied from a scene do not paste into a composition, they have no timing. Tests: - Unit composition-edit, clipboard, composition-play: 3 new for pasting, 1 new for the clock waiting in the game - e2e 69i (play from the end waits for the video), 69j (paste under the pointer and at the playhead), 68d (a composition started from On start waits for its video) - each new check is red without its fix - README: 2036 editor checks Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
410 lines
16 KiB
TypeScript
410 lines
16 KiB
TypeScript
/**
|
||
* A composition placed in a scene and played (#417): its items become objects below the placement
|
||
* while they last, its clips and videos reach them, and `On composition finished` hears the end.
|
||
*/
|
||
import { describe, expect, it } from 'vitest';
|
||
import {
|
||
AddAnimationTrackCommand,
|
||
SetAnimationKeyCommand,
|
||
SetAnimationRootCommand,
|
||
} from '../../src/core/commands/animationCommands';
|
||
import { CreateAssetCommand, CreateVideoCommand } from '../../src/core/commands/assetCommands';
|
||
import type { CompositionAsset, ContainerAsset } from '../../src/core/model/Asset';
|
||
import {
|
||
compositionHolder,
|
||
placedPicture,
|
||
playComposition,
|
||
stopComposition,
|
||
} from '../../src/core/model/composition';
|
||
import { contentId, instanceChildId, resolveObjects } from '../../src/core/model/instance';
|
||
import { findAssetOf, findScene } from '../../src/core/model/Project';
|
||
import { createSceneObject } from '../../src/core/model/SceneObject';
|
||
import { VIDEO_CLOCK_KEY, VIDEO_TIME_KEY } from '../../src/core/properties/videos';
|
||
import { addGameSystems } from '../../src/runtime/systems/BehaviorSystem';
|
||
import type { VideoPictures } from '../../src/runtime/systems/CompositionSystem';
|
||
import { Input } from '../../src/runtime/systems/Input';
|
||
import { World } from '../../src/runtime/World';
|
||
import { stackAt } from '../../src/viewport/pick';
|
||
import { collectHolderItems, collectWorldItems } from '../../src/viewport/items';
|
||
import { graphEditor, makeBehavior, makeProject, type ProjectFixture } from '../helpers';
|
||
import { PlaceAssetCommand } from '../../src/core/commands/sceneObjectCommands';
|
||
|
||
const FILE = `a1b2c3d4e5f6/${'c7'.repeat(32)}.mp4`;
|
||
|
||
interface Stage extends ProjectFixture {
|
||
sceneId: string;
|
||
spriteId: string;
|
||
/** The composition's object in the scene. */
|
||
placement: string;
|
||
comp(): CompositionAsset;
|
||
track(): string;
|
||
item(track: string, assetId: string, start: number, length: number, offset?: number): string;
|
||
/** An animation over `root` moving x (or a property of `target`) from 0 to 90 over frames 0–9 at 10 fps. */
|
||
move(root: string, property?: string, target?: string): string;
|
||
clip(track: string, animation: string, start: number): void;
|
||
}
|
||
|
||
/** A scene "Level" with a composition "Cut" at 100/50, ten frames a second, and a sprite "Box". */
|
||
function makeStage(): Stage {
|
||
const f = makeProject();
|
||
const scene = new CreateAssetCommand('scene', 'Level');
|
||
const comp = new CreateAssetCommand('composition', 'Cut', { fps: 10 });
|
||
const sprite = new CreateAssetCommand('sprite', 'Box');
|
||
for (const c of [scene, comp, sprite]) f.stack.execute(c);
|
||
const place = new PlaceAssetCommand(scene.asset.id, comp.asset.id, { x: 100, y: 50 });
|
||
f.stack.execute(place);
|
||
const current = () => findAssetOf(f.project, comp.asset.id, 'composition')!;
|
||
let next = 0;
|
||
return {
|
||
...f,
|
||
sceneId: scene.asset.id,
|
||
spriteId: sprite.asset.id,
|
||
placement: place.objectId,
|
||
comp: current,
|
||
track() {
|
||
const id = `t${++next}`;
|
||
current().tracks.push({ id, name: id });
|
||
return id;
|
||
},
|
||
item(track, assetId, start, length, offset = 0) {
|
||
const id = `i${++next}`;
|
||
current().objects.push(createSceneObject(id, assetId, { x: 0, y: 0 }));
|
||
current().items.push({ id, track, start, length, offset });
|
||
return id;
|
||
},
|
||
move(root, property = 'x', target = '') {
|
||
const create = new CreateAssetCommand('animation', 'Move', { fps: 10, playback: 'loop' });
|
||
f.stack.execute(create);
|
||
f.stack.execute(new SetAnimationRootCommand(create.asset.id, root));
|
||
const row = new AddAnimationTrackCommand(create.asset.id, target, property);
|
||
f.stack.execute(row);
|
||
f.stack.execute(new SetAnimationKeyCommand(create.asset.id, row.trackId, 0, 0));
|
||
f.stack.execute(new SetAnimationKeyCommand(create.asset.id, row.trackId, 9, 90));
|
||
return create.asset.id;
|
||
},
|
||
clip(track, animation, start) {
|
||
current().clips.push({ id: `c${++next}`, track, animation, start });
|
||
},
|
||
};
|
||
}
|
||
|
||
const first = (stage: Stage) => stage.comp().tracks[0]!.id;
|
||
|
||
/** A behavior below the placement: On start plays it, On composition finished logs "done". */
|
||
function director(stage: Stage): void {
|
||
const behavior = makeBehavior(stage, 'Director');
|
||
stage.stack.execute(new PlaceAssetCommand(stage.sceneId, behavior.id, { x: 0, y: 0 }, stage.placement));
|
||
const edit = graphEditor(stage, behavior);
|
||
const start = edit.addNode('OnStart');
|
||
const owner = edit.addNode('Owner');
|
||
const play = edit.addNode('PlayComposition');
|
||
edit.connect(start, 'exec', play, 'exec');
|
||
edit.connect(owner, 'object', play, 'object');
|
||
const finished = edit.addNode('OnCompositionFinished', { composition: stage.comp().id });
|
||
const log = edit.addNode('Log', { message: 'done' });
|
||
edit.connect(finished, 'exec', log, 'exec');
|
||
}
|
||
|
||
/** The scene played as a game: `frame()` ticks a tenth of a second and writes back like Play does. */
|
||
function play(stage: Stage, playsCompositions = true, pictures: VideoPictures | null = null) {
|
||
const world = World.instantiate(stage.project, stage.sceneId);
|
||
addGameSystems(world, stage.project, new Input(), { playsCompositions, pictures });
|
||
const placement = () => world.findByObjectId(stage.placement)!;
|
||
const at = (item: string) => world.findByObjectId(contentId(stage.placement, item));
|
||
return {
|
||
world,
|
||
placement,
|
||
at,
|
||
frame(times = 1) {
|
||
for (let i = 0; i < times; i++) {
|
||
world.tick(0.1);
|
||
world.syncToModel();
|
||
}
|
||
},
|
||
stored: () => findScene(stage.project, stage.sceneId)!.objects.map((o) => o.id),
|
||
};
|
||
}
|
||
|
||
describe('a composition played in a scene', () => {
|
||
it('builds an item’s object below the placement when it begins and destroys it when it ends', () => {
|
||
const stage = makeStage();
|
||
director(stage);
|
||
const a = stage.item(first(stage), stage.spriteId, 0, 3);
|
||
const b = stage.item(first(stage), stage.spriteId, 3, 2);
|
||
const run = play(stage);
|
||
expect(run.at(a)).toBeUndefined();
|
||
run.frame();
|
||
// The first tick shows frame 0, the clock runs from the next.
|
||
expect(run.at(a)?.parent).toBe(run.placement());
|
||
expect(run.at(a)?.objectId).toBe(`${stage.placement}~${a}`);
|
||
expect(run.at(b)).toBeUndefined();
|
||
run.frame(3);
|
||
expect(run.at(a)).toBeUndefined();
|
||
expect(run.stored()).not.toContain(contentId(stage.placement, a));
|
||
expect(run.at(b)?.name).toBe('Box');
|
||
expect(run.stored()).toContain(contentId(stage.placement, b));
|
||
run.frame(2);
|
||
expect(run.at(b)).toBeUndefined();
|
||
expect(run.world.log).toEqual(['done']);
|
||
expect(run.placement().composition).toBeUndefined();
|
||
run.frame(5);
|
||
expect(run.world.log).toEqual(['done']);
|
||
});
|
||
|
||
it('lays the first track in front, whatever began first', () => {
|
||
const stage = makeStage();
|
||
director(stage);
|
||
const back = stage.track();
|
||
// Built after the front one, the item of the back track still has to go behind it.
|
||
const front = stage.item(first(stage), stage.spriteId, 0, 5);
|
||
const late = stage.item(back, stage.spriteId, 2, 5);
|
||
const run = play(stage);
|
||
run.frame(3);
|
||
const order = (ids: string[]) =>
|
||
[late, front].map((item) => ids.indexOf(contentId(stage.placement, item)));
|
||
const [behind, inFront] = order(run.world.entities.map((e) => e.objectId));
|
||
expect(behind).toBeLessThan(inFront!);
|
||
const [storedBehind, storedFront] = order(run.stored());
|
||
expect(storedBehind).toBeLessThan(storedFront!);
|
||
});
|
||
|
||
it('writes its clips over the items as the editor shows them, and holds the last value', () => {
|
||
const stage = makeStage();
|
||
director(stage);
|
||
const item = stage.item(first(stage), stage.spriteId, 0, 30);
|
||
stage.clip(first(stage), stage.move(stage.spriteId), 2);
|
||
const run = play(stage);
|
||
const shown = (frame: number) =>
|
||
compositionHolder(stage.project, stage.comp(), frame).objects.find((o) => o.id === item)!.position.x;
|
||
let at = -1;
|
||
for (const frame of [0, 1, 2, 7, 11, 20]) {
|
||
run.frame(frame - at);
|
||
at = frame;
|
||
expect(run.at(item)!.x, `frame ${frame}`).toBe(shown(frame));
|
||
}
|
||
expect(run.at(item)!.x).toBe(90);
|
||
});
|
||
|
||
it('reaches into a container item by its template path', () => {
|
||
const stage = makeStage();
|
||
director(stage);
|
||
const rig = new CreateAssetCommand('container', 'Rig');
|
||
const arm = new CreateAssetCommand('sprite', 'Arm');
|
||
stage.stack.execute(rig);
|
||
stage.stack.execute(arm);
|
||
(findAssetOf(stage.project, rig.asset.id, 'container') as ContainerAsset).objects.push(
|
||
createSceneObject('arm', arm.asset.id, { x: 5, y: 0 }),
|
||
);
|
||
const item = stage.item(first(stage), rig.asset.id, 0, 30);
|
||
stage.clip(first(stage), stage.move(rig.asset.id, 'rotation', 'arm'), 0);
|
||
const run = play(stage);
|
||
run.frame(6);
|
||
const armOf = run.world.findByObjectId(contentId(contentId(stage.placement, item), 'arm'));
|
||
expect(armOf?.objectId).toBe(`${stage.placement}~${item}/arm`);
|
||
expect(armOf?.parent).toBe(run.at(item));
|
||
expect(armOf?.props.rotation).toBe(50);
|
||
});
|
||
|
||
it('drives the video an item wears at its video time', () => {
|
||
const stage = makeStage();
|
||
director(stage);
|
||
const video = new CreateVideoCommand('Intro', FILE, {
|
||
width: 320,
|
||
height: 180,
|
||
duration: 10,
|
||
fps: 30,
|
||
audio: false,
|
||
});
|
||
stage.stack.execute(video);
|
||
const screen = new CreateAssetCommand('sprite', 'Screen', { texture: video.asset.id });
|
||
stage.stack.execute(screen);
|
||
const item = stage.item(first(stage), screen.asset.id, 0, 50, 1.5);
|
||
const run = play(stage);
|
||
run.frame(6);
|
||
expect(run.at(item)!.props[VIDEO_CLOCK_KEY]).toBe('driven');
|
||
expect(run.at(item)!.props[VIDEO_TIME_KEY]).toBeCloseTo(2, 9);
|
||
});
|
||
|
||
it('stands still while the view waits for the first picture of an item’s video, 3 s at most', () => {
|
||
const stage = makeStage();
|
||
director(stage);
|
||
const video = new CreateVideoCommand('Intro', FILE, {
|
||
width: 320,
|
||
height: 180,
|
||
duration: 10,
|
||
fps: 30,
|
||
audio: false,
|
||
});
|
||
stage.stack.execute(video);
|
||
const screen = new CreateAssetCommand('sprite', 'Screen', { texture: video.asset.id });
|
||
stage.stack.execute(screen);
|
||
const item = stage.item(first(stage), screen.asset.id, 0, 100);
|
||
const missing = new Set<string>();
|
||
const run = play(stage, true, { missing: (id) => missing.has(id) });
|
||
const time = () => run.placement().composition!.time;
|
||
run.frame();
|
||
// The view drew the new item without its picture.
|
||
missing.add(contentId(stage.placement, item));
|
||
run.frame(5);
|
||
expect(time()).toBe(0);
|
||
expect(run.at(item)!.props[VIDEO_TIME_KEY]).toBe(0);
|
||
missing.clear();
|
||
run.frame(2);
|
||
expect(time()).toBeCloseTo(0.2, 9);
|
||
// A picture that never comes holds it 3 s, then it goes on without.
|
||
missing.add(contentId(stage.placement, item));
|
||
run.frame(29);
|
||
expect(time()).toBeCloseTo(0.2, 9);
|
||
run.frame(10);
|
||
expect(time()).toBeGreaterThan(1);
|
||
});
|
||
|
||
it('hands the view the video of the next item a second ahead, by the id the item will take', () => {
|
||
const stage = makeStage();
|
||
director(stage);
|
||
const video = new CreateVideoCommand('Intro', FILE, {
|
||
width: 320,
|
||
height: 180,
|
||
duration: 10,
|
||
fps: 30,
|
||
audio: false,
|
||
});
|
||
stage.stack.execute(video);
|
||
const screen = new CreateAssetCommand('sprite', 'Screen', { texture: video.asset.id });
|
||
stage.stack.execute(screen);
|
||
stage.item(first(stage), screen.asset.id, 0, 20);
|
||
const next = stage.item(first(stage), screen.asset.id, 20, 10, 0.5);
|
||
const run = play(stage);
|
||
const ahead = () => collectWorldItems(run.world).ahead ?? [];
|
||
run.frame(10);
|
||
expect(ahead()).toEqual([]);
|
||
run.frame();
|
||
const [coming] = ahead();
|
||
expect(coming?.id).toBe(contentId(stage.placement, next));
|
||
expect(coming?.texture?.file).toMatch(/^video:/);
|
||
expect(coming?.props[VIDEO_CLOCK_KEY]).toBe('driven');
|
||
expect(coming?.props[VIDEO_TIME_KEY]).toBe(0.5);
|
||
run.frame(10);
|
||
expect(run.at(next)).toBeDefined();
|
||
expect(ahead()).toEqual([]);
|
||
});
|
||
|
||
it('loops without finishing, building an item anew each time round', () => {
|
||
const stage = makeStage();
|
||
director(stage);
|
||
stage.comp().props.loop = true;
|
||
const a = stage.item(first(stage), stage.spriteId, 0, 2);
|
||
stage.item(first(stage), stage.spriteId, 2, 2);
|
||
const run = play(stage);
|
||
run.frame();
|
||
const firstRound = run.at(a);
|
||
run.frame(4);
|
||
expect(run.at(a)).toBeDefined();
|
||
expect(run.at(a)).not.toBe(firstRound);
|
||
run.frame(20);
|
||
expect(run.world.log).toEqual([]);
|
||
});
|
||
|
||
it('takes the items away on Stop, without finishing, and starts over on the next play', () => {
|
||
const stage = makeStage();
|
||
director(stage);
|
||
const a = stage.item(first(stage), stage.spriteId, 0, 30);
|
||
const run = play(stage);
|
||
run.frame(3);
|
||
const before = run.at(a);
|
||
stopComposition(run.placement());
|
||
run.frame();
|
||
expect(run.at(a)).toBeUndefined();
|
||
expect(run.placement().composition).toBeUndefined();
|
||
playComposition(run.placement());
|
||
run.frame();
|
||
expect(run.at(a)).toBeDefined();
|
||
expect(run.at(a)).not.toBe(before);
|
||
expect(run.world.log).toEqual([]);
|
||
});
|
||
|
||
it('builds every item anew on a restart, and plays on when asked to play again', () => {
|
||
const stage = makeStage();
|
||
director(stage);
|
||
const a = stage.item(first(stage), stage.spriteId, 0, 30);
|
||
const run = play(stage);
|
||
run.frame(5);
|
||
const playing = run.at(a);
|
||
playComposition(run.placement());
|
||
run.frame();
|
||
expect(run.at(a)).toBe(playing);
|
||
expect(run.placement().composition!.time).toBeCloseTo(0.5, 9);
|
||
playComposition(run.placement(), true);
|
||
run.frame();
|
||
expect(run.at(a)).not.toBe(playing);
|
||
expect(run.placement().composition!.time).toBe(0);
|
||
});
|
||
|
||
it('stands still while the game is paused', () => {
|
||
const stage = makeStage();
|
||
director(stage);
|
||
stage.item(first(stage), stage.spriteId, 0, 30);
|
||
const run = play(stage);
|
||
run.frame(3);
|
||
run.world.paused = true;
|
||
run.frame(5);
|
||
expect(run.placement().composition!.time).toBeCloseTo(0.2, 9);
|
||
});
|
||
|
||
it('plays nothing in the editor run, and says so', () => {
|
||
const stage = makeStage();
|
||
director(stage);
|
||
const a = stage.item(first(stage), stage.spriteId, 0, 30);
|
||
const run = play(stage, false);
|
||
run.frame(3);
|
||
expect(run.at(a)).toBeUndefined();
|
||
expect(run.world.log).toEqual(['Play composition is ignored in the editor run']);
|
||
});
|
||
});
|
||
|
||
describe('a placed composition in the editor', () => {
|
||
it('shows its first frame below the placement, with the ids its items take in a run', () => {
|
||
const stage = makeStage();
|
||
const a = stage.item(first(stage), stage.spriteId, 0, 3);
|
||
stage.item(first(stage), stage.spriteId, 5, 3);
|
||
const comp = stage.comp();
|
||
expect(placedPicture(stage.project, comp, stage.placement).map((o) => [o.id, o.parentId])).toEqual([
|
||
[contentId(stage.placement, a), stage.placement],
|
||
]);
|
||
// Nothing of it is stored: the placement is sealed.
|
||
expect(resolveObjects(stage.project, findScene(stage.project, stage.sceneId)!)).toHaveLength(1);
|
||
});
|
||
|
||
it('draws that frame where the placement stands, and a click on it picks the placement', () => {
|
||
const stage = makeStage();
|
||
const a = stage.item(first(stage), stage.spriteId, 0, 3);
|
||
const scene = findScene(stage.project, stage.sceneId)!;
|
||
const { items } = collectHolderItems(stage.project, scene, 0);
|
||
const drawn = items.find((i) => i.id === contentId(stage.placement, a))!;
|
||
expect([drawn.x, drawn.y]).toEqual([100, 50]);
|
||
expect(drawn.sealedIn).toBe(stage.placement);
|
||
expect(stackAt(items, [{ id: drawn.id, layers: null }])).toEqual([stage.placement]);
|
||
// Play mode draws the run's own objects instead.
|
||
expect(collectHolderItems(stage.project, scene, 0, null, false).items.map((i) => i.id)).toEqual([
|
||
stage.placement,
|
||
]);
|
||
});
|
||
|
||
it('gives the content of a container item the ids a run gives it', () => {
|
||
const stage = makeStage();
|
||
const rig = new CreateAssetCommand('container', 'Rig');
|
||
stage.stack.execute(rig);
|
||
(findAssetOf(stage.project, rig.asset.id, 'container') as ContainerAsset).objects.push(
|
||
createSceneObject('arm', stage.spriteId, { x: 5, y: 0 }),
|
||
);
|
||
const item = stage.item(first(stage), rig.asset.id, 0, 3);
|
||
const picture = placedPicture(stage.project, stage.comp(), stage.placement);
|
||
const top = contentId(stage.placement, item);
|
||
expect(picture.map((o) => [o.id, o.parentId])).toEqual([
|
||
[top, stage.placement],
|
||
[contentId(top, 'arm'), top],
|
||
]);
|
||
expect(contentId(top, 'arm')).toBe(instanceChildId(stage.placement, `${item}/arm`));
|
||
});
|
||
});
|