/** * 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, 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(); }); });