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>
216 lines
8.8 KiB
TypeScript
216 lines
8.8 KiB
TypeScript
/**
|
||
* 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();
|
||
});
|
||
});
|