Files
feuerwerk/tests/core/composition-sprites.test.ts
T
adminandClaude Opus 5.5 406839baea 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>
2026-10-15 08:01:04 +02:00

268 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* What a composition keeps to itself (#419): a texture or video laid on a track wears a sprite the
* composition owns, one per picture. It goes when nothing uses it, comes back with a pasted cut,
* turns into a project asset once placed in a scene and is copied for another composition.
*/
import { describe, expect, it } from 'vitest';
import {
CreateAssetCommand,
CreateVideoCommand,
DeleteAssetCommand,
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, 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';
const FILE = `a1b2c3d4e5f6/${'c7'.repeat(32)}.mp4`;
function makeFilm() {
const f = makeProject();
const run = (command: Command) => f.stack.execute(new KeepCompositionSpritesCommand(command));
const scene = new CreateAssetCommand('scene', 'Level');
const film = new CreateAssetCommand('composition', 'Film');
const video = new CreateVideoCommand('Intro', FILE, {
width: 320,
height: 180,
duration: 5,
fps: 30,
audio: false,
});
for (const c of [scene, film, video]) run(c);
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, dropped = video.asset.id) => {
const { command, objectId } = addPictureItem(f.project, compId, droppedPicture(f.project, dropped)!, {
track: comp(compId).tracks[0]!.id,
start,
length: 150,
offset: 0,
fitMedia: true,
});
run(command);
return objectId;
};
const wornBy = (holder: { objects: { id: string; assetId: string }[] }, id: string) =>
holder.objects.find((o) => o.id === id)!.assetId;
return {
...f,
run,
sceneId: scene.asset.id,
filmId: film.asset.id,
videoId: video.asset.id,
comp,
sprites,
drop,
wornBy,
};
}
describe('a picture laid on a track', () => {
it('wears a sprite the composition keeps, at the picture size, left out of the lists', () => {
const film = makeFilm();
const id = film.drop(0);
const [sprite] = film.sprites();
expect(sprite).toMatchObject({
owner: film.filmId,
name: 'Intro',
props: { texture: film.videoId, width: 320, height: 180 },
});
expect(film.wornBy(film.comp(), id)).toBe(sprite!.id);
expect(film.comp().items).toMatchObject([{ id, start: 0, length: 150, fitMedia: true }]);
expect(listedAssets(film.project).some((a) => a.id === sprite!.id)).toBe(false);
expect(assetTitle(film.project, sprite!)).toBe('Film › Intro');
});
it('is a texture, a drawn picture or a video, and a video lasts as long as it plays', () => {
const film = makeFilm();
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.id)).toBe(300);
});
it('wears a sprite that "Make project asset" lists like any other, and it stays when the item goes', () => {
const film = makeFilm();
const id = film.drop(0);
const sprite = film.wornBy(film.comp(), id);
film.run(new MakeProjectAssetCommand(sprite));
expect(findAsset(film.project, sprite)).toMatchObject({ name: 'Intro 2' });
expect(listedAssets(film.project).some((a) => a.id === sprite)).toBe(true);
film.run(removeFromComposition(film.project, film.filmId, [id], []));
expect(findAsset(film.project, sprite)).toBeDefined();
});
it('shares the sprite with every item of the same picture, and the first undo takes only the item', () => {
const film = makeFilm();
const first = film.drop(0);
const second = film.drop(200);
expect(film.sprites()).toHaveLength(1);
expect(film.wornBy(film.comp(), second)).toBe(film.wornBy(film.comp(), first));
film.stack.undo();
expect(film.sprites()).toHaveLength(1);
film.stack.undo();
expect(film.sprites()).toHaveLength(0);
expect(film.comp().objects).toHaveLength(0);
});
it('takes its sprite along when its last item goes, and an undo brings both back', () => {
const film = makeFilm();
const a = film.drop(0);
const b = film.drop(200);
film.run(removeFromComposition(film.project, film.filmId, [a], []));
expect(film.sprites()).toHaveLength(1);
film.run(removeFromComposition(film.project, film.filmId, [b], []));
expect(film.sprites()).toHaveLength(0);
film.stack.undo();
expect(film.sprites()).toHaveLength(1);
expect(film.wornBy(film.comp(), b)).toBe(film.sprites()[0]!.id);
});
});
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();
const id = film.drop(0);
const sprite = film.sprites()[0]!;
const clip = copyObjects(film.project, film.comp(), [id])!;
film.run(removeFromComposition(film.project, film.filmId, [id], []));
expect(film.sprites()).toHaveLength(0);
const pasted = pasteCompositionItems(film.project, film.filmId, clip, 50)!;
film.run(pasted.command);
expect(film.sprites()).toEqual([sprite]);
expect(film.wornBy(film.comp(), pasted.itemIds[0]!)).toBe(sprite.id);
film.stack.undo();
expect(film.sprites()).toHaveLength(0);
});
it('pasted into a scene makes the sprite a project asset, named apart from the video', () => {
const film = makeFilm();
const id = film.drop(0);
const clip = copyObjects(film.project, film.comp(), [id])!;
const paste = new PasteObjectsCommand(film.sceneId, clip, {}, { x: 0, y: 0 });
film.run(paste);
const sprite = findAsset(film.project, film.wornBy(film.comp(), id))!;
expect(sprite.owner).toBeUndefined();
expect(sprite.name).toBe('Intro 2');
expect(film.wornBy(findAssetOf(film.project, film.sceneId, 'scene')!, paste.rootIds[0]!)).toBe(sprite.id);
film.stack.undo();
expect(findAsset(film.project, sprite.id)).toMatchObject({ owner: film.filmId, name: 'Intro' });
});
it('pasted into another composition gets a copy that one keeps', () => {
const film = makeFilm();
const other = new CreateAssetCommand('composition', 'Other');
film.run(other);
const id = film.drop(0);
const clip = copyObjects(film.project, film.comp(), [id])!;
const pasted = pasteCompositionItems(film.project, other.asset.id, clip, 0)!;
film.run(pasted.command);
const mine = film.wornBy(film.comp(), id);
const theirs = film.wornBy(film.comp(other.asset.id), pasted.itemIds[0]!);
expect(theirs).not.toBe(mine);
expect(findAsset(film.project, mine)!.owner).toBe(film.filmId);
expect(findAsset(film.project, theirs)).toMatchObject({
owner: other.asset.id,
props: { texture: film.videoId },
});
film.stack.undo();
expect(film.sprites().map((s) => s.id)).toEqual([mine]);
});
});
describe('the composition', () => {
it('duplicated, keeps sprites of its own', () => {
const film = makeFilm();
film.drop(0);
const duplicate = new DuplicateAssetCommand(film.filmId);
film.run(duplicate);
const copy = film.comp(duplicate.copyId);
const worn = copy.objects[0]!.assetId;
expect(worn).not.toBe(film.comp().objects[0]!.assetId);
expect(findAsset(film.project, worn)!.owner).toBe(duplicate.copyId);
});
it('deleted, takes its sprites along', () => {
const film = makeFilm();
film.drop(0);
film.run(new DeleteAssetCommand(film.filmId));
expect(film.sprites()).toHaveLength(0);
});
it('saved and loaded, still keeps its sprites; one whose composition is gone is a project asset', () => {
const film = makeFilm();
film.drop(0);
const sprite = film.sprites()[0]!;
const raw = serializeProject(film.project) as { assets: { id: string; owner?: string }[] };
expect(findAsset(deserializeProject(structuredClone(raw)), sprite.id)!.owner).toBe(film.filmId);
raw.assets = raw.assets.filter((a) => a.id !== film.filmId);
expect(findAsset(deserializeProject(raw), sprite.id)!.owner).toBeUndefined();
});
});