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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-12 07:47:22 +02:00
1 parent 0334d2508d
commit e435c54afb
24 files changed
+2534 -19

No files matched your search

+262
View File
@@ -0,0 +1,262 @@
/**
* 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);
});
});