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>
566 lines
24 KiB
TypeScript
566 lines
24 KiB
TypeScript
/**
|
||
* Editing a composition (#417): items of a track may overlap, clips may not – a dropped clip lands in
|
||
* the gap it falls into –, the edges of an item go where they are dragged, its video's edges marked
|
||
* and snapped to, 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,
|
||
pasteCompositionItems,
|
||
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 {
|
||
carriedClips,
|
||
clampedMove,
|
||
droppedLength,
|
||
droppedSpan,
|
||
fadedItem,
|
||
heldMove,
|
||
type ItemMedia,
|
||
mediaEdges,
|
||
mediaMarks,
|
||
movedClips,
|
||
movedItems,
|
||
movedWithClips,
|
||
pastedClips,
|
||
pastedItemsWithClips,
|
||
resizedItem,
|
||
snappedToMedia,
|
||
trimmedItem,
|
||
} from '../../src/core/model/compositionEdit';
|
||
import { copyObjects } from '../../src/core/model/clipboard';
|
||
import {
|
||
clipsInBand,
|
||
hitAt,
|
||
itemsInBand,
|
||
overlaps,
|
||
uncoveredEnd,
|
||
} from '../../src/editor/composition/compositionView';
|
||
import { fadeLevel, itemFades } from '../../src/core/model/composition';
|
||
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<CompositionItem> = {},
|
||
) => ({ 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('lets an item onto another, and refuses one before 0 or off the tracks', () => {
|
||
expect(movedItems(items, new Set(['a']), tracks, 50, 0)?.map((i) => i.start)).toEqual([50, 100, 0]);
|
||
expect(movedItems(items, new Set(['a']), tracks, 0, 1)?.map((i) => i.track)).toEqual(['t2', 't1', 't2']);
|
||
expect(movedItems(items, new Set(['b']), tracks, -150, 0)).toBeNull();
|
||
expect(movedItems(items, new Set(['c']), tracks, 0, 1)).toBeNull();
|
||
});
|
||
|
||
it('holds a move at frame 0 and the tracks there are, as far as the pointer goes', () => {
|
||
expect(clampedMove(items, new Set(['b']), tracks, -150, 0)).toEqual({ frames: -100, tracks: 0 });
|
||
expect(clampedMove(items, new Set(['a', 'c']), tracks, 20, 1)).toEqual({ frames: 20, tracks: 0 });
|
||
expect(clampedMove(items, new Set(['a']), tracks, 0, -3)).toEqual({ frames: 0, tracks: 0 });
|
||
});
|
||
|
||
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();
|
||
});
|
||
});
|
||
|
||
const clip = (id: string, track: string, start: number) =>
|
||
({ id, track, animation: 'anim', start }) satisfies CompositionClip;
|
||
|
||
describe('clips that go with their item', () => {
|
||
const tracks = ['t1', 't2'];
|
||
// Every clip is 10 frames long.
|
||
const lengthOf = () => 10;
|
||
|
||
it('belong to the item at their first frame, of two there the one in front', () => {
|
||
const items = [item('a', 't1', 0, 60), item('b', 't1', 40, 60), item('c', 't1', 40, 10)];
|
||
// `x` starts in `a` alone; `y` where `a`, `b` and `c` lie – `c`, the later in the list, is in
|
||
// front; `z` starts before `b` and only reaches into it, so it stays with `a`.
|
||
const clips = [clip('x', 't1', 10), clip('y', 't1', 45), clip('z', 't1', 35), clip('w', 't2', 10)];
|
||
expect([...carriedClips(items, clips, new Set(['a']))]).toEqual(['x', 'z']);
|
||
expect([...carriedClips(items, clips, new Set(['b']))]).toEqual([]);
|
||
expect([...carriedClips(items, clips, new Set(['c']))]).toEqual(['y']);
|
||
// One past an item's end it is not the item's any more.
|
||
expect([...carriedClips(items, [clip('v', 't1', 100)], new Set(['b']))]).toEqual([]);
|
||
});
|
||
|
||
it('move with their item, also onto another track; with Alt they stay', () => {
|
||
const items = [item('a', 't1', 0, 60), item('b', 't2', 0, 60)];
|
||
const clips = [clip('x', 't1', 10), clip('y', 't2', 50)];
|
||
const moved = movedWithClips(items, clips, new Set(['a']), tracks, 5, 1, lengthOf)!;
|
||
expect(moved.items[0]).toMatchObject({ start: 5, track: 't2' });
|
||
expect(moved.clips).toEqual([clip('x', 't2', 15), clip('y', 't2', 50)]);
|
||
const alone = movedWithClips(items, clips, new Set(['a']), tracks, 5, 1, lengthOf, true)!;
|
||
expect(alone.clips).toEqual(clips);
|
||
});
|
||
|
||
it('hold the move short of another clip, the item with them', () => {
|
||
const items = [item('a', 't1', 0, 60)];
|
||
const clips = [clip('x', 't1', 10), clip('y', 't1', 70)];
|
||
const picked = new Set(['a']);
|
||
const fits = (by: number) => movedWithClips(items, clips, picked, tracks, by, 0, lengthOf) !== null;
|
||
// `x` at 10 + 55 would lie over `y` (70–80): the move stops where `x` ends right at 70.
|
||
expect(movedWithClips(items, clips, picked, tracks, 55, 0, lengthOf)).toBeNull();
|
||
expect(heldMove({ frames: 55, tracks: 0 }, { frames: 0, tracks: 0 }, fits)).toEqual({
|
||
frames: 50,
|
||
tracks: 0,
|
||
});
|
||
// Past `y` it fits again.
|
||
expect(heldMove({ frames: 70, tracks: 0 }, { frames: 0, tracks: 0 }, fits)).toEqual({
|
||
frames: 70,
|
||
tracks: 0,
|
||
});
|
||
});
|
||
|
||
it('are pasted with their items, all moved on until the clips lie apart', () => {
|
||
const items = [item('a', 't1', 20, 60)];
|
||
const copied = [clip('x', 't1', 30)];
|
||
const ids = new Map([
|
||
['a', 'a2'],
|
||
['x', 'x2'],
|
||
]);
|
||
// Landing at 100, `x2` would start at 110, over `y` (105–115): both move on by 5.
|
||
const out = pastedItemsWithClips(items, copied, [clip('y', 't1', 105)], ids, 100, tracks, lengthOf);
|
||
expect(out.items).toEqual([item('a2', 't1', 105, 60)]);
|
||
expect(out.clips).toEqual([clip('x2', 't1', 115)]);
|
||
});
|
||
});
|
||
|
||
describe('the edges of an item', () => {
|
||
const b = item('b', 't1', 100, 60, { fitMedia: true, offset: 1 });
|
||
|
||
it('a dragged end goes where it is let go, at least a frame, and the length is the item’s own from then on', () => {
|
||
expect(resizedItem(b, 500)).toEqual({ id: 'b', track: 't1', start: 100, length: 500, offset: 1 });
|
||
expect(resizedItem(b, 0).length).toBe(1);
|
||
});
|
||
|
||
it('a dragged start moves where the video begins with it', () => {
|
||
const trimmed = trimmedItem(b, 130, 60, true);
|
||
expect([trimmed.start, trimmed.length, trimmed.offset, trimmed.fitMedia]).toEqual([
|
||
130,
|
||
30,
|
||
1.5,
|
||
undefined,
|
||
]);
|
||
expect(trimmedItem(b, 10, 60, false)).toMatchObject({ start: 10, length: 150, offset: 1 });
|
||
expect(trimmedItem(b, 500, 60, false).start).toBe(159);
|
||
});
|
||
|
||
it('with a video, a dragged start goes on before the video’s beginning, the offset below 0', () => {
|
||
// One second into its video: back a second the video begins with the item, back 1.5 s half a second after it.
|
||
expect(trimmedItem(b, 40, 60, true)).toMatchObject({ start: 40, length: 120, offset: 0 });
|
||
expect(trimmedItem(b, 10, 60, true)).toMatchObject({ start: 10, length: 150, offset: -0.5 });
|
||
expect(trimmedItem(b, -20, 60, true).start).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe('the video of an item on the timeline', () => {
|
||
// A 2 s video at 60 fps that began at frame 40: the item from 100 is one second in.
|
||
const once: ItemMedia = { begins: 40, length: 120, loops: false };
|
||
const round: ItemMedia = { ...once, loops: true };
|
||
|
||
it('has its edges where it begins and each pass ends, round only when it loops', () => {
|
||
expect(mediaEdges(once, 0, 1000)).toEqual([40, 160]);
|
||
expect(mediaEdges(round, 100, 500)).toEqual([160, 280, 400]);
|
||
expect(mediaEdges(round, 0, 30)).toEqual([]);
|
||
});
|
||
|
||
it('marks an end cutting into the video, and a line where the item reaches past it', () => {
|
||
expect(mediaMarks(item('i', 't', 100, 30), once)).toEqual({ seams: [], cutStart: true, cutEnd: true });
|
||
expect(mediaMarks(item('i', 't', 40, 120), once)).toEqual({ seams: [], cutStart: false, cutEnd: false });
|
||
expect(mediaMarks(item('i', 't', 20, 200), once)).toEqual({
|
||
seams: [40, 160],
|
||
cutStart: false,
|
||
cutEnd: false,
|
||
});
|
||
expect(mediaMarks(item('i', 't', 100, 200), round).seams).toEqual([160, 280]);
|
||
});
|
||
|
||
it('pulls a dragged edge onto the video’s within reach, and leaves it alone further off', () => {
|
||
expect(snappedToMedia(157, once, 4)).toBe(160);
|
||
expect(snappedToMedia(150, once, 4)).toBe(150);
|
||
expect(snappedToMedia(42, null, 4)).toBe(42);
|
||
});
|
||
});
|
||
|
||
describe('overlapping items on the timeline', () => {
|
||
const view = { panX: 0, pxPerTick: 2, panY: 0, trackHeight: 48 };
|
||
const tracks = [{ id: 't1', name: 'Track 1' }];
|
||
// `late` covers `early` from frame 50 on; on screen the overlap runs from x 100 to 120.
|
||
const early = item('early', 't1', 0, 60);
|
||
const late = item('late', 't1', 50, 60);
|
||
const hit = (x: number) => hitAt(view, tracks, [late, early], [], () => 0, { x, y: 20 });
|
||
|
||
it('takes the end of the one covered, the body of the one on top', () => {
|
||
expect(hit(119)).toEqual({ kind: 'item', id: 'early', part: 'end' });
|
||
expect(hit(110)).toEqual({ kind: 'item', id: 'late', part: 'body' });
|
||
expect(hit(101)).toEqual({ kind: 'item', id: 'late', part: 'start' });
|
||
expect(hit(40)).toEqual({ kind: 'item', id: 'early', part: 'body' });
|
||
});
|
||
|
||
it('knows where they overlap and how much of the earlier one is seen', () => {
|
||
expect(overlaps([late, early])).toEqual([{ track: 't1', start: 50, end: 60 }]);
|
||
expect(overlaps([early, item('other', 't2', 0, 60)])).toEqual([]);
|
||
expect(uncoveredEnd([late, early], early)).toBe(50);
|
||
expect(uncoveredEnd([late, early], late)).toBe(110);
|
||
});
|
||
});
|
||
|
||
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('pasted items keep their timing from the frame they land at, on their tracks or shifted to a row', () => {
|
||
const copied = [item('a', 't2', 30, 60, { offset: 1.5 }), item('b', 't3', 50, 20)];
|
||
const ids = new Map([
|
||
['a', 'a2'],
|
||
['b', 'b2'],
|
||
]);
|
||
const tracks = ['t1', 't2', 't3'];
|
||
expect(pastedItemsWithClips(copied, [], [], ids, 100, tracks, () => 1).items).toEqual([
|
||
item('a2', 't2', 100, 60, { offset: 1.5 }),
|
||
item('b2', 't3', 120, 20),
|
||
]);
|
||
expect(
|
||
pastedItemsWithClips(copied, [], [], ids, 0, tracks, () => 1, 0).items.map((i) => i.track),
|
||
).toEqual(['t1', 't2']);
|
||
// As far as there are tracks: the bottom one may not fall off.
|
||
expect(
|
||
pastedItemsWithClips(copied, [], [], ids, 0, tracks, () => 1, 2).items.map((i) => i.track),
|
||
).toEqual(['t2', 't3']);
|
||
});
|
||
|
||
it('pasted clips keep their timing and move on until none lies over another clip', () => {
|
||
const clip = (id: string, track: string, start: number) =>
|
||
({ id, track, animation: 'anim', start }) satisfies CompositionClip;
|
||
// Every clip is 10 frames long.
|
||
const lengthOf = () => 10;
|
||
const tracks = ['t1', 't2'];
|
||
const copied = [clip('a', 't1', 0), clip('b', 't2', 15)];
|
||
const ids = new Map([
|
||
['a', 'a2'],
|
||
['b', 'b2'],
|
||
]);
|
||
// Room where they land: there they stay.
|
||
expect(pastedClips(copied, [clip('x', 't1', 50)], ids, 30, tracks, lengthOf)).toEqual([
|
||
clip('a2', 't1', 30),
|
||
clip('b2', 't2', 45),
|
||
]);
|
||
// `a2` at 45 would lie over `x` (50–60), `b2` over `y` (60–70): `a2` waits until 60, `b2` comes along.
|
||
const taken = [clip('x', 't1', 50), clip('y', 't2', 60)];
|
||
expect(pastedClips(copied, taken, ids, 45, tracks, lengthOf)).toEqual([
|
||
clip('a2', 't1', 60),
|
||
clip('b2', 't2', 75),
|
||
]);
|
||
// Onto the clips they were copied from, they go right behind them, shifted to a row as far as it goes.
|
||
expect(pastedClips([clip('a', 't1', 0)], [clip('a', 't1', 0)], ids, 0, tracks, lengthOf, 1)).toEqual([
|
||
clip('a2', 't2', 0),
|
||
]);
|
||
expect(pastedClips([clip('a', 't1', 0)], [clip('a', 't1', 0)], ids, 0, tracks, lengthOf)).toEqual([
|
||
clip('a2', 't1', 10),
|
||
]);
|
||
});
|
||
|
||
it('a band picks the items and clips it touches', () => {
|
||
const view = { panX: 0, pxPerTick: 1, panY: 0, trackHeight: 40 };
|
||
const tracks = [
|
||
{ id: 't1', name: 'One' },
|
||
{ id: 't2', name: 'Two' },
|
||
];
|
||
const items = [item('a', 't1', 0, 50), item('b', 't1', 100, 50), item('c', 't2', 0, 50)];
|
||
const clips = [{ id: 'k', track: 't2', animation: 'anim', start: 20 }];
|
||
// Across the first track, from 40 to 60: only `a` reaches in.
|
||
expect(itemsInBand(view, tracks, items, { x: 40, y: 5 }, { x: 60, y: 30 })).toEqual(['a']);
|
||
// Down over both tracks at the start, drawn from bottom right to top left.
|
||
expect(itemsInBand(view, tracks, items, { x: 10, y: 70 }, { x: 5, y: 10 })).toEqual(['a', 'c']);
|
||
// Clips lie in the lower part of their track: a band through its upper part misses them.
|
||
expect(clipsInBand(view, tracks, clips, () => 10, { x: 0, y: 42 }, { x: 60, y: 50 })).toEqual([]);
|
||
expect(clipsInBand(view, tracks, clips, () => 10, { x: 0, y: 42 }, { x: 60, y: 70 })).toEqual(['k']);
|
||
});
|
||
|
||
it('a copied item is pasted with its object and overrides under new ids, in one undo step', () => {
|
||
const cut = makeCut();
|
||
const id = cut.add(10, 60);
|
||
cut.stack.execute(new SetOverrideCommand(cut.compId, id, 'width', 42));
|
||
const clip = copyObjects(cut.project, cut.comp(), [id])!;
|
||
const pasted = pasteCompositionItems(cut.project, cut.compId, clip, 100)!;
|
||
cut.stack.execute(pasted.command);
|
||
const [copy] = pasted.itemIds;
|
||
expect(copy).not.toBe(id);
|
||
expect(cut.comp().items.find((i) => i.id === copy)).toMatchObject({
|
||
track: cut.track,
|
||
start: 100,
|
||
length: 60,
|
||
});
|
||
expect(cut.comp().objects.find((o) => o.id === copy)?.overrides).toMatchObject({ width: 42 });
|
||
cut.stack.undo();
|
||
expect(cut.comp().items.map((i) => i.id)).toEqual([id]);
|
||
expect(cut.comp().objects.map((o) => o.id)).toEqual([id]);
|
||
});
|
||
|
||
it('objects without their items, as a scene copies them, are not pasted into a composition', () => {
|
||
const cut = makeCut();
|
||
const id = cut.add(0, 60);
|
||
const { items, ...plain } = copyObjects(cut.project, cut.comp(), [id])!;
|
||
expect(items).toHaveLength(1);
|
||
expect(pasteCompositionItems(cut.project, cut.compId, plain, 0)).toBeNull();
|
||
});
|
||
|
||
it('a deleted item takes its clips with it, in the same undo step', () => {
|
||
const cut = makeCut();
|
||
const a = cut.add(0, 60);
|
||
const b = cut.add(60, 60);
|
||
const clips = [clip('mine', cut.track, 10), clip('next', cut.track, 70)];
|
||
cut.stack.execute(new SetCompositionCommand(cut.compId, { clips }));
|
||
cut.stack.execute(removeFromComposition(cut.project, cut.compId, [a], []));
|
||
expect(cut.comp().items.map((i) => i.id)).toEqual([b]);
|
||
expect(cut.comp().clips.map((c) => c.id)).toEqual(['next']);
|
||
cut.stack.undo();
|
||
expect(cut.comp().clips.map((c) => c.id)).toEqual(['mine', 'next']);
|
||
});
|
||
|
||
it('a copied item is pasted with its clips under new ids, moved on with them where a clip lies', () => {
|
||
const cut = makeCut();
|
||
const a = cut.add(0, 60);
|
||
cut.stack.execute(new SetCompositionCommand(cut.compId, { clips: [clip('mine', cut.track, 10)] }));
|
||
const copy = copyObjects(cut.project, cut.comp(), [a])!;
|
||
expect(copy.clips?.map((c) => c.id)).toEqual(['mine']);
|
||
// Its animation is not there, so the clip is a frame long. Landing at 0 the copy of the clip
|
||
// would lie right over `mine`: item and clip move on by a frame.
|
||
const pasted = pasteCompositionItems(cut.project, cut.compId, copy, 0)!;
|
||
cut.stack.execute(pasted.command);
|
||
const [b] = pasted.itemIds;
|
||
expect(cut.comp().items.find((i) => i.id === b)?.start).toBe(1);
|
||
const added = cut.comp().clips.filter((c) => c.id !== 'mine');
|
||
expect(added).toHaveLength(1);
|
||
expect(added[0]).toMatchObject({ track: cut.track, animation: 'anim', start: 11 });
|
||
cut.stack.undo();
|
||
expect(cut.comp().clips.map((c) => c.id)).toEqual(['mine']);
|
||
});
|
||
|
||
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.id)).toBe(300);
|
||
expect(droppedLength(cut.project, cut.comp(), cut.boxId)).toBe(180);
|
||
});
|
||
});
|
||
|
||
describe('an item that follows its media (fitMedia)', () => {
|
||
const wear = (cut: ReturnType<typeof makeCut>, 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('runs on over the next item of its track, 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, 300],
|
||
[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(i, 90));
|
||
cut.stack.execute(new SetCompositionCommand(cut.compId, { items }));
|
||
wear(cut, id, cut.videoId);
|
||
expect(cut.comp().items[0]!.length).toBe(90);
|
||
});
|
||
});
|
||
|
||
describe('the fades of an item (#418)', () => {
|
||
const sound = item('a', 't1', 100, 60, { fadeIn: 10, fadeOut: 20 });
|
||
|
||
it('go where their handle is dragged, no further than the other fade, and none at the edge', () => {
|
||
expect(fadedItem(sound, 'in', 130).fadeIn).toBe(30);
|
||
expect(fadedItem(sound, 'in', 150).fadeIn).toBe(40);
|
||
expect('fadeIn' in fadedItem(sound, 'in', 90)).toBe(false);
|
||
expect(fadedItem(sound, 'out', 150.4).fadeOut).toBe(10);
|
||
expect(fadedItem(sound, 'out', 100).fadeOut).toBe(50);
|
||
expect('fadeOut' in fadedItem(sound, 'out', 170)).toBe(false);
|
||
});
|
||
|
||
it('shape the level along their curves, silent at the very edges', () => {
|
||
expect(fadeLevel(sound, 0)).toBe(0);
|
||
expect(fadeLevel(sound, 5)).toBeCloseTo(0.5, 9);
|
||
expect(fadeLevel(sound, 30)).toBe(1);
|
||
expect(fadeLevel(sound, 50)).toBeCloseTo(0.5, 9);
|
||
expect(fadeLevel({ ...sound, curveIn: 'equalPower' }, 5)).toBeCloseTo(Math.SQRT1_2, 9);
|
||
expect(fadeLevel(item('b', 't1', 0, 60), 0)).toBe(1);
|
||
});
|
||
|
||
it('are taken by their handles on the top edge, before the item’s ends', () => {
|
||
const view = { panX: 0, pxPerTick: 2, panY: 0, trackHeight: 48 };
|
||
const tracks = [{ id: 't1', name: 'Track 1' }];
|
||
const fades = (i: CompositionItem) => (i.id === 'a' ? itemFades(i) : null);
|
||
const hit = (x: number, y: number, list = [sound]) =>
|
||
hitAt(view, tracks, list, [], () => 0, { x, y }, fades);
|
||
// The item runs from x 200 to 320; its fade in ends at 220, its fade out starts at 280.
|
||
expect(hit(222, 4)).toEqual({ kind: 'item', id: 'a', part: 'fadeIn' });
|
||
expect(hit(278, 10)).toEqual({ kind: 'item', id: 'a', part: 'fadeOut' });
|
||
expect(hit(222, 30)).toEqual({ kind: 'item', id: 'a', part: 'body' });
|
||
expect(hit(250, 4)).toEqual({ kind: 'item', id: 'a', part: 'body' });
|
||
// Without fades the handles sit on the corners; lower down the corner is the edge.
|
||
const plain = item('a', 't1', 100, 60);
|
||
expect(hit(202, 4, [plain])).toEqual({ kind: 'item', id: 'a', part: 'fadeIn' });
|
||
expect(hit(202, 30, [plain])).toEqual({ kind: 'item', id: 'a', part: 'start' });
|
||
// An item without sound has none.
|
||
expect(hit(202, 4, [item('b', 't1', 100, 60)])).toEqual({ kind: 'item', id: 'b', part: 'start' });
|
||
});
|
||
});
|