// clip-ops.ts — composed timeline clip operations (feature-enhanced-video-editing). // // The verbs the iMovie-style editor speaks: each is a pure `applyX(source, …) // → { source', … }` built from the canvas-edit primitives + the Phase-0 ripple // engine, plus a disk wrapper (atomic write + cross-process lock). Lives in its // own module because ripple.ts already imports canvas-edit.ts — composing here // keeps the import graph acyclic. import MagicString from 'magic-string'; import { parseSync } from 'oxc-parser'; import { applyRemoveClip, assertCompSemantics, CanvasEditError, type ClipInfo, enumerateClips, escapeAttr, lineStartInfo, spanWithFraming, withLock, } from './canvas-edit.ts'; import { applyRippleAfterClip, collectConsts, resolveNum } from './ripple.ts'; /** Parse a ``'s duration out of its source span * (`timing={linearTiming({ durationInFrames: XF })}` — the attr the AST-level * numAttr can't see because it nests inside the timing call). */ export function transitionDurationFrames( source: string, transition: ClipInfo, consts?: Record ): number | null { const text = source.slice(transition.start, transition.end); const m = text.match(/durationInFrames:\s*([^,}\s)]+)/); if (!m) return null; return resolveNum(m[1] as string, consts ?? collectConsts(source)); } /** The transition applyRemoveClip will drop alongside a series clip (its NEXT * transition, else its PREVIOUS — the same preference order). */ function adjacentTransition(clips: ClipInfo[], idx: number): ClipInfo | null { const next = clips[idx + 1]; const prev = clips[idx - 1]; if (next?.kind === 'transition') return next; if (prev?.kind === 'transition') return prev; return null; } /** * Remove a clip WITH ripple (the iMovie Delete): a series beat's removal * shrinks the comp TOTAL by (clip duration − the dropped transition's overlap) * so the cut stays truthful; a standalone `` (overlay/audio band) is * removed WITHOUT ripple — overlays are absolutely positioned by design and * deleting one must not move the rest of the cut. Pure. */ export function applyRemoveClipRippled( canvasAbsPath: string, source: string, artboardId: string | undefined, stableId: string, expectedHash: string | undefined ): { source: string; rippled: boolean } { const { clips } = enumerateClips(canvasAbsPath, source, artboardId); const idx = clips.findIndex((c) => c.stableId === stableId); const clip = idx >= 0 ? (clips[idx] as ClipInfo) : null; let mid = source; let rippled = false; if ( clip && clip.kind === 'sequence' && (clip.tag.startsWith('TransitionSeries.') || clip.tag.startsWith('Series.')) && clip.durationInFrames != null ) { const t = adjacentTransition(clips, idx); const overlap = t ? (transitionDurationFrames(source, t) ?? 0) : 0; const delta = -(clip.durationInFrames - overlap); if (delta !== 0) { // TOTAL only — series siblings position themselves, and overlay-band // standalone clips deliberately stay put on a storyline delete. const r = applyRippleAfterClip(canvasAbsPath, mid, artboardId, stableId, delta, { shiftFroms: false, }); mid = r.source; rippled = r.totalEdited; } } // Ripple never touches the clip's own span text, so the caller's contentHash // still fingerprints it — applyRemoveClip re-verifies on the rippled source. const out = applyRemoveClip(canvasAbsPath, mid, artboardId, stableId, expectedHash); return { source: out.source, rippled }; } /** Remove-with-ripple on disk (atomic write + cross-process lock). */ export async function removeClipRippled( canvasAbsPath: string, artboardId: string | undefined, stableId: string, expectedHash: string | undefined ): Promise<{ source: string; rippled: boolean }> { return withLock(canvasAbsPath, async () => { const file = Bun.file(canvasAbsPath); if (!(await file.exists())) { throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, { canvas: canvasAbsPath, id: stableId, }); } const source = await file.text(); const next = applyRemoveClipRippled(canvasAbsPath, source, artboardId, stableId, expectedHash); if (next.source === source) return next; const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`; await Bun.write(tmp, next.source); const { rename } = await import('node:fs/promises'); await rename(tmp, canvasAbsPath); return next; }); } // --------------------------------------------------------------------------- // Task 6 — magnetic storyline reorder (a real MOVE, not the ▲▼ adjacent swap). /** Resolve a clip by stableId + optional fingerprint (local mirror of * canvas-edit's resolveClip, against an already-enumerated list). */ function mustResolve( canvasAbsPath: string, clips: ClipInfo[], stableId: string, expectedHash: string | undefined ): { clip: ClipInfo; idx: number } { const idx = clips.findIndex((c) => c.stableId === stableId); if (idx < 0) { throw new CanvasEditError(`clip "${stableId}" not found`, { canvas: canvasAbsPath, id: stableId, }); } const clip = clips[idx] as ClipInfo; if (expectedHash != null && expectedHash !== clip.contentHash) { throw new CanvasEditError( `clip "${stableId}" changed since it was read (concurrent edit); reload and retry`, { canvas: canvasAbsPath, id: stableId } ); } return { clip, idx }; } const isSeriesSeq = (c: ClipInfo) => c.kind === 'sequence' && (c.tag.startsWith('TransitionSeries.') || c.tag.startsWith('Series.')); /** * Move a series beat before/after another beat — the drag-to-reorder commit. * Cuts the beat plus ONE companion transition (its preceding one when it has * one, else the following) and reinserts the pair at the target so the * S/T/S/T alternation survives any distance: inserting BEFORE a beat emits * `B T`, inserting AFTER emits `T B`. Pure; semantic-gated. */ export function applySeriesMove( canvasAbsPath: string, source: string, artboardId: string | undefined, movedStableId: string, movedHash: string | undefined, refStableId: string, refHash: string | undefined, position: 'before' | 'after' ): { source: string; stableId: string } { if (movedStableId === refStableId) { throw new CanvasEditError('cannot move a clip relative to itself', { canvas: canvasAbsPath, id: movedStableId, }); } const { clips } = enumerateClips(canvasAbsPath, source, artboardId); const { clip: moved, idx } = mustResolve(canvasAbsPath, clips, movedStableId, movedHash); const { clip: ref } = mustResolve(canvasAbsPath, clips, refStableId, refHash); if (!isSeriesSeq(moved) || !isSeriesSeq(ref)) { throw new CanvasEditError('series move needs two series beats', { canvas: canvasAbsPath, id: movedStableId, }); } const prev = clips[idx - 1]; const next = clips[idx + 1]; const companion = prev?.kind === 'transition' ? prev : next?.kind === 'transition' ? next : null; const movedSpan = spanWithFraming(source, moved.start, moved.end); const companionSpan = companion ? spanWithFraming(source, companion.start, companion.end) : null; const beatText = source.slice(movedSpan[0], movedSpan[1]); const transitionText = companionSpan ? source.slice(companionSpan[0], companionSpan[1]) : ''; const s = new MagicString(source); s.remove(movedSpan[0], movedSpan[1]); if (companionSpan) s.remove(companionSpan[0], companionSpan[1]); const insertText = position === 'before' ? `${beatText}${transitionText}` : `${transitionText}${beatText}`; if (position === 'before') { const [rs] = spanWithFraming(source, ref.start, ref.end); s.appendLeft(rs, insertText); } else { s.appendRight(ref.end, insertText); } const out = s.toString(); assertParses(canvasAbsPath, out, movedStableId); assertCompSemantics(canvasAbsPath, out); const after = enumerateClips(canvasAbsPath, out, artboardId); const settled = after.clips.find((c) => c.contentHash === moved.contentHash); return { source: out, stableId: settled?.stableId ?? movedStableId }; } /** Series move on disk (atomic write + cross-process lock). */ export async function seriesMove( canvasAbsPath: string, artboardId: string | undefined, movedStableId: string, movedHash: string | undefined, refStableId: string, refHash: string | undefined, position: 'before' | 'after' ): Promise<{ source: string; stableId: string }> { return withLock(canvasAbsPath, async () => { const source = await Bun.file(canvasAbsPath).text(); const next = applySeriesMove( canvasAbsPath, source, artboardId, movedStableId, movedHash, refStableId, refHash, position ); if (next.source === source) return next; const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`; await Bun.write(tmp, next.source); const { rename } = await import('node:fs/promises'); await rename(tmp, canvasAbsPath); return next; }); } // --------------------------------------------------------------------------- // Task 6 — positional insert (the drop caret): storyline index-aware insert, // or a standalone overlay/audio clip that lands OUTSIDE the series. const INSERTABLE_MEDIA = new Set(['Video', 'Audio', 'Img', 'OffthreadVideo']); function assertContainedSrc(canvasAbsPath: string, src: string, id: string): void { // Allowlist, not blocklist (security review 2026-07-30): the src the export // renderer fetches must be a contained asset — a relative path or a `/`-rooted // path under the design root — never a scheme (`javascript:`/`data:`/`http(s):`) // and never a protocol-relative `//host/...` (which the blocklist let slip → // SSRF residue in the server-side render). `/\...` (a single leading slash) is // allowed; `//...` is not. const t = src.trim(); const contained = t.length > 0 && !/\.\./.test(t) && !/^[a-z][a-z0-9+.-]*:/i.test(t) && // any URI scheme !t.startsWith('//'); // protocol-relative if (!contained) { throw new CanvasEditError( 'media src must be a contained asset path (no ../, schemes, or //host)', { canvas: canvasAbsPath, id, } ); } } export interface InsertAtOptions { lane: 'storyline' | 'overlay' | 'audio'; /** storyline only: beat position 0..N (N = append at the end). */ index?: number; /** overlay/audio only: start frame. */ from?: number; durationInFrames: number; mediaTag?: string | null; src?: string | null; /** Task 22 — a prompt-carrying AI slate instead of media. Prompt is USER * TEXT → JSON.stringify into the JSX (DDR-150 P1). */ placeholder?: { prompt: string; kind: string } | null; } const PLACEHOLDER_KINDS = new Set(['veo', 'motion', 'image']); function placeholderChildText( canvasAbsPath: string, artboardId: string | undefined, p: { prompt: string; kind: string }, indent: string, dur: number ): string { const kind = PLACEHOLDER_KINDS.has(p.kind) ? p.kind : 'veo'; const prompt = String(p.prompt ?? '').slice(0, 2000); if (!prompt.trim()) { throw new CanvasEditError('an AI placeholder needs a prompt', { canvas: canvasAbsPath, id: artboardId ?? '', }); } // Paired-tag form: the prompt lives in a plain child (a stamped, // data-cd-editable leaf — double-click in the artboard edits it in place; // the modal-prompt flow is gone). JSON.stringify keeps DDR-150 P1 discipline. return ( `\n${indent} ` + `\n${indent} {${JSON.stringify(prompt)}}` + `\n${indent} \n${indent}` ); } /** * Insert a clip at a POSITION (unlike canvas-edit's append-only insert): * • `lane: 'storyline'` inserts a series beat at `index`, cloning an existing * transition to keep the alternation (B T at the head, T B elsewhere) and * bumping the comp TOTAL by (duration − transition overlap); * • `lane: 'overlay' | 'audio'` inserts a standalone `` as a * SIBLING after the series (never inside it), no TOTAL change. * Pure; returns the new clip's stableId. */ export function applyInsertClipAt( canvasAbsPath: string, source: string, artboardId: string | undefined, opts: InsertAtOptions ): { source: string; stableId: string | null } { const cc = enumerateClips(canvasAbsPath, source, artboardId); if (!cc.compName) { // A `kind="video"` artboard without a comp is the user saying "this is a // video" — upgrade it in place and retry, instead of refusing. if (opts.lane === 'storyline') { const ensured = applyEnsureVideoComp(canvasAbsPath, source, artboardId); if (ensured) { return applyInsertClipAt(canvasAbsPath, ensured.source, ensured.artboardId, opts); } } throw new CanvasEditError( 'no video-comp for this artboard — set an artboard’s Kind to "Video" in the Inspector and drop again, or use ⌘K → New video…', { canvas: canvasAbsPath, id: artboardId ?? '' } ); } const dur = Math.max(1, Math.round(opts.durationInFrames)); const srcTrim = (opts.src ?? '').trim(); const media: { tag: string; src: string; importName: string; specifier: string } | null = opts.mediaTag && INSERTABLE_MEDIA.has(opts.mediaTag) && srcTrim ? { tag: opts.mediaTag, src: srcTrim, importName: opts.mediaTag, specifier: 'remotion' } : null; if (media) { assertContainedSrc(canvasAbsPath, media.src, artboardId ?? ''); // This used to rewrite