/** * Editing a composition (#417): drops land in the gap they fall into, nothing of a track overlaps, * the edges of an item keep to its neighbours, and an item that follows its media is fitted to it * again – in the same undo step – when an override gives it another video. */ import { describe, expect, it } from 'vitest'; import { CreateAssetCommand, CreateVideoCommand } from '../../src/core/commands/assetCommands'; import { addCompositionItem, FitMediaCommand, removeCompositionTrack, removeFromComposition, SetCompositionCommand, } from '../../src/core/commands/compositionCommands'; import { ClearOverrideCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; import type { CompositionClip, CompositionItem } from '../../src/core/model/Asset'; import { droppedLength, droppedSpan, movedClips, movedItems, resizedItem, trimmedItem, } from '../../src/core/model/compositionEdit'; import { placementRejection } from '../../src/core/model/placement'; import { findAsset, findAssetOf } from '../../src/core/model/Project'; import { makeProject } from '../helpers'; const FILE = `a1b2c3d4e5f6/${'c7'.repeat(32)}.mp4`; const item = ( id: string, track: string, start: number, length: number, extra: Partial = {}, ) => ({ id, track, start, length, offset: 0, ...extra }) satisfies CompositionItem; describe('a drop', () => { const taken = [ { start: 60, length: 60 }, { start: 200, length: 30 }, ]; it('lands where it is let go when that is free, as long as it is', () => { expect(droppedSpan(taken, 0, 50)).toEqual({ start: 0, length: 50 }); }); it('lands right after an item it falls on, and is cut short where the next begins', () => { expect(droppedSpan(taken, 90, 180)).toEqual({ start: 120, length: 80 }); }); it('is cut short before the item after the gap it lands in', () => { expect(droppedSpan(taken, 30, 180)).toEqual({ start: 30, length: 30 }); }); it('lands behind two items that touch when it falls on the first', () => { expect( droppedSpan( [ { start: 0, length: 10 }, { start: 10, length: 10 }, ], 5, 60, ), ).toEqual({ start: 20, length: 60 }); }); }); describe('moving', () => { const items = [item('a', 't1', 0, 60), item('b', 't1', 100, 60), item('c', 't2', 0, 60)]; const tracks = ['t1', 't2']; it('moves the picked items by frames and tracks', () => { expect(movedItems(items, new Set(['a']), tracks, 30, 0)?.find((i) => i.id === 'a')?.start).toBe(30); expect(movedItems(items, new Set(['b']), tracks, 0, 1)?.find((i) => i.id === 'b')?.track).toBe('t2'); }); it('refuses a move onto another item, before 0 or off the tracks', () => { expect(movedItems(items, new Set(['a']), tracks, 50, 0)).toBeNull(); expect(movedItems(items, new Set(['b']), tracks, -150, 0)).toBeNull(); expect(movedItems(items, new Set(['c']), tracks, 0, 1)).toBeNull(); expect(movedItems(items, new Set(['a']), tracks, 0, 1)).toBeNull(); }); it('lets a picked item go where another picked one was', () => { const moved = movedItems(items, new Set(['a', 'b']), tracks, 40, 0); expect(moved?.map((i) => i.start)).toEqual([40, 140, 0]); }); it('keeps clips of a track apart by their own lengths', () => { const clips: CompositionClip[] = [ { id: 'x', track: 't1', animation: 'm', start: 0 }, { id: 'y', track: 't1', animation: 'm', start: 50 }, ]; const lengthOf = () => 40; expect(movedClips(clips, new Set(['x']), tracks, 5, 0, lengthOf)).not.toBeNull(); expect(movedClips(clips, new Set(['x']), tracks, 20, 0, lengthOf)).toBeNull(); }); }); describe('the edges of an item', () => { const items = [ item('a', 't1', 0, 60), item('b', 't1', 100, 60, { fitMedia: true, offset: 1 }), item('c', 't1', 200, 10), ]; it('a dragged end stops where the next item begins, and the length is the item’s own from then on', () => { const b = items[1]!; expect(resizedItem(items, b, 500)).toEqual({ id: 'b', track: 't1', start: 100, length: 100, offset: 1 }); expect(resizedItem(items, b, 0).length).toBe(1); }); it('a dragged start stops at the item before, and moves where the video begins with it', () => { const b = items[1]!; const trimmed = trimmedItem(items, b, 130, 60, true); expect([trimmed.start, trimmed.length, trimmed.offset, trimmed.fitMedia]).toEqual([ 130, 30, 1.5, undefined, ]); expect(trimmedItem(items, b, 70, 60, false).start).toBe(70); expect(trimmedItem(items, b, 10, 60, false).start).toBe(60); }); it('with a video, a dragged start stops at the video’s beginning', () => { const b = items[1]!; const trimmed = trimmedItem(items, b, 70, 60, true); expect([trimmed.start, trimmed.offset]).toEqual([70, 0.5]); // Alone on its track, one second into its video: it can reach back one second, no further. expect(trimmedItem([b], b, 0, 60, true)).toMatchObject({ start: 40, length: 120, offset: 0 }); expect(trimmedItem([b], b, 0, 60, false)).toMatchObject({ start: 0, length: 160, offset: 1 }); }); }); function makeCut() { const f = makeProject(); const comp = new CreateAssetCommand('composition', 'Cut'); const box = new CreateAssetCommand('sprite', 'Box'); const video = new CreateVideoCommand('Intro', FILE, { width: 320, height: 180, duration: 5, fps: 30, audio: false, }); f.stack.execute(comp); f.stack.execute(box); f.stack.execute(video); const current = () => findAssetOf(f.project, comp.asset.id, 'composition')!; const track = current().tracks[0]!.id; /** An item of the box, run through the stack as the timeline does it. */ const add = (start: number, length: number, assetId = box.asset.id) => { const { command, objectId } = addCompositionItem(f.project, comp.asset.id, assetId, { track, start, length, offset: 0, fitMedia: true, }); f.stack.execute(command); return objectId; }; return { ...f, compId: comp.asset.id, boxId: box.asset.id, videoId: video.asset.id, comp: current, track, add, }; } describe('items and their objects', () => { it('an item comes with its object in one undo step, and goes with it', () => { const cut = makeCut(); const id = cut.add(0, 180); expect(cut.comp().objects.map((o) => o.id)).toEqual([id]); expect(cut.comp().items.map((i) => i.id)).toEqual([id]); cut.stack.execute(removeFromComposition(cut.project, cut.compId, [id], [])); expect([cut.comp().objects, cut.comp().items]).toEqual([[], []]); cut.stack.undo(); expect(cut.comp().items.map((i) => i.id)).toEqual([id]); cut.stack.undo(); expect([cut.comp().objects, cut.comp().items]).toEqual([[], []]); }); it('a track goes with what lies on it, and the last track stays', () => { const cut = makeCut(); const second = { id: 't2', name: 'Track 2' }; cut.stack.execute(new SetCompositionCommand(cut.compId, { tracks: [...cut.comp().tracks, second] })); cut.add(0, 60); cut.stack.execute(removeCompositionTrack(cut.project, cut.compId, cut.track)); expect(cut.comp().tracks.map((t) => t.id)).toEqual(['t2']); expect([cut.comp().objects, cut.comp().items]).toEqual([[], []]); cut.stack.execute(removeCompositionTrack(cut.project, cut.compId, 't2')); expect(cut.comp().tracks).toHaveLength(1); }); it('a composition cannot be placed into a composition', () => { const cut = makeCut(); expect(placementRejection(cut.project, cut.comp(), findAsset(cut.project, cut.compId)!)).not.toBe(''); }); it('a sprite wearing a video is dropped in as long as the video, anything else 3 s', () => { const cut = makeCut(); const screen = new CreateAssetCommand('sprite', 'Screen', { texture: cut.videoId }); cut.stack.execute(screen); expect(droppedLength(cut.project, cut.comp(), screen.asset)).toBe(300); expect(droppedLength(cut.project, cut.comp(), findAsset(cut.project, cut.boxId)!)).toBe(180); }); }); describe('an item that follows its media (fitMedia)', () => { const wear = (cut: ReturnType, id: string, texture: string) => cut.stack.execute(new FitMediaCommand(new SetOverrideCommand(cut.compId, id, 'texture', texture))); it('takes the length of a video an override gives it, in the same undo step', () => { const cut = makeCut(); const id = cut.add(0, 180); wear(cut, id, cut.videoId); expect(cut.comp().items[0]!.length).toBe(300); cut.stack.undo(); expect(cut.comp().items[0]!.length).toBe(180); expect(cut.comp().objects[0]!.overrides.texture).toBeUndefined(); cut.stack.redo(); expect(cut.comp().items[0]!.length).toBe(300); }); it('goes back to 3 s when the video is taken off again', () => { const cut = makeCut(); const id = cut.add(0, 180); wear(cut, id, cut.videoId); expect(cut.comp().items[0]!.length).toBe(300); cut.stack.execute(new FitMediaCommand(new ClearOverrideCommand(cut.compId, id, 'texture'))); expect(cut.comp().items[0]!.length).toBe(180); }); it('stops where the next item of its track begins, which stays where it is', () => { const cut = makeCut(); const id = cut.add(0, 180); const next = cut.add(240, 60); wear(cut, id, cut.videoId); expect(cut.comp().items.map((i) => [i.id, i.start, i.length])).toEqual([ [id, 0, 240], [next, 240, 60], ]); }); it('is left alone once its length is its own', () => { const cut = makeCut(); const id = cut.add(0, 180); const items = cut.comp().items.map((i) => resizedItem(cut.comp().items, i, 90)); cut.stack.execute(new SetCompositionCommand(cut.compId, { items })); wear(cut, id, cut.videoId); expect(cut.comp().items[0]!.length).toBe(90); }); });