Files
feuerwerk/tools/e2e/68-composition.mjs
T
adminandClaude Opus 5.5 6e6b6a4d9a Wait for a composition's videos and copy and paste its items (#417)
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>
2026-10-12 10:38:19 +02:00

228 lines
9.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { readFileSync } from 'node:fs';
import { uploadVideo } from '../lib/projects.mjs';
import { MAKE_OBJECT } from './lib/scaffold.mjs';
/**
* A composition placed in the scene (#417). In the editor the placement shows the composition's
* first frame, and a click on that picture picks the placement. Played, a behavior below it starts
* it: each item becomes an object below the placement while it lasts, the first track in front, a
* clip moves the item it overlaps, and `On composition finished` hears the end, by which time no
* item is left. Nothing of the run stays in the scene. An item's video is waited for: the clock
* stands until the Game tab has its first picture.
*
* Everything it adds carries a `cp68` id and goes again at the end.
*/
const PLACE = 'cp68-place';
const DONE = 'cut 68 done';
const node = (id, type, x, y, params = {}) => ({ id: `cp68-${id}`, type, x, y, params });
const link = (id, from, fromPort, to, toPort) => ({
id: `cp68-${id}`,
from: { node: `cp68-${from}`, port: fromPort },
to: { node: `cp68-${to}`, port: toPort },
});
export async function composition(t) {
const { check, sleep, json, evaluate, mutate, play, stop, showTab } = t;
await showTab('scene');
const graph = {
nodes: [
node('start', 'OnStart', 0, 0),
node('owner', 'Owner', 0, 150),
node('play', 'PlayComposition', 300, 0),
node('finished', 'OnCompositionFinished', 0, 300, { composition: 'cp68-cut' }),
node('log', 'Log', 300, 300, { message: DONE }),
],
edges: [
link('e1', 'start', 'exec', 'play', 'exec'),
link('e2', 'owner', 'object', 'play', 'object'),
link('e3', 'finished', 'exec', 'log', 'exec'),
],
};
// Red from the start, a larger blue behind it from the next frame – built after red, it still goes
// behind – and green after them, moved by a clip; ten frames a second, so the cut lasts 1.2 s. Placed where the camera looks, in front of what is there.
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
const cam = scene.objects.find((o) => o.id === 'o3').position;
${MAKE_OBJECT}
const sprite = p.assets.find((a) => a.type === 'sprite');
const look = (o, size, color) => { o.overrides = { width: size, height: size, color, texture: '' }; return o; };
const red = look(make('cp68-red', sprite.id, '', 0, 'Red 68'), 200, 0xff0000);
const blue = look(make('cp68-blue', sprite.id, '', 0, 'Blue 68'), 300, 0x0000ff);
const green = look(make('cp68-green', sprite.id, '', 0, 'Green 68'), 200, 0x00ff00);
for (const o of [red, blue, green]) delete o.groupId;
p.assets.push(
{
id: 'cp68-cut',
type: 'composition',
name: 'Cut 68',
props: { fps: 10, loop: false },
objects: [red, blue, green],
groups: [],
tracks: [{ id: 'cp68-front', name: 'Front' }, { id: 'cp68-back', name: 'Back' }],
items: [
{ id: 'cp68-red', track: 'cp68-front', start: 0, length: 6, offset: 0 },
{ id: 'cp68-blue', track: 'cp68-back', start: 1, length: 5, offset: 0 },
{ id: 'cp68-green', track: 'cp68-front', start: 6, length: 6, offset: 0 },
],
clips: [{ id: 'cp68-clip', track: 'cp68-front', animation: 'cp68-move', start: 6 }],
},
{
id: 'cp68-move',
type: 'animation',
name: 'Slide 68',
props: { fps: 10, playback: 'once' },
root: sprite.id,
tracks: [{ id: 'cp68-row', target: '', property: 'x', keys: [{ frame: 0, value: 0 }, { frame: 5, value: 100 }] }],
},
{ id: 'cp68-director', type: 'behavior', name: 'Director 68', props: { enabled: true, runInEditor: false }, variables: [], graph: ${JSON.stringify(graph)} },
);
const place = make('${PLACE}', 'cp68-cut', '', 0, 'Cut 68');
Object.assign(place.position, { x: cam.x, y: cam.y, z: 2000 });
scene.objects.push(place, make('cp68-logic', 'cp68-director', '${PLACE}', 0, 'Director 68'));
__gm.lookAt(cam.x, cam.y);
`);
const id = (item) => `${PLACE}~cp68-${item}`;
// 68a. In the editor the placement shows its first frame – red alone – and a click on the picture
// picks the placement, since what it shows is edited in the composition.
const order = await json('__gm.drawOrder()');
// Off the centre, where the placement's own symbol sits.
const rect = await json(`__gm.objectRect(${JSON.stringify(id('red'))})`);
const stack = rect
? await json(`__gm.sceneStack(${rect.x + rect.w * 0.35}, ${rect.y + rect.h * 0.35})`)
: null;
check(
'a placed composition shows its first frame, and a click on it picks the placement',
order.includes(id('red')) &&
!order.includes(id('blue')) &&
!order.includes(id('green')) &&
JSON.stringify(stack) === JSON.stringify([PLACE]),
JSON.stringify({ order: order.filter((o) => o.startsWith('cp68')), stack }),
);
// 68b. Played: the items come and go below the placement, sampled every frame of the editor.
await play();
const seen = await evaluate(`new Promise((resolve) => {
const frames = [];
const sample = () => {
const world = __gm.world();
if (!world) return resolve(JSON.stringify(frames));
const items = world.entities.filter((e) => e.objectId.startsWith('${PLACE}~'));
const green = items.find((e) => e.objectId === '${id('green')}');
frames.push({
ids: items.map((e) => e.objectId.slice('${PLACE}~cp68-'.length)),
parents: [...new Set(items.map((e) => e.parent))],
greenX: green ? green.x : null,
done: world.log.includes('${DONE}'),
});
if (frames.length > 400 || frames.at(-1).done) return resolve(JSON.stringify(frames));
requestAnimationFrame(sample);
};
sample();
})`);
const frames = JSON.parse(seen);
const both = frames.find((f) => f.ids.length === 2);
const greenXs = frames.map((f) => f.greenX).filter((x) => x !== null);
check(
'Play composition builds the items below the placement, the first track in front, one after the other',
both !== undefined &&
JSON.stringify(both.ids) === JSON.stringify(['blue', 'red']) &&
both.parents.length === 1 &&
both.parents[0] === PLACE &&
!frames.some((f) => f.ids.join('+') === 'red+blue') &&
frames.some((f) => JSON.stringify(f.ids) === JSON.stringify(['green'])) &&
!frames.some((f) => f.ids.includes('green') && f.ids.includes('red')),
JSON.stringify(frames.filter((_, i) => i % 5 === 0).map((f) => f.ids.join('+'))),
);
check(
'a clip moves the item it overlaps, from its value on',
greenXs.length >= 3 &&
greenXs[0] < 40 &&
greenXs.at(-1) > greenXs[0] &&
greenXs.every((x, i) => i === 0 || x >= greenXs[i - 1]),
JSON.stringify(greenXs),
);
const last = frames.at(-1);
check(
'On composition finished hears the end, by which time no item is left',
last?.done === true && last.ids.length === 0,
JSON.stringify(last),
);
await sleep(300);
await stop();
// 68c. The run's objects stay in the run: the scene holds the placement and its behavior only.
const stored = await json(
`__gm.state.activeScene.objects.filter((o) => o.id.startsWith('cp68')).map((o) => o.id).sort()`,
);
check(
'nothing of a played composition stays in the scene',
JSON.stringify(stored) === JSON.stringify(['cp68-logic', PLACE].sort()),
JSON.stringify(stored),
);
// 68d. A video item at frame 0, played from On start: the composition's clock stands while the Game
// tab loads the video, so its first frames are seen – it ran on without them before.
const uploaded = await uploadVideo(
t.baseUrl,
readFileSync(new URL('./fixtures/colors.webm', import.meta.url)),
'colors.webm',
);
await mutate(`
const p = __gm.state.project;
p.assets.push(
{ id: 'cp68-video', type: 'video', name: 'Colors 68', props: {}, file: ${JSON.stringify(uploaded.file)},
width: 320, height: 180, duration: 2, fps: 30, audio: true },
{ id: 'cp68-screen', type: 'sprite', name: 'Screen 68', props: { width: 160, height: 90, color: 0xffffff, texture: 'cp68-video' } },
);
const c = p.assets.find((a) => a.id === 'cp68-cut');
// Sixty frames a second: in steps of a tenth, a clock that ran on for a few frames still read 0.
c.props.fps = 60;
c.objects = [{ ...structuredClone(c.objects[0]), id: 'cp68-v', assetId: 'cp68-screen', name: 'Video 68', overrides: {} }];
c.items = [{ id: 'cp68-v', track: 'cp68-front', start: 0, length: 120, offset: 0 }];
c.clips = [];`);
await play();
// The video time the composition writes into its item every frame.
const clock = `__gm.world()?.entities.find((e) => e.objectId === '${id('v')}')?.props.videoTime ?? null`;
const wait = JSON.parse(
await evaluate(`new Promise((done) => {
const loading = [];
const step = () => {
const time = ${clock};
const player = __gm.videos().find((v) => v.key.endsWith(':${id('v')}'));
if (player?.ready) return done(JSON.stringify({ loading, ready: time }));
if (player) loading.push(time);
if (!__gm.world() || loading.length > 300) return done(JSON.stringify({ loading, ready: null }));
requestAnimationFrame(step);
};
step();
})`),
);
await sleep(500);
const after = await json(clock);
check(
'a played composition waits for the first picture of an item’s video, then runs on',
wait.loading.length > 0 &&
wait.loading.every((time) => time === 0) &&
wait.ready !== null &&
wait.ready === 0 &&
after > 0.2,
JSON.stringify({
frames: wait.loading.length,
loading: wait.loading.slice(0, 8),
ready: wait.ready,
after,
}),
);
await stop();
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => !a.id.startsWith('cp68-'));
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => !o.id.startsWith('cp68-'));`);
}