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:
1 parent
c4650e43f0
commit
6e6b6a4d9a
20 files changed
+421
-19
No files matched your search
@@ -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-'));
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user