Files
feuerwerk/tools/e2e/69-composition-tab.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

316 lines
14 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.
/**
* The Composition tab (#417). A sprite dragged in from the Assets panel becomes an item, 3 s long
* and picked, so the inspector shows its TIMING; a second one dropped onto the first lands there,
* over it. The end of the first, covered by the second, lights up under the pointer and is dragged
* past it. An animation dragged in becomes a clip that moves the item it overlaps in the picture. The hierarchy lists what is alive at the playhead, Delete
* takes an item with its object, and Play runs the playhead to the end. The video of an item about
* to begin is opened ahead, so the cut to it shows its picture at once, and Play waits for a video
* whose player is still loading. A copied item is pasted under the pointer or at the playhead.
*
* Everything it adds carries a `ct69` id and goes again at the end.
*/
import { readFileSync } from 'node:fs';
import { uploadVideo } from '../lib/projects.mjs';
const CUT = 'ct69-cut';
const BOX = 'ct69-box';
const SLIDE = 'ct69-slide';
const VIDEO = 'ct69-video';
const SCREEN = 'ct69-screen';
export async function compositionTab(t) {
const { check, sleep, json, evaluate, mutate, mouse, drag, press, showTab } = t;
/** Drags a row of the Assets panel to a point step by step: the drop only exists while the button is down. */
const dragAsset = async (assetId, to) => {
// A press on the row of the drag before, this soon, would read as a double-click that opens it.
await sleep(450);
const from = await json(`__gm.assetRow(${JSON.stringify(assetId)})`);
await mouse('mouseMoved', from.x, from.y);
await mouse('mousePressed', from.x, from.y);
for (let i = 1; i <= 8; i++) {
await mouse(
'mouseMoved',
from.x + ((to.x - from.x) * i) / 8,
from.y + ((to.y - from.y) * i) / 8,
'left',
{
buttons: 1,
},
);
await sleep(16);
}
const drop = await json('__gm.composition.drop()');
await mouse('mouseReleased', to.x, to.y);
await sleep(200);
return drop;
};
const model = () =>
json(`(() => {
const c = __gm.state.project.assets.find((a) => a.id === '${CUT}');
return { objects: c.objects.map((o) => o.id), items: c.items, clips: c.clips };
})()`);
await press('Escape');
await showTab('scene');
// Ten frames a second keeps the numbers small: an item of 3 s is 30 frames, the slide 5.
await mutate(`
const p = __gm.state.project;
p.assets.push(
{ id: '${CUT}', type: 'composition', name: 'Cut 69', props: { fps: 10, loop: false }, objects: [], groups: [],
tracks: [{ id: 'ct69-t1', name: 'Front' }, { id: 'ct69-t2', name: 'Back' }], items: [], clips: [] },
{ id: '${BOX}', type: 'sprite', name: 'Box 69', props: { width: 100, height: 100, color: 0xff0000, texture: '' } },
{ id: '${SLIDE}', type: 'animation', name: 'Slide 69', props: { fps: 10, playback: 'once' }, root: '${BOX}',
tracks: [{ id: 'ct69-row', target: '', property: 'x', keys: [{ frame: 0, value: 0 }, { frame: 5, value: 100 }] }] },
);`);
await evaluate(`__gm.state.openAsset('${CUT}')`);
await sleep(300);
const opened = await json(`{ tab: __gm.tab(), current: __gm.composition.current() }`);
check(
'opening a composition brings up its tab',
opened.tab === 'composition' && opened.current === CUT,
JSON.stringify(opened),
);
// 69a. A sprite dropped on the first track at frame 5 is an item there, 3 s long, picked.
const first = await dragAsset(BOX, await json(`__gm.composition.laneAt('ct69-t1', 5)`));
let m = await model();
const a = m.items[0];
const picked = await json('__gm.state.selection');
check(
'a sprite dragged onto a track becomes an item where it is let go, 3 s long and picked',
first?.what === 'item' &&
first.rejection === '' &&
m.items.length === 1 &&
a.start === 5 &&
a.length === 30 &&
a.fitMedia === true &&
JSON.stringify(m.objects) === JSON.stringify([a.id]) &&
picked?.kind === 'object' &&
picked.holderId === CUT &&
picked.objectId === a.id,
JSON.stringify({ first, m, picked }),
);
const timing = await json(`__gm.section('TIMING')`);
check('the inspector shows the picked item’s TIMING', timing !== null, JSON.stringify(timing));
// 69b. A second one let go over the first lands where it is let go, over the first.
await dragAsset(BOX, await json(`__gm.composition.laneAt('ct69-t1', 20)`));
m = await model();
const b = m.items.find((i) => i.id !== a.id);
check(
'one dropped onto an item lands where it is let go, over it',
m.items.length === 2 && b?.start === 20 && b.length === 30,
JSON.stringify(m.items),
);
// 69c. The first item's end, under the second, lights up with a resize cursor and is dragged past it.
const box = await json(`__gm.composition.box('${a.id}')`);
const end = { x: box.x + box.w - 2, y: box.y + box.h / 2 };
await mouse('mouseMoved', end.x - 20, end.y);
await mouse('mouseMoved', end.x, end.y);
await sleep(80);
const hovered = await json('__gm.composition.view()');
const far = await json(`__gm.composition.laneAt('ct69-t1', 60)`);
await drag(end.x, end.y, far.x, end.y);
await sleep(150);
m = await model();
const resized = m.items.find((i) => i.id === a.id);
check(
'the covered end of an item lights up with a resize cursor, and is dragged past the item over it',
hovered.hover?.id === a.id &&
hovered.hover.part === 'end' &&
hovered.cursor === 'ew-resize' &&
resized?.length === 55 &&
resized.fitMedia === undefined,
JSON.stringify({ hover: hovered.hover, cursor: hovered.cursor, resized }),
);
// 69d. The animation dropped at frame 5 is a clip one pass long that moves the item in the picture.
const clipDrop = await dragAsset(SLIDE, await json(`__gm.composition.laneAt('ct69-t1', 5)`));
m = await model();
const x = async (frame) => {
await evaluate(`__gm.composition.scrub(${frame})`);
await sleep(60);
return (await json('__gm.composition.shown()')).find((o) => o.id === a.id)?.x ?? null;
};
const xs = [await x(5), await x(8), await x(20)];
check(
'an animation dragged onto a track becomes a clip, and moves the item it overlaps in the picture',
clipDrop?.what === 'clip' &&
m.clips.length === 1 &&
m.clips[0].start === 5 &&
m.clips[0].animation === SLIDE &&
xs[0] === 0 &&
xs[1] === 60 &&
xs[2] === 100,
JSON.stringify({ clipDrop, clips: m.clips, xs }),
);
// 69e. The hierarchy lists what is alive at the playhead – read off the rows it shows.
const rowsAt = async (frame) => {
await evaluate(`__gm.composition.scrub(${frame})`);
await sleep(80);
const shown = await json(`[${m.items.map((i) => `__gm.hierarchyRow('${i.id}')`).join(',')}]`);
return m.items.filter((_, n) => shown[n] !== null).map((i) => i.id);
};
const rows = [await rowsAt(2), await rowsAt(10), await rowsAt(40)];
check(
'the hierarchy lists the items alive at the playhead',
rows[0].length === 0 &&
JSON.stringify(rows[1]) === JSON.stringify([a.id]) &&
JSON.stringify(rows[2]) === JSON.stringify([a.id, b.id]),
JSON.stringify(rows),
);
// 69f. Delete takes the picked item with its object; undo brings both back.
await evaluate(`__gm.state.select({ kind: 'object', holderId: '${CUT}', objectId: '${b.id}' })`);
await press('Delete');
await sleep(150);
const gone = await model();
await press('z', { ctrl: true });
await sleep(150);
const back = await model();
check(
'Delete takes an item with its object, undo brings both back',
!gone.items.some((i) => i.id === b.id) &&
!gone.objects.includes(b.id) &&
back.items.some((i) => i.id === b.id) &&
back.objects.includes(b.id),
JSON.stringify({ gone, back }),
);
// 69g. Play runs the playhead from the start to the end of the last item, and stops there.
await evaluate(`__gm.composition.scrub(0)`);
const playAt = await json('__gm.composition.playAt()');
await mouse('mouseMoved', playAt.x, playAt.y);
await mouse('mousePressed', playAt.x, playAt.y);
await mouse('mouseReleased', playAt.x, playAt.y);
await sleep(500);
const running = await json('__gm.composition.view()');
let ended = running;
for (let i = 0; i < 40 && ended.playing; i++) {
await sleep(150);
ended = await json('__gm.composition.view()');
}
check(
'Play runs the playhead to the end and stops there',
running.playing && running.playhead > 0 && !ended.playing && ended.playhead === 60,
JSON.stringify({ running, ended }),
);
// 69h. Two video items back to back: the second one's video is opened a second ahead, so the cut
// draws its picture in the very redraw it begins – loaded only then, it was left out until it came.
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: '${VIDEO}', type: 'video', name: 'Colors 69', props: {}, file: ${JSON.stringify(uploaded.file)},
width: 320, height: 180, duration: 2, fps: 30, audio: true },
{ id: '${SCREEN}', type: 'sprite', name: 'Screen 69', props: { width: 160, height: 90, color: 0xffffff, texture: '${VIDEO}' } },
);
const c = p.assets.find((a) => a.id === '${CUT}');
const object = (id) => ({ ...structuredClone(c.objects[0]), id, assetId: '${SCREEN}', overrides: {}, instanceEdits: {} });
c.objects = [object('ct69-v1'), object('ct69-v2')];
c.items = [
{ id: 'ct69-v1', track: 'ct69-t1', start: 0, length: 20, offset: 0 },
{ id: 'ct69-v2', track: 'ct69-t1', start: 20, length: 10, offset: 1 },
];
c.clips = [];`);
const second = `__gm.videos().find((v) => v.key.endsWith(':ct69-v2')) ?? null`;
await evaluate(`__gm.composition.scrub(5)`);
await sleep(300);
const early = await json(second);
await evaluate(`__gm.composition.scrub(12)`);
let ahead = null;
// Settled: a seek still landing would hold the view a frame, which is no picture missing.
for (let i = 0; i < 40 && !(ahead?.ready && !ahead.seeking && !ahead.landing); i++) {
await sleep(150);
ahead = await json(second);
}
const cut = await json(
`(() => { __gm.composition.scrub(20); return __gm.composition.drawn('ct69-v2'); })()`,
);
check(
'a coming video item is opened a second ahead, at its offset, and drawn in the redraw it begins',
early === null && ahead?.ready && ahead.paused && Math.abs(ahead.time - 1) < 0.05 && cut !== null,
JSON.stringify({ early, ahead, cut }),
);
// 69i. Play at the end starts over at 0, where the item's video was never opened: the playhead
// waits for its first picture, so its first frames are seen – it ran on without them before.
await mutate(`
const c = __gm.state.project.assets.find((a) => a.id === '${CUT}');
c.objects = [{ ...structuredClone(c.objects[0]), id: 'ct69-v3' }];
c.items = [{ id: 'ct69-v3', track: 'ct69-t1', start: 0, length: 20, offset: 0 }];`);
await evaluate(`__gm.composition.scrub(20)`);
await sleep(200);
await mouse('mousePressed', playAt.x, playAt.y);
await mouse('mouseReleased', playAt.x, playAt.y);
const wait = JSON.parse(
await evaluate(`new Promise((done) => {
const loading = [];
const step = () => {
const player = __gm.videos().find((v) => v.key.endsWith(':ct69-v3'));
const { playhead } = __gm.composition.view();
if (player?.ready) return done(JSON.stringify({ loading, ready: playhead }));
if (player) loading.push(playhead);
if (loading.length > 300) return done(JSON.stringify({ loading, ready: null }));
requestAnimationFrame(step);
};
step();
})`),
);
await sleep(500);
const after = await json('__gm.composition.view()');
check(
'Play waits at a video item whose player is still loading, then runs on',
wait.loading.length > 0 &&
wait.loading.every((at) => at === 0) &&
wait.ready !== null &&
wait.ready < 0.5 &&
after.playhead > 2,
JSON.stringify({ frames: wait.loading.length, ready: wait.ready, after: after.playhead }),
);
// 69j. Ctrl+C copies the picked item. Ctrl+V pastes it under the pointer while that is over the
// lanes, on the track there; with the pointer off the lanes at the playhead, on the picked track.
for (let i = 0; i < 30 && (await json('__gm.composition.view()')).playing; i++) await sleep(150);
await mutate(`
const c = __gm.state.project.assets.find((a) => a.id === '${CUT}');
c.objects = [{ ...structuredClone(c.objects[0]), id: 'ct69-p1', assetId: '${BOX}' }];
c.items = [{ id: 'ct69-p1', track: 'ct69-t1', start: 0, length: 30, offset: 0 }];`);
const original = await json(`__gm.composition.box('ct69-p1')`);
await t.click(original.x + original.w / 2, original.y + original.h / 2);
await press('c', { ctrl: true });
const lane = await json(`__gm.composition.laneAt('ct69-t2', 50)`);
await mouse('mouseMoved', lane.x, lane.y);
await press('v', { ctrl: true });
await evaluate(`__gm.composition.scrub(12)`);
await mouse('mouseMoved', playAt.x, playAt.y);
await press('v', { ctrl: true });
m = await model();
const copies = m.items.filter((i) => i.id !== 'ct69-p1');
const pickedNow = await json('__gm.state.selection');
check(
'Ctrl+V pastes a copied item under the pointer, else at the playhead on the picked track',
m.items.length === 3 &&
m.objects.length === 3 &&
copies[0]?.track === 'ct69-t2' &&
copies[0].start === 50 &&
copies[0].length === 30 &&
copies[1]?.track === 'ct69-t2' &&
copies[1].start === 12 &&
pickedNow?.objectIds?.[0] === copies[1].id,
JSON.stringify({ copies, picked: pickedNow?.objectIds }),
);
await showTab('scene');
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => !a.id.startsWith('ct69-'));`);
}