Drag clips of videos and sounds straight onto a track (#425)
A clip of a video could not be laid on a composition track at all, and neither could a clip of a sound, although #418 and the wiki said so. The only place a drag starts is the asset panel, and it listed assets, not clips. The texture label of an item playing a video clip was dead too, because the reference lookup did not know video clips. - The asset panel gives a sound or video with clips a chevron of its own. It unfolds one row per clip and starts closed. - A clip row drags like an asset. One click shows the clip in the inspector without leaving the tab (`EditorState.glance`). A double click opens its sound or video with the clip picked. - A video clip dropped on a composition track becomes an item as long as the clip. It wears the sprite of its whole video, so a composition keeps one sprite per video, and the clip is a texture override on the object. Sprite, item and override are one undo step (`droppedPicture`). - A sound clip becomes an audio item as long as the clip. The song tab takes sound clips on the same way, through `findSound`. - `referencedTarget` lets a reference label link to the clip itself. One click pings the clip row, a double click opens the asset with the clip picked. The Plays row of an audio item reads "Sound / Clip". - `clipOwner` in `sound.ts` finds the sound or video a clip id belongs to. `activateAssetOrClip` uses it for the double clicks. Found on the way: the Blip of the e2e seed is only 0.25 s long, so a clip of 0.2 to 0.7 s on it is cut to one frame. 69s takes the Drone (0.75 s) instead. Tests: - tsc, eslint, Prettier clean - Unit on stein2 (composition, references, sound, video, editor): 94 files, 1238 tests green - e2e 69 on melektaus-atx: ALL PASSED, 25 checks, 2 of them new (69s) - Gate on melektaus-atx with the fix for 68k on top: CHECK EXIT 0, 313 files, 4841 tests - Counter checks, all red: no clip rows in e2e 69 (no drag starts), no clip override in the unit test, the link aimed at the asset instead of the clip - Not covered by its own e2e check: sound clips on the song tab Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0701fe3856
commit
406839baea
20 files changed
+469
-94
No files matched your search
@@ -12,7 +12,8 @@
|
||||
* dragged in is an audio item, heard while the playhead runs; the handle of its fade in drags out a
|
||||
* fade its bus rises along. A video item's sprite reads as driven by the composition and goes into
|
||||
* its bus, which has a node in the master routing; after a hitch the sound is started again where
|
||||
* the playhead is.
|
||||
* the playhead is. Clips of a sound and a video have rows in the Assets panel, are dragged onto a
|
||||
* track like an asset, and the Texture label links the clip.
|
||||
*
|
||||
* Everything it adds carries a `ct69` id and goes again at the end.
|
||||
*/
|
||||
@@ -702,6 +703,133 @@ export async function compositionTab(t) {
|
||||
JSON.stringify({ hitchBefore, hitchAfter }),
|
||||
);
|
||||
|
||||
// 69s (#425). The clips of a sound and of a video have rows of their own under it in the Assets
|
||||
// panel, folded until its chevron opens them. Each dragged onto a track is an item as long as the
|
||||
// clip: the sound's an audio item playing it, the video's wearing the sprite of its video with the
|
||||
// clip as texture, its picture from the clip's start. Its Texture label links the clip: a click
|
||||
// pings its row, a double-click opens the video with the clip picked.
|
||||
// The drone of the seed lasts 0.75 s, so its clip of 0.5 s is told apart from it.
|
||||
const drone = sounds.find((item) => item.bytes === 12044);
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
p.assets.find((a) => a.id === '${VIDEO}').clips = [{ id: 'ct69-vclip', name: 'Middle', props: { start: 0.5, end: 1.5 } }];
|
||||
p.assets.push({ id: 'ct69-drone', type: 'sound', name: 'Drone 69', props: {}, file: ${JSON.stringify(drone?.file ?? '')},
|
||||
clips: [{ id: 'ct69-sclip', name: 'Bit', props: { start: 0.2, end: 0.7 } }] });
|
||||
const c = p.assets.find((a) => a.id === '${CUT}');
|
||||
c.objects = [];
|
||||
c.items = [];
|
||||
c.clips = [];`);
|
||||
await evaluate('__gm.composition.scrub(0)');
|
||||
await json(`__gm.assetRow('${VIDEO}')`);
|
||||
const folded = await json(`__gm.assetRowShown('ct69-vclip')`);
|
||||
const chevron = await json(`__gm.assetChevron('${VIDEO}')`);
|
||||
if (chevron) await t.click(chevron.x, chevron.y);
|
||||
const unfolded = await json(`__gm.assetRowShown('ct69-vclip')`);
|
||||
/** Like `dragAsset`, but held over the lane until the drop is as long as `frames`: a cut decodes first. */
|
||||
const dragClip = async (id, to, frames) => {
|
||||
await sleep(450);
|
||||
const from = await json(`__gm.assetRow(${JSON.stringify(id)})`);
|
||||
// No row, nothing to drag: the check says so rather than the module throwing.
|
||||
if (!from) return null;
|
||||
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);
|
||||
}
|
||||
let drop = await json('__gm.composition.drop()');
|
||||
for (let i = 0; i < 20 && drop?.length !== frames; i++) {
|
||||
await sleep(50);
|
||||
await mouse('mouseMoved', to.x + (i % 2), to.y, 'left', { buttons: 1 });
|
||||
drop = await json('__gm.composition.drop()');
|
||||
}
|
||||
await mouse('mouseReleased', to.x, to.y);
|
||||
await sleep(200);
|
||||
return drop;
|
||||
};
|
||||
const videoClipDrop = await dragClip(
|
||||
'ct69-vclip',
|
||||
await json(`__gm.composition.laneAt('ct69-t1', 40)`),
|
||||
10,
|
||||
);
|
||||
const soundClipDrop = await dragClip('ct69-sclip', await json(`__gm.composition.laneAt('ct69-t2', 10)`), 5);
|
||||
const placed = await json(`(() => {
|
||||
const p = __gm.state.project;
|
||||
const c = p.assets.find((a) => a.id === '${CUT}');
|
||||
const pictureItem = c.items.find((i) => !i.audio);
|
||||
const object = c.objects.find((o) => o.id === pictureItem?.id);
|
||||
const sprite = object && p.assets.find((a) => a.id === object.assetId);
|
||||
return { pictureItem, audioItem: c.items.find((i) => i.audio) ?? null, overrides: object?.overrides ?? null,
|
||||
sprite: sprite ? { owner: sprite.owner, texture: sprite.props.texture } : null };
|
||||
})()`);
|
||||
const clipPlayer = `__gm.videos().find((v) => v.key.includes('#0.5~1.5~') && v.key.endsWith(':${placed.pictureItem?.id}')) ?? null`;
|
||||
await evaluate('__gm.composition.scrub(43)');
|
||||
let middle = null;
|
||||
for (let i = 0; i < 40 && !(middle?.ready && !middle.seeking && !middle.landing); i++) {
|
||||
await sleep(150);
|
||||
middle = await json(clipPlayer);
|
||||
}
|
||||
const middleDrawn = await json(`__gm.composition.drawn('${placed.pictureItem?.id}')`);
|
||||
check(
|
||||
'clips have folded rows of their own, and a clip of a video and of a sound lands as long as the clip',
|
||||
folded === null &&
|
||||
chevron !== null &&
|
||||
unfolded !== null &&
|
||||
videoClipDrop?.what === 'item' &&
|
||||
videoClipDrop.rejection === '' &&
|
||||
placed.pictureItem?.start === 40 &&
|
||||
placed.pictureItem.length === 10 &&
|
||||
placed.pictureItem.fitMedia === true &&
|
||||
placed.overrides?.texture === 'ct69-vclip' &&
|
||||
placed.sprite?.owner === CUT &&
|
||||
placed.sprite.texture === VIDEO &&
|
||||
drone !== undefined &&
|
||||
soundClipDrop?.what === 'item' &&
|
||||
placed.audioItem?.audio?.asset === 'ct69-sclip' &&
|
||||
placed.audioItem.start === 10 &&
|
||||
placed.audioItem.length === 5 &&
|
||||
Math.abs((middle?.time ?? 0) - 0.8) < 0.05 &&
|
||||
middleDrawn !== null,
|
||||
JSON.stringify({ folded, chevron, unfolded, videoClipDrop, soundClipDrop, placed, middle, middleDrawn }),
|
||||
);
|
||||
|
||||
await evaluate(
|
||||
`__gm.state.select({ kind: 'object', holderId: '${CUT}', objectId: '${placed.pictureItem?.id}' })`,
|
||||
);
|
||||
await sleep(200);
|
||||
// Folded again, so the ping has to unfold the clips itself.
|
||||
const again = await json(`__gm.assetChevron('${VIDEO}')`);
|
||||
if (again) await t.click(again.x, again.y);
|
||||
const refolded = await json(`__gm.assetRowShown('ct69-vclip')`);
|
||||
const textureLink = await json(`__gm.inspectorLink('Texture')`);
|
||||
if (textureLink) await t.click(textureLink.x, textureLink.y);
|
||||
const clipPing = { ping: await json('__gm.assetPing()'), selection: await json('__gm.state.selection') };
|
||||
await sleep(1200);
|
||||
const relink = await json(`__gm.inspectorLink('Texture')`);
|
||||
if (relink) await t.doubleClick(relink.x, relink.y);
|
||||
await sleep(200);
|
||||
const clipOpened = { selection: await json('__gm.state.selection'), docs: await json('__gm.documents()') };
|
||||
check(
|
||||
'the Texture label links the clip: a click pings its row, a double-click opens the video with the clip picked',
|
||||
refolded === null &&
|
||||
textureLink?.underlined === true &&
|
||||
textureLink.pressable === true &&
|
||||
clipPing.ping?.id === 'ct69-vclip' &&
|
||||
clipPing.selection?.kind === 'object' &&
|
||||
clipOpened.selection?.kind === 'clip' &&
|
||||
clipOpened.selection.clipId === 'ct69-vclip' &&
|
||||
clipOpened.docs.active === `asset:${VIDEO}`,
|
||||
JSON.stringify({ refolded, textureLink, clipPing, clipOpened }),
|
||||
);
|
||||
|
||||
await showTab('scene');
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
|
||||
Reference in new issue
Block a user