From e435c54afba02396c9ce0ca29ce8b943877771d4 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Mon, 12 Oct 2026 07:47:22 +0200 Subject: [PATCH] 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 --- README.md | 2 +- src/core/commands/compositionCommands.ts | 175 +++ src/core/commands/require.ts | 7 + src/core/model/compositionEdit.ts | 188 +++ src/core/model/placement.ts | 2 + src/editor/Editor.ts | 29 +- src/editor/actions.ts | 15 + src/editor/assetTabs.ts | 1 + src/editor/composition/CompositionCanvas.ts | 80 ++ src/editor/composition/CompositionEditor.ts | 128 +++ src/editor/composition/CompositionTimeline.ts | 1009 +++++++++++++++++ src/editor/composition/compositionView.ts | 97 ++ src/editor/debug/documentHooks.ts | 19 + src/editor/inspector/Inspector.ts | 13 +- src/editor/inspector/compositionSection.ts | 240 ++++ src/editor/inspector/objectSection.ts | 8 +- src/editor/objectMenu.ts | 29 +- src/editor/panels/AssetPanel.ts | 6 +- src/editor/panels/HierarchyPanel.ts | 9 +- src/editor/state.ts | 32 +- src/editor/viewport/SceneViewport.ts | 11 +- tests/core/composition-edit.test.ts | 262 +++++ tools/e2e/69-composition-tab.mjs | 189 +++ tools/e2e/index.mjs | 2 + 24 files changed, 2534 insertions(+), 19 deletions(-) create mode 100644 src/core/commands/compositionCommands.ts create mode 100644 src/core/model/compositionEdit.ts create mode 100644 src/editor/composition/CompositionCanvas.ts create mode 100644 src/editor/composition/CompositionEditor.ts create mode 100644 src/editor/composition/CompositionTimeline.ts create mode 100644 src/editor/composition/compositionView.ts create mode 100644 src/editor/inspector/compositionSection.ts create mode 100644 tests/core/composition-edit.test.ts create mode 100644 tools/e2e/69-composition-tab.mjs diff --git a/README.md b/README.md index bfce189b..ff1644b9 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2023 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2032 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/commands/compositionCommands.ts b/src/core/commands/compositionCommands.ts new file mode 100644 index 00000000..3c0fc474 --- /dev/null +++ b/src/core/commands/compositionCommands.ts @@ -0,0 +1,175 @@ +/** + * Editing a composition (#417). As with a song, one gesture is one command: tracks, items and clips + * are written whole once the pointer lets go. An item comes and goes together with its object. + */ +import type { CompositionAsset, CompositionClip, CompositionItem, CompositionTrack } from '../model/Asset'; +import { heldCompositionClips, heldCompositionItems, heldCompositionTracks } from '../model/composition'; +import { mediaKey, refittedLength } from '../model/compositionEdit'; +import type { Project } from '../model/Project'; +import type { Command } from './Command'; +import { oneStep } from './CompoundCommand'; +import { requireComposition } from './require'; +import { PlaceAssetCommand, RemoveSceneObjectsCommand } from './sceneObjectCommands'; + +export interface CompositionParts { + tracks?: readonly CompositionTrack[]; + items?: readonly CompositionItem[]; + clips?: readonly CompositionClip[]; +} + +/** Writes the tracks, items and clips whole, held to what each may be. */ +export class SetCompositionCommand implements Command { + private previous: { + tracks: CompositionTrack[]; + items: CompositionItem[]; + clips: CompositionClip[]; + } | null = null; + + constructor( + private readonly assetId: string, + private readonly parts: CompositionParts, + readonly label = 'Edit composition', + ) {} + + /** Nothing to do when the composition already reads exactly like this – no empty undo step. */ + static changes(project: Project, assetId: string, parts: CompositionParts): boolean { + const asset = requireComposition(project, assetId); + return JSON.stringify(held(asset, parts)) !== JSON.stringify([asset.tracks, asset.items, asset.clips]); + } + + execute(project: Project): void { + const asset = requireComposition(project, this.assetId); + this.previous = { tracks: asset.tracks, items: asset.items, clips: asset.clips }; + [asset.tracks, asset.items, asset.clips] = held(asset, this.parts); + } + + undo(project: Project): void { + if (!this.previous) return; + const asset = requireComposition(project, this.assetId); + ({ tracks: asset.tracks, items: asset.items, clips: asset.clips } = this.previous); + } +} + +function held( + asset: CompositionAsset, + parts: CompositionParts, +): [CompositionTrack[], CompositionItem[], CompositionClip[]] { + const tracks = heldCompositionTracks(parts.tracks ?? asset.tracks); + const items = heldCompositionItems(parts.items ?? asset.items, tracks, asset.objects); + return [tracks, items, heldCompositionClips(parts.clips ?? asset.clips, tracks)]; +} + +/** + * An asset laid on a track as a new item: its object at the composition's origin and the item + * spanning `start` and `length`. `objectId` is the item's id, to pick it afterwards. + */ +export function addCompositionItem( + project: Project, + assetId: string, + placed: string, + item: Omit, +): { command: Command; objectId: string } { + const place = new PlaceAssetCommand(assetId, placed, { x: 0, y: 0 }); + const comp = requireComposition(project, assetId); + const set = new SetCompositionCommand(assetId, { items: [...comp.items, { ...item, id: place.objectId }] }); + return { command: oneStep('Add item', [place, set]), objectId: place.objectId }; +} + +/** Items and clips taken off together, the items' objects with them. */ +export function removeFromComposition( + project: Project, + assetId: string, + itemIds: readonly string[], + clipIds: readonly string[], +): Command { + const comp = requireComposition(project, assetId); + const gone = new Set([...itemIds, ...clipIds]); + const objects = comp.objects.filter((o) => gone.has(o.id)).map((o) => o.id); + const set = new SetCompositionCommand(assetId, { + items: comp.items.filter((i) => !gone.has(i.id)), + clips: comp.clips.filter((c) => !gone.has(c.id)), + }); + const count = itemIds.length + clipIds.length; + const label = count === 1 ? (itemIds.length === 1 ? 'Remove item' : 'Remove clip') : 'Remove items'; + return oneStep(label, objects.length > 0 ? [new RemoveSceneObjectsCommand(assetId, objects), set] : [set]); +} + +/** A track taken off with everything on it. */ +export function removeCompositionTrack(project: Project, assetId: string, trackId: string): Command { + const comp = requireComposition(project, assetId); + const items = comp.items.filter((i) => i.track === trackId).map((i) => i.id); + const clips = comp.clips.filter((c) => c.track === trackId).map((c) => c.id); + const objects = comp.objects.filter((o) => items.includes(o.id)).map((o) => o.id); + const set = new SetCompositionCommand(assetId, { + tracks: comp.tracks.filter((t) => t.id !== trackId), + items: comp.items.filter((i) => !items.includes(i.id)), + clips: comp.clips.filter((c) => !clips.includes(c.id)), + }); + return oneStep( + 'Remove track', + objects.length > 0 ? [new RemoveSceneObjectsCommand(assetId, objects), set] : [set], + ); +} + +interface Refit { + assetId: string; + itemId: string; + from: number; + to: number; +} + +/** + * Any edit, with every item that follows its media fitted to it again where the edit changed what it + * wears (#417): a texture turned to a video takes the video's length, one turned back the default. + * In the same undo step as the edit, and done again with it. + */ +export class FitMediaCommand implements Command { + private refits: Refit[] | null = null; + + constructor(readonly inner: Command) {} + + get label(): string { + return this.inner.label; + } + + execute(project: Project): void { + const before = this.refits ? null : mediaKeys(project); + this.inner.execute(project); + this.refits ??= refitsAfter(project, before!); + for (const r of this.refits) setLength(project, r, r.to); + } + + undo(project: Project): void { + for (const r of [...(this.refits ?? [])].reverse()) setLength(project, r, r.from); + this.inner.undo(project); + } +} + +function mediaKeys(project: Project): Map { + const keys = new Map(); + for (const comp of project.assets) { + if (comp.type !== 'composition') continue; + for (const item of comp.items) + if (item.fitMedia) keys.set(`${comp.id}/${item.id}`, mediaKey(project, comp, item)); + } + return keys; +} + +function refitsAfter(project: Project, before: ReadonlyMap): Refit[] { + const refits: Refit[] = []; + for (const comp of project.assets) { + if (comp.type !== 'composition') continue; + for (const item of comp.items) { + const was = before.get(`${comp.id}/${item.id}`); + if (!item.fitMedia || was === undefined || was === mediaKey(project, comp, item)) continue; + const to = refittedLength(project, comp, item); + if (to !== item.length) refits.push({ assetId: comp.id, itemId: item.id, from: item.length, to }); + } + } + return refits; +} + +function setLength(project: Project, refit: Refit, length: number): void { + const comp = requireComposition(project, refit.assetId); + comp.items = comp.items.map((i) => (i.id === refit.itemId ? { ...i, length } : i)); +} diff --git a/src/core/commands/require.ts b/src/core/commands/require.ts index d2bdacef..77643a57 100644 --- a/src/core/commands/require.ts +++ b/src/core/commands/require.ts @@ -3,6 +3,7 @@ import { findNode, type Graph, type GraphEdge, type GraphNode } from '../graph/G import { type NodeDef, nodeDef } from '../graph/NodeRegistry'; import type { Asset, + CompositionAsset, CurveAsset, PatternAsset, SongAsset, @@ -155,6 +156,12 @@ export function requireSong(project: Project, assetId: string): SongAsset { return asset; } +export function requireComposition(project: Project, assetId: string): CompositionAsset { + const asset = findAssetOf(project, assetId, 'composition'); + if (!asset) throw new CommandError(`Composition ${assetId} not found`); + return asset; +} + export function requireVariablesAsset(project: Project, assetId: string): VariablesAsset { const asset = findAssetOf(project, assetId, 'variables'); if (!asset) throw new CommandError(`Variables ${assetId} not found`); diff --git a/src/core/model/compositionEdit.ts b/src/core/model/compositionEdit.ts new file mode 100644 index 00000000..1033caa4 --- /dev/null +++ b/src/core/model/compositionEdit.ts @@ -0,0 +1,188 @@ +/** + * What the Composition tab does to items and clips (#417), as plain functions on lists so a test can + * check them. Items of a track never overlap, nor do clips of a track; an edit that would make them + * is held at the last place it fits, a drop lands in the gap it falls into. + */ +import type { Asset, CompositionAsset, CompositionClip, CompositionItem } from './Asset'; +import { compositionFps, DEFAULT_ITEM_SECONDS, fittedLength, itemEnd, itemVideo } from './composition'; +import type { Project } from './Project'; +import { findVideo, videoLengthOf } from './video'; + +/** A stretch of a track, in frames. */ +export interface Span { + start: number; + length: number; +} + +interface Placed { + id: string; + track: string; + start: number; +} + +/** + * Where something `length` frames long lands when dropped at `at`: there, or right after whatever + * of `taken` covers `at`, and cut short where the next begins. Null where not a frame is free. + */ +export function droppedSpan(taken: readonly Span[], at: number, length: number): Span | null { + let start = Math.max(0, Math.round(at)); + for (const span of [...taken].sort((a, b) => a.start - b.start)) + if (span.start <= start && start < span.start + span.length) start = span.start + span.length; + const next = Math.min(...taken.filter((s) => s.start >= start).map((s) => s.start)); + const room = Number.isFinite(next) ? next - start : Infinity; + return room >= 1 ? { start, length: Math.min(Math.max(1, length), room) } : null; +} + +/** The stretches the items of a track cover, all but `except`. */ +export function itemSpans( + items: readonly CompositionItem[], + track: string, + except: ReadonlySet = new Set(), +): Span[] { + return items.filter((i) => i.track === track && !except.has(i.id)); +} + +/** The stretches the clips of a track cover, all but `except`. */ +export function clipSpans( + clips: readonly CompositionClip[], + track: string, + lengthOf: (clip: CompositionClip) => number, + except: ReadonlySet = new Set(), +): Span[] { + return clips + .filter((c) => c.track === track && !except.has(c.id)) + .map((c) => ({ start: c.start, length: Math.max(1, lengthOf(c)) })); +} + +/** Whether no two of `spans` on the same track share a frame. */ +function apart(list: readonly T[], lengthOf: (t: T) => number): boolean { + const byTrack = new Map(); + for (const t of list) byTrack.set(t.track, [...(byTrack.get(t.track) ?? []), t]); + for (const on of byTrack.values()) { + const sorted = [...on].sort((a, b) => a.start - b.start); + for (let i = 1; i < sorted.length; i++) + if (sorted[i]!.start < sorted[i - 1]!.start + Math.max(1, lengthOf(sorted[i - 1]!))) return false; + } + return true; +} + +/** + * The picked ones moved by `frames` and `tracks`; null where one would start before 0, leave the + * tracks there are, or come to lie over another. + */ +function moved( + list: readonly T[], + picked: ReadonlySet, + trackIds: readonly string[], + frames: number, + tracks: number, + lengthOf: (t: T) => number, +): T[] | null { + const out: T[] = []; + for (const t of list) { + if (!picked.has(t.id)) { + out.push(t); + continue; + } + const track = trackIds[trackIds.indexOf(t.track) + tracks]; + if (track === undefined || t.start + frames < 0) return null; + out.push({ ...t, start: t.start + frames, track }); + } + return apart(out, lengthOf) ? out : null; +} + +export function movedItems( + items: readonly CompositionItem[], + picked: ReadonlySet, + trackIds: readonly string[], + frames: number, + tracks: number, +): CompositionItem[] | null { + return moved(items, picked, trackIds, frames, tracks, (i) => i.length); +} + +export function movedClips( + clips: readonly CompositionClip[], + picked: ReadonlySet, + trackIds: readonly string[], + frames: number, + tracks: number, + lengthOf: (clip: CompositionClip) => number, +): CompositionClip[] | null { + return moved(clips, picked, trackIds, frames, tracks, lengthOf); +} + +/** The first frame after `item` another item of its track takes; Infinity when none follows. */ +function nextStart(items: readonly CompositionItem[], item: CompositionItem): number { + return Math.min( + ...items + .filter((i) => i.track === item.track && i.id !== item.id && i.start >= item.start) + .map((i) => i.start), + ); +} + +/** The end of the item before `item` on its track, 0 when none comes before. */ +function previousEnd(items: readonly CompositionItem[], item: CompositionItem): number { + return Math.max( + 0, + ...items.filter((i) => i.track === item.track && i.id !== item.id && i.start < item.start).map(itemEnd), + ); +} + +/** + * The item with its end edge dragged to make it `wanted` frames long: at least a frame, and no + * further than where the next item of its track begins. Its length is its own from then on. + */ +export function resizedItem( + items: readonly CompositionItem[], + item: CompositionItem, + wanted: number, +): CompositionItem { + const most = nextStart(items, item) - item.start; + const { fitMedia: _, ...rest } = item; + return { ...rest, length: Math.max(1, Math.min(Math.round(wanted), most)) }; +} + +/** + * The item with its start edge dragged to `wanted`: never onto its end, before 0 or into the item + * before. What it shows stays where it was in time – the video starts that much later or sooner – + * so an item wearing a video stops at the video's beginning. + */ +export function trimmedItem( + items: readonly CompositionItem[], + item: CompositionItem, + wanted: number, + fps: number, + video: boolean, +): CompositionItem { + let start = Math.min(Math.round(wanted), itemEnd(item) - 1); + start = Math.max(start, previousEnd(items, item)); + if (video) start = Math.max(start, item.start - Math.floor(item.offset * fps + 1e-6)); + const by = start - item.start; + const { fitMedia: _, ...rest } = item; + const offset = video ? Math.max(0, item.offset + by / fps) : item.offset; + return { ...rest, start, length: item.length - by, offset }; +} + +/** + * The length an item that follows its media takes now, held to where the next item of its track + * begins: the items after it stay where they are. + */ +export function refittedLength(project: Project, comp: CompositionAsset, item: CompositionItem): number { + return Math.max(1, Math.min(fittedLength(project, comp, item), nextStart(comp.items, item) - item.start)); +} + +/** How long an item of `asset` is when it is dropped in: as long as the video its sprite wears, else 3 s. */ +export function droppedLength(project: Project, comp: CompositionAsset, asset: Asset): number { + const texture = asset.type === 'sprite' ? asset.props.texture : undefined; + const video = typeof texture === 'string' ? findVideo(project, texture) : null; + const fps = compositionFps(comp); + const seconds = video ? videoLengthOf(video) : 0; + return seconds > 0 ? Math.max(1, Math.round(seconds * fps)) : Math.round(DEFAULT_ITEM_SECONDS * fps); +} + +/** What an item wears that its length follows: the video or clip's id, '' for none. */ +export function mediaKey(project: Project, comp: CompositionAsset, item: CompositionItem): string { + const video = itemVideo(project, comp, item); + return video ? (video.clip?.id ?? video.asset.id) : ''; +} diff --git a/src/core/model/placement.ts b/src/core/model/placement.ts index 53572d15..3e42ae9b 100644 --- a/src/core/model/placement.ts +++ b/src/core/model/placement.ts @@ -14,5 +14,7 @@ export function placementRejection(project: Project, holder: ObjectHolder, asset return `A ${ASSET_TYPE_LABELS[asset.type].toLowerCase()} cannot be placed into a scene`; if (asset.type === 'container' && containerReaches(project, asset, holder.id)) return `${asset.name} cannot contain ${holder.name}: that nests forever`; + if (asset.type === 'composition' && holder.type === 'composition') + return 'A composition cannot be placed into a composition'; return ''; } diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 6c06f309..8e4b43b8 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -55,6 +55,7 @@ import { dialogSlot } from './FormDialog'; import { generalSettings, keepGeneralSettings } from './generalSettings'; import { GeneralSettingsDialog } from './GeneralSettingsDialog'; import { ContainerEditor } from './container/ContainerEditor'; +import { CompositionEditor } from './composition/CompositionEditor'; import { EditorRun } from './EditorRun'; import { GraphEditor } from './graph/GraphEditor'; import { Inspector } from './inspector/Inspector'; @@ -126,6 +127,7 @@ export type CentreTab = | 'curve' | 'tile' | 'container' + | 'composition' | 'variables' | 'routing' | 'eq' @@ -186,6 +188,7 @@ const PANE_ORDER = [ 'curve', 'tile', 'container', + 'composition', 'variables', 'routing', 'eq', @@ -215,6 +218,7 @@ const TAB_LABELS: Record = { curve: 'Curve', tile: 'Tile', container: 'Container', + composition: 'Composition', variables: 'Variables', routing: 'Master routing', eq: 'Parametric EQ', @@ -286,6 +290,7 @@ export class Editor implements PlayHost { readonly curveEditor: CurveEditor; readonly tileEditor: TileEditor; readonly containerEditor: ContainerEditor; + readonly compositionEditor: CompositionEditor; readonly variablesEditor: VariablesEditor; readonly routingEditor: RoutingEditor; readonly eqEditor: EqEditor; @@ -395,12 +400,16 @@ export class Editor implements PlayHost { this.curveEditor = new CurveEditor(state); this.tileEditor = new TileEditor(state); this.containerEditor = new ContainerEditor(state); + this.compositionEditor = new CompositionEditor(state); this.variablesEditor = new VariablesEditor(state); this.routingEditor = new RoutingEditor(state); this.eqEditor = new EqEditor(state); this.compressorEditor = new CompressorEditor(state); this.filterEditor = new EqEditor(state, 'filter'); this.hierarchy = new HierarchyPanel(state); + this.compositionEditor.onLiveItems = () => { + if (this.tab === 'composition') this.hierarchy.refresh(); + }; this.inspector = new Inspector(state); this.tilemapPanel = new TilemapPanel(state); this.recordings = new RecordingUploads({ state, videos }); @@ -517,6 +526,7 @@ export class Editor implements PlayHost { curve: this.curveEditor, tile: this.tileEditor, container: this.containerEditor, + composition: this.compositionEditor, }; this.panes = { scene: this.scenePane, @@ -614,9 +624,16 @@ export class Editor implements PlayHost { state.on('selection', () => { const s = state.selection; if (s?.kind === 'node' && s.assetId === ROUTING_ID) this.openDocument('routing'); - else if (s?.kind === 'node' || s?.kind === 'frame' || s?.kind === 'clip' || s?.kind === 'song') + else if ( + s?.kind === 'node' || + s?.kind === 'frame' || + s?.kind === 'clip' || + s?.kind === 'song' || + s?.kind === 'composition' + ) this.openDocument(`asset:${s.assetId}`); - if (s?.kind === 'object' && findAsset(state.project, s.holderId)?.type === 'container') + const holderType = s?.kind === 'object' ? findAsset(state.project, s.holderId)?.type : undefined; + if (s?.kind === 'object' && (holderType === 'container' || holderType === 'composition')) this.openDocument(`asset:${s.holderId}`); }); state.on('open', () => { @@ -718,6 +735,8 @@ export class Editor implements PlayHost { return this.viewport.removePickedCorner(); case 'container': return this.containerEditor.canvas.removePickedCorner(); + case 'composition': + return this.compositionEditor.preview.removePickedCorner(); case 'texture': return this.textureEditor.canvas.removePickedPoint(); case 'vectorart': @@ -1233,7 +1252,9 @@ export class Editor implements PlayHost { this.hierarchy.showObjectsOf( tab === 'container' ? () => findObjectHolder(this.state.project, this.containerEditor.currentAssetId) - : () => this.state.activeScene, + : tab === 'composition' + ? () => this.compositionEditor.shownHolder() + : () => this.state.activeScene, ); // A pane in a window shows there whatever the row shows. for (const key of PANE_ORDER) if (!this.floats.has(key)) this.panes[key].visible = key === tab; @@ -1348,6 +1369,7 @@ export class Editor implements PlayHost { this.play.recordFrame(); this.console.refresh(); if (this.shows('animation')) this.animationEditor.tick(step); + if (this.shows('composition')) this.compositionEditor.tick(dt); if (this.shows('tile')) this.tileEditor.tick(step); // The preview is the editor's own, so it runs on while the game is paused. if (this.shows('curve')) this.curveEditor.tick(dt); @@ -1403,6 +1425,7 @@ export class Editor implements PlayHost { this.shows('pixelart') || (this.shows('shader') && this.shaderEditor.animates) || (this.shows('animation') && this.animationEditor.isPlaying) || + (this.shows('composition') && this.compositionEditor.isPlaying) || (this.shows('tile') && this.tileEditor.isPlaying) || (this.shows('curve') && this.curveEditor.isPlaying) || (this.shows('pattern') && this.patternEditor.isPlaying) || diff --git a/src/editor/actions.ts b/src/editor/actions.ts index 873b8492..b99b9b4c 100644 --- a/src/editor/actions.ts +++ b/src/editor/actions.ts @@ -15,6 +15,7 @@ import { } from '../core/commands/colliderCommands'; import { oneStep } from '../core/commands/CompoundCommand'; import { CreateContainerCommand } from '../core/commands/containerCommands'; +import { removeCompositionTrack, removeFromComposition } from '../core/commands/compositionCommands'; import { RemoveClipCommand } from '../core/commands/clipCommands'; import { RemoveFrameCommand } from '../core/commands/frameCommands'; import { DisconnectCommand, FoldEdgeCommand, RemoveNodeCommand } from '../core/commands/graphCommands'; @@ -105,6 +106,9 @@ export function addJoint(state: EditorState, holderId: string, objectId: string, /** Removes the objects (with their subtrees) as one undo step. */ export function removeObjects(state: EditorState, holderId: string, objectIds: readonly string[]): boolean { + // An object of a composition is an item's (#417): the item goes with it. + if (findAsset(state.project, holderId)?.type === 'composition') + return state.execute(removeFromComposition(state.project, holderId, objectIds, [])); return state.execute(new RemoveSceneObjectsCommand(holderId, objectIds)); } @@ -233,6 +237,17 @@ export function removeSelection(state: EditorState): boolean { if (s.itemIds.length > 0) removeSongItems(state, s.assetId, s.itemIds); else removeTrack(state, s.assetId, s.trackId); return true; + case 'composition': + // The clips when some are picked, else the track and what lies on it. + if ( + state.execute( + s.clipIds.length > 0 + ? removeFromComposition(state.project, s.assetId, [], s.clipIds) + : removeCompositionTrack(state.project, s.assetId, s.trackId), + ) + ) + state.select({ kind: 'asset', assetId: s.assetId }); + return true; } } diff --git a/src/editor/assetTabs.ts b/src/editor/assetTabs.ts index 8ac38613..03ef3835 100644 --- a/src/editor/assetTabs.ts +++ b/src/editor/assetTabs.ts @@ -22,6 +22,7 @@ export const TAB_OF_ASSET: Partial> = { curve: 'curve', tile: 'tile', container: 'container', + composition: 'composition', }; /** diff --git a/src/editor/composition/CompositionCanvas.ts b/src/editor/composition/CompositionCanvas.ts new file mode 100644 index 00000000..c77d223f --- /dev/null +++ b/src/editor/composition/CompositionCanvas.ts @@ -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; + } +} diff --git a/src/editor/composition/CompositionEditor.ts b/src/editor/composition/CompositionEditor.ts new file mode 100644 index 00000000..7e98c2c2 --- /dev/null +++ b/src/editor/composition/CompositionEditor.ts @@ -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 { + /** 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(); + } +} diff --git a/src/editor/composition/CompositionTimeline.ts b/src/editor/composition/CompositionTimeline.ts new file mode 100644 index 00000000..52d05458 --- /dev/null +++ b/src/editor/composition/CompositionTimeline.ts @@ -0,0 +1,1009 @@ +import { + Container, + type FederatedPointerEvent, + type FederatedWheelEvent, + Graphics, + type Text, +} from 'pixi.js'; +import { + addCompositionItem, + removeFromComposition, + SetCompositionCommand, +} from '../../core/commands/compositionCommands'; +import { newId } from '../../core/ids'; +import { isPlaceableType } from '../../core/model/AssetTypes'; +import type { CompositionAsset, CompositionClip, CompositionItem } from '../../core/model/Asset'; +import { + clipLength, + compositionFps, + compositionLength, + compositionLoops, + itemObject, + itemVideo, +} from '../../core/model/composition'; +import { + clipSpans, + droppedLength, + droppedSpan, + itemSpans, + movedClips, + movedItems, + resizedItem, + trimmedItem, +} from '../../core/model/compositionEdit'; +import { placementRejection } from '../../core/model/placement'; +import type { Vec2 } from '../../core/model/Properties'; +import { findAsset, findAssetOf } from '../../core/model/Project'; +import { objectName } from '../../core/model/SceneObject'; +import { + activeDrag, + crispText, + endDrag, + Menu, + onDragChange, + type Size, + textStyle, + Theme, + truncate, + ui, + Widget, + zoomFactor, +} from '../../ui'; +import { fitToMedia } from '../inspector/compositionSection'; +import { clampTimePan, tickToX, xToTick, zoomTime } from '../music/timeView'; +import { clampTracks, fitSong, nearestTrack, trackAtY, trackTop, zoomTracks } from '../song/songView'; +import { type EditorState, objectSelection } from '../state'; +import { axisTween } from '../viewTween'; +import { + clipBox, + type CompositionView, + HEAD_WIDTH, + type Hit, + hitAt, + itemBox, + MAX_PX_PER_FRAME, + RULER_HEIGHT, + rulerStep, + rulerText, + TRACK_HEIGHT, +} from './compositionView'; + +const DRAG_THRESHOLD = 3; +const ITEM_COLOR = 0x35598f; +const VIDEO_COLOR = 0x2f6f52; +const CLIP_COLOR = 0x8a5a2b; +const REJECTED_COLOR = 0x6b3a3a; +const DEFAULT_VIEW: CompositionView = { panX: 0, pxPerTick: 2, panY: 0, trackHeight: TRACK_HEIGHT }; + +type Gesture = + | { kind: 'pan'; pointer: Vec2; panX: number; panY: number } + | { kind: 'scrub' } + | { + kind: 'move'; + what: 'item' | 'clip'; + anchor: { id: string; start: number; track: string }; + grab: number; + pointer: Vec2; + moved: boolean; + frames: number; + tracks: number; + } + | { kind: 'resize' | 'trim'; anchor: CompositionItem; item: CompositionItem }; + +/** An asset dragged in from the panel: an item or, for an animation, a clip – and whether it fits there. */ +interface Drop { + what: 'item' | 'clip'; + assetId: string; + track: string; + start: number; + length: number; + rejection: string; +} + +interface Placed { + text: string; + x: number; + y: number; + w: number; +} + +/** + * The timeline of the Composition tab (#417): track heads on the left, a ruler in seconds on top, + * the items on their tracks and the animation clips over them, narrower. An asset dragged in from + * the panel becomes an item – an animation a clip –, an item is moved, its ends dragged; a clip is + * moved. Nothing of a track overlaps: a drop lands in the gap it falls into, a move stops short. + * The ruler scrubs the playhead the picture above shows. + */ +export class CompositionTimeline extends Widget { + private readonly bg = new Graphics(); + private readonly lanes = new Graphics(); + private readonly over = new Graphics(); + private readonly clip = new Graphics(); + private readonly itemLayer = new Container(); + private readonly headLayer = new Container(); + private readonly rulerLayer = new Container(); + private readonly itemTexts: Text[] = []; + private readonly headTexts: Text[] = []; + private readonly rulerTexts: Text[] = []; + private readonly hint = crispText({ text: '', style: textStyle('muted') }); + private view: CompositionView = { ...DEFAULT_VIEW }; + private readonly timeTween = axisTween( + () => ({ scale: this.view.pxPerTick, offset: this.view.panX }), + ({ scale, offset }) => (this.view = { ...this.view, pxPerTick: scale, panX: offset }), + () => this.redraw(), + ); + private readonly trackTween = axisTween( + () => ({ scale: this.view.trackHeight, offset: this.view.panY }), + ({ scale, offset }) => (this.view = { ...this.view, trackHeight: scale, panY: offset }), + () => this.redraw(), + ); + private assetId = ''; + private gesture: Gesture | null = null; + private drop: Drop | null = null; + private needsFit = true; + /** Where the playhead stands, in frames – fractional while playing. */ + playhead = 0; + private playingNow = false; + /** Told whenever the playhead moves – the picture above draws the frame it stands on. */ + onPlayhead: () => void = () => undefined; + /** Told whenever play starts or stops – the header's button says which. */ + onPlaying: () => void = () => undefined; + + constructor(private readonly state: EditorState) { + super(); + this.addChild( + this.bg, + this.lanes, + this.itemLayer, + this.over, + this.headLayer, + this.rulerLayer, + this.hint, + this.clip, + ); + this.lanes.mask = this.clip; + this.itemLayer.mask = this.clip; + this.over.mask = this.clip; + this.hint.eventMode = 'none'; + this.itemLayer.eventMode = 'none'; + this.headLayer.eventMode = 'none'; + this.rulerLayer.eventMode = 'none'; + this.focusable = true; + + this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e)); + this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e)); + this.on('pointerup', () => this.onPointerUp()); + this.on('pointerupoutside', () => this.onPointerUp()); + this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e)); + state.on('project', () => this.redraw()); + state.on('selection', () => this.redraw()); + state.on('load', () => { + this.view = { ...DEFAULT_VIEW }; + this.needsFit = true; + }); + onDragChange(() => { + if (!activeDrag() && this.drop) { + this.drop = null; + this.redraw(); + } + }); + } + + override measure(): Size { + return { w: Math.max(this.minWidth, 300), h: Math.max(this.minHeight, 140) }; + } + + get currentAssetId(): string { + return this.assetId; + } + + /** Another composition starts at its beginning, fitted. */ + show(assetId: string): void { + if (this.assetId === assetId) return; + this.assetId = assetId; + this.needsFit = true; + this.pause(); + this.scrub(0); + } + + saveView(): CompositionView { + this.timeTween.land(); + this.trackTween.land(); + return { ...this.view }; + } + + restoreView(view: unknown): void { + this.view = { ...(view as CompositionView) }; + this.needsFit = false; + this.redraw(); + } + + /** The whole composition in the lanes, its tracks sharing the height. */ + fit(): void { + const asset = this.asset(); + if (!asset || this.lanesWidth <= 0) return; + const length = Math.max(compositionLength(this.state.project, asset), compositionFps(asset) * 10); + const fitted = fitSong(length, asset.tracks.length, this.lanesWidth, this.lanesHeight); + this.timeTween.change(() => { + this.view = { ...fitted, pxPerTick: Math.min(fitted.pxPerTick, MAX_PX_PER_FRAME), panY: 0 }; + this.redraw(); + }); + } + + get isPlaying(): boolean { + return this.playingNow; + } + + /** Runs from where the playhead stands; at the end it starts over. */ + play(): void { + const asset = this.asset(); + if (!asset) return; + if (this.playhead >= compositionLength(this.state.project, asset)) this.playhead = 0; + this.setPlaying(true); + this.redraw(); + this.onPlayhead(); + } + + pause(): void { + this.setPlaying(false); + this.redraw(); + } + + private setPlaying(playing: boolean): void { + if (this.playingNow === playing) return; + this.playingNow = playing; + this.onPlaying(); + } + + /** Moves the playhead `dt` seconds on; past the end it goes round when the composition loops, else stops there. */ + tick(dt: number): void { + const asset = this.asset(); + if (!this.playingNow || !asset) return; + const end = compositionLength(this.state.project, asset); + let at = this.playhead + dt * compositionFps(asset); + if (end <= 0) at = 0; + else if (at >= end && compositionLoops(asset)) at %= end; + else if (at >= end) { + at = end; + this.setPlaying(false); + } + this.playhead = at; + this.redraw(); + this.onPlayhead(); + } + + /** Puts the playhead on a frame – what the ruler does, and what a check does without a pointer. */ + scrub(frame: number): void { + this.playhead = Math.max(0, frame); + this.redraw(); + this.onPlayhead(); + } + + // --- test hooks -------------------------------------------------------------------------------- + + /** Where a track and frame lie on screen, in global coordinates: the middle of the track. */ + laneGlobal(trackId: string, frame: number): Vec2 | null { + const row = this.asset()?.tracks.findIndex((t) => t.id === trackId) ?? -1; + if (row < 0) return null; + return this.toGlobal({ + x: HEAD_WIDTH + tickToX(this.view, frame), + y: RULER_HEIGHT + trackTop(this.view, row) + this.view.trackHeight / 2, + }); + } + + /** An item's or clip's box on screen, in global coordinates. */ + boxGlobal(id: string): { x: number; y: number; w: number; h: number } | null { + const asset = this.asset(); + if (!asset) return null; + const rows = new Map(asset.tracks.map((t, i) => [t.id, i])); + const item = asset.items.find((i) => i.id === id); + const clip = asset.clips.find((c) => c.id === id); + const row = rows.get(item?.track ?? clip?.track ?? ''); + if (row === undefined) return null; + const box = item + ? itemBox(this.view, row, item) + : clipBox(this.view, row, { start: clip!.start, length: clipLength(this.state.project, asset, clip!) }); + const at = this.toGlobal({ x: HEAD_WIDTH + box.x, y: RULER_HEIGHT + box.y }); + return { x: at.x, y: at.y, w: box.w, h: box.h }; + } + + /** The pending drop of an asset dragged over the lanes (test hook). */ + dropInfo(): Drop | null { + return this.drop && { ...this.drop }; + } + + viewInfo(): CompositionView & { playhead: number; playing: boolean } { + return { ...this.view, playhead: this.playhead, playing: this.playingNow }; + } + + // --- pointer ----------------------------------------------------------------------------------- + + private asset(): CompositionAsset | null { + return this.assetId ? (findAssetOf(this.state.project, this.assetId, 'composition') ?? null) : null; + } + + private get lanesWidth(): number { + return Math.max(0, this.w - HEAD_WIDTH); + } + + private get lanesHeight(): number { + return Math.max(0, this.h - RULER_HEIGHT); + } + + private inLanes(local: Vec2): Vec2 { + return { x: local.x - HEAD_WIDTH, y: local.y - RULER_HEIGHT }; + } + + private lengthOf(asset: CompositionAsset): (clip: CompositionClip) => number { + return (clip) => Math.max(1, clipLength(this.state.project, asset, clip)); + } + + /** The items of the composition there is an object for: one whose object went is not on the tracks. */ + private liveItems(asset: CompositionAsset): CompositionItem[] { + return asset.items.filter((i) => itemObject(asset, i)); + } + + private pickedItems(asset: CompositionAsset): string[] { + const s = this.state.selection; + return s?.kind === 'object' && s.holderId === asset.id + ? s.objectIds.filter((id) => asset.items.some((i) => i.id === id)) + : []; + } + + private pickedClips(asset: CompositionAsset): string[] { + const s = this.state.selection; + return s?.kind === 'composition' && s.assetId === asset.id ? s.clipIds : []; + } + + private hitAt(asset: CompositionAsset, g: Vec2): Hit | null { + return hitAt(this.view, asset.tracks, this.liveItems(asset), asset.clips, this.lengthOf(asset), g); + } + + private onPointerDown(e: FederatedPointerEvent): void { + const asset = this.asset(); + if (!asset) return; + const local = this.toLocal(e.global); + const point = { x: local.x, y: local.y }; + const g = this.inLanes(point); + if (e.button === 1 || (e.button === 0 && e.shiftKey)) { + this.timeTween.target(() => + this.trackTween.target(() => { + this.gesture = { kind: 'pan', pointer: point, panX: this.view.panX, panY: this.view.panY }; + }), + ); + e.stopPropagation(); + return; + } + if (e.button === 2) { + e.stopPropagation(); + this.openMenu(asset, point, g, e.global); + return; + } + if (e.button !== 0) return; + ui().focus.focus(this); + e.stopPropagation(); + if (local.y < RULER_HEIGHT) { + this.gesture = { kind: 'scrub' }; + this.pause(); + this.scrub(Math.max(0, Math.round(xToTick(this.view, g.x)))); + return; + } + const row = trackAtY(this.view, asset.tracks.length, g.y); + if (local.x < HEAD_WIDTH) { + if (row !== null) + this.state.select({ + kind: 'composition', + assetId: asset.id, + trackId: asset.tracks[row]!.id, + clipIds: [], + }); + return; + } + const hit = this.hitAt(asset, g); + if (!hit) { + this.state.select({ kind: 'asset', assetId: asset.id }); + return; + } + const toggle = e.ctrlKey || e.metaKey; + if (hit.kind === 'clip') { + const clip = asset.clips.find((c) => c.id === hit.id)!; + const picked = this.pickedClips(asset); + if (toggle) { + const next = picked.includes(clip.id) ? picked.filter((id) => id !== clip.id) : [...picked, clip.id]; + this.state.select({ kind: 'composition', assetId: asset.id, trackId: clip.track, clipIds: next }); + return; + } + if (!picked.includes(clip.id)) + this.state.select({ + kind: 'composition', + assetId: asset.id, + trackId: clip.track, + clipIds: [clip.id], + }); + this.startMove('clip', clip, g); + return; + } + const item = asset.items.find((i) => i.id === hit.id)!; + const picked = this.pickedItems(asset); + if (toggle) { + this.state.toggleObject(asset.id, item.id); + return; + } + if (!picked.includes(item.id) || hit.part !== 'body') + this.state.select(objectSelection(asset.id, [item.id])); + if (hit.part === 'end') this.gesture = { kind: 'resize', anchor: item, item }; + else if (hit.part === 'start') this.gesture = { kind: 'trim', anchor: item, item }; + else this.startMove('item', item, g); + } + + private startMove( + what: 'item' | 'clip', + anchor: { id: string; start: number; track: string }, + g: Vec2, + ): void { + const grab = xToTick(this.view, g.x) - anchor.start; + this.gesture = { kind: 'move', what, anchor, grab, pointer: g, moved: false, frames: 0, tracks: 0 }; + } + + private onPointerMove(e: FederatedPointerEvent): void { + const asset = this.asset(); + const local = this.toLocal(e.global); + const g = this.inLanes({ x: local.x, y: local.y }); + const dragged = activeDrag(); + if (dragged?.kind === 'asset' && asset) { + this.followDrop(asset, dragged.id, local, g); + return; + } + const gesture = this.gesture; + if (!gesture || !asset) return; + const frame = xToTick(this.view, g.x); + switch (gesture.kind) { + case 'pan': + this.timeTween.carry(() => + this.trackTween.carry(() => { + this.view = clampTracks( + clampTimePan( + { + ...this.view, + panX: gesture.panX + (local.x - gesture.pointer.x), + panY: gesture.panY + (local.y - gesture.pointer.y), + }, + this.lanesWidth, + ), + asset.tracks.length, + this.lanesHeight, + ); + }), + ); + break; + case 'scrub': + this.scrub(Math.max(0, Math.round(frame))); + return; + case 'move': { + if (!gesture.moved && Math.hypot(g.x - gesture.pointer.x, g.y - gesture.pointer.y) < DRAG_THRESHOLD) + return; + gesture.moved = true; + const frames = Math.round(frame - gesture.grab) - gesture.anchor.start; + const row = nearestTrack(this.view, asset.tracks.length, g.y); + const tracks = row - asset.tracks.findIndex((t) => t.id === gesture.anchor.track); + // Where it would overlap, it stays at the last place it fitted. + if (this.movedList(asset, gesture.what, frames, tracks)) { + gesture.frames = frames; + gesture.tracks = tracks; + } else if (this.movedList(asset, gesture.what, frames, gesture.tracks)) gesture.frames = frames; + break; + } + case 'resize': + gesture.item = resizedItem(asset.items, gesture.anchor, Math.round(frame) - gesture.anchor.start); + break; + case 'trim': { + const video = itemVideo(this.state.project, asset, gesture.anchor) !== null; + gesture.item = trimmedItem( + asset.items, + gesture.anchor, + Math.round(frame), + compositionFps(asset), + video, + ); + break; + } + } + this.redraw(); + } + + private movedList( + asset: CompositionAsset, + what: 'item' | 'clip', + frames: number, + tracks: number, + ): CompositionItem[] | CompositionClip[] | null { + const ids = asset.tracks.map((t) => t.id); + return what === 'item' + ? movedItems(asset.items, new Set(this.pickedItems(asset)), ids, frames, tracks) + : movedClips(asset.clips, new Set(this.pickedClips(asset)), ids, frames, tracks, this.lengthOf(asset)); + } + + private onPointerUp(): void { + const asset = this.asset(); + const drop = this.drop; + if (drop && asset) { + this.drop = null; + endDrag(); + this.land(asset, drop); + this.redraw(); + return; + } + const gesture = this.gesture; + this.gesture = null; + if (!gesture || !asset) return; + switch (gesture.kind) { + case 'move': { + if (!gesture.moved) { + if (gesture.what === 'item') this.state.select(objectSelection(asset.id, [gesture.anchor.id])); + break; + } + const list = this.movedList(asset, gesture.what, gesture.frames, gesture.tracks); + if (!list || (gesture.frames === 0 && gesture.tracks === 0)) break; + const several = + (gesture.what === 'item' ? this.pickedItems(asset) : this.pickedClips(asset)).length > 1; + const parts = + gesture.what === 'item' + ? { items: list as CompositionItem[] } + : { clips: list as CompositionClip[] }; + const label = `Move ${gesture.what}${several ? 's' : ''}`; + this.commit(asset, parts, label); + break; + } + case 'resize': + case 'trim': + if (JSON.stringify(gesture.item) !== JSON.stringify(gesture.anchor)) + this.commit( + asset, + { items: asset.items.map((i) => (i.id === gesture.anchor.id ? gesture.item : i)) }, + gesture.kind === 'resize' ? 'Resize item' : 'Trim item', + ); + break; + default: + break; + } + this.redraw(); + } + + private commit( + asset: CompositionAsset, + parts: ConstructorParameters[1], + label: string, + ): void { + if (SetCompositionCommand.changes(this.state.project, asset.id, parts)) + this.state.execute(new SetCompositionCommand(asset.id, parts, label)); + } + + /** + * Follows an asset dragged in from the panel: a placeable one lands as an item in the gap under the + * pointer, as long as its media or 3 s; an animation as a clip one pass long, where it fits whole. + */ + private followDrop(asset: CompositionAsset, assetId: string, local: Vec2, g: Vec2): void { + const dragged = findAsset(this.state.project, assetId); + const row = trackAtY(this.view, asset.tracks.length, g.y); + const inside = local.x >= HEAD_WIDTH && local.x <= this.w && local.y >= RULER_HEIGHT && local.y <= this.h; + const usable = dragged && (dragged.type === 'animation' || isPlaceableType(dragged.type)); + if (!inside || row === null || !dragged || !usable) { + if (this.drop) { + this.drop = null; + this.redraw(); + } + return; + } + const track = asset.tracks[row]!.id; + const at = Math.max(0, Math.round(xToTick(this.view, g.x))); + if (dragged.type === 'animation') { + const length = clipLength(this.state.project, asset, { + id: '', + track, + animation: dragged.id, + start: 0, + }); + const span = + length > 0 ? droppedSpan(clipSpans(asset.clips, track, this.lengthOf(asset)), at, length) : null; + const rejection = + length === 0 + ? `${dragged.name} has no frames to play here` + : !span || span.length < length + ? 'No room for the whole clip there' + : ''; + this.drop = { + what: 'clip', + assetId, + track, + start: span?.start ?? at, + length: Math.max(1, length), + rejection, + }; + } else { + const wanted = droppedLength(this.state.project, asset, dragged); + const span = droppedSpan(itemSpans(this.liveItems(asset), track), at, wanted); + const rejection = + placementRejection(this.state.project, asset, dragged) || (span ? '' : 'No room there'); + this.drop = { + what: 'item', + assetId, + track, + start: span?.start ?? at, + length: span?.length ?? wanted, + rejection, + }; + } + this.redraw(); + } + + private land(asset: CompositionAsset, drop: Drop): void { + if (drop.rejection) { + this.state.setNotice(drop.rejection); + return; + } + if (drop.what === 'clip') { + const clip: CompositionClip = { + id: newId(), + track: drop.track, + animation: drop.assetId, + start: drop.start, + }; + if ( + this.state.execute(new SetCompositionCommand(asset.id, { clips: [...asset.clips, clip] }, 'Add clip')) + ) + this.state.select({ + kind: 'composition', + assetId: asset.id, + trackId: drop.track, + clipIds: [clip.id], + }); + return; + } + const { command, objectId } = addCompositionItem(this.state.project, asset.id, drop.assetId, { + track: drop.track, + start: drop.start, + length: drop.length, + offset: 0, + fitMedia: true, + }); + if (this.state.execute(command)) this.state.select(objectSelection(asset.id, [objectId])); + } + + private onWheel(e: FederatedWheelEvent): void { + const asset = this.asset(); + if (!asset) return; + const local = this.toLocal(e.global); + const g = this.inLanes({ x: local.x, y: local.y }); + const factor = zoomFactor(e.deltaY); + const tracks = local.x < HEAD_WIDTH && local.y >= RULER_HEIGHT; + (tracks ? this.trackTween : this.timeTween).change(() => { + const view = tracks + ? zoomTracks(this.view, factor, g.y) + : { ...this.view, ...zoomTime(this.view, factor, g.x, MAX_PX_PER_FRAME) }; + this.view = clampTracks(clampTimePan(view, this.lanesWidth), asset.tracks.length, this.lanesHeight); + this.needsFit = false; + this.redraw(); + }); + e.preventDefault(); + } + + // --- menus ------------------------------------------------------------------------------------- + + private openMenu(asset: CompositionAsset, local: Vec2, g: Vec2, global: Vec2): void { + const row = local.y >= RULER_HEIGHT ? trackAtY(this.view, asset.tracks.length, g.y) : null; + const hit = local.x >= HEAD_WIDTH && local.y >= RULER_HEIGHT ? this.hitAt(asset, g) : null; + const addTrack = { label: 'Add track', onSelect: () => this.addTrack() }; + if (hit?.kind === 'item') { + if (!this.pickedItems(asset).includes(hit.id)) this.state.select(objectSelection(asset.id, [hit.id])); + const ids = this.pickedItems(asset); + new Menu([ + { + label: 'Fit to media', + disabled: ids.length !== 1, + onSelect: () => fitToMedia({ state: this.state }, asset.id, hit.id), + }, + { separator: true }, + { + label: ids.length > 1 ? `Remove ${ids.length} items` : 'Remove item', + danger: true, + onSelect: () => this.state.execute(removeFromComposition(this.state.project, asset.id, ids, [])), + }, + ]).open(global.x, global.y); + return; + } + if (hit?.kind === 'clip') { + const clip = asset.clips.find((c) => c.id === hit.id)!; + if (!this.pickedClips(asset).includes(clip.id)) + this.state.select({ + kind: 'composition', + assetId: asset.id, + trackId: clip.track, + clipIds: [clip.id], + }); + const ids = this.pickedClips(asset); + new Menu([ + { + label: 'Open animation', + disabled: !findAssetOf(this.state.project, clip.animation, 'animation'), + onSelect: () => this.state.openAsset(clip.animation), + }, + { separator: true }, + { + label: ids.length > 1 ? `Remove ${ids.length} clips` : 'Remove clip', + danger: true, + onSelect: () => { + if (this.state.execute(removeFromComposition(this.state.project, asset.id, [], ids))) + this.state.select({ kind: 'asset', assetId: asset.id }); + }, + }, + ]).open(global.x, global.y); + return; + } + const track = row === null ? undefined : asset.tracks[row]; + new Menu([ + addTrack, + ...(track + ? [ + { + label: `Select "${track.name}"`, + onSelect: () => + this.state.select({ kind: 'composition', assetId: asset.id, trackId: track.id, clipIds: [] }), + }, + ] + : []), + ]).open(global.x, global.y); + } + + /** A new track at the bottom, behind all the others. */ + addTrack(): void { + const asset = this.asset(); + if (!asset) return; + const track = { id: newId(), name: `Track ${asset.tracks.length + 1}` }; + if ( + this.state.execute( + new SetCompositionCommand(asset.id, { tracks: [...asset.tracks, track] }, 'Add track'), + ) + ) + this.state.select({ kind: 'composition', assetId: asset.id, trackId: track.id, clipIds: [] }); + } + + // --- drawing ----------------------------------------------------------------------------------- + + /** The items as the running gesture would leave them. */ + private shownItems(asset: CompositionAsset): CompositionItem[] { + const g = this.gesture; + const live = this.liveItems(asset); + if (g?.kind === 'move' && g.what === 'item' && g.moved) { + const moved = movedItems( + live, + new Set(this.pickedItems(asset)), + asset.tracks.map((t) => t.id), + g.frames, + g.tracks, + ); + return moved ?? live; + } + if (g?.kind === 'resize' || g?.kind === 'trim') + return live.map((i) => (i.id === g.anchor.id ? g.item : i)); + return live; + } + + private shownClips(asset: CompositionAsset): CompositionClip[] { + const g = this.gesture; + if (g?.kind === 'move' && g.what === 'clip' && g.moved) + return ( + movedClips( + asset.clips, + new Set(this.pickedClips(asset)), + asset.tracks.map((t) => t.id), + g.frames, + g.tracks, + this.lengthOf(asset), + ) ?? asset.clips + ); + return asset.clips; + } + + override redraw(): void { + const c = Theme.colors; + const asset = this.asset(); + if (asset && this.needsFit && this.w > 0 && this.h > 0) { + this.needsFit = false; + const length = Math.max(compositionLength(this.state.project, asset), compositionFps(asset) * 10); + const fitted = fitSong(length, asset.tracks.length, this.lanesWidth, this.lanesHeight); + this.view = { + ...fitted, + pxPerTick: Math.min(fitted.pxPerTick, MAX_PX_PER_FRAME), + panY: 0, + trackHeight: TRACK_HEIGHT, + }; + } + this.bg.clear().rect(0, 0, this.w, this.h).fill(c.viewport); + this.bg.rect(0, 0, HEAD_WIDTH, this.h).fill(c.panel); + this.bg.rect(0, 0, this.w, RULER_HEIGHT).fill(c.panelHeader); + this.bg.rect(HEAD_WIDTH - 1, 0, 1, this.h).fill(c.border); + this.bg.rect(0, RULER_HEIGHT - 1, this.w, 1).fill(c.border); + this.clip.clear().rect(HEAD_WIDTH, RULER_HEIGHT, this.lanesWidth, this.lanesHeight).fill(0xffffff); + this.lanes.clear(); + this.over.clear(); + const items: Placed[] = []; + const heads: Placed[] = []; + const ruler: Placed[] = []; + if (asset) { + this.drawTracks(asset, heads); + this.drawItems(asset, items); + this.drawClips(asset, items); + this.drawDrop(asset); + this.drawRuler(asset, ruler); + this.drawPlayhead(); + } + syncTexts(this.itemTexts, this.itemLayer, items, 'small'); + syncTexts(this.headTexts, this.headLayer, heads, 'body'); + syncTexts(this.rulerTexts, this.rulerLayer, ruler, 'small'); + const hint = !asset + ? '' + : asset.items.length === 0 && asset.clips.length === 0 + ? 'Drag a sprite, text, camera or container onto a track, an animation to play on it.' + : ''; + this.hint.text = hint; + this.hint.visible = hint !== ''; + this.hint.position.set(HEAD_WIDTH + Theme.padding * 2, this.h - Theme.padding * 2 - this.hint.height); + } + + private drawTracks(asset: CompositionAsset, heads: Placed[]): void { + const c = Theme.colors; + const s = this.state.selection; + const lit = + s?.kind === 'composition' && s.assetId === asset.id && s.clipIds.length === 0 ? s.trackId : ''; + const end = HEAD_WIDTH + tickToX(this.view, compositionLength(this.state.project, asset)); + const zero = HEAD_WIDTH + tickToX(this.view, 0); + asset.tracks.forEach((track, row) => { + const y = RULER_HEIGHT + trackTop(this.view, row); + const h = this.view.trackHeight; + if (y + h < RULER_HEIGHT || y > this.h) return; + this.lanes + .rect(HEAD_WIDTH, y, this.lanesWidth, h) + .fill({ color: 0xffffff, alpha: row % 2 === 0 ? 0.02 : 0.04 }); + if (end > zero) this.lanes.rect(zero, y, end - zero, h).fill({ color: 0xffffff, alpha: 0.02 }); + this.lanes.rect(HEAD_WIDTH, y + h - 1, this.lanesWidth, 1).fill({ color: c.border, alpha: 0.6 }); + if (y < RULER_HEIGHT) return; + if (track.id === lit) this.bg.rect(0, y, HEAD_WIDTH - 1, h).fill({ color: c.selection, alpha: 0.6 }); + this.bg.rect(0, y + h - 1, HEAD_WIDTH, 1).fill({ color: c.border, alpha: 0.6 }); + heads.push({ text: track.name, x: 8, y: y + 4, w: HEAD_WIDTH - 16 }); + }); + if (zero > HEAD_WIDTH) + this.lanes + .rect(HEAD_WIDTH, RULER_HEIGHT, zero - HEAD_WIDTH, this.lanesHeight) + .fill({ color: 0x000000, alpha: 0.3 }); + } + + private drawItems(asset: CompositionAsset, labels: Placed[]): void { + const c = Theme.colors; + const rows = new Map(asset.tracks.map((t, i) => [t.id, i])); + const picked = new Set(this.pickedItems(asset)); + for (const item of this.shownItems(asset)) { + const row = rows.get(item.track); + if (row === undefined) continue; + const box = this.onScreen(itemBox(this.view, row, item)); + if (box.x > this.w || box.x + box.w < HEAD_WIDTH) continue; + const lit = picked.has(item.id); + const color = itemVideo(this.state.project, asset, item) ? VIDEO_COLOR : ITEM_COLOR; + this.lanes + .roundRect(box.x, box.y, box.w, box.h, 3) + .fill({ color, alpha: lit ? 1 : 0.85 }) + .stroke({ + width: lit ? 2 : 1, + color: lit ? c.accentText : 0x000000, + alpha: lit ? 1 : 0.45, + alignment: 1, + }); + const object = itemObject(asset, item); + const placed = object && findAsset(this.state.project, object.assetId); + const name = object ? objectName(object, placed) : ''; + if (box.w > 18) + labels.push({ text: name, x: Math.max(box.x, HEAD_WIDTH) + 4, y: box.y + 2, w: box.w - 8 }); + } + } + + private drawClips(asset: CompositionAsset, labels: Placed[]): void { + const c = Theme.colors; + const rows = new Map(asset.tracks.map((t, i) => [t.id, i])); + const picked = new Set(this.pickedClips(asset)); + const lengthOf = this.lengthOf(asset); + for (const clip of this.shownClips(asset)) { + const row = rows.get(clip.track); + if (row === undefined) continue; + const box = this.onScreen(clipBox(this.view, row, { start: clip.start, length: lengthOf(clip) })); + if (box.x > this.w || box.x + box.w < HEAD_WIDTH) continue; + const lit = picked.has(clip.id); + const animation = findAssetOf(this.state.project, clip.animation, 'animation'); + this.over + .roundRect(box.x, box.y, box.w, box.h, 2) + .fill({ color: animation ? CLIP_COLOR : REJECTED_COLOR, alpha: lit ? 1 : 0.9 }) + .stroke({ + width: lit ? 2 : 1, + color: lit ? c.accentText : 0x000000, + alpha: lit ? 1 : 0.5, + alignment: 1, + }); + if (box.w > 18 && box.h >= 12) + labels.push({ + text: animation?.name ?? '(missing)', + x: Math.max(box.x, HEAD_WIDTH) + 4, + y: box.y + (box.h - 12) / 2 - 1, + w: box.w - 8, + }); + } + } + + private drawDrop(asset: CompositionAsset): void { + const drop = this.drop; + const row = drop ? asset.tracks.findIndex((t) => t.id === drop.track) : -1; + if (!drop || row < 0) return; + const span = { start: drop.start, length: drop.length }; + const box = this.onScreen( + drop.what === 'clip' ? clipBox(this.view, row, span) : itemBox(this.view, row, span), + ); + const color = drop.rejection ? REJECTED_COLOR : drop.what === 'clip' ? CLIP_COLOR : ITEM_COLOR; + this.over + .roundRect(box.x, box.y, box.w, box.h, 3) + .fill({ color, alpha: 0.5 }) + .stroke({ width: 1, color: 0xffffff, alpha: 0.6 }); + } + + private drawRuler(asset: CompositionAsset, labels: Placed[]): void { + const c = Theme.colors; + const fps = compositionFps(asset); + const end = compositionLength(this.state.project, asset); + const x0 = Math.max(HEAD_WIDTH, HEAD_WIDTH + tickToX(this.view, 0)); + const x1 = Math.min(this.w, HEAD_WIDTH + tickToX(this.view, end)); + if (x1 > x0) this.bg.rect(x0, 0, x1 - x0, RULER_HEIGHT - 1).fill({ color: c.accent, alpha: 0.15 }); + const step = rulerStep(this.view, fps); + const first = Math.max(0, Math.floor(xToTick(this.view, 0) / step) * step); + for (let frame = first; ; frame += step) { + const x = HEAD_WIDTH + tickToX(this.view, frame); + if (x > this.w) break; + if (x < HEAD_WIDTH) continue; + this.bg.rect(x, RULER_HEIGHT - 6, 1, 6).fill(c.textMuted); + labels.push({ text: rulerText(frame / fps), x: x + 3, y: 4, w: 80 }); + } + } + + private drawPlayhead(): void { + const x = HEAD_WIDTH + tickToX(this.view, this.playhead); + if (x < HEAD_WIDTH || x > this.w) return; + this.over.rect(x - 0.5, 0, 1, this.h).fill(Theme.colors.hot); + this.bg + .moveTo(x - 5, 0) + .lineTo(x + 5, 0) + .lineTo(x, 7) + .closePath() + .fill(Theme.colors.hot); + } + + private onScreen(box: { x: number; y: number; w: number; h: number }): { + x: number; + y: number; + w: number; + h: number; + } { + return { ...box, x: box.x + HEAD_WIDTH, y: box.y + RULER_HEIGHT }; + } +} + +/** Puts the texts where they are wanted, reusing the ones there are and hiding the rest. */ +function syncTexts(pool: Text[], layer: Container, wanted: readonly Placed[], style: 'small' | 'body'): void { + wanted.forEach((place, i) => { + let text = pool[i]; + if (!text) { + text = crispText({ style: textStyle(style) }); + pool.push(text); + layer.addChild(text); + } + const shown = truncate(place.text, text.style, place.w); + if (text.text !== shown) text.text = shown; + text.position.set(place.x, place.y); + text.visible = true; + }); + for (let i = wanted.length; i < pool.length; i++) pool[i]!.visible = false; +} diff --git a/src/editor/composition/compositionView.ts b/src/editor/composition/compositionView.ts new file mode 100644 index 00000000..180dbe1d --- /dev/null +++ b/src/editor/composition/compositionView.ts @@ -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')}`; +} diff --git a/src/editor/debug/documentHooks.ts b/src/editor/debug/documentHooks.ts index 7d526295..83c03c7b 100644 --- a/src/editor/debug/documentHooks.ts +++ b/src/editor/debug/documentHooks.ts @@ -74,6 +74,25 @@ export function documentHooks({ state, editor }: HookContext) { fitAt: () => centreOf(editor.animationEditor.fitButton), }, }, + /** The Composition tab (#417): where tracks, items and clips sit, the playhead and the picture. */ + composition: { + current: () => editor.compositionEditor.currentAssetId, + info: () => editor.compositionEditor.shownInfo, + playAt: () => ({ + ...centreOf(editor.compositionEditor.playButton), + text: editor.compositionEditor.playButton.text, + }), + addTrackAt: () => centreOf(editor.compositionEditor.addTrackButton), + laneAt: (trackId: string, frame: number) => editor.compositionEditor.canvas.laneGlobal(trackId, frame), + box: (id: string) => editor.compositionEditor.canvas.boxGlobal(id), + drop: () => editor.compositionEditor.canvas.dropInfo(), + view: () => editor.compositionEditor.canvas.viewInfo(), + scrub: (frame: number) => editor.compositionEditor.canvas.scrub(frame), + /** What the picture shows at the playhead, in drawing order, and where one is drawn. */ + shown: () => + (editor.compositionEditor.shownHolder()?.objects ?? []).map((o) => ({ id: o.id, x: o.position.x })), + objectScreen: (objectId: string) => editor.compositionEditor.preview.objectScreen(objectId), + }, tile: { current: () => editor.tileEditor.currentAssetId, info: () => editor.tileEditor.shownInfo, diff --git a/src/editor/inspector/Inspector.ts b/src/editor/inspector/Inspector.ts index 0f5a64f6..b68dc2ee 100644 --- a/src/editor/inspector/Inspector.ts +++ b/src/editor/inspector/Inspector.ts @@ -19,6 +19,11 @@ import { } from './objectSection'; import { buildRoutingNodeSection, refreshRoutingNodeSection, routingNodeSignature } from './routingSection'; import { buildSongSection, refreshSongSection, songSignature } from './songSection'; +import { + buildCompositionSection, + compositionSignature, + refreshCompositionSection, +} from './compositionSection'; import { ROUTING_ID } from '../../core/model/Project'; import { buildTrackSection, refreshTrackSection, trackSignature } from './trackSection'; import { INLINE_LIMIT } from '../variables/target'; @@ -141,6 +146,7 @@ export class Inspector extends Panel { else if (s.kind === 'track') buildTrackSection(this.host, s.assetId, s.trackId, s.keys); else if (s.kind === 'edge') buildEdgeSection(this.host, s.assetId, s.edgeId, s.edgeIds); else if (s.kind === 'song') buildSongSection(this.host, s.assetId, s.trackId, s.itemIds); + else if (s.kind === 'composition') buildCompositionSection(this.host, s.assetId, s.trackId, s.clipIds); else if (s.objectIds.length > 1) buildObjectsSection(this.host, s.holderId, s.objectIds); else buildObjectSection(this.host, s.holderId, s.objectId); this.scroll.setContent(this.host.body); @@ -160,6 +166,8 @@ export class Inspector extends Panel { if (s.kind === 'track') return trackSignature(this.state.project, s.assetId, s.trackId, s.keys); if (s.kind === 'edge') return edgeSignature(this.state.project, s.assetId, s.edgeId, s.edgeIds); if (s.kind === 'song') return songSignature(this.state.project, s.assetId, s.trackId, s.itemIds); + if (s.kind === 'composition') + return compositionSignature(this.state.project, s.assetId, s.trackId, s.clipIds); return objectSignature(this.state, s.holderId, s.objectIds, s.objectId); } @@ -199,7 +207,10 @@ export class Inspector extends Panel { ? refreshEdgeSection(this.host, s.assetId, s.edgeId) : s.kind === 'song' ? refreshSongSection(this.host, s.assetId, s.trackId, s.itemIds) - : s.objectIds.length > 1 || refreshObjectSection(this.host, s.holderId, s.objectId); + : s.kind === 'composition' + ? refreshCompositionSection(this.host, s.assetId, s.trackId, s.clipIds) + : s.objectIds.length > 1 || + refreshObjectSection(this.host, s.holderId, s.objectId); if (!ok) this.rebuild(); } } diff --git a/src/editor/inspector/compositionSection.ts b/src/editor/inspector/compositionSection.ts new file mode 100644 index 00000000..2e6be8a8 --- /dev/null +++ b/src/editor/inspector/compositionSection.ts @@ -0,0 +1,240 @@ +import { + removeCompositionTrack, + removeFromComposition, + SetCompositionCommand, +} from '../../core/commands/compositionCommands'; +import type { CompositionAsset, CompositionItem } from '../../core/model/Asset'; +import { clipLength, compositionFps, itemVideo } from '../../core/model/composition'; +import { refittedLength, resizedItem } from '../../core/model/compositionEdit'; +import { findAssetOf, type Project } from '../../core/model/Project'; +import type { PropertyDef } from '../../core/properties/PropertySchema'; +import { Button, formatNumber } from '../../ui'; +import { createFieldRow } from './fieldFactory'; +import { addGap, addNameField, addNote, addTitle, type InspectorHost } from './host'; +import { assetLink } from './objectSection'; + +const START_ROW = 'composition:start'; +const LENGTH_ROW = 'composition:length'; +const OFFSET_ROW = 'composition:offset'; +const CLIP_START_ROW = 'composition:clipStart'; +const ANIMATION_ROW = 'composition:animation'; + +const seconds = (key: string, label: string): PropertyDef => ({ + key, + label, + kind: 'number', + default: 0, + min: 0, + step: 0.1, + decimals: 3, +}); + +/** Structure-defining facts of a track or clip selection: which it is, and whether it is still there. */ +export function compositionSignature( + project: Project, + assetId: string, + trackId: string, + clipIds: string[], +): string { + const comp = findAssetOf(project, assetId, 'composition'); + if (!comp?.tracks.some((t) => t.id === trackId)) return 'missing'; + return clipIds.length > 0 ? `clips:${clipIds.join(',')}` : 'track'; +} + +/** A track: its name and the way to remove it. Clips: what they play, from when, and the way to remove them. */ +export function buildCompositionSection( + host: InspectorHost, + assetId: string, + trackId: string, + clipIds: string[], +): void { + const comp = findAssetOf(host.state.project, assetId, 'composition'); + const track = comp?.tracks.find((t) => t.id === trackId); + if (!comp || !track) return; + if (clipIds.length === 0) { + addTitle(host, 'TRACK'); + addNameField(host, track.name, { + onChange: (name) => { + const tracks = comp.tracks.map((t) => (t.id === trackId ? { ...t, name: name.trim() } : t)); + if (name.trim()) host.state.execute(new SetCompositionCommand(assetId, { tracks }, 'Rename track')); + else host.nameField?.setValue(track.name); + }, + }); + addNote(host, 'An upper track lies in front of the ones below it.'); + addGap(host); + host.body.add( + new Button('Remove track', { + variant: 'danger', + onClick: () => host.state.execute(removeCompositionTrack(host.state.project, assetId, trackId)), + }), + ); + return; + } + const clip = clipIds.length === 1 ? comp.clips.find((c) => c.id === clipIds[0]) : undefined; + if (clip) { + addTitle(host, 'ANIMATION CLIP'); + const animation = findAssetOf(host.state.project, clip.animation, 'animation'); + if (animation) assetLink(host, ANIMATION_ROW, 'Animation', animation); + else addNote(host, 'Its animation is gone.', 'warning'); + const fps = compositionFps(comp); + const row = createFieldRow(host, seconds(CLIP_START_ROW, 'Start'), clip.start / fps, { + onInput: () => undefined, + onChange: (value) => { + const start = Math.max(0, Math.round(Number(value) * fps)); + const current = findAssetOf(host.state.project, assetId, 'composition'); + if (!current) return; + const clips = current.clips.map((c) => (c.id === clip.id ? { ...c, start } : c)); + if (SetCompositionCommand.changes(host.state.project, assetId, { clips })) + host.state.execute(new SetCompositionCommand(assetId, { clips }, 'Move clip')); + }, + }); + host.fields.set(CLIP_START_ROW, row); + host.body.add(row.row); + const length = clipLength(host.state.project, comp, clip); + addNote( + host, + `One pass: ${formatNumber(length / fps, 2)} s. It plays on the item of its track it overlaps.`, + ); + } else addTitle(host, `${clipIds.length} ANIMATION CLIPS`); + addGap(host); + host.body.add( + new Button(clipIds.length === 1 ? 'Remove clip' : 'Remove clips', { + variant: 'danger', + onClick: () => { + if (host.state.execute(removeFromComposition(host.state.project, assetId, [], clipIds))) + host.state.select({ kind: 'asset', assetId }); + }, + }), + ); +} + +export function refreshCompositionSection( + host: InspectorHost, + assetId: string, + trackId: string, + clipIds: string[], +): boolean { + const comp = findAssetOf(host.state.project, assetId, 'composition'); + const track = comp?.tracks.find((t) => t.id === trackId); + if (!comp || !track) return false; + if (clipIds.length === 0) host.nameField?.setValue(track.name); + const clip = clipIds.length === 1 ? comp.clips.find((c) => c.id === clipIds[0]) : undefined; + if (clip) host.fields.get(CLIP_START_ROW)?.setValue(clip.start / compositionFps(comp)); + return true; +} + +/** The item an object of a composition stands for; undefined for any other object. */ +export function compositionItemOf( + project: Project, + holderId: string, + objectId: string, +): { comp: CompositionAsset; item: CompositionItem } | undefined { + const comp = findAssetOf(project, holderId, 'composition'); + const item = comp?.items.find((i) => i.id === objectId); + return comp && item ? { comp, item } : undefined; +} + +/** + * When the object's item lives in the composition, and how far into its video it begins (#417). A + * length typed in or an edge moved is the item's own from then on; "Fit to media" hands it back. + */ +export function addTimingRows(host: InspectorHost, holderId: string, objectId: string): void { + const found = compositionItemOf(host.state.project, holderId, objectId); + if (!found) return; + const { comp, item } = found; + const fps = compositionFps(comp); + const video = itemVideo(host.state.project, comp, item) !== null; + addTitle(host, 'TIMING'); + const write = ( + label: string, + edit: (current: CompositionItem, all: CompositionItem[]) => CompositionItem, + ) => { + const current = compositionItemOf(host.state.project, holderId, objectId); + if (!current) return; + const items = current.comp.items.map((i) => (i.id === objectId ? edit(i, current.comp.items) : i)); + if (SetCompositionCommand.changes(host.state.project, holderId, { items })) + host.state.execute(new SetCompositionCommand(holderId, { items }, label)); + }; + const frames = (value: unknown) => Math.round(Number(value) * fps); + const rows: [string, string, number, (value: unknown) => void][] = [ + [ + START_ROW, + 'Start', + item.start / fps, + (value) => write('Move item', (i, all) => moveTo(all, i, frames(value))), + ], + [ + LENGTH_ROW, + 'Length', + item.length / fps, + (value) => write('Resize item', (i, all) => resizedItem(all, i, frames(value))), + ], + ]; + if (video) + rows.push([ + OFFSET_ROW, + 'Offset', + item.offset, + (value) => write('Set item offset', (i) => withOffset(i, Number(value))), + ]); + for (const [key, label, value, onChange] of rows) { + const row = createFieldRow(host, seconds(key, label), value, { onInput: () => undefined, onChange }); + host.fields.set(key, row); + host.body.add(row.row); + } + addNote( + host, + item.fitMedia + ? video + ? 'Its length follows its video.' + : 'Its length follows a video it is given, 3 s while it has none.' + : 'Its length is its own.', + 'small', + ); + const fit = new Button('Fit to media', { onClick: () => fitToMedia(host, holderId, objectId) }); + fit.setEnabled(!(item.fitMedia === true && item.length === refittedLength(host.state.project, comp, item))); + host.body.add(fit); +} + +export function refreshTimingRows(host: InspectorHost, holderId: string, objectId: string): void { + const found = compositionItemOf(host.state.project, holderId, objectId); + if (!found) return; + const fps = compositionFps(found.comp); + host.fields.get(START_ROW)?.setValue(found.item.start / fps); + host.fields.get(LENGTH_ROW)?.setValue(found.item.length / fps); + host.fields.get(OFFSET_ROW)?.setValue(found.item.offset); +} + +/** What shapes the timing rows: whether there is a video, and whether the length still follows it. */ +export function timingSignature(project: Project, holderId: string, objectId: string): string { + const found = compositionItemOf(project, holderId, objectId); + if (!found) return ''; + const video = itemVideo(project, found.comp, found.item) !== null; + const fitted = found.item.length === refittedLength(project, found.comp, found.item); + return `|timing:${video}:${found.item.fitMedia === true}:${fitted}`; +} + +/** The item's length taken from its video again, and following it from then on (#417). */ +export function fitToMedia(host: Pick, holderId: string, objectId: string): void { + const found = compositionItemOf(host.state.project, holderId, objectId); + if (!found) return; + const length = refittedLength(host.state.project, found.comp, found.item); + const items = found.comp.items.map((i) => (i.id === objectId ? { ...i, length, fitMedia: true } : i)); + if (SetCompositionCommand.changes(host.state.project, holderId, { items })) + host.state.execute(new SetCompositionCommand(holderId, { items }, 'Fit to media')); +} + +/** The item moved to `start`, as far as the items around it let it: a clash keeps it where it was. */ +function moveTo(all: readonly CompositionItem[], item: CompositionItem, start: number): CompositionItem { + const at = Math.max(0, start); + const clash = all.some( + (i) => + i.id !== item.id && i.track === item.track && i.start < at + item.length && at < i.start + i.length, + ); + return clash ? item : { ...item, start: at }; +} + +function withOffset(item: CompositionItem, offset: number): CompositionItem { + const { fitMedia: _, ...rest } = item; + return { ...rest, offset: Number.isFinite(offset) && offset > 0 ? offset : 0 }; +} diff --git a/src/editor/inspector/objectSection.ts b/src/editor/inspector/objectSection.ts index bbfc5070..84658dc5 100644 --- a/src/editor/inspector/objectSection.ts +++ b/src/editor/inspector/objectSection.ts @@ -76,6 +76,7 @@ import { viewOf, } from './objectQueries'; import { componentRow, OFFSET_AXES, offsetKey, POSITION_AXES } from './vectorRows'; +import { addTimingRows, refreshTimingRows, timingSignature } from './compositionSection'; /** * The rows a placed object shows for what it was placed from: a behavior's declared variables but @@ -112,7 +113,8 @@ export function objectSignature( const placed = asset.type === 'tilemap' ? `|placed:${findUsages(project, asset.id).length}` : ''; // A variable turned private leaves the rows, with nothing else about it changed (#257). const hidden = asset.type === 'behavior' ? `|private:${(asset.privateVariables ?? []).join(',')}` : ''; - return `${object.parentId}|${kind}${placed}${hidden}|` + rows.map(defSignature).join(','); + const timing = timingSignature(project, holderId, objectId); + return `${object.parentId}|${kind}${placed}${hidden}${timing}|` + rows.map(defSignature).join(','); } /** Several objects: what they are, and the actions that apply to all of them at once. */ @@ -165,6 +167,7 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object host.body.add(parentRow(host, view, object, target !== undefined)); // Content of a placed container: values compare against the template, not just the asset. if (target) assetLink(host, PART_OF_ROW, 'Part of', target.asset); + addTimingRows(host, holderId, objectId); // The switches in a group of their own (#290), above everything type-specific. addTitle(host, 'SWITCHES'); @@ -316,7 +319,7 @@ const PART_OF_ROW = '#part-of'; * What the object was placed from: the label is a link like on any reference row (click pings, * double-click opens), the name next to it only reads – changing it would mean another object. */ -function assetLink(host: InspectorHost, key: string, text: string, asset: Asset): void { +export function assetLink(host: InspectorHost, key: string, text: string, asset: Asset): void { const { row, label } = labelledRow(text); linkLabel(host, label)(asset.id); const name = new TextField(asset.name, { readOnly: true }); @@ -361,6 +364,7 @@ export function refreshObjectSection(host: InspectorHost, holderId: string, obje host.fields.get(ASSET_ROW)?.setValue(asset.name); const target = targetOf(project, holderId, objectId); if (target) host.fields.get(PART_OF_ROW)?.setValue(target.asset.name); + refreshTimingRows(host, holderId, objectId); for (const axis of POSITION_AXES) host.fields.get(axis)?.setValue(object.position[axis]); for (const axis of OFFSET_AXES) host.fields.get(offsetKey(axis))?.setValue(object.editorOffset[axis]); const values = resolveSceneObjectProps(project, asset, object); diff --git a/src/editor/objectMenu.ts b/src/editor/objectMenu.ts index 6be55170..16959df4 100644 --- a/src/editor/objectMenu.ts +++ b/src/editor/objectMenu.ts @@ -8,7 +8,7 @@ import { findAsset, type ObjectHolder } from '../core/model/Project'; import { type PropertyDef, schemaForObject } from '../core/properties/PropertySchema'; import { referencesOf } from '../core/properties/references'; import { resolveSceneObjectProps } from '../core/properties/resolve'; -import { Menu, type MenuItem } from '../ui'; +import { Menu, type MenuEntry, type MenuItem } from '../ui'; import { activateAsset, addCollider, @@ -22,6 +22,7 @@ import { } from './actions'; import { hasAssetEditor } from './assetTabs'; import { canPasteObjects, copyObjectIds, pasteObjects } from './clipboard'; +import { fitToMedia } from './inspector/compositionSection'; import { groupMenuItems } from './objectGroups'; import type { EditorState } from './state'; @@ -51,7 +52,7 @@ export function openObjectMenu( // Inside a multi-selection the menu acts on all selected objects. const selected = state.selectedObjectIds.includes(id) ? state.selectedObjectIds : [id]; const many = selected.length > 1; - const menu = new Menu([ + const head: MenuEntry[] = [ { label: 'Select asset', onSelect: () => object && state.select({ kind: 'asset', assetId: object.assetId }), @@ -70,6 +71,30 @@ export function openObjectMenu( resolveSceneObjectProps(state.project, asset, object), ) : []), + ]; + // An object of a composition is an item's (#417): nothing may join it that has no time of its own. + if (holder.type === 'composition') { + const items = selected.filter((oid) => !isTemplateContent(source, oid)); + const menu = new Menu([ + ...head, + { separator: true }, + { + label: 'Fit to media', + disabled: many || fromTemplate, + onSelect: () => fitToMedia({ state }, holder.id, id), + }, + { + label: items.length > 1 ? `Remove ${items.length} items` : 'Remove item', + danger: true, + disabled: items.length === 0, + onSelect: () => removeObjects(state, holder.id, items), + }, + ]); + menu.open(x, y); + return menu; + } + const menu = new Menu([ + ...head, { separator: true }, { label: many ? `Copy ${selected.length} objects` : 'Copy', diff --git a/src/editor/panels/AssetPanel.ts b/src/editor/panels/AssetPanel.ts index 85713f5c..0c94c1e2 100644 --- a/src/editor/panels/AssetPanel.ts +++ b/src/editor/panels/AssetPanel.ts @@ -444,12 +444,12 @@ export class AssetPanel extends Panel { /** * Hands the asset to the view under the pointer: a scene places it, a song lays out a pattern or a - * sound (#334). A scene has nowhere to go. + * sound (#334), a composition an animation as a clip (#417). A scene has nowhere to go. */ private startPlacementDrag(assetId: string): void { const asset = findAsset(this.state.project, assetId); - if (!asset || !(isPlaceableType(asset.type) || asset.type === 'pattern' || asset.type === 'sound')) - return; + const laid = asset?.type === 'pattern' || asset?.type === 'sound' || asset?.type === 'animation'; + if (!asset || !(isPlaceableType(asset.type) || laid)) return; startDrag({ kind: 'asset', id: assetId }, asset.name); } diff --git a/src/editor/panels/HierarchyPanel.ts b/src/editor/panels/HierarchyPanel.ts index 7a3cb1e9..446ec8b2 100644 --- a/src/editor/panels/HierarchyPanel.ts +++ b/src/editor/panels/HierarchyPanel.ts @@ -93,7 +93,8 @@ export class HierarchyPanel extends Panel { */ private drop(draggedId: string, spot: DropSpot): void { const holder = this.holder(); - if (!holder) return; + // The items of a composition stand at its top level in the order of their tracks (#417). + if (!holder || holder.type === 'composition') return; const s = this.state.selection; const selected = s?.kind === 'object' && s.holderId === holder.id ? s.objectIds : []; const isOpen = (rowId: string) => this.expanded.has(rowId); @@ -283,10 +284,12 @@ export class HierarchyPanel extends Panel { return changed; } - private refresh(): void { + /** Lists the rows anew – the Composition tab asks whenever other items come alive (#417). */ + refresh(): void { const holder = this.holder(); this.shownHolderId = holder?.id ?? ''; - const kind = holder?.type === 'container' ? 'Container' : 'Scene'; + const kind = + holder?.type === 'container' ? 'Container' : holder?.type === 'composition' ? 'Composition' : 'Scene'; this.setTitle(holder ? `${kind}: ${holder.name}` : 'Scene objects'); // From the stored holder, not the resolved one: what an object follows depends on where it // lives, and in a resolved view container content looks stored. diff --git a/src/editor/state.ts b/src/editor/state.ts index bbc02c82..b17d077f 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -31,6 +31,7 @@ import type { PlaceableAssetType } from '../core/model/AssetTypes'; import type { VisibilityPart } from '../core/model/sceneVisibility'; import type { ValuePath } from '../core/properties/valuePath'; import { KeepRoutingCommand } from '../core/commands/routingCommands'; +import { FitMediaCommand } from '../core/commands/compositionCommands'; import { settleRouting } from '../core/music/projectRouting'; export type Selection = @@ -79,6 +80,11 @@ export type Selection = * empty when the track itself was clicked. The inspector shows the item picked last, else the track. */ | { kind: 'song'; assetId: string; trackId: string; itemIds: string[] } + /** + * A track of a composition (#417) and the animation clips picked on it or on others: `clipIds` is + * empty when the track itself was clicked. Its items are objects, picked as an object selection. + */ + | { kind: 'composition'; assetId: string; trackId: string; clipIds: string[] } | null; /** @@ -466,7 +472,7 @@ export class EditorState { */ execute(command: Command): boolean { return this.runEdit(() => { - this.commands.execute(new KeepRoutingCommand(command)); + this.commands.execute(new KeepRoutingCommand(new FitMediaCommand(command))); return true; }); } @@ -842,6 +848,17 @@ export class EditorState { else if (items.length !== s.itemIds.length) this.select({ ...s, itemIds: items }); } } + if (s.kind === 'composition') { + const asset = findAssetOf(this.project, s.assetId, 'composition'); + if (!asset) this.select(null); + else { + // The same as a song's: a clip that died leaves, a track that died hands over to the composition. + const clips = s.clipIds.filter((id) => asset.clips.some((clip) => clip.id === id)); + const trackAlive = asset.tracks.some((track) => track.id === s.trackId); + if (!trackAlive) this.select({ kind: 'asset', assetId: s.assetId }); + else if (clips.length !== s.clipIds.length) this.select({ ...s, clipIds: clips }); + } + } } /** The song the selection refers to (#334): the asset, or a track or items of it. */ @@ -851,6 +868,17 @@ export class EditorState { return findAssetOf(this.project, s.assetId, 'song')?.id ?? null; } + /** + * The composition the selection refers to (#417): the asset, a track or clips of it, or an item's + * object – so picking an item keeps the Composition tab on it. + */ + get selectedCompositionId(): string | null { + const s = this.selection; + if (s?.kind === 'object') return findAssetOf(this.project, s.holderId, 'composition')?.id ?? null; + if (s?.kind !== 'asset' && s?.kind !== 'composition') return null; + return findAssetOf(this.project, s.assetId, 'composition')?.id ?? null; + } + /** The texture asset the current selection refers to (the asset itself or one of its frames). */ get selectedTextureId(): string | null { const s = this.selection; @@ -1055,6 +1083,8 @@ export function sameSelection(a: Selection, b: Selection): boolean { return a.assetId === b.assetId && a.edgeId === b.edgeId && sameIds(a.edgeIds, b.edgeIds); if (a.kind === 'song' && b.kind === 'song') return a.assetId === b.assetId && a.trackId === b.trackId && sameIds(a.itemIds, b.itemIds); + if (a.kind === 'composition' && b.kind === 'composition') + return a.assetId === b.assetId && a.trackId === b.trackId && sameIds(a.clipIds, b.clipIds); return false; } diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts index 820aa98b..6b827905 100644 --- a/src/editor/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -331,7 +331,12 @@ export class SceneViewport extends FreeViewport { /** Where Ctrl+V puts objects: under the pointer while it is over this view, else nowhere in particular. */ pasteHere(): boolean { const holder = this.holder(); - return holder ? pasteObjects(this.state, holder.id, this.pointerWorld()) : false; + return holder && this.takesAssets() ? pasteObjects(this.state, holder.id, this.pointerWorld()) : false; + } + + /** Whether an asset dropped or an object pasted lands here; a composition's picture takes neither (#417). */ + protected takesAssets(): boolean { + return true; } /** The pointer in world units, or null when it is not over this view. */ @@ -351,7 +356,7 @@ export class SceneViewport extends FreeViewport { new Menu([ { label: 'Paste', - disabled: !canPasteObjects(this.state), + disabled: !this.takesAssets() || !canPasteObjects(this.state), onSelect: () => pasteObjects(this.state, holder.id, at), }, ]).open(e.global.x, e.global.y); @@ -446,7 +451,7 @@ export class SceneViewport extends FreeViewport { const holder = this.holder(); const asset = assetId ? findAsset(this.state.project, assetId) : undefined; const inside = local.x >= 0 && local.x <= this.w && local.y >= 0 && local.y <= this.h; - if (!asset || !holder || !inside) { + if (!asset || !holder || !inside || !this.takesAssets()) { this.setDropPreview(null); return; } diff --git a/tests/core/composition-edit.test.ts b/tests/core/composition-edit.test.ts new file mode 100644 index 00000000..630573ea --- /dev/null +++ b/tests/core/composition-edit.test.ts @@ -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 = {}, +) => ({ 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, 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); + }); +}); diff --git a/tools/e2e/69-composition-tab.mjs b/tools/e2e/69-composition-tab.mjs new file mode 100644 index 00000000..9ff8a536 --- /dev/null +++ b/tools/e2e/69-composition-tab.mjs @@ -0,0 +1,189 @@ +/** + * The Composition tab (#417). A sprite dragged in from the Assets panel becomes an item, 3 s long + * and picked, so the inspector shows its TIMING; a second one dropped onto the first lands right + * after it. A dragged end stops at the next item. An animation dragged in becomes a clip that moves + * the item it overlaps in the picture. The hierarchy lists what is alive at the playhead, Delete + * takes an item with its object, and Play runs the playhead to the end. + * + * Everything it adds carries a `ct69` id and goes again at the end. + */ +const CUT = 'ct69-cut'; +const BOX = 'ct69-box'; +const SLIDE = 'ct69-slide'; + +export async function compositionTab(t) { + const { check, sleep, json, evaluate, mutate, mouse, drag, press, showTab } = t; + /** Drags a row of the Assets panel to a point step by step: the drop only exists while the button is down. */ + const dragAsset = async (assetId, to) => { + // A press on the row of the drag before, this soon, would read as a double-click that opens it. + await sleep(450); + const from = await json(`__gm.assetRow(${JSON.stringify(assetId)})`); + await mouse('mouseMoved', from.x, from.y); + await mouse('mousePressed', from.x, from.y); + for (let i = 1; i <= 8; i++) { + await mouse( + 'mouseMoved', + from.x + ((to.x - from.x) * i) / 8, + from.y + ((to.y - from.y) * i) / 8, + 'left', + { + buttons: 1, + }, + ); + await sleep(16); + } + const drop = await json('__gm.composition.drop()'); + await mouse('mouseReleased', to.x, to.y); + await sleep(200); + return drop; + }; + const model = () => + json(`(() => { + const c = __gm.state.project.assets.find((a) => a.id === '${CUT}'); + return { objects: c.objects.map((o) => o.id), items: c.items, clips: c.clips }; + })()`); + + await press('Escape'); + await showTab('scene'); + // Ten frames a second keeps the numbers small: an item of 3 s is 30 frames, the slide 5. + await mutate(` + const p = __gm.state.project; + p.assets.push( + { id: '${CUT}', type: 'composition', name: 'Cut 69', props: { fps: 10, loop: false }, objects: [], groups: [], + tracks: [{ id: 'ct69-t1', name: 'Front' }, { id: 'ct69-t2', name: 'Back' }], items: [], clips: [] }, + { id: '${BOX}', type: 'sprite', name: 'Box 69', props: { width: 100, height: 100, color: 0xff0000, texture: '' } }, + { id: '${SLIDE}', type: 'animation', name: 'Slide 69', props: { fps: 10, playback: 'once' }, root: '${BOX}', + tracks: [{ id: 'ct69-row', target: '', property: 'x', keys: [{ frame: 0, value: 0 }, { frame: 5, value: 100 }] }] }, + );`); + await evaluate(`__gm.state.openAsset('${CUT}')`); + await sleep(300); + const opened = await json(`{ tab: __gm.tab(), current: __gm.composition.current() }`); + check( + 'opening a composition brings up its tab', + opened.tab === 'composition' && opened.current === CUT, + JSON.stringify(opened), + ); + + // 69a. A sprite dropped on the first track at frame 5 is an item there, 3 s long, picked. + const first = await dragAsset(BOX, await json(`__gm.composition.laneAt('ct69-t1', 5)`)); + let m = await model(); + const a = m.items[0]; + const picked = await json('__gm.state.selection'); + check( + 'a sprite dragged onto a track becomes an item where it is let go, 3 s long and picked', + first?.what === 'item' && + first.rejection === '' && + m.items.length === 1 && + a.start === 5 && + a.length === 30 && + a.fitMedia === true && + JSON.stringify(m.objects) === JSON.stringify([a.id]) && + picked?.kind === 'object' && + picked.holderId === CUT && + picked.objectId === a.id, + JSON.stringify({ first, m, picked }), + ); + const timing = await json(`__gm.section('TIMING')`); + check('the inspector shows the picked item’s TIMING', timing !== null, JSON.stringify(timing)); + + // 69b. A second one let go over the first lands right after it. + await dragAsset(BOX, await json(`__gm.composition.laneAt('ct69-t1', 20)`)); + m = await model(); + const b = m.items.find((i) => i.id !== a.id); + check( + 'one dropped onto an item lands right after it', + m.items.length === 2 && b?.start === 35 && b.length === 30, + JSON.stringify(m.items), + ); + + // 69c. The first item's end dragged past the second stops where the second begins. + const box = await json(`__gm.composition.box('${a.id}')`); + const far = await json(`__gm.composition.laneAt('ct69-t1', 60)`); + await drag(box.x + box.w - 2, box.y + box.h / 2, far.x, box.y + box.h / 2); + await sleep(150); + m = await model(); + const resized = m.items.find((i) => i.id === a.id); + check( + 'a dragged end stops where the next item begins, and the length is the item’s own then', + resized?.length === 30 && resized.fitMedia === undefined, + JSON.stringify(resized), + ); + + // 69d. The animation dropped at frame 5 is a clip one pass long that moves the item in the picture. + const clipDrop = await dragAsset(SLIDE, await json(`__gm.composition.laneAt('ct69-t1', 5)`)); + m = await model(); + const x = async (frame) => { + await evaluate(`__gm.composition.scrub(${frame})`); + await sleep(60); + return (await json('__gm.composition.shown()')).find((o) => o.id === a.id)?.x ?? null; + }; + const xs = [await x(5), await x(8), await x(20)]; + check( + 'an animation dragged onto a track becomes a clip, and moves the item it overlaps in the picture', + clipDrop?.what === 'clip' && + m.clips.length === 1 && + m.clips[0].start === 5 && + m.clips[0].animation === SLIDE && + xs[0] === 0 && + xs[1] === 60 && + xs[2] === 100, + JSON.stringify({ clipDrop, clips: m.clips, xs }), + ); + + // 69e. The hierarchy lists what is alive at the playhead – read off the rows it shows. + const rowsAt = async (frame) => { + await evaluate(`__gm.composition.scrub(${frame})`); + await sleep(80); + const shown = await json(`[${m.items.map((i) => `__gm.hierarchyRow('${i.id}')`).join(',')}]`); + return m.items.filter((_, n) => shown[n] !== null).map((i) => i.id); + }; + const rows = [await rowsAt(2), await rowsAt(10), await rowsAt(40)]; + check( + 'the hierarchy lists the items alive at the playhead', + rows[0].length === 0 && + JSON.stringify(rows[1]) === JSON.stringify([a.id]) && + JSON.stringify(rows[2]) === JSON.stringify([b.id]), + JSON.stringify(rows), + ); + + // 69f. Delete takes the picked item with its object; undo brings both back. + await evaluate(`__gm.state.select({ kind: 'object', holderId: '${CUT}', objectId: '${b.id}' })`); + await press('Delete'); + await sleep(150); + const gone = await model(); + await press('z', { ctrl: true }); + await sleep(150); + const back = await model(); + check( + 'Delete takes an item with its object, undo brings both back', + !gone.items.some((i) => i.id === b.id) && + !gone.objects.includes(b.id) && + back.items.some((i) => i.id === b.id) && + back.objects.includes(b.id), + JSON.stringify({ gone, back }), + ); + + // 69g. Play runs the playhead from the start to the end of the last item, and stops there. + await evaluate(`__gm.composition.scrub(0)`); + const playAt = await json('__gm.composition.playAt()'); + await mouse('mouseMoved', playAt.x, playAt.y); + await mouse('mousePressed', playAt.x, playAt.y); + await mouse('mouseReleased', playAt.x, playAt.y); + await sleep(500); + const running = await json('__gm.composition.view()'); + let ended = running; + for (let i = 0; i < 40 && ended.playing; i++) { + await sleep(150); + ended = await json('__gm.composition.view()'); + } + check( + 'Play runs the playhead to the end and stops there', + running.playing && running.playhead > 0 && !ended.playing && ended.playhead === 65, + JSON.stringify({ running, ended }), + ); + + await showTab('scene'); + await mutate(` + const p = __gm.state.project; + p.assets = p.assets.filter((a) => !a.id.startsWith('ct69-'));`); +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index 8e87ecc6..4e0cfccf 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -71,6 +71,7 @@ import { song } from './65-song.mjs'; import { video } from './66-video.mjs'; import { recording } from './67-recording.mjs'; import { composition } from './68-composition.mjs'; +import { compositionTab } from './69-composition-tab.mjs'; export const STEPS = [ hierarchy, @@ -141,4 +142,5 @@ export const STEPS = [ video, recording, composition, + compositionTab, ];