A track item has always carried an object, and an object needs an asset. Instead of objects without one, a dropped picture or video gets a sprite the composition owns, the way a song owns its sounds (#355). To the rest of the code it is a sprite like any other. - The asset list starts a drag for textures, vector art, pixel art and videos. Dropped on a track, they make the sprite and the item in one undo step. A video item is as long as the video and follows it. - One sprite per picture and composition. A second drop of the same video shares it. Once no item wears it, it goes in the same step. - Deleting a composition takes its sprites along. A duplicate gets sprites of its own. - Pasting into a scene or container turns the sprite into a project asset. Pasting into another composition gives that one a copy. The clipboard carries the sprite, so cut and paste loses nothing. - Owned sprites stay out of the asset list and the pickers. The inspector shows them with a dot in front. - "Make project asset" in the item's menu, on the timeline and in the hierarchy, turns the sprite into an ordinary one. - The first track of the Composition tab gets its top line, as the Song tab's has. Found on the way: loading kept an owner only for the asset types a song can hold. A composition's sprite would have lost its owner and turned up in the list after a reload. Tests: - Unit composition-sprites: 11 new. Without the rule 5 of them are red, without the loading fix the loading test is. - e2e 69 on melektaus-atx: ALL PASSED, 15 checks, 69k new - Gate on melektaus-atx: 305 files, 4714 tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
356 lines
16 KiB
JavaScript
356 lines
16 KiB
JavaScript
/**
|
||
* 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. A
|
||
* video dragged in wears a sprite the composition keeps to itself.
|
||
*
|
||
* 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 }),
|
||
);
|
||
|
||
// 69k. A video dragged straight onto a track (#419) is an item as long as the video, wearing a sprite
|
||
// the composition keeps and the Assets panel leaves out; a second drop shares it, and undoing both
|
||
// takes the sprite along.
|
||
const kept = (id) =>
|
||
json(`(() => {
|
||
const p = __gm.state.project;
|
||
const c = p.assets.find((a) => a.id === '${CUT}');
|
||
const object = c.objects.find((o) => o.id === ${JSON.stringify(id)});
|
||
const sprite = object && p.assets.find((a) => a.id === object.assetId);
|
||
return { item: c.items.find((i) => i.id === ${JSON.stringify(id)}) ?? null,
|
||
sprite: sprite ? { id: sprite.id, type: sprite.type, owner: sprite.owner, texture: sprite.props.texture } : null,
|
||
owned: p.assets.filter((a) => a.owner === '${CUT}').length };
|
||
})()`);
|
||
const videoDrop = await dragAsset(VIDEO, await json(`__gm.composition.laneAt('ct69-t2', 70)`));
|
||
const one = await kept((await json('__gm.state.selection'))?.objectIds?.[0] ?? '');
|
||
const row = one.sprite ? await json(`__gm.assetRow('${one.sprite.id}')`) : 'none';
|
||
await dragAsset(VIDEO, await json(`__gm.composition.laneAt('ct69-t2', 100)`));
|
||
const two = await kept((await json('__gm.state.selection'))?.objectIds?.[0] ?? '');
|
||
await evaluate('__gm.state.undo()');
|
||
await evaluate('__gm.state.undo()');
|
||
const undone = await kept('');
|
||
check(
|
||
'a video dropped on a track wears a sprite the composition keeps, shared by a second drop',
|
||
videoDrop?.what === 'item' &&
|
||
videoDrop.rejection === '' &&
|
||
one.item?.track === 'ct69-t2' &&
|
||
one.item.start === 70 &&
|
||
one.item.length === 20 &&
|
||
one.sprite?.type === 'sprite' &&
|
||
one.sprite.owner === CUT &&
|
||
one.sprite.texture === VIDEO &&
|
||
row === null &&
|
||
two.item?.start === 100 &&
|
||
two.sprite?.id === one.sprite.id &&
|
||
two.owned === 1 &&
|
||
undone.owned === 0,
|
||
JSON.stringify({ videoDrop, one, row, two, undone }),
|
||
);
|
||
|
||
await showTab('scene');
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
p.assets = p.assets.filter((a) => !a.id.startsWith('ct69-') && !a.owner?.startsWith('ct69-'));`);
|
||
}
|