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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-12 10:38:19 +02:00
1 parent c4650e43f0
commit 6e6b6a4d9a
20 files changed
+421 -19

No files matched your search

+59 -1
View File
@@ -1,3 +1,5 @@
import { readFileSync } from 'node:fs';
import { uploadVideo } from '../lib/projects.mjs';
import { MAKE_OBJECT } from './lib/scaffold.mjs';
/**
@@ -5,7 +7,8 @@ import { MAKE_OBJECT } from './lib/scaffold.mjs';
* 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.
* 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.
*/
@@ -161,6 +164,61 @@ export async function composition(t) {
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-'));
+70 -1
View File
@@ -4,7 +4,8 @@
* 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.
* 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.
*/
@@ -239,6 +240,74 @@ export async function compositionTab(t) {
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;