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

@@ -0,0 +1,80 @@
import { compositionHolder } from '../../core/model/composition';
import type { CompositionAsset } from '../../core/model/Asset';
import { findAssetOf } from '../../core/model/Project';
import type { EditorState } from '../state';
import { SceneViewport } from '../viewport/SceneViewport';
/**
* The picture above a composition's timeline (#417): what is alive at the playhead, with its clips
* and videos at that time, drawn and edited like a container's template. What it shows comes from
* `compositionHolder`, a copy with the items' own ids, so a click picks the item's object, a drag
* moves it and the inspector edits it in the composition itself. Behaviors do not run here.
*/
export class CompositionCanvas extends SceneViewport {
private assetId = '';
/** The copy shown, built once per frame and edit: a drag moves its objects until the pointer lets go. */
private shown: { frame: number; version: number; holder: CompositionAsset } | null = null;
private version = 0;
constructor(
state: EditorState,
private readonly playhead: () => number,
) {
super(state);
state.on('project', () => this.rebuildPicture());
state.on('load', () => this.rebuildPicture());
}
get currentAssetId(): string {
return this.assetId;
}
show(assetId: string): void {
if (this.assetId === assetId) return;
this.assetId = assetId;
this.rebuildPicture();
}
/** Built again for the next draw: the document changed, or the playhead stands somewhere else. */
rebuildPicture(): void {
this.version += 1;
this.redraw();
}
/** The composition as drawn at the playhead – the hierarchy lists the same. */
shownHolder(): CompositionAsset | undefined {
const comp = this.assetId ? findAssetOf(this.state.project, this.assetId, 'composition') : undefined;
if (!comp) return undefined;
const frame = Math.max(0, Math.floor(this.playhead() + 1e-6));
if (this.shown?.frame !== frame || this.shown.version !== this.version)
this.shown = {
frame,
version: this.version,
holder: compositionHolder(this.state.project, comp, frame),
};
return this.shown.holder;
}
protected override holder(): CompositionAsset | undefined {
return this.shownHolder();
}
/** Each composition keeps the view it was left with, as a container does (#171). */
protected override framesOnOpen(): boolean {
return false;
}
protected override paintsBackground(): boolean {
return false;
}
/** An asset goes on a track of the timeline, not into the picture: here it would have no time. */
protected override takesAssets(): boolean {
return false;
}
/** Heard while it plays at about 1x, so a cut can be timed by its sound (#404). */
protected override hearsVideo(): boolean {
return true;
}
}
+128
View File
@@ -0,0 +1,128 @@
import type { Asset, CompositionAsset } from '../../core/model/Asset';
import { compositionFps, compositionLength } from '../../core/model/composition';
import { Button, formatNumber, VStack, type Widget } from '../../ui';
import { AssetTabEditor } from '../AssetTabEditor';
import type { EditorState } from '../state';
import { CompositionCanvas } from './CompositionCanvas';
import { CompositionTimeline } from './CompositionTimeline';
/** Room kept for the "3 tracks · 5 items · 12.5 s · 60 fps" readout. */
const INFO_WIDTH = 230;
/**
* The Composition tab (#417): the picture at the playhead above, the timeline below. Items are
* objects of the composition, so the hierarchy lists the ones alive at the playhead and the
* inspector edits them; `fps` and `loop` are properties of the asset, edited in the inspector.
*/
export class CompositionEditor extends AssetTabEditor<CompositionAsset, CompositionTimeline> {
/** Public for the check scripts. */
readonly addTrackButton: Button;
readonly playButton: Button;
readonly preview: CompositionCanvas;
constructor(state: EditorState) {
super(state, { infoWidth: INFO_WIDTH });
this.addTrackButton = new Button('+ Track', { onClick: () => this.canvas.addTrack() });
this.playButton = new Button('Play', { width: 64, onClick: () => this.togglePlay() });
const timeline = new CompositionTimeline(state);
this.preview = new CompositionCanvas(state, () => timeline.playhead);
timeline.onPlayhead = () => {
this.preview.redraw();
this.followLiveItems();
};
timeline.onPlaying = () => this.syncPlayButton();
this.mount(timeline, [this.addTrackButton, this.playButton]);
}
override showAsset(assetId: string): void {
super.showAsset(assetId);
this.preview.show(assetId);
}
protected override closed(assetId: string): void {
if (this.currentAssetId === assetId) this.canvas.pause();
}
/** Fit frames the picture; the timeline has its own on the wheel. */
protected override fit(): void {
this.preview.fit();
this.canvas.fit();
}
protected override saveView(): unknown {
return { timeline: this.canvas.saveView(), preview: this.preview.saveView() };
}
protected override restoreView(view: unknown): void {
const saved = view as { timeline: unknown; preview: unknown };
this.canvas.restoreView(saved.timeline);
this.preview.show(this.currentAssetId);
this.preview.restoreView(saved.preview);
}
/** The picture above, the timeline below, each taking half. */
protected override body(canvas: CompositionTimeline): Widget {
const stack = new VStack();
stack.add(this.preview, 1);
stack.add(canvas, 1);
return stack;
}
/** Told when other items come alive or go at the playhead: the hierarchy lists those. */
onLiveItems: () => void = () => undefined;
private liveKey = '';
private followLiveItems(): void {
const key = (this.shownHolder()?.objects ?? []).map((o) => o.id).join(',');
if (key === this.liveKey) return;
this.liveKey = key;
this.onLiveItems();
}
/** The composition as drawn at the playhead – what the hierarchy lists. */
shownHolder(): CompositionAsset | undefined {
return this.preview.shownHolder();
}
/** Only called while the tab is visible. */
tick(dt: number): void {
const drawn = this.preview.redrawCount;
if (this.canvas.isPlaying) this.canvas.tick(dt);
this.preview.animate(drawn);
}
get isPlaying(): boolean {
return this.canvas.isPlaying;
}
override selectedId(): string {
return this.state.selectedCompositionId ?? '';
}
override isMine(asset: Asset): asset is CompositionAsset {
return asset.type === 'composition';
}
protected override infoText(asset: CompositionAsset): string {
const tracks = asset.tracks.length;
const items = asset.items.length;
const fps = compositionFps(asset);
const seconds = formatNumber(compositionLength(this.state.project, asset) / fps, 1);
return `${tracks} ${tracks === 1 ? 'track' : 'tracks'} · ${items} ${items === 1 ? 'item' : 'items'} · ${seconds} s · ${fps} fps`;
}
protected override syncExtras(asset: CompositionAsset | null): void {
this.addTrackButton.setEnabled(asset !== null);
this.playButton.setEnabled(asset !== null && compositionLength(this.state.project, asset) > 0);
this.syncPlayButton();
}
private syncPlayButton(): void {
this.playButton.setText(this.canvas.isPlaying ? 'Pause' : 'Play');
}
private togglePlay(): void {
if (this.canvas.isPlaying) this.canvas.pause();
else this.canvas.play();
}
}
File diff suppressed because it is too large. Load diff
+97
View File
@@ -0,0 +1,97 @@
import type { CompositionClip, CompositionItem, CompositionTrack } from '../../core/model/Asset';
import type { Vec2 } from '../../core/model/Properties';
import { formatNumber } from '../../ui';
import { tickToX } from '../music/timeView';
import { type Box, EDGE_REACH, type SongView, trackTop } from '../song/songView';
/**
* The geometry of the Composition tab's timeline (#417), apart from Pixi so a test can drive it. It is
* the Song tab's (#334) with frames for ticks: the tracks stacked down the lanes, `y` from the top of
* the lanes, `x` from their left edge as on the ruler.
*/
export type CompositionView = SongView;
export const HEAD_WIDTH = 150;
export const RULER_HEIGHT = 24;
export const TRACK_HEIGHT = 48;
/** Up to 8 pixels a frame: at 60 fps a second is 480 pixels wide. */
export const MAX_PX_PER_FRAME = 8;
const INSET = 2;
/** A clip is drawn over the lower part of its track, narrower than an item, so both can be seen. */
const CLIP_TOP = 0.56;
/** Labels of the ruler stand at least this far apart. */
const RULER_LABEL_MIN_PX = 56;
export function itemBox(view: CompositionView, row: number, span: { start: number; length: number }): Box {
const x = tickToX(view, span.start);
return {
x,
y: trackTop(view, row) + INSET,
w: Math.max(1, tickToX(view, span.start + span.length) - x),
h: view.trackHeight - INSET * 2,
};
}
export function clipBox(view: CompositionView, row: number, span: { start: number; length: number }): Box {
const x = tickToX(view, span.start);
const top = trackTop(view, row) + Math.round(view.trackHeight * CLIP_TOP);
return {
x,
y: top,
w: Math.max(1, tickToX(view, span.start + span.length) - x),
h: Math.max(4, trackTop(view, row) + view.trackHeight - INSET * 2 - top),
};
}
export type Part = 'start' | 'body' | 'end';
/** What lies under a point of the lanes: a clip before an item, as clips are drawn over items. */
export type Hit = { kind: 'clip'; id: string } | { kind: 'item'; id: string; part: Part };
export function hitAt(
view: CompositionView,
tracks: readonly CompositionTrack[],
items: readonly CompositionItem[],
clips: readonly CompositionClip[],
clipLength: (clip: CompositionClip) => number,
p: Vec2,
): Hit | null {
const rows = new Map(tracks.map((t, i) => [t.id, i]));
const inside = (b: Box) => p.x >= b.x && p.x <= b.x + b.w && p.y >= b.y && p.y <= b.y + b.h;
for (let i = clips.length - 1; i >= 0; i--) {
const clip = clips[i]!;
const row = rows.get(clip.track);
if (row !== undefined && inside(clipBox(view, row, { start: clip.start, length: clipLength(clip) })))
return { kind: 'clip', id: clip.id };
}
for (let i = items.length - 1; i >= 0; i--) {
const item = items[i]!;
const row = rows.get(item.track);
if (row === undefined) continue;
const box = itemBox(view, row, item);
if (!inside(box)) continue;
const reach = Math.min(EDGE_REACH, box.w / 3);
const part = p.x >= box.x + box.w - reach ? 'end' : p.x <= box.x + reach ? 'start' : 'body';
return { kind: 'item', id: item.id, part };
}
return null;
}
/**
* Every how many frames the ruler gets a label: a round number of seconds or parts of one, and
* never less than a frame, so the labels stand far enough apart to be read.
*/
export function rulerStep(view: CompositionView, fps: number): number {
for (const seconds of [0.1, 0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 1800, 3600]) {
const frames = Math.max(1, Math.round(seconds * fps));
if (frames * view.pxPerTick >= RULER_LABEL_MIN_PX) return frames;
}
return Math.round(3600 * fps);
}
/** A time on the ruler: "1.5s" below a minute, "2:05" from there on. */
export function rulerText(seconds: number): string {
if (seconds < 60) return `${formatNumber(seconds, 2)}s`;
const whole = Math.round(seconds);
return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, '0')}`;
}