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:
adminandClaude Opus 5.5 committed 2026-10-15 08:01:04 +02:00
1 parent 0701fe3856
commit 406839baea
20 files changed
+469 -94

No files matched your search

+129 -1
View File
@@ -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;