Drop a picture or video onto a composition track (#419)

A track item has always carried an object, and an object needs an
asset. Instead of objects without one, a dropped picture or video gets
a sprite the composition owns, the way a song owns its sounds (#355).
To the rest of the code it is a sprite like any other.

- The asset list starts a drag for textures, vector art, pixel art and
  videos. Dropped on a track, they make the sprite and the item in one
  undo step. A video item is as long as the video and follows it.
- One sprite per picture and composition. A second drop of the same
  video shares it. Once no item wears it, it goes in the same step.
- Deleting a composition takes its sprites along. A duplicate gets
  sprites of its own.
- Pasting into a scene or container turns the sprite into a project
  asset. Pasting into another composition gives that one a copy. The
  clipboard carries the sprite, so cut and paste loses nothing.
- Owned sprites stay out of the asset list and the pickers. The
  inspector shows them with a dot in front.
- "Make project asset" in the item's menu, on the timeline and in the
  hierarchy, turns the sprite into an ordinary one.
- The first track of the Composition tab gets its top line, as the
  Song tab's has.

Found on the way: loading kept an owner only for the asset types a
song can hold. A composition's sprite would have lost its owner and
turned up in the list after a reload.

Tests:
- Unit composition-sprites: 11 new. Without the rule 5 of them are
  red, without the loading fix the loading test is.
- e2e 69 on melektaus-atx: ALL PASSED, 15 checks, 69k new
- Gate on melektaus-atx: 305 files, 4714 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-12 18:48:20 +02:00
1 parent f171c77f91
commit b16fe8c718
18 files changed
+573 -49

No files matched your search

+215
View File
@@ -0,0 +1,215 @@
/**
* 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 type { Command } from '../../src/core/commands/Command';
import { pasteCompositionItems, removeFromComposition } from '../../src/core/commands/compositionCommands';
import {
addPictureItem,
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 { droppedLength } from '../../src/core/model/compositionEdit';
import { findAsset, findAssetOf } from '../../src/core/model/Project';
import { assetTitle, listedAssets } from '../../src/core/music/embedded';
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) => {
const { command, objectId } = addPictureItem(f.project, compId, video.asset.id, 'Intro', {
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)).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 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();
});
});