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>
268 lines
11 KiB
TypeScript
268 lines
11 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 { 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();
|
||
});
|
||
});
|