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

+2 -2
View File
@@ -472,8 +472,8 @@ describe('items and their objects', () => {
const cut = makeCut();
const screen = new CreateAssetCommand('sprite', 'Screen', { texture: cut.videoId });
cut.stack.execute(screen);
expect(droppedLength(cut.project, cut.comp(), screen.asset)).toBe(300);
expect(droppedLength(cut.project, cut.comp(), findAsset(cut.project, cut.boxId)!)).toBe(180);
expect(droppedLength(cut.project, cut.comp(), screen.asset.id)).toBe(300);
expect(droppedLength(cut.project, cut.comp(), cut.boxId)).toBe(180);
});
});
+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();
+52 -2
View File
@@ -1,11 +1,17 @@
/** Which asset a reference field points at – what "Open texture" and the inspector link open (#221). */
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand, CreateTextureCommand } from '../../src/core/commands/assetCommands';
import {
CreateAssetCommand,
CreateSoundCommand,
CreateTextureCommand,
CreateVideoCommand,
} from '../../src/core/commands/assetCommands';
import { AddClipCommand } from '../../src/core/commands/clipCommands';
import { AddFrameCommand } from '../../src/core/commands/frameCommands';
import type { PixelArtAsset } from '../../src/core/model/Asset';
import { findAsset } from '../../src/core/model/Project';
import { type PropertyDef, schemaForAsset } from '../../src/core/properties/PropertySchema';
import { referencedAssetId, referencesOf } from '../../src/core/properties/references';
import { referencedAssetId, referencedTarget, referencesOf } from '../../src/core/properties/references';
import { resolveAssetProps } from '../../src/core/properties/resolve';
import { SHADER_KEY } from '../../src/core/properties/shaders';
import { makeSpriteScene } from '../helpers';
@@ -20,6 +26,33 @@ const SHADER: PropertyDef = {
assetTypes: ['shader'],
};
const SOUND: PropertyDef = { key: 'sound', label: 'Sound', kind: 'sound', default: '' };
function withClips() {
const f = setup();
const video = new CreateVideoCommand('Film', `${'cd'.repeat(32)}.mp4`, {
width: 320,
height: 180,
duration: 4,
fps: 30,
audio: false,
});
const sound = new CreateSoundCommand('Kick', `${'ef'.repeat(32)}.ogg`);
f.stack.execute(video);
f.stack.execute(sound);
const videoClip = new AddClipCommand(video.asset.id, { start: 1, end: 2 });
const soundClip = new AddClipCommand(sound.asset.id, { start: 0.1, end: 0.2 });
f.stack.execute(videoClip);
f.stack.execute(soundClip);
return {
...f,
videoId: video.asset.id,
soundId: sound.asset.id,
videoClip: videoClip.clipId,
soundClip: soundClip.clipId,
};
}
function setup() {
const fixture = makeSpriteScene();
const texture = new CreateTextureCommand('Sheet', { file: FILE, width: 64, height: 32 });
@@ -69,6 +102,23 @@ describe('referencedAssetId', () => {
const text: PropertyDef = { key: 'name', label: 'Name', kind: 'text', default: '' };
expect(referencedAssetId(f.project, text, f.textureId)).toBeNull();
});
it('is the video for a clip of one in a texture field (#425)', () => {
const f = withClips();
expect(referencedAssetId(f.project, TEXTURE, f.videoClip)).toBe(f.videoId);
expect(referencedAssetId(f.project, SOUND, f.soundClip)).toBe(f.soundId);
});
});
describe('referencedTarget (#425)', () => {
it('is the clip itself for a clip of a video or a sound, the asset for anything else', () => {
const f = withClips();
expect(referencedTarget(f.project, TEXTURE, f.videoClip)).toBe(f.videoClip);
expect(referencedTarget(f.project, SOUND, f.soundClip)).toBe(f.soundClip);
expect(referencedTarget(f.project, TEXTURE, f.videoId)).toBe(f.videoId);
expect(referencedTarget(f.project, TEXTURE, f.frameId)).toBe(f.textureId);
expect(referencedTarget(f.project, SHADER, f.videoClip)).toBeNull();
});
});
describe('referencesOf', () => {