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:
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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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')}`;
|
||||
}
|
||||
Reference in new issue
Block a user