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

+56 -4
View File
@@ -11,19 +11,22 @@ import {
DuplicateAssetCommand,
MakeProjectAssetCommand,
} from '../../src/core/commands/assetCommands';
import { AddClipCommand } from '../../src/core/commands/clipCommands';
import type { Command } from '../../src/core/commands/Command';
import { pasteCompositionItems, removeFromComposition } from '../../src/core/commands/compositionCommands';
import {
addPictureItem,
droppedPicture,
KeepCompositionSpritesCommand,
wornPicture,
} from '../../src/core/commands/compositionSprites';
import { PasteObjectsCommand } from '../../src/core/commands/pasteCommands';
import { copyObjects } from '../../src/core/model/clipboard';
import { isCompositionSprite } from '../../src/core/model/composition';
import { isCompositionSprite, itemVideo } from '../../src/core/model/composition';
import { droppedLength } from '../../src/core/model/compositionEdit';
import { findAsset, findAssetOf } from '../../src/core/model/Project';
import { assetTitle, listedAssets } from '../../src/core/music/embedded';
import { VIDEO_END_KEY, VIDEO_START_KEY } from '../../src/core/properties/videos';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
import { makeProject } from '../helpers';
@@ -45,8 +48,8 @@ function makeFilm() {
const comp = (id = film.asset.id) => findAssetOf(f.project, id, 'composition')!;
const sprites = () => f.project.assets.filter(isCompositionSprite);
/** The video laid on the first track of `compId` at `start`, run through the stack as the timeline does. */
const drop = (start: number, compId = film.asset.id) => {
const { command, objectId } = addPictureItem(f.project, compId, video.asset.id, 'Intro', {
const drop = (start: number, compId = film.asset.id, dropped = video.asset.id) => {
const { command, objectId } = addPictureItem(f.project, compId, droppedPicture(f.project, dropped)!, {
track: comp(compId).tracks[0]!.id,
start,
length: 150,
@@ -92,7 +95,7 @@ describe('a picture laid on a track', () => {
const video = findAsset(film.project, film.videoId)!;
expect(wornPicture(video)).toBe(film.videoId);
expect(wornPicture(findAsset(film.project, film.sceneId)!)).toBeNull();
expect(droppedLength(film.project, film.comp(), video)).toBe(300);
expect(droppedLength(film.project, film.comp(), video.id)).toBe(300);
});
it('wears a sprite that "Make project asset" lists like any other, and it stays when the item goes', () => {
@@ -133,6 +136,55 @@ describe('a picture laid on a track', () => {
});
});
describe('a clip of a video laid on a track (#425)', () => {
const withClip = () => {
const film = makeFilm();
const add = new AddClipCommand(film.videoId, { [VIDEO_START_KEY]: 1, [VIDEO_END_KEY]: 3 });
film.run(add);
return { ...film, clipId: add.clipId };
};
const objectOf = (film: ReturnType<typeof withClip>, id: string) =>
film.comp().objects.find((o) => o.id === id)!;
it('wears the sprite of its video with the clip as the texture, as long as the clip', () => {
const film = withClip();
expect(droppedPicture(film.project, film.clipId)).toEqual({
picture: film.videoId,
clip: film.clipId,
name: 'Intro',
});
expect(droppedLength(film.project, film.comp(), film.clipId)).toBe(120);
const id = film.drop(0, film.filmId, film.clipId);
const [sprite] = film.sprites();
expect(sprite!.props).toMatchObject({ texture: film.videoId, width: 320, height: 180 });
expect(objectOf(film, id)).toMatchObject({ assetId: sprite!.id, overrides: { texture: film.clipId } });
const item = film.comp().items.find((i) => i.id === id)!;
expect(itemVideo(film.project, film.comp(), item)?.clip?.id).toBe(film.clipId);
});
it('shares the sprite with the whole video, and one undo takes a first item with its sprite', () => {
const film = withClip();
const whole = film.drop(0);
const cut = film.drop(200, film.filmId, film.clipId);
expect(film.sprites()).toHaveLength(1);
expect(film.wornBy(film.comp(), cut)).toBe(film.wornBy(film.comp(), whole));
expect(objectOf(film, whole).overrides).toEqual({});
film.stack.undo();
expect(film.comp().objects.map((o) => o.id)).toEqual([whole]);
film.stack.undo();
film.drop(0, film.filmId, film.clipId);
film.stack.undo();
expect(film.sprites()).toHaveLength(0);
expect(film.comp().objects).toHaveLength(0);
});
it('is no picture for a sound or a scene', () => {
const film = withClip();
expect(droppedPicture(film.project, film.sceneId)).toBeNull();
expect(droppedPicture(film.project, 'nothing')).toBeNull();
});
});
describe('a copied item', () => {
it('pasted after a cut wears its sprite again', () => {
const film = makeFilm();