/** * The clips of a sound (#357) or a video (#402): stretches of its file with a name, as frames are * of a texture. */ import { newId } from '../ids'; import type { SoundClip } from '../model/Asset'; import type { PropertyBag, PropertyValue } from '../model/Properties'; import type { Project } from '../model/Project'; import { type ClipOwner, clipsOf, hasClips, nextClipName, uniqueClipName } from '../model/sound'; import type { PropertyDef } from '../properties/PropertySchema'; import { CLIP_ROWS } from '../properties/sounds'; import { VIDEO_CLIP_ROWS } from '../properties/videos'; import { type Removed, removeFirst, restoreAt } from '../util/list'; import { type Command, CommandError } from './Command'; import { requireAsset } from './require'; /** A clip is the same shape on a sound and on a video; only its rows differ. */ type Clip = SoundClip; function requireSound(project: Project, assetId: string): ClipOwner { const asset = requireAsset(project, assetId); if (!hasClips(asset)) throw new CommandError(`${asset.name} has no clips`); return asset; } function requireClip(asset: ClipOwner, clipId: string): Clip { const clip = clipsOf(asset).find((c) => c.id === clipId); if (!clip) throw new CommandError(`Clip ${clipId} not found in ${asset.name}`); return clip; } function clipRowsOf(asset: ClipOwner): readonly PropertyDef[] { return asset.type === 'video' ? VIDEO_CLIP_ROWS : CLIP_ROWS; } /** What a clip may hold: a row of its asset's clip rows, a finite number at least 0, a curve it offers. */ function heldClipValue(asset: ClipOwner, key: string, value: PropertyValue): PropertyValue { const def = clipRowsOf(asset).find((row) => row.key === key); if (!def) throw new CommandError(`A clip has no "${key}"`); if (def.kind === 'number' && (typeof value !== 'number' || !Number.isFinite(value) || value < 0)) throw new CommandError(`${def.label} needs a number of at least 0`); if (def.kind === 'boolean' && typeof value !== 'boolean') throw new CommandError(`${def.label} is on or off`); if (def.kind === 'enum' && !def.options.some((option) => option.value === value)) throw new CommandError(`${def.label} has no ${JSON.stringify(value)}`); return value; } /** `clipId` is known before execution, so the caller can select the new clip right after executing. */ export class AddClipCommand implements Command { readonly label = 'Add clip'; readonly clipId = newId(); constructor( private readonly assetId: string, private readonly props: PropertyBag, ) {} execute(project: Project): void { const asset = requireSound(project, this.assetId); const props: PropertyBag = {}; for (const [key, value] of Object.entries(this.props)) props[key] = heldClipValue(asset, key, value); // Named per execution: a redo has to fit the names that exist when it runs. asset.clips = [...clipsOf(asset), { id: this.clipId, name: nextClipName(asset), props }]; } undo(project: Project): void { const asset = requireSound(project, this.assetId); asset.clips = clipsOf(asset).filter((c) => c.id !== this.clipId); } } /** * Some of a clip's values in one step – a drag sets its start and its end together. The values to * revert to are read at execution time. */ export class SetClipPropsCommand implements Command { private previous: [string, PropertyValue | undefined][] = []; constructor( private readonly assetId: string, private readonly clipId: string, private readonly values: PropertyBag, readonly label = 'Set clip', ) {} execute(project: Project): void { const asset = requireSound(project, this.assetId); const clip = requireClip(asset, this.clipId); const held = Object.entries(this.values).map( ([key, value]) => [key, heldClipValue(asset, key, value)] as const, ); this.previous = held.map(([key]) => [key, clip.props[key]]); for (const [key, value] of held) clip.props[key] = value; } undo(project: Project): void { const clip = requireClip(requireSound(project, this.assetId), this.clipId); for (const [key, value] of this.previous) if (value === undefined) delete clip.props[key]; else clip.props[key] = value; } } export class RenameClipCommand implements Command { readonly label = 'Rename clip'; private previous = ''; constructor( private readonly assetId: string, private readonly clipId: string, private readonly name: string, ) {} execute(project: Project): void { const asset = requireSound(project, this.assetId); const clip = requireClip(asset, this.clipId); const name = this.name.trim(); if (!name) throw new CommandError('A clip needs a name'); this.previous = clip.name; clip.name = uniqueClipName(asset, name, this.clipId); } undo(project: Project): void { requireClip(requireSound(project, this.assetId), this.clipId).name = this.previous; } } /** * Undo puts it back where it was. What plays it is left pointing at it, as a sprite is at a deleted * frame: the reference reads as missing and comes back by itself on undo. */ export class RemoveClipCommand implements Command { readonly label = 'Delete clip'; private removed?: Removed; constructor( private readonly assetId: string, private readonly clipId: string, ) {} execute(project: Project): void { const asset = requireSound(project, this.assetId); const clips = [...clipsOf(asset)]; const removed = removeFirst(clips, (c) => c.id === this.clipId); if (!removed) throw new CommandError(`Clip ${this.clipId} not found in ${asset.name}`); this.removed = removed; asset.clips = clips; } undo(project: Project): void { if (!this.removed) return; const asset = requireSound(project, this.assetId); const clips = [...clipsOf(asset)]; restoreAt(clips, [this.removed]); asset.clips = clips; } }