Files
feuerwerk/tests/core/composition-sprites.test.ts
T
adminandClaude Opus 5.5 b16fe8c718 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>
2026-10-12 18:48:20 +02:00

216 lines
8.8 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 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();
});
});