import { MakeProjectAssetCommand } from '../../core/commands/assetCommands'; import { removeFromComposition, SetCompositionCommand } from '../../core/commands/compositionCommands'; import type { CompositionAsset, CompositionItem } from '../../core/model/Asset'; import { clipLength, compositionFps, fittedLength, isCompositionSprite, itemVideo, } from '../../core/model/composition'; import { resizedItem } from '../../core/model/compositionEdit'; import { findAsset, findAssetOf, type Project } from '../../core/model/Project'; import type { PropertyDef } from '../../core/properties/PropertySchema'; import { Button, formatNumber, type MenuEntry } 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, negative = false): PropertyDef => ({ key, label, kind: 'number', default: 0, ...(negative ? {} : { 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; the timeline's menu adds, moves and deletes tracks. 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.'); 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 every 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) => CompositionItem) => { const current = compositionItemOf(host.state.project, holderId, objectId); if (!current) return; const items = current.comp.items.map((i) => (i.id === objectId ? edit(i) : 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, boolean?][] = [ [ START_ROW, 'Start', item.start / fps, (value) => write('Move item', (i) => ({ ...i, start: Math.max(0, frames(value)) })), ], [ LENGTH_ROW, 'Length', item.length / fps, (value) => write('Resize item', (i) => resizedItem(i, frames(value))), ], ]; if (video) rows.push([ OFFSET_ROW, 'Offset', item.offset, (value) => write('Set item offset', (i) => withOffset(i, Number(value))), true, ]); // An offset below 0 starts the item before its video. for (const [key, label, value, onChange, negative] of rows) { const row = createFieldRow(host, seconds(key, label, negative), 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 === fittedLength(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 === fittedLength(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 = fittedLength(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')); } /** * For an item wearing a sprite its composition keeps (#419): that sprite turned into a project asset, * listed and placeable anywhere. Greyed out for any other. */ export function makeProjectAssetEntry( host: Pick, holderId: string, objectId: string, ): MenuEntry { const { project } = host.state; const object = findAssetOf(project, holderId, 'composition')?.objects.find((o) => o.id === objectId); const asset = object && findAsset(project, object.assetId); return { label: 'Make project asset', disabled: !asset || !isCompositionSprite(asset), onSelect: () => asset && host.state.execute(new MakeProjectAssetCommand(asset.id)), }; } function withOffset(item: CompositionItem, offset: number): CompositionItem { const { fitMedia: _, ...rest } = item; return { ...rest, offset: Number.isFinite(offset) ? offset : 0 }; }