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

+8 -14
View File
@@ -5,13 +5,7 @@
* into.
*/
import { videoLoopsOf } from '../properties/videos';
import type {
Asset,
CompositionAsset,
CompositionAudioKind,
CompositionClip,
CompositionItem,
} from './Asset';
import type { CompositionAsset, CompositionAudioKind, CompositionClip, CompositionItem } from './Asset';
import {
audioRoundSeconds,
audioSeconds,
@@ -407,11 +401,11 @@ export function audioKindOf(project: Project, assetId: string): CompositionAudio
}
/**
* How long an item of `asset` is when it is dropped in: as long as the video it is (#419) or its
* sprite wears, or what it plays as an audio item (#418); else 3 s.
* How long an item of the asset `id` names is when it is dropped in: as long as the video or clip of
* one it is (#419, #425) or its sprite wears, or what it plays as an audio item (#418); else 3 s.
*/
export function droppedLength(project: Project, comp: CompositionAsset, asset: Asset): number {
const kind = audioKindOf(project, asset.id);
export function droppedLength(project: Project, comp: CompositionAsset, id: string): number {
const kind = audioKindOf(project, id);
if (kind)
return fittedLength(project, comp, {
id: '',
@@ -419,10 +413,10 @@ export function droppedLength(project: Project, comp: CompositionAsset, asset: A
start: 0,
length: 1,
offset: 0,
audio: { kind, asset: asset.id, volume: 0, bus: '' },
audio: { kind, asset: id, volume: 0, bus: '' },
});
const texture =
asset.type === 'sprite' ? asset.props.texture : asset.type === 'video' ? asset.id : undefined;
const asset = findAsset(project, id);
const texture = asset?.type === 'sprite' ? asset.props.texture : id;
const video = typeof texture === 'string' ? findVideo(project, texture) : null;
const fps = compositionFps(comp);
const seconds = video ? videoLengthOf(video) : 0;
+14 -1
View File
@@ -55,6 +55,14 @@ export function assetNamedBy(assets: readonly Asset[], id: string): Asset | unde
);
}
/** The sound or video `id` is a clip of; undefined for an asset's own id or anything else. */
export function clipOwner(project: Project, id: string): ClipOwner | undefined {
if (id === '' || findAsset(project, id)) return undefined;
return project.assets.find(
(a): a is ClipOwner => hasClips(a) && clipsOf<{ id: string }>(a).some((c) => c.id === id),
);
}
/**
* Gives the clips of a copy fresh ids, since a clip's id is named across the document, and notes
* each old id with its new one in `ids` so the places that named a clip can follow.
@@ -70,7 +78,12 @@ export function renewClipIds(copy: Asset, ids: Map<string, string>): void {
/** "Kick" for the sound, "Kick / Clip 1" for a clip of it. */
export function soundRefName(ref: SoundRef, soundName = ref.asset.name): string {
return ref.clip ? `${soundName}${CLIP_SEPARATOR}${ref.clip.name}` : soundName;
return ref.clip ? clipRefName(soundName, ref.clip.name) : soundName;
}
/** "Kick / Clip 1": a clip of a sound or a video by name. */
export function clipRefName(assetName: string, clipName: string): string {
return `${assetName}${CLIP_SEPARATOR}${clipName}`;
}
/** "Clip 1", 2, 3 …: the first number none of the asset's clips has. */