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>
228 lines
9.8 KiB
JavaScript
228 lines
9.8 KiB
JavaScript
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-'));`);
|
||
}
|