Files
feuerwerk/tests/core/composition-edit.test.ts
T
adminandClaude Opus 5.5 e435c54afb Edit a composition's tracks, items and clips in its own tab (#417)
A composition could be placed and played, but not filled. Its tab
puts the picture at the playhead above a timeline of tracks, so a cut
or a cutscene is laid out by dragging assets onto tracks.

- The timeline takes the song tab's geometry with frames for ticks:
  ruler in seconds, scrubbing, play and pause, + Track. Zoom and pan
  glide through viewTween like the other tabs.
- A placeable asset dropped on a track becomes an item, 3 s long or
  as long as its sprite's video. Dropped on an item it lands behind.
- Items move without overlapping. The right edge stops at the next
  item. The left edge trims into a video and not before its start.
- An animation dropped on a track becomes a narrower clip bar above
  the item it plays on. The asset panel now starts a drag for
  animations too.
- The picture is a scene view over compositionHolder. A click picks
  an item or its child, a drag moves the real object. It takes no
  assets, since an object there would have no item.
- The hierarchy lists the items alive at the playhead. Reparenting is
  off there, items stay at the top level.
- The inspector shows a TIMING block on an item (start, length,
  offset for a video, Fit to media) and edits tracks and clips.
- Delete, the context menu and Remove item take item and object in
  one undo step.
- fitMedia: an override that gives an item another video sets its
  length in the same undo step, capped at the next item. Dragging an
  edge or typing a length turns it off.
- A composition inside a composition is refused.

Tests:
- Unit composition-edit (new): 19 tests. Four counter-checks each
  turned their test red. The "back to 3 s" test got a step in
  between after one stayed green.
- e2e 69 (new): 11 checks, 9 own. The hierarchy check reads the rows
  shown, after a counter-check on the model's rows stayed green.
- Gate on melektaus-atx: CHECK EXIT 0, 302 files, 4662 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:47:22 +02:00

263 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<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('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<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('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);
});
});