diff --git a/README.md b/README.md index 65f5489..e2a8b08 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ The open-source, local-first AI video editor — chat, CLI, or browser timeline. License: MIT Node 24+ npm - Tests: 356 passing + Tests: 539 passing Telemetry: none

@@ -143,14 +143,14 @@ Legend: ✅ great fit · ⚠️ works but not the best · ❌ doesn't fit.
-Architecture — three surfaces, one registry, one append-only op log +Architecture — three surfaces, one composition document, one append-only op log ``` -Claude Code → skill triggers → npx -y @makemyclip/editor +Claude Code → skill triggers → npx -y @makemyclip/editor │ -Browser UI → /api/tools/:name → TOOL_REGISTRY (19 tools) +Browser UI → /api/tools/:name · /api/timeline/verbs → registry + verb layer │ -Chat panel → /api/chat → AI SDK + Anthropic + tool dispatch +Chat panel → /api/chat → AI SDK + Anthropic + timeline verbs │ ▼ Tool handlers (TypeScript) @@ -158,11 +158,27 @@ Chat panel → /api/chat → AI SDK + Anthropic + tool dispatch ▼ FFmpeg subprocess (args as array, no shell) │ - ▼ - session.json (append-only op log) + ┌───────────────────────┴───────────────────────┐ + ▼ ▼ + Composition document session.json + (non-destructive timeline, (append-only op log + op-log undo/redo) of every tool call) +``` + +Two layers of state, by design. The **composition document** is the source of truth for assembled edits: a non-destructive, multi-track timeline that `clip timeline`, the browser UI, and the chat agent all mutate through one op-aware path, with a coupled op log that powers undo/redo. **`session.json`** is an append-only op log of every tool invocation — `{ id, tool, args, result, timestamp }` — written through the same `appendOp()` path by the single-file tools (`clip trim`, `/api/tools/:name`, …); it is the audit trail and recovery layer. Both layers are written through one serialized path, so any combination of human + agent edits stays consistent. + +**Build a composition end-to-end** (CLI shown; the browser UI and chat agent drive the same document): + +```bash +clip timeline new # start an empty composition +clip timeline add-media intro.mp4 # ingest + append a clip +clip timeline add-media demo.mp4 +clip timeline transition fade 0.5 +clip timeline show # inspect tracks, clips, timings +clip timeline export final.mp4 # compile the document to a render ``` -The session is the source of truth: every editing operation appends one entry with `{ id, tool, args, result, timestamp }`. The CLI, browser UI, and chat panel all write through the same `appendOp()` path, so any combination of human + agent edits stays consistent. +Every edit is undoable (`clip timeline undo` / `redo`, `clip timeline log`), and `clip timeline at ` / `frame ` give an agent read-only eyes on the document. Run `clip timeline --help` for the full subcommand list. - **Language:** TypeScript (Node 24+) and React 19 (browser UI) - **Timeline schema:** [Zod](https://zod.dev/) (shared with the [MakeMyClip.com](https://makemyclip.com) web app) @@ -193,7 +209,7 @@ Only the chat panel inside `clip ui` needs `ANTHROPIC_API_KEY`. The CLI, the bro ### Capabilities & limits **Is it production-ready?** -This release is feature-complete for local editing — 19 tools, 356 tests passing, browser UI shipped, chat panel shipped. The API surface is still pre-1.0 (tool schemas may change in minor ways before 1.0). Use it for real work; pin a version in CI. +This release is feature-complete for local editing — 19 tools, 539 tests passing, browser UI shipped, chat panel shipped. The API surface is still pre-1.0 (tool schemas may change in minor ways before 1.0). Use it for real work; pin a version in CI. **What's the maximum video size or duration?** No hardcoded limit. FFmpeg streams the file rather than loading it into memory, and the browser UI streams uploads to disk — multi-GB recordings work fine. This release isn't tuned for projects with hundreds of clips or runtime above 30 minutes; for those, drive the CLI directly. diff --git a/SKILL.md b/SKILL.md index 8f45cb4..d269fa7 100644 --- a/SKILL.md +++ b/SKILL.md @@ -5,7 +5,6 @@ license: MIT compatibility: Requires Node 24+. FFmpeg is auto-downloaded via ffmpeg-static on first use; override with MAKEMYCLIP_FFMPEG_PATH. metadata: author: makemyclip - version: "0.0.1" homepage: https://github.com/MakeMyClip/editor runtime: node install: npx skills add MakeMyClip/editor @@ -299,6 +298,21 @@ Two-pass `vidstab`: pass 1 (`vidstabdetect`) analyzes motion and writes a transf Requires `vidstab`-enabled ffmpeg. The bundled `ffmpeg-static` includes it; the tool fails with a clear error otherwise. +### Build a multi-clip composition — the timeline (implemented) + +The single-file tools above each produce a new output file. To assemble several clips into one edit — with non-destructive trims, transitions, and **undo/redo** — drive the **timeline**: a composition document that is the source of truth for the assembled edit. The CLI, the browser UI, and this skill all mutate the same document. + +```bash +npx -y @makemyclip/editor timeline new # start an empty composition +npx -y @makemyclip/editor timeline add-media intro.mp4 # ingest + append a clip +npx -y @makemyclip/editor timeline add-media demo.mp4 +npx -y @makemyclip/editor timeline transition fade 0.5 +npx -y @makemyclip/editor timeline show # read tracks, clips, timings +npx -y @makemyclip/editor timeline export final.mp4 # compile the document to a render +``` + +Prefer the timeline over chaining single-file tools whenever the user is *assembling* a video (multiple clips, transitions, a final render): edits are non-destructive and reversible (`timeline undo` / `redo`, `timeline log`), and `timeline at ` / `timeline frame ` give you read-only eyes on the current document before you change it. Run `npx -y @makemyclip/editor timeline --help` for the full subcommand list. + ### Open the local UI (implemented) ```bash diff --git a/llms.txt b/llms.txt index 93a9864..0b3fe58 100644 --- a/llms.txt +++ b/llms.txt @@ -9,7 +9,7 @@ - **Backend:** FFmpeg (bundled via `ffmpeg-static`) - **Surfaces:** Claude Code skill · `clip` CLI · browser UI at `127.0.0.1:5573` - **AI integration:** Anthropic via Vercel AI SDK (optional; rest of editor works without API key) -- **Tests:** 356 passing +- **Tests:** 539 passing - **License:** MIT (code) + GPL (bundled FFmpeg binary, subprocess-isolated) ## How to install diff --git a/src/cli-timeline.ts b/src/cli-timeline.ts index 8e90bb7..5228de6 100644 --- a/src/cli-timeline.ts +++ b/src/cli-timeline.ts @@ -1,17 +1,28 @@ +import { resolve } from 'node:path'; import { appendOp } from './session/store.js'; -import { compileTimeline } from './timeline/compile.js'; +import { buildFrameAtPlan, CompileError, compileTimeline } from './timeline/compile.js'; import { + type Clip, type Composition, + clipDuration, clipEndSec, + clipsAtTime, compositionDuration, emptyComposition, makeClipId, } from './timeline/composition.js'; -import { mutateComposition, readComposition, resetComposition } from './timeline/document-store.js'; +import { + mutateComposition, + readComposition, + readDocOpLog, + redoDocOp, + resetComposition, + undoLastDocOp, +} from './timeline/document-store.js'; import { buildMediaMap } from './timeline/media-registry.js'; -import type { CompositionOp } from './timeline/ops.js'; -import { colorClip, mediaClip, textClip, videoTrack } from './timeline/ops.js'; +import { colorClip, mediaClip, textClip } from './timeline/ops.js'; import { runPlan } from './timeline/run-plan.js'; +import { DEFAULT_VERB_TRACK as DEFAULT_TRACK, ensureTrack, trackEnd } from './timeline/verbs.js'; import { ingest } from './tools/ingest.js'; import { getWorkspace, newOutputPath, resolveInput } from './workspace.js'; @@ -27,6 +38,11 @@ const TIMELINE_HELP = `clip timeline — build and export a non-destructive comp clip timeline split clip timeline remove clip timeline show + clip timeline at + clip timeline frame [] + clip timeline undo + clip timeline redo + clip timeline log clip timeline export [] `; @@ -61,20 +77,35 @@ function num(value: string | undefined, fallback: number): number { return n; } -const DEFAULT_TRACK = 'v0'; - -/** Ops to create the named video track if it doesn't exist yet. */ -function ensureTrack(comp: Composition, trackId: string): CompositionOp[] { - return comp.tracks.some((t) => t.id === trackId) - ? [] - : [{ op: 'addTrack', track: videoTrack({ id: trackId }) }]; +/** A short, human-readable label for a clip (for `show` / `at`). */ +function clipLabel(clip: Clip): string { + switch (clip.kind) { + case 'media': + return clip.mediaId; + case 'text': + return clip.text.length > 30 ? `${clip.text.slice(0, 30)}…` : clip.text; + case 'color': + return clip.color; + } } -/** End time of the last clip on a track (0 if empty/missing) — the append point. */ -function trackEnd(comp: Composition, trackId: string): number { - const track = comp.tracks.find((t) => t.id === trackId); - if (!track) return 0; - return track.clips.reduce((end, c) => Math.max(end, clipEndSec(c)), 0); +/** Dry-run the export compiler (pure — no FFmpeg runs) to report whether the + * document can export and, if not, the first blocker. */ +function checkExportable( + comp: Composition, + media: Awaited>, +): { exportable: boolean; blockers: string[] } { + try { + compileTimeline(comp, { + media, + dir: getWorkspace(), + output: resolve(getWorkspace(), '.probe.mp4'), + }); + return { exportable: true, blockers: [] }; + } catch (err) { + if (err instanceof CompileError) return { exportable: false, blockers: [err.message] }; + throw err; + } } export async function runTimeline(args: string[]): Promise { @@ -232,11 +263,102 @@ export async function runTimeline(args: string[]): Promise { case 'show': { const comp = await readComposition(); + const { exportable, blockers } = checkExportable(comp, await buildMediaMap()); out({ + rev: comp.rev, durationSec: compositionDuration(comp), canvas: { width: comp.width, height: comp.height, fps: comp.fps }, - tracks: comp.tracks.map((t) => ({ id: t.id, kind: t.kind, clips: t.clips.length })), - composition: comp, + exportable, + blockers, + tracks: comp.tracks.map((t) => ({ + id: t.id, + kind: t.kind, + muted: t.muted, + clips: t.clips.map((c) => ({ + id: c.id, + kind: c.kind, + startSec: c.startSec, + endSec: clipEndSec(c), + durationSec: clipDuration(c), + label: clipLabel(c), + })), + transitions: t.transitions.map((tr) => ({ + afterClipId: tr.afterClipId, + kind: tr.kind, + durationSec: tr.durationSec, + })), + })), + }); + return; + } + + case 'at': { + const [at] = positional; + if (!at) throw new Error('Usage: clip timeline at '); + const atSec = num(at, 0); + const comp = await readComposition(); + out({ + atSec, + clips: clipsAtTime(comp, atSec).map((h) => ({ + track: h.track.id, + clipId: h.clip.id, + kind: h.clip.kind, + localOffsetSec: h.localOffsetSec, + label: clipLabel(h.clip), + })), + }); + return; + } + + case 'frame': { + const [at, output] = positional; + if (!at) throw new Error('Usage: clip timeline frame []'); + const atSec = num(at, 0); + const comp = await readComposition(); + const media = await buildMediaMap(); + const finalOutput = output ? resolveInput(output) : newOutputPath('timeline-frame', 'jpg'); + const plan = buildFrameAtPlan( + comp, + { media, dir: getWorkspace(), output: finalOutput }, + atSec, + ); + const result = await runPlan(plan); + out({ frame: result.output, atSec }); + return; + } + + case 'undo': { + const { undone, doc, label } = await undoLastDocOp(); + out( + undone + ? { undone: true, label, rev: doc.rev } + : { undone: false, message: 'Nothing to undo.' }, + ); + return; + } + + case 'redo': { + const { redone, doc, label } = await redoDocOp(); + out( + redone + ? { redone: true, label, rev: doc.rev } + : { redone: false, message: 'Nothing to redo.' }, + ); + return; + } + + case 'log': { + const log = await readDocOpLog(); + out({ + rev: log.rev, + cursor: log.cursor, + canUndo: log.cursor > 0, + canRedo: log.cursor < log.entries.length, + entries: log.entries.map((e, i) => ({ + id: e.id, + label: e.label, + state: i < log.cursor ? 'applied' : 'undone', + })), }); return; } diff --git a/src/cli.ts b/src/cli.ts index 1934f36..03c98a4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -60,6 +60,11 @@ Specialty: clip stabilize [] [] [] [] Two-pass vidstab. Defaults: shakiness=5, smoothing=10, accuracy=9, zoom=5. +Timeline (non-destructive composition — the source of truth for assembled edits): + clip timeline Build and export a multi-clip composition with + undo/redo. new → add-media → … → export. + clip timeline --help List the timeline subcommands. + UI: clip ui Start the local browser UI on http://127.0.0.1:5573. Renders the session log; click an op to play its output. diff --git a/src/ffmpeg/args/transition.ts b/src/ffmpeg/args/transition.ts index 4231d46..dc4b737 100644 --- a/src/ffmpeg/args/transition.ts +++ b/src/ffmpeg/args/transition.ts @@ -1,7 +1,7 @@ /** - * Subset of ffmpeg's `xfade` transition kinds. Chosen to match the common - * iMovie / CapCut palette without overwhelming the agent with 40+ obscure - * options. Each one maps 1:1 to an `xfade` `transition=` value. + * Subset of ffmpeg's `xfade` transition kinds. Chosen to cover the common + * consumer-editor transition palette without overwhelming the agent with 40+ + * obscure options. Each one maps 1:1 to an `xfade` `transition=` value. */ export type TransitionKind = | 'fade' diff --git a/src/index.ts b/src/index.ts index 2b793eb..b2be125 100644 --- a/src/index.ts +++ b/src/index.ts @@ -79,6 +79,14 @@ export { SessionSchema, } from './session/types.js'; export { + createRevisionedStore, + RevisionConflictError, + type Revisioned, + type RevisionedStore, + type RevisionedStoreConfig, +} from './storage/revisioned-store.js'; +export { + buildFrameAtPlan, type CompileContext, CompileError, compileTimeline, @@ -99,6 +107,7 @@ export { CompositionSchema, clipDuration, clipEndSec, + clipsAtTime, compositionDuration, type Effect, EffectSchema, @@ -124,23 +133,35 @@ export { TransitionSchema, } from './timeline/composition.js'; export { + applyVerbs, + CompositionConflictError, CompositionCorruptError, + compositionOpsPath, compositionPath, mutateComposition, + overwriteComposition, readComposition, + readDocOpLog, + redoDocOp, resetComposition, + undoLastDocOp, writeComposition, + writeCompositionIfUnchanged, } from './timeline/document-store.js'; export { buildMediaMap } from './timeline/media-registry.js'; export { applyOp, applyOps, + applyOpsTracked, audioTrack, type CompositionOp, CompositionOpError, type CompositionOpKind, + CompositionOpSchema, colorClip, + invertOp, mediaClip, + type TrackedApply, textClip, videoTrack, } from './timeline/ops.js'; @@ -156,6 +177,15 @@ export { TimecodeSchema, VideoStreamSchema, } from './timeline/schema.js'; +export { + type CompositionVerb, + type CompositionVerbKind, + CompositionVerbSchema, + DEFAULT_VERB_TRACK, + lowerVerb, + lowerVerbs, + type VerbContext, +} from './timeline/verbs.js'; export { AddAudioInput, type AddAudioInputType, diff --git a/src/session/store.ts b/src/session/store.ts index 1c13ab8..fef746c 100644 --- a/src/session/store.ts +++ b/src/session/store.ts @@ -1,6 +1,7 @@ import { randomBytes } from 'node:crypto'; import { type FileHandle, mkdir, open, readFile, rename, unlink } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; +import { createRevisionedStore } from '../storage/revisioned-store.js'; import { ensureWorkspace, getWorkspace } from '../workspace.js'; import { type Session, type SessionEntry, SessionSchema } from './types.js'; @@ -70,24 +71,23 @@ function freshEmpty(): Session { return { version: 1, rev: 0, entries: [] }; } -// ─── In-process single-writer serialization ────────────────────────────────── +// ─── Shared revisioned-store core (in-process serialization + CAS) ─────────── // -// Every mutation runs through this promise chain, so two concurrent callers in -// the SAME process (the embedded chat agent and a UI request both calling -// `appendOp`) can never interleave their read-modify-write and lose an entry. -// Reads are intentionally NOT serialized: atomic writes mean a reader always -// sees a complete file, so reads stay lock-free. -let writeChain: Promise = Promise.resolve(); - -function runExclusive(fn: () => Promise): Promise { - const run = writeChain.then(fn, fn); - // Keep the chain alive regardless of whether `fn` resolved or rejected. - writeChain = run.then( - () => undefined, - () => undefined, - ); - return run; -} +// The session log and the CompositionDoc share this machinery (see +// `storage/revisioned-store.ts`) so the two stores can't drift. Every mutation +// runs through a serialized chain, so two concurrent callers in the SAME process +// (the embedded chat agent and a UI request both calling `appendOp`) can never +// interleave their read-modify-write and lose an entry. Reads are intentionally +// NOT serialized: atomic writes mean a reader always sees a complete file. +const sessionStore = createRevisionedStore({ + read: readSession, + write: writeSession, + getRev: (session) => session.rev, + withRev: (session, rev) => ({ ...session, rev }), + readRevTolerant, + maxAttempts: MAX_MUTATE_ATTEMPTS, + onConflict: (expectedRev, actualRev) => new SessionConflictError(expectedRev, actualRev), +}); export async function readSession(): Promise { await ensureWorkspace(); @@ -211,19 +211,8 @@ async function fsyncDir(dir: string): Promise { * win undetected. A real cross-process lock is deferred until multi-process * editing is on the roadmap (the shipping surface is single-process). */ -export async function writeSessionIfUnchanged( - session: Session, - expectedRev: number, -): Promise { - return runExclusive(async () => { - const actualRev = await readDiskRev(); - if (actualRev !== expectedRev) { - throw new SessionConflictError(expectedRev, actualRev); - } - const next: Session = { ...session, rev: expectedRev + 1 }; - await writeSession(next); - return next; - }); +export function writeSessionIfUnchanged(session: Session, expectedRev: number): Promise { + return sessionStore.writeIfUnchanged(session, expectedRev); } /** @@ -247,37 +236,14 @@ export async function writeSessionIfUnchanged( export async function mutateSession( mutator: (session: Session) => T, ): Promise<{ result: T; session: Session }> { - return runExclusive(async () => { - for (let attempt = 1; attempt <= MAX_MUTATE_ATTEMPTS; attempt++) { - const current = await readSession(); - const baseRev = current.rev; - const working: Session = { version: 1, rev: baseRev, entries: [...current.entries] }; - - const result = mutator(working); - - // Re-read the on-disk rev immediately before committing. In-process the - // chain guarantees it's unchanged; a mismatch means another process wrote. - const diskRev = await readDiskRev(); - if (diskRev !== baseRev) { - if (attempt === MAX_MUTATE_ATTEMPTS) { - throw new SessionConflictError(baseRev, diskRev); - } - continue; - } - - working.rev = baseRev + 1; - await writeSession(working); - return { result, session: working }; - } - // Unreachable: the loop either returns or throws on the final attempt. - throw new SessionConflictError(-1, -1); + const { result, state } = await sessionStore.mutate((current) => { + // A private, mutable copy: the mutator appends to `entries` (or throws to + // abort) without aliasing the freshly-read state's backing array. + const working: Session = { version: 1, rev: current.rev, entries: [...current.entries] }; + const value = mutator(working); + return { next: working, result: value }; }); -} - -/** Read just the revision counter; a missing file reads as rev 0. Corrupt files - * still surface via `readSession`. */ -async function readDiskRev(): Promise { - return (await readSession()).rev; + return { result, session: state }; } /** @@ -288,13 +254,8 @@ async function readDiskRev(): Promise { * through `mutateSession` (whose first act is a strict `readSession`). `rev` * advances past the last good revision when one is readable, else restarts at 1. */ -export async function overwriteSession(entries: SessionEntry[]): Promise { - return runExclusive(async () => { - const baseRev = await readRevTolerant(); - const next: Session = { version: 1, rev: baseRev + 1, entries: [...entries] }; - await writeSession(next); - return next; - }); +export function overwriteSession(entries: SessionEntry[]): Promise { + return sessionStore.overwrite({ version: 1, rev: 0, entries: [...entries] }); } /** On-disk rev, treating a corrupt file as rev 0 (a deliberate overwrite is diff --git a/src/storage/revisioned-store.ts b/src/storage/revisioned-store.ts new file mode 100644 index 0000000..7bbcbd6 --- /dev/null +++ b/src/storage/revisioned-store.ts @@ -0,0 +1,154 @@ +/** + * Shared optimistic-concurrency core for the workspace's revisioned JSON + * documents (the session log and the CompositionDoc). Both files are a single + * source of truth that more than one writer can touch — the embedded chat agent + * and a `clip ui` request in the same process, or a CLI command run while the UI + * is open. Without serialization a lock-free read-modify-write loses updates (the + * original session bug); without a `rev` compare-and-swap a cross-process writer + * can still last-writer-win. + * + * This factory captures that machinery ONCE so the two stores can't drift: each + * store supplies how to `read`/`write` its state and read/set its `rev`, and gets + * back in-process serialization + a CAS retry loop + a CAS write + a + * parse-free overwrite. The previous design hand-rolled all of this inside the + * session store; the doc store had none of it. + * + * Each call returns an INDEPENDENT store (its own `runExclusive` chain), so + * session writes and composition writes serialize against themselves but not + * needlessly against each other — they are different files. + */ + +/** A monotonic revision counter on a persisted document. */ +export interface Revisioned { + rev: number; +} + +export interface RevisionedStoreConfig { + /** Strict read: returns the parsed state, throwing on a corrupt file. */ + read: () => Promise; + /** Atomic write of a committed state. */ + write: (state: S) => Promise; + getRev: (state: S) => number; + /** Return a copy of `state` with its `rev` set — never mutate the input. */ + withRev: (state: S, rev: number) => S; + /** + * Read just the rev, treating a corrupt file as rev 0 (used only by + * `overwrite`, which is about to discard whatever is there). Defaults to + * `getRev(read())`, i.e. corrupt files propagate — supply a tolerant reader + * when the store needs parse-free recovery. + */ + readRevTolerant?: () => Promise; + /** How many times `mutate` re-runs when a cross-process write is detected. */ + maxAttempts?: number; + /** Build the store's domain-specific conflict error (e.g. SessionConflictError). */ + onConflict?: (expectedRev: number, actualRev: number) => Error; +} + +export interface RevisionedStore { + /** Serialize `fn` after every prior in-process write to this store. */ + runExclusive: (fn: () => Promise) => Promise; + /** + * Serialized, atomic read → derive-next → write. `mutator` receives the + * freshly-read state and returns the `next` state plus a `result`; it must be + * free of external side effects (a detected cross-process race re-runs it). + * A throw from `mutator` aborts immediately and propagates. + */ + mutate: (mutator: (state: S) => { next: S; result: T }) => Promise<{ result: T; state: S }>; + /** CAS: write `state` only if the on-disk rev still equals `expectedRev`, then + * bump to `expectedRev + 1`; throws the conflict error otherwise. */ + writeIfUnchanged: (state: S, expectedRev: number) => Promise; + /** Replace the document WITHOUT trusting the current file to parse, advancing + * rev past the last readable revision (or restarting at 1 from a corrupt one). */ + overwrite: (state: S) => Promise; +} + +/** Default conflict error when a store does not supply its own. */ +export class RevisionConflictError extends Error { + readonly expectedRev: number; + readonly actualRev: number; + constructor(expectedRev: number, actualRev: number) { + super( + `Write rejected: expected rev ${expectedRev} but on-disk rev is ${actualRev}. ` + + `Another writer committed first — re-read and reapply.`, + ); + this.name = 'RevisionConflictError'; + this.expectedRev = expectedRev; + this.actualRev = actualRev; + } +} + +export function createRevisionedStore(cfg: RevisionedStoreConfig): RevisionedStore { + // Floor at 1 so `mutate` always makes at least one read-modify-write pass — a + // misconfigured `maxAttempts: 0` would otherwise skip the loop body entirely + // and surface a meaningless (-1, -1) conflict instead of applying the mutation. + const maxAttempts = Math.max(1, cfg.maxAttempts ?? 8); + const conflict = cfg.onConflict ?? ((e, a) => new RevisionConflictError(e, a)); + + // Every mutation runs through this promise chain, so two concurrent callers in + // the SAME process can never interleave their read-modify-write. Reads are + // intentionally NOT serialized: atomic writes mean a reader always sees a + // complete file, so reads stay lock-free. + let writeChain: Promise = Promise.resolve(); + + function runExclusive(fn: () => Promise): Promise { + const run = writeChain.then(fn, fn); + // Keep the chain alive regardless of whether `fn` resolved or rejected. + writeChain = run.then( + () => undefined, + () => undefined, + ); + return run; + } + + async function diskRev(): Promise { + return cfg.getRev(await cfg.read()); + } + + async function mutate( + mutator: (state: S) => { next: S; result: T }, + ): Promise<{ result: T; state: S }> { + return runExclusive(async () => { + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const current = await cfg.read(); + const baseRev = cfg.getRev(current); + + const { next, result } = mutator(current); + + // Re-read the on-disk rev immediately before committing. In-process the + // chain guarantees it's unchanged; a mismatch means another process wrote. + const observed = await diskRev(); + if (observed !== baseRev) { + if (attempt === maxAttempts) throw conflict(baseRev, observed); + continue; + } + + const committed = cfg.withRev(next, baseRev + 1); + await cfg.write(committed); + return { result, state: committed }; + } + // Unreachable: the loop either returns or throws on the final attempt. + throw conflict(-1, -1); + }); + } + + async function writeIfUnchanged(state: S, expectedRev: number): Promise { + return runExclusive(async () => { + const actualRev = await diskRev(); + if (actualRev !== expectedRev) throw conflict(expectedRev, actualRev); + const next = cfg.withRev(state, expectedRev + 1); + await cfg.write(next); + return next; + }); + } + + async function overwrite(state: S): Promise { + return runExclusive(async () => { + const baseRev = cfg.readRevTolerant ? await cfg.readRevTolerant() : await diskRev(); + const next = cfg.withRev(state, baseRev + 1); + await cfg.write(next); + return next; + }); + } + + return { runExclusive, mutate, writeIfUnchanged, overwrite }; +} diff --git a/src/timeline/compile.ts b/src/timeline/compile.ts index b7ea478..6392b16 100644 --- a/src/timeline/compile.ts +++ b/src/timeline/compile.ts @@ -1,4 +1,5 @@ import { resolve } from 'node:path'; +import { buildPreviewFrameArgs } from '../ffmpeg/args/preview.js'; import { buildTransitionArgs } from '../ffmpeg/args/transition.js'; import { quoteFilterArg } from '../ffmpeg/escape.js'; import { @@ -29,10 +30,11 @@ import type { MediaId } from './schema.js'; * per-clip transform, chromaKey (which needs a background layer), or timeline * gaps/overlaps — throw a clear `CompileError` rather than emitting a wrong graph. * - * Known interaction (v1): a per-clip fadeOut/fadeIn placed on the SAME boundary - * as a transition double-darkens, since the xfade already supplies the blend over - * that window. Prefer one or the other on a given cut; fixing the overlap is a - * follow-up. + * Transition boundaries own their blend: a per-clip fadeOut/fadeIn on the SAME + * cut as a transition is dropped during segment normalization (see the + * `FadeSuppression` plumbing), so the xfade's blend isn't stacked over a fade + * over the same window. Fades on outer boundaries (open from black, close to + * black) and on plain cuts are untouched. */ export interface MediaInfo { @@ -150,9 +152,22 @@ interface FilterChains { audio: string[]; } +/** Whether a clip's leading/trailing per-clip fade should be dropped because a + * transition already owns the blend over that boundary (else they double-darken). */ +interface FadeSuppression { + in: boolean; + out: boolean; +} + /** Translate a clip's effect stack into video/audio filter fragments, applied - * after the geometry/source fragments. Order: color → speed → fades. */ -function effectFilters(clip: Clip, outDur: number): FilterChains { + * after the geometry/source fragments. Order: color → speed → fades. A fade on a + * boundary that carries a transition is skipped (`suppress`) so the xfade's blend + * isn't stacked over a per-clip fade on the same window. */ +function effectFilters( + clip: Clip, + outDur: number, + suppress: FadeSuppression = { in: false, out: false }, +): FilterChains { const video: string[] = []; const audio: string[] = []; @@ -180,11 +195,13 @@ function effectFilters(clip: Clip, outDur: number): FilterChains { for (const e of clip.effects) { // Clamp the fade to the clip so a fade longer than the clip still reaches // full black/silence within the available window instead of ramping partway. - if (e.type === 'fadeIn') { + // Skip the fade entirely when a transition owns this boundary (the xfade + // already blends that window — a per-clip fade on top double-darkens it). + if (e.type === 'fadeIn' && !suppress.in) { const d = Math.min(e.durationSec, outDur); video.push(`fade=t=in:st=0:d=${d}`); audio.push(`afade=t=in:st=0:d=${d}`); - } else if (e.type === 'fadeOut') { + } else if (e.type === 'fadeOut' && !suppress.out) { const d = Math.min(e.durationSec, outDur); const st = Math.max(0, outDur - d); video.push(`fade=t=out:st=${st}:d=${d}`); @@ -230,12 +247,13 @@ function buildSegmentStep( clip: Clip, index: number, ctx: CompileContext, + fadeSuppress: FadeSuppression = { in: false, out: false }, ): FfmpegStep { assertCompilable(clip); const outDur = outputDuration(clip); const { width, height, fps, background } = comp; const out = segPath(ctx.dir, index, clip.id); - const { video: fx, audio: afx } = effectFilters(clip, outDur); + const { video: fx, audio: afx } = effectFilters(clip, outDur, fadeSuppress); const textFiles: StepSideFile[] = []; const inputArgs: string[] = []; @@ -399,14 +417,13 @@ function buildHardCutArgs(a: string, b: string, output: string): string[] { * sums of durations, so allow a hair of accumulated rounding. */ const ABUT_EPSILON = 1e-4; -export function compileTimeline(comp: Composition, ctx: CompileContext): FfmpegPlan { - const track = selectVideoTrack(comp); - const clips = [...track.clips].sort((a, b) => a.startSec - b.startSec); - const transitionAfter = new Map(track.transitions.map((t) => [t.afterClipId, t])); - - // v1 renders a track as an abutting sequence. Reject gaps/overlaps with a clear - // error rather than silently collapsing them — the document is the source of - // truth, so export must not diverge from the timeline it describes. +/** + * v1 renders a track as an abutting sequence. Reject gaps/overlaps with a clear + * error rather than silently collapsing them — the document is the source of + * truth, so neither export NOR the frame preview may diverge from the timeline it + * describes. `clips` must be pre-sorted by `startSec`. + */ +function assertAbutting(clips: Clip[]): void { for (let i = 1; i < clips.length; i++) { const prev = clips[i - 1]; const cur = clips[i]; @@ -417,18 +434,99 @@ export function compileTimeline(comp: Composition, ctx: CompileContext): FfmpegP const kind = delta > 0 ? 'gap' : 'overlap'; throw new CompileError( `Timeline ${kind} between "${prev.id}" (ends ${prevEnd.toFixed(3)}s) and "${cur.id}" ` + - `(starts ${cur.startSec}s): export needs clips to abut. ` + + `(starts ${cur.startSec}s): the timeline needs clips to abut. ` + `${kind === 'gap' ? 'Close the gap (or add a filler clip)' : 'Remove the overlap'} — ` + - `positioned gaps/overlaps are not supported in export yet.`, + `positioned gaps/overlaps are not supported yet.`, ); } } +} + +/** + * Render ONE frame at timeline time `atSec` — the agent's "eyes" on the doc. + * Goes through the REAL segment path (`buildSegmentStep`) so the preview can't + * diverge from what export would produce, then extracts a still from that + * normalized segment. Two steps: encode the active clip's segment, then grab the + * frame at the mapped offset. Runs the SAME abutting check as export, so it fails + * (rather than guessing a clip) on an unexportable doc; throws `CompileError` past + * the end, in a gap, or on an overlap, and inherits the v1 single-video-track / + * no-compositing guards. + * + * Known v1 limits (flag, don't fix here): a frame inside a transition window + * shows the underlying clip, not the xfade blend; `-ss` is keyframe-accurate + * (off by up to a GOP) — fine for a thumbnail; and a long clip re-encodes a whole + * segment to grab one frame. + */ +export function buildFrameAtPlan( + comp: Composition, + ctx: CompileContext, + atSec: number, +): FfmpegPlan { + if (atSec < 0) throw new CompileError(`Frame time ${atSec}s is before the timeline start.`); + const track = selectVideoTrack(comp); + const clips = [...track.clips].sort((a, b) => a.startSec - b.startSec); + // Fail the way export does on an unexportable doc, so the preview tells the + // truth instead of silently picking one of several overlapping clips. + assertAbutting(clips); + const index = clips.findIndex((c) => atSec >= c.startSec && atSec < clipEndSec(c)); + const clip = clips[index]; + if (!clip) { + const last = clips[clips.length - 1]; + const end = last ? clipEndSec(last) : 0; + throw new CompileError( + `No clip at ${atSec}s — the timeline runs to ${end.toFixed(3)}s and ${atSec}s is ` + + `${atSec >= end ? 'past the end' : 'in a gap'}.`, + ); + } + assertCompilable(clip); + + // Encode the clip's segment exactly as export would, then extract the frame. + const segStep = buildSegmentStep(comp, clip, index, ctx); + + // Map doc-local time to the post-speed SEGMENT timebase. The ratio is 1 unless + // the clip carries a speed effect, which shortens the segment relative to the + // document extent — so a frame still lands on the source content the document + // places at `atSec`. + const clipDur = clipDuration(clip); + const outDur = outputDuration(clip); + const localDoc = atSec - clip.startSec; + const segTime = clipDur > 0 ? (localDoc * outDur) / clipDur : 0; + const lastFrame = Math.max(0, outDur - 1 / comp.fps); + const clamped = Math.max(0, Math.min(segTime, lastFrame)); + + const frameStep: FfmpegStep = { + label: `frame:${clip.id}@${atSec}`, + args: buildPreviewFrameArgs({ input: segStep.output, output: ctx.output, atSec: clamped }), + output: ctx.output, + textFiles: [], + }; + + return { steps: [segStep, frameStep], output: ctx.output, durationSec: 0 }; +} + +export function compileTimeline(comp: Composition, ctx: CompileContext): FfmpegPlan { + const track = selectVideoTrack(comp); + const clips = [...track.clips].sort((a, b) => a.startSec - b.startSec); + const transitionAfter = new Map(track.transitions.map((t) => [t.afterClipId, t])); + + assertAbutting(clips); const steps: FfmpegStep[] = []; - // 1. Normalize every clip to a segment. + // 1. Normalize every clip to a segment. Drop a per-clip fade on a boundary that + // a transition already blends: fadeOut when a transition follows this clip, + // fadeIn when one precedes it (the preceding clip has a transition after it). + // The fold (step 2) only xfades a transition that has a FOLLOWING clip, so a + // transition keyed to the last clip (e.g. left dangling after the next clip was + // removed) blends nothing — keep that clip's fadeOut rather than silently + // dropping a fade-to-black to a hard cut. const segments = clips.map((clip, i) => { - const step = buildSegmentStep(comp, clip, i, ctx); + const prevClip = clips[i - 1]; + const fadeSuppress: FadeSuppression = { + in: prevClip ? transitionAfter.has(prevClip.id) : false, + out: i < clips.length - 1 && transitionAfter.has(clip.id), + }; + const step = buildSegmentStep(comp, clip, i, ctx, fadeSuppress); steps.push(step); return { clip, output: step.output, durationSec: outputDuration(clip) }; }); diff --git a/src/timeline/composition.ts b/src/timeline/composition.ts index deb3e8d..5d41752 100644 --- a/src/timeline/composition.ts +++ b/src/timeline/composition.ts @@ -176,6 +176,14 @@ export type Track = z.infer; export const CompositionSchema = z.object({ version: z.literal(COMPOSITION_VERSION), + /** + * Monotonic revision counter, bumped on every successful write — the + * compare-and-swap token for optimistic concurrency when the agent and the + * `clip ui` co-edit this document (mirrors `Session.rev`). Distinct from + * `version` (the format pin). Optional with a `0` default so documents written + * before this field existed — and hand-authored fixtures — still parse. + */ + rev: z.number().int().nonnegative().default(0), width: z.number().int().positive().default(1920), height: z.number().int().positive().default(1080), fps: z.number().positive().default(30), @@ -238,3 +246,22 @@ export function findClip( } return null; } + +/** Every clip live at timeline time `atSec`, across all tracks, with its offset + * into the clip. Half-open `[startSec, clipEndSec)` so a clip that ends exactly + * where the next begins hands `atSec` to the later clip (abutting clips don't + * both claim the boundary). Pure read — the "what is at T" query. */ +export function clipsAtTime( + comp: Composition, + atSec: number, +): Array<{ track: Track; clip: Clip; localOffsetSec: number }> { + const hits: Array<{ track: Track; clip: Clip; localOffsetSec: number }> = []; + for (const track of comp.tracks) { + for (const clip of track.clips) { + if (atSec >= clip.startSec && atSec < clipEndSec(clip)) { + hits.push({ track, clip, localOffsetSec: atSec - clip.startSec }); + } + } + } + return hits; +} diff --git a/src/timeline/doc-op-log.ts b/src/timeline/doc-op-log.ts new file mode 100644 index 0000000..17d329b --- /dev/null +++ b/src/timeline/doc-op-log.ts @@ -0,0 +1,114 @@ +import { z } from 'zod'; +import { type CompositionOp, CompositionOpSchema } from './ops.js'; + +/** + * The undo/redo history for a CompositionDoc — an append-only list of applied + * op batches plus a `cursor` that marks how many are currently in effect. + * Persisted next to the document in `composition-ops.json`, deliberately SEPARATE + * from `composition.json` so the render input stays a clean document. + * + * Model (a standard undo stack): + * - `entries[0..cursor-1]` are applied; `entries[cursor..]` are undone and + * available to redo. + * - undo: apply `entries[cursor-1].inverse`, cursor--. + * - redo: apply `entries[cursor].forward`, cursor++. + * - a fresh edit truncates the redo tail (`entries[cursor..]`) before appending. + * + * `rev` mirrors the document rev the log is consistent with — the coupling that + * lets `reconcile` detect (and conservatively discard) a log left inconsistent by + * a crash between the two files' writes. These functions are PURE: the I/O layer + * (document-store) reads/writes the file, mints entry ids, and applies the ops. + * + * Types are hand-written (`CompositionOp[]`, not the schema's inferred type) so + * callers apply entries directly; `DocOpLogSchema` validates the persisted form. + */ +export interface DocOpLogEntry { + id: string; + /** Human-readable summary of the batch (e.g. "addClip+addTransition"). */ + label: string; + /** The ops as originally applied — replayed on redo. */ + forward: CompositionOp[]; + /** The ops that undo this batch — replayed on undo. */ + inverse: CompositionOp[]; +} + +export interface DocOpLog { + version: 1; + rev: number; + cursor: number; + entries: DocOpLogEntry[]; +} + +const DocOpLogEntrySchema = z.object({ + id: z.string().min(1), + label: z.string(), + forward: z.array(CompositionOpSchema), + inverse: z.array(CompositionOpSchema), +}); + +export const DocOpLogSchema = z.object({ + version: z.literal(1), + rev: z.number().int().nonnegative().default(0), + cursor: z.number().int().nonnegative().default(0), + entries: z.array(DocOpLogEntrySchema).default([]), +}); + +export function emptyDocOpLog(rev = 0): DocOpLog { + return { version: 1, rev, cursor: 0, entries: [] }; +} + +/** Record a freshly-applied batch: drop any redo tail at the cursor, append the + * entry, and advance the cursor to the new end. */ +export function recordOps(log: DocOpLog, entry: DocOpLogEntry): DocOpLog { + const entries = log.entries.slice(0, log.cursor); + entries.push(entry); + return { ...log, entries, cursor: entries.length }; +} + +export function canUndo(log: DocOpLog): boolean { + return log.cursor > 0; +} + +export function canRedo(log: DocOpLog): boolean { + return log.cursor < log.entries.length; +} + +/** The entry an undo would reverse (apply its `inverse`) plus the log with the + * cursor moved back, or null if there is nothing to undo. */ +export function undo(log: DocOpLog): { entry: DocOpLogEntry; log: DocOpLog } | null { + if (!canUndo(log)) return null; + const entry = log.entries[log.cursor - 1]; + if (!entry) return null; + return { entry, log: { ...log, cursor: log.cursor - 1 } }; +} + +/** The entry a redo would re-apply (apply its `forward`) plus the log with the + * cursor moved forward, or null if there is nothing to redo. */ +export function redo(log: DocOpLog): { entry: DocOpLogEntry; log: DocOpLog } | null { + if (!canRedo(log)) return null; + const entry = log.entries[log.cursor]; + if (!entry) return null; + return { entry, log: { ...log, cursor: log.cursor + 1 } }; +} + +/** + * Bring the log into agreement with the authoritative document rev. If they + * match, the log is trusted as-is. If not, the only cause is a crash BETWEEN the + * two files' writes — the history can no longer be safely trusted (the last + * recorded change may or may not be reflected in the doc), so we conservatively + * DROP it, keeping the document intact. Losing undo history after a crash is an + * acceptable cost; replaying a stale inverse onto the wrong document is not. + */ +export function reconcile(log: DocOpLog, docRev: number): DocOpLog { + return log.rev === docRev ? log : emptyDocOpLog(docRev); +} + +/** Parse + validate a persisted log. Throws on a malformed file (the reader + * treats that the same as a desync: drop history, keep the doc). */ +export function parseDocOpLog(raw: unknown): DocOpLog { + return DocOpLogSchema.parse(raw) as unknown as DocOpLog; +} + +export function serializeDocOpLog(log: DocOpLog): string { + return `${JSON.stringify(log, null, 2)}\n`; +} diff --git a/src/timeline/document-store.ts b/src/timeline/document-store.ts index 3697d63..d3f71c1 100644 --- a/src/timeline/document-store.ts +++ b/src/timeline/document-store.ts @@ -1,16 +1,35 @@ +import { randomBytes } from 'node:crypto'; import { readFile } from 'node:fs/promises'; import { resolve } from 'node:path'; import { atomicWriteFile } from '../session/store.js'; +import { createRevisionedStore } from '../storage/revisioned-store.js'; import { ensureWorkspace, getWorkspace } from '../workspace.js'; import { type Composition, CompositionSchema, emptyComposition } from './composition.js'; -import { applyOps, type CompositionOp } from './ops.js'; +import { + type DocOpLog, + type DocOpLogEntry, + emptyDocOpLog, + parseDocOpLog, + reconcile, + recordOps, + redo as redoLog, + serializeDocOpLog, + undo as undoLog, +} from './doc-op-log.js'; +import { applyOps, applyOpsTracked, type CompositionOp } from './ops.js'; +import { type CompositionVerb, lowerVerbs, type VerbContext } from './verbs.js'; const COMPOSITION_FILE = 'composition.json'; +const COMPOSITION_OPS_FILE = 'composition-ops.json'; export function compositionPath(): string { return resolve(getWorkspace(), COMPOSITION_FILE); } +export function compositionOpsPath(): string { + return resolve(getWorkspace(), COMPOSITION_OPS_FILE); +} + /** Thrown when composition.json exists but cannot be parsed/validated — surfaced, * never silently reset (same posture as the session store). */ export class CompositionCorruptError extends Error { @@ -26,6 +45,26 @@ export class CompositionCorruptError extends Error { } } +/** + * Thrown by `writeCompositionIfUnchanged` when the on-disk `rev` no longer + * matches the revision the caller based its edit on — another writer committed + * in between. Re-read and reapply rather than clobber (mirrors + * `SessionConflictError`). + */ +export class CompositionConflictError extends Error { + readonly expectedRev: number; + readonly actualRev: number; + constructor(expectedRev: number, actualRev: number) { + super( + `Composition write rejected: expected rev ${expectedRev} but on-disk rev is ${actualRev}. ` + + `Another writer committed first — re-read and reapply.`, + ); + this.name = 'CompositionConflictError'; + this.expectedRev = expectedRev; + this.actualRev = actualRev; + } +} + export async function readComposition(): Promise { await ensureWorkspace(); let raw: string; @@ -46,21 +85,275 @@ export async function writeComposition(comp: Composition): Promise { await atomicWriteFile(compositionPath(), `${JSON.stringify(comp, null, 2)}\n`); } +/** On-disk rev, treating a corrupt file as rev 0 — a deliberate overwrite is + * about to discard it anyway. Non-corruption read errors still propagate. */ +async function readCompositionRevTolerant(): Promise { + try { + return (await readComposition()).rev; + } catch (err) { + if (err instanceof CompositionCorruptError) return 0; + throw err; + } +} + +// Serialization + compare-and-swap, shared with the session store (see +// `storage/revisioned-store.ts`) so co-editing the doc (the agent + `clip ui`) +// can't silently lose updates. +const docStore = createRevisionedStore({ + read: readComposition, + write: writeComposition, + getRev: (comp) => comp.rev, + withRev: (comp, rev) => ({ ...comp, rev }), + readRevTolerant: readCompositionRevTolerant, + onConflict: (expectedRev, actualRev) => new CompositionConflictError(expectedRev, actualRev), +}); + +// ─── op-log (undo/redo) ────────────────────────────────────────────────────── +// +// The undo/redo history lives in a SEPARATE file from composition.json. Each +// mutation writes the log FIRST, then the doc: a crash in between leaves the log +// "ahead", which `reconcile` safely discards on the next read. The reverse +// ordering could leave the doc ahead and replay a stale inverse onto it, so it is +// never used. doc.rev and log.rev advance together so `reconcile` can spot the +// crash window. + +const MAX_COMMIT_ATTEMPTS = 8; + +function makeDocOpId(): string { + return `dop_${randomBytes(4).toString('hex')}`; +} + +function labelForOps(ops: CompositionOp[]): string { + return ops.map((o) => o.op).join('+') || 'edit'; +} + +/** Read the raw op-log file. Missing OR corrupt → an empty log: undo history is a + * convenience, never worth failing a read for. The caller reconciles it against + * the authoritative document rev. */ +async function readDocOpLogFile(): Promise { + await ensureWorkspace(); + let raw: string; + try { + raw = await readFile(compositionOpsPath(), 'utf-8'); + } catch (err: unknown) { + if (err instanceof Error && 'code' in err && err.code === 'ENOENT') return emptyDocOpLog(0); + throw err; + } + try { + return parseDocOpLog(JSON.parse(raw)); + } catch { + return emptyDocOpLog(0); + } +} + +async function writeDocOpLog(log: DocOpLog): Promise { + await atomicWriteFile(compositionOpsPath(), serializeDocOpLog(log)); +} + +/** The op-log reconciled against the current document rev — the trustworthy view + * of undo/redo state (drops history left inconsistent by a crash). Both reads run + * inside the store's exclusive section so an interleaved IN-PROCESS commit can't + * make a consistent log momentarily read as empty (cross-process reads stay + * best-effort, as everywhere in this store). */ +export async function readDocOpLog(): Promise { + return docStore.runExclusive(async () => { + const docRev = (await readComposition()).rev; + return reconcile(await readDocOpLogFile(), docRev); + }); +} + +/** + * Serialized commit of a coupled doc + op-log change. `derive` receives the + * current doc and the reconciled log and returns the next doc + next log (+ a + * caller result), or null for a no-op (e.g. undo with empty history). Writes the + * log first, then the doc, both advanced to the next rev. In-process this is + * exact (the revisioned store serializes writes); across processes a detected + * mid-flight write re-derives and ultimately throws `CompositionConflictError`. + * Cross-process safety is best-effort, and the window here is WIDER than the + * single-file session store's: two atomic writes (log then doc) sit between the + * rev re-check and the doc landing, so a colliding cross-process writer can still + * last-writer-win. A real cross-process lock is deferred (single-process is the + * shipping surface). + */ +async function commitDocAndLog( + derive: ( + doc: Composition, + log: DocOpLog, + ) => { doc: Composition; log: DocOpLog; result: T } | null, +): Promise<{ doc: Composition; result: T } | null> { + return docStore.runExclusive(async () => { + for (let attempt = 1; attempt <= MAX_COMMIT_ATTEMPTS; attempt++) { + const current = await readComposition(); + const log = reconcile(await readDocOpLogFile(), current.rev); + const derived = derive(current, log); + if (derived === null) return null; + + const observed = (await readComposition()).rev; + if (observed !== current.rev) { + if (attempt === MAX_COMMIT_ATTEMPTS) { + throw new CompositionConflictError(current.rev, observed); + } + continue; + } + + const nextRev = current.rev + 1; + const committedDoc = { ...derived.doc, rev: nextRev }; + const committedLog = { ...derived.log, rev: nextRev }; + await writeDocOpLog(committedLog); + await writeComposition(committedDoc); + return { doc: committedDoc, result: derived.result }; + } + throw new CompositionConflictError(-1, -1); + }); +} + +/** + * Read → apply ops → write, returning the new composition, and record the batch + * (with its computed inverse) to the op-log so the edit is undoable. Serialized + + * rev compare-and-swap via the shared revisioned store, so the agent and the UI + * can co-edit without lost updates. An empty batch is a true no-op. + */ +export async function mutateComposition( + ops: CompositionOp[], + opts?: { expectedBaseRev?: number }, +): Promise { + if (ops.length === 0) return readComposition(); + const committed = await commitDocAndLog((current, log) => { + // Optimistic-concurrency guard for callers whose ops were lowered against a + // specific revision (e.g. `applyVerbs`, where a default append point is baked + // from a snapshot read): if the doc moved under us, reject so the caller can + // re-lower against the fresh state instead of committing stale positions. + if (opts?.expectedBaseRev !== undefined && current.rev !== opts.expectedBaseRev) { + throw new CompositionConflictError(opts.expectedBaseRev, current.rev); + } + const { doc, inverse } = applyOpsTracked(current, ops); + const entry: DocOpLogEntry = { + id: makeDocOpId(), + label: labelForOps(ops), + forward: ops, + inverse, + }; + return { doc, log: recordOps(log, entry), result: null }; + }); + return committed ? committed.doc : readComposition(); +} + /** - * Read → apply ops → write, returning the new composition. The CLI drives this - * sequentially (one process per command), so it needs no in-process lock; the - * single-writer concurrency the session store carries lands here when the UI and - * agent co-edit the doc (a later phase). Writes are already atomic. + * Lower a batch of editing VERBS to ops and apply them as ONE undoable edit — the + * op-aware mutation path the agent and `clip ui` share. Lowering (which ingests + * files and mints ids) runs outside the write lock; `mutateComposition` then + * validates, applies, and records the ops (so the edit lands in the undo stack). + * Returns the new document and the ops that were applied. */ -export async function mutateComposition(ops: CompositionOp[]): Promise { - const next = applyOps(await readComposition(), ops); - await writeComposition(next); - return next; +export async function applyVerbs( + verbs: CompositionVerb[], + ctx: VerbContext, +): Promise<{ doc: Composition; ops: CompositionOp[] }> { + // The default append point (`trackEnd`) is baked from the snapshot we lower + // against, so if another in-process writer commits between the read and the + // apply, re-lower against the fresh doc rather than land overlapping clips. A + // retry re-runs the impure lowering (it may re-`ingest`), which is acceptable + // on the rare conflict path. Retry to the same cap as the inner commit loop so + // a burst of N concurrent edits each re-lower and land instead of the (N-cap) + // tail silently losing to a `CompositionConflictError`. + for (let attempt = 1; attempt <= MAX_COMMIT_ATTEMPTS; attempt++) { + const current = await readComposition(); + const ops = await lowerVerbs(current, verbs, ctx); + try { + const doc = await mutateComposition(ops, { expectedBaseRev: current.rev }); + return { doc, ops }; + } catch (err) { + if (err instanceof CompositionConflictError && attempt < MAX_COMMIT_ATTEMPTS) continue; + throw err; + } + } + // Unreachable: the final attempt either returns or throws. + throw new CompositionConflictError(-1, -1); } -export async function resetComposition( - comp: Composition = emptyComposition(), +/** Undo the most recent recorded edit (apply its inverse), moving the op-log + * cursor back. `undone: false` when there is nothing to undo. */ +export async function undoLastDocOp(): Promise<{ + undone: boolean; + doc: Composition; + label: string | null; +}> { + const committed = await commitDocAndLog((current, log) => { + const step = undoLog(log); + if (!step) return null; + return { doc: applyOps(current, step.entry.inverse), log: step.log, result: step.entry.label }; + }); + if (!committed) return { undone: false, doc: await readComposition(), label: null }; + return { undone: true, doc: committed.doc, label: committed.result }; +} + +/** Redo the most recently undone edit (re-apply its forward ops), moving the + * cursor forward. `redone: false` when there is nothing to redo. */ +export async function redoDocOp(): Promise<{ + redone: boolean; + doc: Composition; + label: string | null; +}> { + const committed = await commitDocAndLog((current, log) => { + const step = redoLog(log); + if (!step) return null; + return { doc: applyOps(current, step.entry.forward), log: step.log, result: step.entry.label }; + }); + if (!committed) return { redone: false, doc: await readComposition(), label: null }; + return { redone: true, doc: committed.doc, label: committed.result }; +} + +/** + * Compare-and-swap write of a WHOLE document: persist `comp` only if the on-disk + * `rev` still equals `expectedRev`, then bump to `expectedRev + 1`. Throws + * `CompositionConflictError` if another writer committed first. + * + * This is a low-level CAS primitive that carries NO recorded ops, so it RESETS + * the undo history (the prior inverses no longer match the replaced document). + * Undoable co-editing must go through `mutateComposition` / `undoLastDocOp` / + * `redoDocOp`, which keep the op-log in lockstep. The reset is safe here without + * the in-section log-first dance because this always advances rev by exactly one, + * so a stale log can never collide with the new rev (unlike a recovery overwrite). + */ +export async function writeCompositionIfUnchanged( + comp: Composition, + expectedRev: number, ): Promise { - await writeComposition(comp); - return comp; + const committed = await docStore.writeIfUnchanged(comp, expectedRev); + await writeDocOpLog(emptyDocOpLog(committed.rev)); + return committed; +} + +/** + * Replace the whole document and reset the op-log to empty at the new rev, both + * inside one exclusive section. Advances `rev` past the last readable revision so + * a fresh/recovered document NEVER reuses a rev — which also closes the window + * where a recovery that lands on a colliding rev could match a stale log and + * replay an inverse against the wrong document. Log-first ordering (the seeded + * log is empty anyway, so a crash resolves to the same empty history). + */ +async function overwriteDocAndResetLog(comp: Composition): Promise { + return docStore.runExclusive(async () => { + const baseRev = await readCompositionRevTolerant(); + const committed: Composition = { ...comp, rev: baseRev + 1 }; + await writeDocOpLog(emptyDocOpLog(committed.rev)); + await writeComposition(committed); + return committed; + }); +} + +/** + * Recovery primitive: replace composition.json WITHOUT trusting the current file + * to parse (so it works precisely when the live file is the corrupt one being + * recovered). Resets undo history — a recovered document has none — and advances + * `rev` so a stale log can never collide with the new revision. + */ +export function overwriteComposition(comp: Composition = emptyComposition()): Promise { + return overwriteDocAndResetLog(comp); +} + +/** Start a fresh timeline (`clip timeline new`): replace the document, clear undo + * history, and advance `rev` past the previous one (never reuse a rev). */ +export function resetComposition(comp: Composition = emptyComposition()): Promise { + return overwriteDocAndResetLog(comp); } diff --git a/src/timeline/ops.ts b/src/timeline/ops.ts index 9807a5e..56dcfe8 100644 --- a/src/timeline/ops.ts +++ b/src/timeline/ops.ts @@ -1,10 +1,13 @@ +import { z } from 'zod'; import { type Clip, + ClipSchema, type ColorClip, ColorClipSchema, type Composition, CompositionSchema, type Effect, + EffectSchema, type MediaClip, MediaClipSchema, makeClipId, @@ -32,7 +35,7 @@ import type { MediaId } from './schema.js'; */ export type CompositionOp = | { op: 'setCanvas'; width?: number; height?: number; fps?: number; background?: string } - | { op: 'addTrack'; track: Track } + | { op: 'addTrack'; track: Track; index?: number } | { op: 'removeTrack'; trackId: string } | { op: 'addClip'; trackId: string; clip: Clip } | { op: 'removeClip'; clipId: string } @@ -43,11 +46,88 @@ export type CompositionOp = | { op: 'addEffect'; clipId: string; effect: Effect; index?: number } | { op: 'removeEffect'; clipId: string; index: number } | { op: 'setTransform'; clipId: string; transform: Partial } + | { op: 'clearTransform'; clipId: string } | { op: 'addTransition'; trackId: string; transition: Transition } | { op: 'removeTransition'; trackId: string; afterClipId: string }; export type CompositionOpKind = CompositionOp['op']; +/** + * `Partial` for `setTransform` — every field optional and WITHOUT a + * default. `TransformSchema.partial()` would still re-inflate omitted fields to + * their defaults, so a stored partial op like `{ scale: 2 }` would round-trip + * into a full transform and silently change what the op overwrites on redo. + */ +const PartialTransformSchema = z.object({ + scale: z.number().positive().optional(), + x: z.number().optional(), + y: z.number().optional(), + rotationDeg: z.number().optional(), + opacity: z.number().min(0).max(1).optional(), +}); + +/** + * Runtime validator for a `CompositionOp`, used to validate ops read back from + * the persisted op-log. Kept structurally in lockstep with the hand-written + * `CompositionOp` union above; `tests/composition-op-schema.test.ts` parses an + * exhaustive sample of every op kind so the two cannot silently drift. + */ +export const CompositionOpSchema = z.discriminatedUnion('op', [ + z.object({ + op: z.literal('setCanvas'), + width: z.number().int().positive().optional(), + height: z.number().int().positive().optional(), + fps: z.number().positive().optional(), + background: z.string().optional(), + }), + z.object({ + op: z.literal('addTrack'), + track: TrackSchema, + index: z.number().int().nonnegative().optional(), + }), + z.object({ op: z.literal('removeTrack'), trackId: z.string() }), + z.object({ op: z.literal('addClip'), trackId: z.string(), clip: ClipSchema }), + z.object({ op: z.literal('removeClip'), clipId: z.string() }), + z.object({ + op: z.literal('moveClip'), + clipId: z.string(), + startSec: z.number().optional(), + toTrackId: z.string().optional(), + }), + z.object({ + op: z.literal('setTrim'), + clipId: z.string(), + sourceInSec: z.number().optional(), + sourceOutSec: z.number().optional(), + }), + z.object({ op: z.literal('setDuration'), clipId: z.string(), durationSec: z.number() }), + z.object({ + op: z.literal('splitClip'), + clipId: z.string(), + atSec: z.number(), + newClipId: z.string(), + }), + z.object({ + op: z.literal('addEffect'), + clipId: z.string(), + effect: EffectSchema, + index: z.number().int().nonnegative().optional(), + }), + z.object({ + op: z.literal('removeEffect'), + clipId: z.string(), + index: z.number().int().nonnegative(), + }), + z.object({ + op: z.literal('setTransform'), + clipId: z.string(), + transform: PartialTransformSchema, + }), + z.object({ op: z.literal('clearTransform'), clipId: z.string() }), + z.object({ op: z.literal('addTransition'), trackId: z.string(), transition: TransitionSchema }), + z.object({ op: z.literal('removeTransition'), trackId: z.string(), afterClipId: z.string() }), +]); + /** Thrown when an op references a missing entity or violates a clip invariant. */ export class CompositionOpError extends Error { constructor(message: string) { @@ -101,7 +181,14 @@ export function applyOp(comp: Composition, op: CompositionOp): Composition { if (draft.tracks.some((t) => t.id === op.track.id)) { throw new CompositionOpError(`Track "${op.track.id}" already exists.`); } - draft.tracks.push(TrackSchema.parse(op.track)); + const track = TrackSchema.parse(op.track); + if (op.index === undefined) { + draft.tracks.push(track); + } else { + // Clamp into range and insert at z-order position (used by undo to + // restore a removed track at its original index). + draft.tracks.splice(Math.max(0, Math.min(op.index, draft.tracks.length)), 0, track); + } break; } case 'removeTrack': { @@ -199,6 +286,12 @@ export function applyOp(comp: Composition, op: CompositionOp): Composition { clip.transform = { ...base, ...op.transform }; break; } + case 'clearTransform': { + const { clip } = locateClip(draft, op.clipId); + // Back to "no transform" (the inverse of a setTransform that created one). + delete clip.transform; + break; + } case 'addTransition': { const track = requireTrack(draft, op.trackId); if (!track.clips.some((c) => c.id === op.transition.afterClipId)) { @@ -223,14 +316,229 @@ export function applyOp(comp: Composition, op: CompositionOp): Composition { } } + canonicalizeTracks(draft); return CompositionSchema.parse(draft); } +/** + * Put every track into a canonical array order — clips by (startSec, then id), + * transitions by afterClipId — so two documents with identical CONTENT always + * compare deep-equal regardless of the op history that built them. Array order is + * otherwise semantically irrelevant: the export compiler keys transitions by + * afterClipId and rejects overlapping (equal-start) clips, so nothing downstream + * depends on it. Canonicalizing here is what makes the op-log reversible — undo + * (apply an op's inverse) lands back on a byte-identical document instead of one + * that merely has the same clips in a different array slot. + */ +function canonicalizeTracks(comp: Composition): void { + const byString = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0); + for (const track of comp.tracks) { + track.clips.sort((a, b) => a.startSec - b.startSec || byString(a.id, b.id)); + track.transitions.sort((a, b) => byString(a.afterClipId, b.afterClipId)); + } +} + /** Fold a sequence of ops left-to-right. */ export function applyOps(comp: Composition, ops: CompositionOp[]): Composition { return ops.reduce(applyOp, comp); } +/** + * Compute the inverse of `op` against the PRE-state `comp`: the op(s) that, + * applied to the post-op document, restore `comp` exactly. Pure — it only reads + * `comp` (capturing whatever the op overwrites or drops) and returns new ops; it + * never mutates. This is what makes the doc op-log reversible: a writer records + * `invertOp(current, op)` alongside the op, and undo replays the inverse. + * + * Assumes `op` is applicable to `comp` (the caller applies it immediately after). + * It locates the entities it must capture and throws `CompositionOpError` if they + * are missing — the same failure `applyOp` would raise. For ops `applyOp` would + * reject on a *value* check (e.g. a degenerate trim), the inverse is harmless and + * simply discarded when `applyOp` throws. + */ +export function invertOp(comp: Composition, op: CompositionOp): CompositionOp[] { + switch (op.op) { + case 'setCanvas': + // Restore only the fields this op actually overwrote. + return [ + { + op: 'setCanvas', + ...(op.width !== undefined ? { width: comp.width } : {}), + ...(op.height !== undefined ? { height: comp.height } : {}), + ...(op.fps !== undefined ? { fps: comp.fps } : {}), + ...(op.background !== undefined ? { background: comp.background } : {}), + }, + ]; + case 'addTrack': + return [{ op: 'removeTrack', trackId: op.track.id }]; + case 'removeTrack': { + const index = comp.tracks.findIndex((t) => t.id === op.trackId); + const track = comp.tracks[index]; + if (!track) throw new CompositionOpError(`No track "${op.trackId}" to invert removal of.`); + // Re-insert the whole track (clips + transitions) at its original z-index. + return [{ op: 'addTrack', track: structuredClone(track), index }]; + } + case 'addClip': + return [{ op: 'removeClip', clipId: op.clip.id }]; + case 'removeClip': { + const { track, clip } = locateClip(comp, op.clipId); + const inverse: CompositionOp[] = [ + { op: 'addClip', trackId: track.id, clip: structuredClone(clip) }, + ]; + // removeClip also drops the transition that followed the clip — restore it. + for (const t of track.transitions) { + if (t.afterClipId === op.clipId) { + inverse.push({ op: 'addTransition', trackId: track.id, transition: structuredClone(t) }); + } + } + return inverse; + } + case 'moveClip': { + const { track, clip } = locateClip(comp, op.clipId); + const inverse: CompositionOp[] = [ + { op: 'moveClip', clipId: op.clipId, startSec: clip.startSec, toTrackId: track.id }, + ]; + // A cross-track move drops any transition that followed the clip on the + // source track; re-add it after the clip is back. + if (op.toTrackId !== undefined && op.toTrackId !== track.id) { + for (const t of track.transitions) { + if (t.afterClipId === op.clipId) { + inverse.push({ + op: 'addTransition', + trackId: track.id, + transition: structuredClone(t), + }); + } + } + } + return inverse; + } + case 'setTrim': { + const { clip } = locateClip(comp, op.clipId); + if (clip.kind !== 'media') return []; // applyOp will reject; nothing to invert. + return [ + { + op: 'setTrim', + clipId: op.clipId, + sourceInSec: clip.sourceInSec, + sourceOutSec: clip.sourceOutSec, + }, + ]; + } + case 'setDuration': { + const { clip } = locateClip(comp, op.clipId); + if (clip.kind === 'media') return []; // applyOp will reject. + return [{ op: 'setDuration', clipId: op.clipId, durationSec: clip.durationSec }]; + } + case 'splitClip': { + const { track, clip } = locateClip(comp, op.clipId); + const inverse: CompositionOp[] = []; + // On split, the transition after the original clip migrates to the new + // second half. Re-home it to the original, then remove the second half + // (which drops the migrated copy), then restore the original's extent. + const moved = track.transitions.find((t) => t.afterClipId === op.clipId); + if (moved) { + inverse.push({ + op: 'addTransition', + trackId: track.id, + transition: structuredClone(moved), + }); + } + inverse.push({ op: 'removeClip', clipId: op.newClipId }); + if (clip.kind === 'media') { + inverse.push({ + op: 'setTrim', + clipId: op.clipId, + sourceInSec: clip.sourceInSec, + sourceOutSec: clip.sourceOutSec, + }); + } else { + inverse.push({ op: 'setDuration', clipId: op.clipId, durationSec: clip.durationSec }); + } + return inverse; + } + case 'addEffect': { + const { clip } = locateClip(comp, op.clipId); + const at = + op.index === undefined + ? clip.effects.length + : Math.max(0, Math.min(op.index, clip.effects.length)); + return [{ op: 'removeEffect', clipId: op.clipId, index: at }]; + } + case 'removeEffect': { + const { clip } = locateClip(comp, op.clipId); + const effect = clip.effects[op.index]; + if (!effect) return []; // out of range — applyOp will reject. + return [ + { op: 'addEffect', clipId: op.clipId, effect: structuredClone(effect), index: op.index }, + ]; + } + case 'setTransform': { + const { clip } = locateClip(comp, op.clipId); + if (clip.transform === undefined) return [{ op: 'clearTransform', clipId: op.clipId }]; + return [ + { op: 'setTransform', clipId: op.clipId, transform: structuredClone(clip.transform) }, + ]; + } + case 'clearTransform': { + const { clip } = locateClip(comp, op.clipId); + if (clip.transform === undefined) return []; // already clear — no-op. + return [ + { op: 'setTransform', clipId: op.clipId, transform: structuredClone(clip.transform) }, + ]; + } + case 'addTransition': { + const track = requireTrack(comp, op.trackId); + const prior = track.transitions.find((t) => t.afterClipId === op.transition.afterClipId); + // addTransition replaces any same-anchor transition: restore the prior one + // if there was one, otherwise just remove what we added. + if (prior) + return [{ op: 'addTransition', trackId: op.trackId, transition: structuredClone(prior) }]; + return [ + { op: 'removeTransition', trackId: op.trackId, afterClipId: op.transition.afterClipId }, + ]; + } + case 'removeTransition': { + const track = requireTrack(comp, op.trackId); + const removed = track.transitions.find((t) => t.afterClipId === op.afterClipId); + if (!removed) return []; // removed nothing — no-op. + return [{ op: 'addTransition', trackId: op.trackId, transition: structuredClone(removed) }]; + } + default: { + const _exhaustive: never = op; + throw new CompositionOpError(`Cannot invert unknown op: ${JSON.stringify(_exhaustive)}`); + } + } +} + +export interface TrackedApply { + doc: Composition; + /** The ops that, applied to `doc`, undo the WHOLE batch (each op's inverse, + * concatenated in reverse order). */ + inverse: CompositionOp[]; +} + +/** + * Apply a batch of ops, threading state so each op's inverse is captured at the + * exact pre-state it saw, and return the resulting doc plus a single inverse + * op-list that undoes the entire batch. This is the unit the op-log records per + * mutation: forward = the batch, inverse = what this returns. + */ +export function applyOpsTracked(comp: Composition, ops: CompositionOp[]): TrackedApply { + let doc = comp; + const perOpInverses: CompositionOp[][] = []; + for (const op of ops) { + perOpInverses.push(invertOp(doc, op)); + doc = applyOp(doc, op); + } + const inverse: CompositionOp[] = []; + for (let i = perOpInverses.length - 1; i >= 0; i--) { + const inv = perOpInverses[i]; + if (inv) inverse.push(...inv); + } + return { doc, inverse }; +} + // ─── internals ─────────────────────────────────────────────────────────────── function locate(comp: Composition, clipId: string): boolean { diff --git a/src/timeline/verbs.ts b/src/timeline/verbs.ts new file mode 100644 index 0000000..a94ab0f --- /dev/null +++ b/src/timeline/verbs.ts @@ -0,0 +1,285 @@ +import { z } from 'zod'; +import { + AnchorSchema, + type Composition, + clipEndSec, + makeClipId, + TransitionKindSchema, +} from './composition.js'; +import { applyOps, type CompositionOp, colorClip, mediaClip, textClip, videoTrack } from './ops.js'; +import type { MediaId } from './schema.js'; + +/** + * The VERB layer — a small, natural-language-shaped editing vocabulary that the + * agent and the `clip ui` emit, lowered to the wire-level `CompositionOp`s the + * reducer applies. Verbs exist so the impure parts of an edit — ingesting a file + * to learn its id/duration, minting clip ids, choosing an append point — live + * OUTSIDE the pure `applyOp` reducer, in `lowerVerb`. The agent and `clip ui` + * emit verbs through `applyVerbs`; the CLI builds the same ops directly through + * `mutateComposition` (and shares `trackEnd`/`ensureTrack` here). Either way every + * edit lands on one op-aware, undoable document — not the legacy file-tools that + * bypassed it. + * + * Per-field `.describe()` text is surfaced to the model by the AI SDK, so the + * verb schema doubles as the agent's tool documentation. + */ +export const CompositionVerbSchema = z.discriminatedUnion('verb', [ + z.object({ + verb: z.literal('add_media'), + id: z + .string() + .optional() + .describe( + 'Optional clip id — supply one to reference this clip in a later verb of the SAME batch.', + ), + path: z.string().describe('Absolute or workspace-relative path to a video/audio/image file.'), + track: z + .string() + .optional() + .describe('Track id to place it on (default: the main video track).'), + startSec: z + .number() + .nonnegative() + .optional() + .describe('Timeline start; default appends after the last clip.'), + sourceInSec: z + .number() + .nonnegative() + .optional() + .describe('Trim in-point within the source (default 0).'), + sourceOutSec: z + .number() + .positive() + .optional() + .describe('Trim out-point within the source (default the full duration).'), + }), + z.object({ + verb: z.literal('add_text'), + id: z + .string() + .optional() + .describe( + 'Optional clip id — supply one to reference this clip in a later verb of the SAME batch.', + ), + text: z.string().min(1).describe('The text to show.'), + durationSec: z.number().positive().describe('How long the text is on screen.'), + track: z.string().optional(), + startSec: z + .number() + .nonnegative() + .optional() + .describe('Timeline start; default appends after the last clip.'), + fontSize: z.number().int().positive().optional(), + color: z.string().optional(), + background: z + .string() + .nullable() + .optional() + .describe('Box color behind the text, or null for none.'), + anchor: AnchorSchema.optional(), + }), + z.object({ + verb: z.literal('add_color'), + id: z + .string() + .optional() + .describe( + 'Optional clip id — supply one to reference this clip in a later verb of the SAME batch.', + ), + durationSec: z.number().positive(), + color: z.string().optional().describe('Card color (default black).'), + track: z.string().optional(), + startSec: z.number().nonnegative().optional(), + }), + z.object({ + verb: z.literal('trim'), + clipId: z.string(), + sourceInSec: z.number().nonnegative().optional(), + sourceOutSec: z.number().positive().optional(), + }), + z.object({ + verb: z.literal('move'), + clipId: z.string(), + startSec: z.number().nonnegative().optional().describe('New timeline start.'), + toTrack: z.string().optional().describe('Move the clip to this track.'), + }), + z.object({ + verb: z.literal('split'), + clipId: z.string(), + atSec: z.number().describe('Timeline time to cut at (must fall inside the clip).'), + }), + z.object({ verb: z.literal('remove'), clipId: z.string() }), + z.object({ + verb: z.literal('transition'), + afterClipId: z + .string() + .describe('Add a transition after this clip, into the one that follows it.'), + kind: TransitionKindSchema.optional(), + durationSec: z.number().positive().max(10).optional(), + track: z.string().optional(), + }), + z.object({ + verb: z.literal('set_transform'), + clipId: z.string(), + scale: z.number().positive().optional(), + x: z.number().optional().describe('Horizontal center in [0,1].'), + y: z.number().optional().describe('Vertical center in [0,1].'), + rotationDeg: z.number().optional(), + opacity: z.number().min(0).max(1).optional(), + }), +]); +export type CompositionVerb = z.infer; +export type CompositionVerbKind = CompositionVerb['verb']; + +/** The impure capabilities `lowerVerb` needs — supplied by the I/O layer so the + * lowering stays unit-testable with stubs. */ +export interface VerbContext { + /** Register a media file and report its id + duration (wraps the ingest tool). */ + ingest: (path: string) => Promise<{ mediaId: MediaId; durationSec: number }>; + /** Track a clip is placed on when the verb doesn't say. */ + defaultTrack: string; +} + +const DEFAULT_TRACK = 'v0'; + +/** End time of the last clip on a track (0 if empty/missing) — the append point. + * Exported so the CLI shares one definition instead of a drifting copy. */ +export function trackEnd(comp: Composition, trackId: string): number { + const track = comp.tracks.find((t) => t.id === trackId); + if (!track) return 0; + return track.clips.reduce((end, c) => Math.max(end, clipEndSec(c)), 0); +} + +/** Ops to create the named video track if it doesn't exist yet. */ +export function ensureTrack(comp: Composition, trackId: string): CompositionOp[] { + return comp.tracks.some((t) => t.id === trackId) + ? [] + : [{ op: 'addTrack', track: videoTrack({ id: trackId }) }]; +} + +/** + * Lower one verb to the `CompositionOp`s that carry it out, against the CURRENT + * document. Impure (mints ids, may `ingest`), but it never mutates `comp` — the + * caller applies the returned ops through `mutateComposition` so the edit is + * validated, recorded, and undoable. + */ +export async function lowerVerb( + comp: Composition, + verb: CompositionVerb, + ctx: VerbContext, +): Promise { + switch (verb.verb) { + case 'add_media': { + const { mediaId, durationSec } = await ctx.ingest(verb.path); + const trackId = verb.track ?? ctx.defaultTrack; + const clip = mediaClip({ + id: verb.id ?? makeClipId(), + mediaId, + sourceInSec: verb.sourceInSec ?? 0, + sourceOutSec: verb.sourceOutSec ?? durationSec, + startSec: verb.startSec ?? trackEnd(comp, trackId), + }); + return [...ensureTrack(comp, trackId), { op: 'addClip', trackId, clip }]; + } + case 'add_text': { + const trackId = verb.track ?? ctx.defaultTrack; + const style: Record = {}; + if (verb.fontSize !== undefined) style.fontSize = verb.fontSize; + if (verb.color !== undefined) style.color = verb.color; + if (verb.background !== undefined) style.background = verb.background; + if (verb.anchor !== undefined) style.anchor = verb.anchor; + const clip = textClip({ + id: verb.id ?? makeClipId(), + text: verb.text, + durationSec: verb.durationSec, + startSec: verb.startSec ?? trackEnd(comp, trackId), + style, + }); + return [...ensureTrack(comp, trackId), { op: 'addClip', trackId, clip }]; + } + case 'add_color': { + const trackId = verb.track ?? ctx.defaultTrack; + const clip = colorClip({ + id: verb.id ?? makeClipId(), + color: verb.color, + durationSec: verb.durationSec, + startSec: verb.startSec ?? trackEnd(comp, trackId), + }); + return [...ensureTrack(comp, trackId), { op: 'addClip', trackId, clip }]; + } + case 'trim': { + // An intent-free verb lowers to nothing; `mutateComposition`'s empty-batch + // guard then makes it a true no-op (no rev bump, no spurious undo entry). + if (verb.sourceInSec === undefined && verb.sourceOutSec === undefined) return []; + return [ + { + op: 'setTrim', + clipId: verb.clipId, + ...(verb.sourceInSec !== undefined ? { sourceInSec: verb.sourceInSec } : {}), + ...(verb.sourceOutSec !== undefined ? { sourceOutSec: verb.sourceOutSec } : {}), + }, + ]; + } + case 'move': { + if (verb.startSec === undefined && verb.toTrack === undefined) return []; + return [ + { + op: 'moveClip', + clipId: verb.clipId, + ...(verb.startSec !== undefined ? { startSec: verb.startSec } : {}), + ...(verb.toTrack !== undefined ? { toTrackId: verb.toTrack } : {}), + }, + ]; + } + case 'split': + return [{ op: 'splitClip', clipId: verb.clipId, atSec: verb.atSec, newClipId: makeClipId() }]; + case 'remove': + return [{ op: 'removeClip', clipId: verb.clipId }]; + case 'transition': + return [ + { + op: 'addTransition', + trackId: verb.track ?? ctx.defaultTrack, + transition: { + afterClipId: verb.afterClipId, + kind: verb.kind ?? 'fade', + durationSec: verb.durationSec ?? 1, + }, + }, + ]; + case 'set_transform': { + const transform: Record = {}; + if (verb.scale !== undefined) transform.scale = verb.scale; + if (verb.x !== undefined) transform.x = verb.x; + if (verb.y !== undefined) transform.y = verb.y; + if (verb.rotationDeg !== undefined) transform.rotationDeg = verb.rotationDeg; + if (verb.opacity !== undefined) transform.opacity = verb.opacity; + if (Object.keys(transform).length === 0) return []; // nothing to set — no-op. + return [{ op: 'setTransform', clipId: verb.clipId, transform }]; + } + default: { + const _exhaustive: never = verb; + throw new Error(`Unknown verb: ${JSON.stringify(_exhaustive)}`); + } + } +} + +/** Lower a batch of verbs against the CURRENT doc, threading state so each verb + * sees the effect of the ones before it (e.g. a default append point shifts as + * earlier verbs add clips). Returns the flat op list to apply atomically. */ +export async function lowerVerbs( + comp: Composition, + verbs: CompositionVerb[], + ctx: VerbContext, +): Promise { + let doc = comp; + const all: CompositionOp[] = []; + for (const verb of verbs) { + const ops = await lowerVerb(doc, verb, ctx); + all.push(...ops); + doc = applyOps(doc, ops); + } + return all; +} + +export { DEFAULT_TRACK as DEFAULT_VERB_TRACK }; diff --git a/src/ui/server.ts b/src/ui/server.ts index 16726e8..2254e59 100644 --- a/src/ui/server.ts +++ b/src/ui/server.ts @@ -12,16 +12,24 @@ import { convertToModelMessages, stepCountIs, streamText, type UIMessage } from import getPort from 'get-port'; import { Hono } from 'hono'; import open from 'open'; -import { ZodError } from 'zod'; +import { ZodError, z } from 'zod'; import { probe } from '../ffmpeg/probe.js'; import { appendOp, readSession, SessionCorruptError, snapshotsDir } from '../session/store.js'; import type { SessionEntry } from '../session/types.js'; +import { + applyVerbs, + CompositionConflictError, + readComposition, +} from '../timeline/document-store.js'; +import { CompositionOpError } from '../timeline/ops.js'; +import { CompositionVerbSchema } from '../timeline/verbs.js'; import { ingest } from '../tools/ingest.js'; import { preview } from '../tools/preview.js'; import { snapshot } from '../tools/snapshot.js'; import { undo } from '../tools/undo.js'; -import { ensureWorkspace, getWorkspace } from '../workspace.js'; +import { ensureWorkspace, getWorkspace, WorkspaceBoundaryError } from '../workspace.js'; import { buildChatTools } from './chat-tools.js'; +import { buildTimelineTools, makeVerbContext, summarizeComposition } from './timeline-tools.js'; import { isRegisteredTool, TOOL_REGISTRY } from './tool-registry.js'; const HERE = dirname(fileURLToPath(import.meta.url)); @@ -143,6 +151,47 @@ export async function startUiServer(options: UiServerOptions = {}): Promise { + return c.json({ document: await readComposition() }); + }); + + /** + * POST /api/timeline/verbs — apply editing verbs to the document as ONE + * undoable change (the same op-aware path the agent and CLI use). Body: + * `{ verbs: CompositionVerb[] }`. + */ + app.post('/api/timeline/verbs', async (c) => { + let body: unknown; + try { + body = await c.req.json(); + } catch { + return c.json({ error: 'Body must be JSON' }, 400); + } + try { + const verbs = z + .array(CompositionVerbSchema) + .min(1) + .parse((body as { verbs?: unknown }).verbs); + const { doc, ops } = await applyVerbs(verbs, makeVerbContext()); + return c.json({ applied: ops.length, document: doc }); + } catch (err) { + if (err instanceof ZodError) { + return c.json({ error: 'Validation failed', issues: err.issues }, 400); + } + // A lost write race is retriable (409); a stale/bad clip reference is a + // client condition (422) — neither is a server fault. + if (err instanceof WorkspaceBoundaryError) return c.json({ error: err.message }, 403); + if (err instanceof CompositionConflictError) return c.json({ error: err.message }, 409); + if (err instanceof CompositionOpError) return c.json({ error: err.message }, 422); const message = err instanceof Error ? err.message : String(err); return c.json({ error: message }, 500); } @@ -263,28 +312,34 @@ export async function startUiServer(options: UiServerOptions = {}): Promise { - return readSession().then((session) => { - const workspace = getWorkspace(); - const recent = session.entries.slice(-20).map(summarizeEntryForChat).join('\n'); - const head = - session.entries.length > 20 - ? `\n(showing last 20 of ${session.entries.length}; older ops exist)\n` - : ''; - return [ - 'You are the assistant inside MakeMyClip Editor — an FFmpeg-backed video editor.', - 'You run editing operations by calling tools. Each tool call produces a new output file and appends one entry to the session log; the UI picks it up automatically.', - '', - 'Conventions:', - '- Refer to media by absolute file paths only — never relative.', - "- Chain ops by passing the previous op result's `path` as the next op's `input` (or `result.ref.path` for ingest).", - '- Prefer stream-copy ops (trim, split, concat) before re-encoding ops (transition, add_text, render) to keep results fast and lossless.', - '- When the user asks for something ambiguous, call `inspect`-style tools or read the session list to ground yourself before editing.', - '', - `Workspace: ${workspace}`, - `Session has ${session.entries.length} entries.${head}`, - recent ? `Recent ops:\n${recent}` : 'Session is empty.', - ].join('\n'); - }); + return Promise.all([readSession(), readComposition().catch(() => null)]).then( + ([session, comp]) => { + const workspace = getWorkspace(); + const recent = session.entries.slice(-12).map(summarizeEntryForChat).join('\n'); + return [ + 'You are the assistant inside MakeMyClip Editor — a local, FFmpeg-backed video editor.', + '', + 'You build a video by editing a TIMELINE DOCUMENT: a non-destructive list of tracks and clips. Every edit is recorded and undoable.', + '', + 'Primary tools — prefer these:', + '- `timeline_edit` — apply editing verbs (add_media, add_text, add_color, trim, move, split, remove, transition, set_transform) to the document. Batch related verbs into ONE call; they apply atomically as a single undoable change.', + '- `timeline_show` — read the current document (tracks, clips, timings). Call it to ground yourself BEFORE editing and to VERIFY AFTER an edit.', + '- `timeline_undo` / `timeline_redo` / `timeline_history` — move through the edit history.', + '', + 'Workflow: timeline_show → timeline_edit → timeline_show to confirm. Refer to clips by their `id` (from timeline_show); refer to media by absolute or workspace-relative path.', + '', + 'The legacy file-tools (chroma_key, stabilize, overlay, render, …) operate on standalone files, NOT the document. Use them only for operations the timeline engine does not cover yet — e.g. final render, chroma key, stabilization.', + '', + `Workspace: ${workspace}`, + comp + ? `Current document: ${JSON.stringify(summarizeComposition(comp))}` + : 'Current document: unreadable (corrupt?) — start over with `timeline_edit` after the user removes composition.json, or guide them to `clip timeline new`.', + recent ? `Recent file-tool ops:\n${recent}` : '', + ] + .filter(Boolean) + .join('\n'); + }, + ); } /** @@ -351,7 +406,9 @@ export async function startUiServer(options: UiServerOptions = {}): Promise { + // Untrusted (agent/UI) input — confine to the workspace. + const resolved = resolveInWorkspace(path); + const result = await ingest({ path: resolved }); + await appendOp({ + tool: 'ingest', + args: { path: resolved }, + result: result as unknown as Record, + }); + return { mediaId: result.mediaId, durationSec: result.ref.durationSec }; + }, + }; +} + +/** Compact, agent-readable view of the document — the textual "eyes". */ +export function summarizeComposition(comp: Composition): unknown { + return { + rev: comp.rev, + durationSec: compositionDuration(comp), + canvas: { width: comp.width, height: comp.height, fps: comp.fps }, + tracks: comp.tracks.map((t) => ({ + id: t.id, + kind: t.kind, + clips: t.clips.map((c) => ({ + id: c.id, + kind: c.kind, + startSec: c.startSec, + endSec: clipEndSec(c), + })), + })), + }; +} + +/** + * The op-aware timeline toolset for the chat agent: edits go through the verb + * layer → `mutateComposition`, so every agent edit lands on the SAME non- + * destructive, undoable document the human and CLI edit — not the legacy file + * tools that produced orphaned output files. Errors return as data so a failed + * call doesn't abort the streaming turn. + */ +export function buildTimelineTools(): Record { + const ctx = makeVerbContext(); + const asData = (fn: () => Promise) => async () => { + try { + return await fn(); + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) }; + } + }; + + return { + timeline_edit: tool({ + description: + 'Edit the timeline document with one or more verbs (applied atomically as a single undoable change). This is the primary way to build a video: add clips, trim, move, split, transition, etc. Returns the updated document summary.', + inputSchema: z.object({ + verbs: z.array(CompositionVerbSchema).min(1).describe('Editing verbs to apply in order.'), + }), + execute: async ({ verbs }) => + asData(async () => { + const { doc, ops } = await applyVerbs(verbs, ctx); + return { applied: ops.length, document: summarizeComposition(doc) }; + })(), + }), + timeline_show: tool({ + description: + 'Read the current timeline document — tracks, clips, and timings. Call this to ground yourself before editing or to verify a change.', + inputSchema: z.object({}), + execute: async () => asData(async () => summarizeComposition(await readComposition()))(), + }), + timeline_undo: tool({ + description: 'Undo the most recent timeline edit.', + inputSchema: z.object({}), + execute: async () => + asData(async () => { + const { undone, label } = await undoLastDocOp(); + return undone ? { undone: true, label } : { undone: false, message: 'Nothing to undo.' }; + })(), + }), + timeline_redo: tool({ + description: 'Redo the most recently undone timeline edit.', + inputSchema: z.object({}), + execute: async () => + asData(async () => { + const { redone, label } = await redoDocOp(); + return redone ? { redone: true, label } : { redone: false, message: 'Nothing to redo.' }; + })(), + }), + timeline_history: tool({ + description: 'List the timeline edit history (what can be undone / redone).', + inputSchema: z.object({}), + execute: async () => + asData(async () => { + const log = await readDocOpLog(); + return { + canUndo: log.cursor > 0, + canRedo: log.cursor < log.entries.length, + entries: log.entries.map((e, i) => ({ + label: e.label, + state: i < log.cursor ? 'applied' : 'undone', + })), + }; + })(), + }), + }; +} diff --git a/src/ui/tool-registry.ts b/src/ui/tool-registry.ts index 4da2f42..c7e44ff 100644 --- a/src/ui/tool-registry.ts +++ b/src/ui/tool-registry.ts @@ -18,12 +18,98 @@ import { StabilizeInput, stabilize } from '../tools/stabilize.js'; import { TransitionInput, transition } from '../tools/transition.js'; import { TrimInput, trim } from '../tools/trim.js'; import { ZoomPanInput, zoomPan } from '../tools/zoom-pan.js'; +import { resolveInWorkspace } from '../workspace.js'; + +interface ToolEntry { + // biome-ignore lint/suspicious/noExplicitAny: each tool has a different schema; heterogeneous registry + schema: z.ZodType; + // biome-ignore lint/suspicious/noExplicitAny: schema validates at runtime; downstream casts + fn: (input: any) => Promise; + /** + * Input fields that carry a source file path (a string, or a string[] of + * paths). At the untrusted agent/UI boundary these are confined to the + * workspace before the handler runs — see `confineToolInput`. A handler whose + * inputs are all in-document/in-workspace by construction omits this. + */ + pathFields?: readonly string[]; +} + +/** + * Confine an untrusted tool input's source-path fields to the workspace. + * + * Both agent/UI dispatch surfaces — `POST /api/tools/:name` (unauthenticated + * localhost) and the chat agent's legacy tool calls — feed model/HTTP-supplied + * paths straight into FFmpeg, which reads whatever the OS will open. Without + * this, pointing any path-bearing tool (`render`, `trim`, `overlay`, …) at a + * file outside the workspace is an arbitrary-file read — and, since the result + * is re-encoded into a workspace file the UI serves back, an exfiltration + * channel. So every such field is routed through `resolveInWorkspace`, which + * throws `WorkspaceBoundaryError` on escape (AGENTS.md non-negotiable #3). The + * trusted CLI never dispatches through here — a user-typed path is consent. + * + * Only the listed fields are touched; the rest of the input passes through + * untouched. A wrong/absent field name would silently leave a path unconfined, + * so `pathFields` is kept in lockstep with each handler's `resolveInput` calls. + */ +function confineToolInput(input: unknown, pathFields: readonly string[]): unknown { + if (input === null || typeof input !== 'object') return input; + const confined: Record = { ...(input as Record) }; + for (const field of pathFields) { + const value = confined[field]; + if (typeof value === 'string') { + confined[field] = resolveInWorkspace(value); + } else if (Array.isArray(value)) { + confined[field] = value.map((item) => + typeof item === 'string' ? resolveInWorkspace(item) : item, + ); + } + } + return confined; +} + +/** + * Raw tool definitions. `pathFields` declares which inputs are source paths; + * the workspace-confining wrapper is applied below when building TOOL_REGISTRY. + */ +const RAW_TOOLS: Record = { + ingest: { schema: IngestInput, fn: ingest, pathFields: ['path'] }, + trim: { schema: TrimInput, fn: trim, pathFields: ['input'] }, + split: { schema: SplitInput, fn: split, pathFields: ['input'] }, + concat: { schema: ConcatInput, fn: concat, pathFields: ['inputs'] }, + add_text: { schema: AddTextInput, fn: addText, pathFields: ['input'] }, + add_audio: { schema: AddAudioInput, fn: addAudio, pathFields: ['input', 'audio'] }, + add_title_card: { schema: AddTitleCardInput, fn: addTitleCard, pathFields: ['input'] }, + transition: { schema: TransitionInput, fn: transition, pathFields: ['inputA', 'inputB'] }, + render: { schema: RenderInput, fn: render, pathFields: ['input'] }, + preview: { schema: PreviewInput, fn: preview, pathFields: ['input'] }, + adjust: { schema: AdjustInput, fn: adjust, pathFields: ['input'] }, + speed: { schema: SpeedInput, fn: speed, pathFields: ['input'] }, + overlay: { schema: OverlayInput, fn: overlay, pathFields: ['input', 'overlay'] }, + zoom_pan: { schema: ZoomPanInput, fn: zoomPan, pathFields: ['input'] }, + stabilize: { schema: StabilizeInput, fn: stabilize, pathFields: ['input'] }, + chroma_key: { schema: ChromaKeyInput, fn: chromaKey, pathFields: ['foreground', 'background'] }, + silence_remove: { schema: SilenceRemoveInput, fn: silenceRemove, pathFields: ['input'] }, + highlight_reel: { schema: HighlightReelInput, fn: highlightReel, pathFields: ['input'] }, + add_captions: { schema: AddCaptionsInput, fn: addCaptions, pathFields: ['input'] }, +}; + +function wrapWithConfinement(entry: ToolEntry): ToolEntry { + const { schema, fn, pathFields } = entry; + if (!pathFields) return { schema, fn }; + // async so a synchronous WorkspaceBoundaryError surfaces as a rejected promise + // (the dispatch sites await entry.fn and turn rejections into 403 / error-data). + return { schema, fn: async (input: unknown) => fn(confineToolInput(input, pathFields)) }; +} /** * Maps tool name → { schema, fn }. The UI uses this to: * - dispatch POST /api/tools/:name to the right function with Zod-validated input * - render forms for the schemas (eventually; for now forms are hand-built) * + * Every entry's path inputs are confined to the workspace (see `confineToolInput`) + * so the agent/UI tool surface cannot read files outside the sandbox; only the + * trusted CLI, which never dispatches through here, stays unconfined. + * * Composites (add_title_card, add_captions, highlight_reel, silence_remove, * chroma_key) are registered here once the UI has a hand-built form for * their schema — including the row-list pattern for structured-input @@ -40,27 +126,9 @@ export const TOOL_REGISTRY: Record< // biome-ignore lint/suspicious/noExplicitAny: schema validates at runtime; downstream casts fn: (input: any) => Promise; } -> = { - ingest: { schema: IngestInput, fn: ingest }, - trim: { schema: TrimInput, fn: trim }, - split: { schema: SplitInput, fn: split }, - concat: { schema: ConcatInput, fn: concat }, - add_text: { schema: AddTextInput, fn: addText }, - add_audio: { schema: AddAudioInput, fn: addAudio }, - add_title_card: { schema: AddTitleCardInput, fn: addTitleCard }, - transition: { schema: TransitionInput, fn: transition }, - render: { schema: RenderInput, fn: render }, - preview: { schema: PreviewInput, fn: preview }, - adjust: { schema: AdjustInput, fn: adjust }, - speed: { schema: SpeedInput, fn: speed }, - overlay: { schema: OverlayInput, fn: overlay }, - zoom_pan: { schema: ZoomPanInput, fn: zoomPan }, - stabilize: { schema: StabilizeInput, fn: stabilize }, - chroma_key: { schema: ChromaKeyInput, fn: chromaKey }, - silence_remove: { schema: SilenceRemoveInput, fn: silenceRemove }, - highlight_reel: { schema: HighlightReelInput, fn: highlightReel }, - add_captions: { schema: AddCaptionsInput, fn: addCaptions }, -}; +> = Object.fromEntries( + Object.entries(RAW_TOOLS).map(([name, entry]) => [name, wrapWithConfinement(entry)] as const), +); export function isRegisteredTool(name: string): name is keyof typeof TOOL_REGISTRY { return name in TOOL_REGISTRY; diff --git a/src/workspace.ts b/src/workspace.ts index ba34ac2..ed62e46 100644 --- a/src/workspace.ts +++ b/src/workspace.ts @@ -1,7 +1,8 @@ import { randomBytes } from 'node:crypto'; +import { lstatSync, readlinkSync, realpathSync } from 'node:fs'; import { mkdir } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { isAbsolute, resolve } from 'node:path'; +import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path'; const WORKSPACE_ENV = 'MAKEMYCLIP_WORKSPACE'; @@ -19,6 +20,109 @@ export function resolveInput(path: string): string { return isAbsolute(path) ? path : resolve(process.cwd(), path); } +/** Thrown when an untrusted surface (agent verb/tool, `clip ui` route) asks to + * read a path outside the workspace — the AGENTS.md non-negotiable #3 boundary. */ +export class WorkspaceBoundaryError extends Error { + readonly path: string; + readonly workspace: string; + constructor(path: string, workspace: string) { + // Audience-neutral: served both to the agent (as tool-result data) and to a + // person via the 403 body, so it states the constraint without advising a + // surface only one of them has (no "use the CLI" — the agent cannot). + super( + `Refusing to read "${path}": it is outside the workspace (${workspace}). ` + + `Only files inside the workspace can be read here — move the file into the ` + + `workspace (its imports/ folder) and reference it from there.`, + ); + this.name = 'WorkspaceBoundaryError'; + this.path = path; + this.workspace = workspace; + } +} + +const MAX_SYMLINK_HOPS = 40; + +/** + * Canonicalize `p` for the workspace containment check: resolve symlinks on the + * existing prefix so the comparison runs on REAL targets, while tolerating a + * not-yet-created trailing path (a tool may be about to create it). Symlinks are + * followed explicitly — including a DANGLING one, whose (possibly out-of-tree) + * target is resolved so it can't be mistaken for an in-workspace leaf. Only a + * genuine `ENOENT` is treated as a missing tail; any other error (`EACCES`, + * `ELOOP`, a symlink cycle) throws so the caller can fail closed rather than + * decide containment on a path it could not fully resolve. + */ +function canonicalizeForCheck(p: string): string { + let current = resolve(p); + const tail: string[] = []; + let hops = 0; + for (;;) { + let stats: ReturnType; + try { + stats = lstatSync(current); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + const parent = dirname(current); + if (parent === current) return resolve(p); + tail.unshift(basename(current)); + current = parent; + continue; + } + if (stats.isSymbolicLink()) { + if (++hops > MAX_SYMLINK_HOPS) { + throw new Error(`Too many symlink hops resolving "${p}".`); + } + // Follow one hop toward the target (keeping the not-yet-resolved tail) so a + // dangling or outward link is canonicalized to its real destination. + current = resolve(dirname(current), readlinkSync(current)); + continue; + } + // A real, non-symlink entry: realpath collapses any symlinks in ITS prefix. + const real = realpathSync(current); + return tail.length > 0 ? resolve(real, ...tail) : real; + } +} + +/** + * Resolve `path` and CONFINE it to the workspace — the trust boundary for + * untrusted input (the agent's verbs/tools and the localhost `clip ui` routes). + * Relative paths resolve against the workspace; absolute paths must already sit + * inside it. Throws `WorkspaceBoundaryError` on traversal or any path outside the + * tree. The trusted CLI keeps `resolveInput`, where a user-typed path is consent. + * + * Containment is checked on the REAL (symlink-collapsed) paths, so an in-workspace + * symlink — existing-target OR dangling — cannot smuggle a read outside the tree: + * each side is canonicalized via `canonicalizeForCheck` before comparing (a + * symlinked workspace root, e.g. macOS `/var` -> `/private/var`, is collapsed too + * so legit paths aren't falsely rejected). If a path can't be fully canonicalized + * (permission error, symlink loop) it fails closed. The lexical resolved path is + * returned, so an in-workspace symlink to an in-workspace target still works. + * + * Residual: an open-time TOCTOU race (the path is re-followed when FFmpeg opens + * it) is not closed here — that needs `O_NOFOLLOW` at the open site. + */ +export function resolveInWorkspace(path: string): string { + const workspace = getWorkspace(); + const resolved = isAbsolute(path) ? resolve(path) : resolve(workspace, path); + let realWorkspace: string; + let realResolved: string; + try { + realWorkspace = canonicalizeForCheck(workspace); + realResolved = canonicalizeForCheck(resolved); + } catch { + // Couldn't canonicalize (EACCES / symlink loop / …) — don't decide on a + // partially-resolved path; refuse. + throw new WorkspaceBoundaryError(path, workspace); + } + const rel = relative(realWorkspace, realResolved); + // Outside the tree if the relative path is empty (the workspace dir itself), + // escapes via '..', or is absolute (e.g. a different Windows drive). + if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { + throw new WorkspaceBoundaryError(path, workspace); + } + return resolved; +} + export function newOutputPath(prefix: string, ext: string): string { const id = randomBytes(4).toString('hex'); return resolve(getWorkspace(), `${prefix}-${id}.${ext}`); diff --git a/tests/compile.test.ts b/tests/compile.test.ts index c129d39..75522f0 100644 --- a/tests/compile.test.ts +++ b/tests/compile.test.ts @@ -205,6 +205,78 @@ describe('compileTimeline — fold (cuts & transitions)', () => { // 4 + 4 − 1 overlap = 7s expect(plan.durationSec).toBe(7); }); + + it('drops a per-clip fade on a transition boundary but keeps outer fades', () => { + const comp = oneTrack( + { + op: 'addClip', + trackId: 'v0', + clip: mediaClip({ id: 'a', mediaId: M1, sourceOutSec: 4, startSec: 0 }), + }, + { + op: 'addClip', + trackId: 'v0', + clip: mediaClip({ id: 'b', mediaId: M2, sourceOutSec: 4, startSec: 4 }), + }, + { op: 'addEffect', clipId: 'a', effect: { type: 'fadeIn', durationSec: 1 } }, + { op: 'addEffect', clipId: 'a', effect: { type: 'fadeOut', durationSec: 1 } }, + { op: 'addEffect', clipId: 'b', effect: { type: 'fadeIn', durationSec: 1 } }, + { op: 'addEffect', clipId: 'b', effect: { type: 'fadeOut', durationSec: 1 } }, + { + op: 'addTransition', + trackId: 'v0', + transition: { afterClipId: 'a', kind: 'dissolve', durationSec: 1 }, + }, + ); + const plan = compileTimeline(comp, ctx()); + const segA = plan.steps.find((s) => s.label === 'segment:a')?.args ?? []; + const fcA = segA[segA.indexOf('-filter_complex') + 1] ?? ''; + const segB = plan.steps.find((s) => s.label === 'segment:b')?.args ?? []; + const fcB = segB[segB.indexOf('-filter_complex') + 1] ?? ''; + + // Clip a opens from black (leading fadeIn kept) but its trailing fadeOut is + // dropped — the dissolve already blends that cut. + expect(fcA).toContain('fade=t=in:st=0:d=1'); + expect(fcA).not.toContain('fade=t=out'); + expect(fcA).not.toContain('afade=t=out'); + // Clip b's leading fadeIn is dropped (xfade owns it); its trailing fadeOut to + // black at the timeline end is kept. + expect(fcB).not.toContain('fade=t=in'); + expect(fcB).not.toContain('afade=t=in'); + expect(fcB).toContain('fade=t=out'); + // The transition fold itself is unaffected. + expect(plan.steps.some((s) => s.label === 'fold:xfade:1')).toBe(true); + }); + + it('keeps the last clip fadeOut when a transition dangles on it (no following clip to xfade)', () => { + // A transition keyed to the LAST clip blends nothing (the fold only xfades a + // transition with a following clip — e.g. one left dangling after the next + // clip was removed). The fade-to-black must survive, not drop to a hard cut. + const comp = oneTrack( + { + op: 'addClip', + trackId: 'v0', + clip: mediaClip({ id: 'a', mediaId: M1, sourceOutSec: 4, startSec: 0 }), + }, + { + op: 'addClip', + trackId: 'v0', + clip: mediaClip({ id: 'b', mediaId: M2, sourceOutSec: 4, startSec: 4 }), + }, + { op: 'addEffect', clipId: 'b', effect: { type: 'fadeOut', durationSec: 1 } }, + { + op: 'addTransition', + trackId: 'v0', + transition: { afterClipId: 'b', kind: 'dissolve', durationSec: 1 }, + }, + ); + const plan = compileTimeline(comp, ctx()); + const segB = plan.steps.find((s) => s.label === 'segment:b')?.args ?? []; + const fcB = segB[segB.indexOf('-filter_complex') + 1] ?? ''; + expect(fcB).toContain('fade=t=out:st=3:d=1'); // preserved + // No xfade exists for a transition with nothing after it; the join is a cut. + expect(plan.steps.some((s) => s.label.startsWith('fold:xfade'))).toBe(false); + }); }); describe('compileTimeline — v1 guards (explicit, not silent-wrong)', () => { diff --git a/tests/composition-invert.test.ts b/tests/composition-invert.test.ts new file mode 100644 index 0000000..c7f1ff9 --- /dev/null +++ b/tests/composition-invert.test.ts @@ -0,0 +1,376 @@ +import { describe, expect, it } from 'vitest'; +import { type Composition, emptyComposition } from '../src/timeline/composition.js'; +import { + applyOp, + applyOps, + audioTrack, + type CompositionOp, + invertOp, + mediaClip, + textClip, + videoTrack, +} from '../src/timeline/ops.js'; +import type { MediaId } from '../src/timeline/schema.js'; + +const M = 'm_aaaaaaaaaaaa' as MediaId; + +/** + * A document exercising the cases inverses must handle: a MIDDLE track (z-order), + * two media clips and a text clip, and a transition after the first clip. + */ +function richDoc(): Composition { + return applyOps(emptyComposition(), [ + { op: 'addTrack', track: videoTrack({ id: 'v0' }) }, + { op: 'addTrack', track: videoTrack({ id: 'v1' }) }, + { op: 'addTrack', track: audioTrack({ id: 'a0' }) }, + { + op: 'addClip', + trackId: 'v0', + clip: mediaClip({ id: 'c1', mediaId: M, sourceOutSec: 5, startSec: 0 }), + }, + { + op: 'addClip', + trackId: 'v0', + clip: mediaClip({ id: 'c2', mediaId: M, sourceOutSec: 4, startSec: 5 }), + }, + { + op: 'addClip', + trackId: 'v1', + clip: textClip({ id: 't1', text: 'hi', durationSec: 3, startSec: 0 }), + }, + { + op: 'addTransition', + trackId: 'v0', + transition: { afterClipId: 'c1', kind: 'fade', durationSec: 1 }, + }, + ]); +} + +/** The core property: applying an op then its inverse restores the pre-state exactly. */ +function expectRoundtrip(pre: Composition, op: CompositionOp): void { + const frozen = structuredClone(pre); + const after = applyOp(pre, op); + const inverse = invertOp(pre, op); + // invertOp must not mutate its input. + expect(pre).toEqual(frozen); + expect(applyOps(after, inverse)).toEqual(pre); +} + +describe('invertOp — per-op round-trip (apply then inverse = identity)', () => { + it('setCanvas (only overwritten fields restored)', () => { + expectRoundtrip(richDoc(), { op: 'setCanvas', width: 1080, fps: 24 }); + }); + + it('addTrack (append) → removeTrack', () => { + expectRoundtrip(richDoc(), { op: 'addTrack', track: videoTrack({ id: 'vNew' }) }); + }); + + it('addTrack (at index) → removeTrack', () => { + expectRoundtrip(richDoc(), { op: 'addTrack', track: videoTrack({ id: 'vNew' }), index: 1 }); + }); + + it('removeTrack of a MIDDLE track restores it at its original z-index', () => { + expectRoundtrip(richDoc(), { op: 'removeTrack', trackId: 'v1' }); + }); + + it('removeTrack of a track carrying clips + a transition', () => { + expectRoundtrip(richDoc(), { op: 'removeTrack', trackId: 'v0' }); + }); + + it('addClip → removeClip', () => { + expectRoundtrip(richDoc(), { + op: 'addClip', + trackId: 'v1', + clip: mediaClip({ id: 'cNew', mediaId: M, sourceOutSec: 2, startSec: 8 }), + }); + }); + + it('removeClip WITH a following transition restores both', () => { + expectRoundtrip(richDoc(), { op: 'removeClip', clipId: 'c1' }); + }); + + it('removeClip without a transition', () => { + expectRoundtrip(richDoc(), { op: 'removeClip', clipId: 'c2' }); + }); + + it('moveClip (same track, startSec only)', () => { + expectRoundtrip(richDoc(), { op: 'moveClip', clipId: 'c2', startSec: 7 }); + }); + + it('moveClip across tracks (drops + restores the source transition)', () => { + expectRoundtrip(richDoc(), { op: 'moveClip', clipId: 'c1', toTrackId: 'v1' }); + }); + + it('moveClip across tracks AND startSec', () => { + expectRoundtrip(richDoc(), { op: 'moveClip', clipId: 'c2', startSec: 1, toTrackId: 'v1' }); + }); + + it('setTrim', () => { + expectRoundtrip(richDoc(), { op: 'setTrim', clipId: 'c1', sourceInSec: 1, sourceOutSec: 4 }); + }); + + it('setDuration (text clip)', () => { + expectRoundtrip(richDoc(), { op: 'setDuration', clipId: 't1', durationSec: 6 }); + }); + + it('splitClip (media) WITH a following transition', () => { + expectRoundtrip(richDoc(), { op: 'splitClip', clipId: 'c1', atSec: 2, newClipId: 'c1b' }); + }); + + it('splitClip (media) without a transition', () => { + expectRoundtrip(richDoc(), { op: 'splitClip', clipId: 'c2', atSec: 6, newClipId: 'c2b' }); + }); + + it('splitClip (text)', () => { + expectRoundtrip(richDoc(), { op: 'splitClip', clipId: 't1', atSec: 1, newClipId: 't1b' }); + }); + + it('addEffect (append)', () => { + expectRoundtrip(richDoc(), { + op: 'addEffect', + clipId: 'c1', + effect: { type: 'speed', factor: 2 }, + }); + }); + + it('addEffect (at index, among existing effects)', () => { + const pre = applyOps(richDoc(), [ + { op: 'addEffect', clipId: 'c1', effect: { type: 'speed', factor: 2 } }, + { op: 'addEffect', clipId: 'c1', effect: { type: 'volume', gain: 0.5 } }, + ]); + expectRoundtrip(pre, { + op: 'addEffect', + clipId: 'c1', + effect: { type: 'fadeIn', durationSec: 1 }, + index: 1, + }); + }); + + it('removeEffect', () => { + const pre = applyOps(richDoc(), [ + { op: 'addEffect', clipId: 'c1', effect: { type: 'speed', factor: 2 } }, + { op: 'addEffect', clipId: 'c1', effect: { type: 'volume', gain: 0.5 } }, + ]); + expectRoundtrip(pre, { op: 'removeEffect', clipId: 'c1', index: 0 }); + }); + + it('setTransform on a clip with NO prior transform (inverse clears)', () => { + expectRoundtrip(richDoc(), { op: 'setTransform', clipId: 'c1', transform: { scale: 2 } }); + }); + + it('setTransform on a clip WITH a prior transform (inverse restores it)', () => { + const pre = applyOp(richDoc(), { + op: 'setTransform', + clipId: 'c1', + transform: { scale: 2, x: 0.25 }, + }); + expectRoundtrip(pre, { + op: 'setTransform', + clipId: 'c1', + transform: { x: 0.75, opacity: 0.5 }, + }); + }); + + it('clearTransform on a clip with a transform (inverse restores it)', () => { + const pre = applyOp(richDoc(), { op: 'setTransform', clipId: 'c1', transform: { scale: 3 } }); + expectRoundtrip(pre, { op: 'clearTransform', clipId: 'c1' }); + }); + + it('clearTransform on a clip with no transform (no-op round-trip)', () => { + expectRoundtrip(richDoc(), { op: 'clearTransform', clipId: 'c1' }); + }); + + it('addTransition (fresh)', () => { + expectRoundtrip(richDoc(), { + op: 'addTransition', + trackId: 'v0', + transition: { afterClipId: 'c2', kind: 'wipeleft', durationSec: 0.5 }, + }); + }); + + it('addTransition REPLACING an existing one restores the prior', () => { + expectRoundtrip(richDoc(), { + op: 'addTransition', + trackId: 'v0', + transition: { afterClipId: 'c1', kind: 'circleopen', durationSec: 2 }, + }); + }); + + it('removeTransition (existing)', () => { + expectRoundtrip(richDoc(), { op: 'removeTransition', trackId: 'v0', afterClipId: 'c1' }); + }); + + it('removeTransition (nonexistent — no-op round-trip)', () => { + expectRoundtrip(richDoc(), { op: 'removeTransition', trackId: 'v0', afterClipId: 'c2' }); + }); +}); + +describe('invertOp — LIFO sequence undo (mirrors the op-log undo path)', () => { + it('undoing a sequence of ops in reverse restores the start state', () => { + const start = richDoc(); + const ops: CompositionOp[] = [ + { op: 'setTransform', clipId: 'c1', transform: { scale: 2 } }, + { op: 'splitClip', clipId: 'c1', atSec: 2, newClipId: 'c1b' }, + { op: 'moveClip', clipId: 't1', toTrackId: 'v0' }, + { op: 'addEffect', clipId: 'c2', effect: { type: 'fadeOut', durationSec: 1 } }, + { op: 'removeTrack', trackId: 'a0' }, + ]; + + // Apply forward, capturing each op's inverse against the state it saw. + let state = start; + const inverses: CompositionOp[][] = []; + for (const op of ops) { + inverses.push(invertOp(state, op)); + state = applyOp(state, op); + } + + // Undo in reverse (LIFO), exactly as popping op-log entries would. + let undone = state; + for (let i = inverses.length - 1; i >= 0; i--) { + const inv = inverses[i]; + if (inv) undone = applyOps(undone, inv); + } + + expect(undone).toEqual(start); + }); +}); + +describe('new op-vocabulary arms', () => { + it('addTrack with index inserts at the z-order position', () => { + const comp = applyOp(richDoc(), { op: 'addTrack', track: videoTrack({ id: 'vX' }), index: 1 }); + expect(comp.tracks.map((t) => t.id)).toEqual(['v0', 'vX', 'v1', 'a0']); + }); + + it('addTrack index clamps out-of-range values', () => { + const comp = applyOp(richDoc(), { op: 'addTrack', track: videoTrack({ id: 'vX' }), index: 99 }); + expect(comp.tracks.map((t) => t.id)).toEqual(['v0', 'v1', 'a0', 'vX']); + }); + + it('clearTransform removes the transform field', () => { + const withTransform = applyOp(richDoc(), { + op: 'setTransform', + clipId: 'c1', + transform: { scale: 2 }, + }); + const cleared = applyOp(withTransform, { op: 'clearTransform', clipId: 'c1' }); + const clip = cleared.tracks[0]?.clips.find((c) => c.id === 'c1'); + expect(clip?.transform).toBeUndefined(); + }); + + it('clearTransform throws on a missing clip', () => { + expect(() => applyOp(richDoc(), { op: 'clearTransform', clipId: 'nope' })).toThrow(); + }); +}); + +describe('invertOp — clips carrying effects + a transform (no silent data loss)', () => { + // A clip with a transform and a non-trivial effect stack is where a sloppy + // inverse would quietly drop data; structuredClone + deep-equal must catch it. + function decoratedDoc(): Composition { + return applyOps(richDoc(), [ + { op: 'setTransform', clipId: 'c1', transform: { scale: 1.5, x: 0.4, opacity: 0.8 } }, + { op: 'addEffect', clipId: 'c1', effect: { type: 'speed', factor: 1.5 } }, + { op: 'addEffect', clipId: 'c1', effect: { type: 'fadeIn', durationSec: 0.5 } }, + ]); + } + + it('splitClip preserves effects + transform on both halves through the round-trip', () => { + expectRoundtrip(decoratedDoc(), { op: 'splitClip', clipId: 'c1', atSec: 2, newClipId: 'c1b' }); + }); + + it('moveClip across tracks preserves effects + transform + the dropped transition', () => { + expectRoundtrip(decoratedDoc(), { op: 'moveClip', clipId: 'c1', toTrackId: 'v1' }); + }); + + it('removeTrack restores a track whose clips carry effects + transforms', () => { + expectRoundtrip(decoratedDoc(), { op: 'removeTrack', trackId: 'v0' }); + }); +}); + +describe('invertOp — array-order edge cases (regressions)', () => { + // A track carrying MORE THAN ONE transition, where the one we disturb is not + // last in the array. A non-canonical inverse would re-append it and fail the + // deep-equal round-trip. + function multiTransitionDoc(): Composition { + return applyOps(emptyComposition(), [ + { op: 'addTrack', track: videoTrack({ id: 'v0' }) }, + { op: 'addTrack', track: videoTrack({ id: 'v1' }) }, + { + op: 'addClip', + trackId: 'v0', + clip: mediaClip({ id: 'c1', mediaId: M, sourceOutSec: 4, startSec: 0 }), + }, + { + op: 'addClip', + trackId: 'v0', + clip: mediaClip({ id: 'c2', mediaId: M, sourceOutSec: 4, startSec: 4 }), + }, + { + op: 'addClip', + trackId: 'v0', + clip: mediaClip({ id: 'c3', mediaId: M, sourceOutSec: 4, startSec: 8 }), + }, + { + op: 'addTransition', + trackId: 'v0', + transition: { afterClipId: 'c1', kind: 'fade', durationSec: 1 }, + }, + { + op: 'addTransition', + trackId: 'v0', + transition: { afterClipId: 'c2', kind: 'wipeleft', durationSec: 1 }, + }, + ]); + } + + it('removeClip of a clip whose transition is NOT last in the array', () => { + expectRoundtrip(multiTransitionDoc(), { op: 'removeClip', clipId: 'c1' }); + }); + + it('moveClip (cross-track) of a clip whose transition is not last', () => { + expectRoundtrip(multiTransitionDoc(), { op: 'moveClip', clipId: 'c1', toTrackId: 'v1' }); + }); + + it('splitClip of a clip whose transition is not last', () => { + expectRoundtrip(multiTransitionDoc(), { + op: 'splitClip', + clipId: 'c1', + atSec: 2, + newClipId: 'c1b', + }); + }); + + it('addTransition replacing one that is not last', () => { + expectRoundtrip(multiTransitionDoc(), { + op: 'addTransition', + trackId: 'v0', + transition: { afterClipId: 'c1', kind: 'circleopen', durationSec: 2 }, + }); + }); + + // Two clips sharing a startSec on one track — a startSec-only sort would flip + // their relative order when one is removed and re-added. + function equalStartDoc(): Composition { + return applyOps(emptyComposition(), [ + { op: 'addTrack', track: videoTrack({ id: 'v0' }) }, + { op: 'addTrack', track: videoTrack({ id: 'v1' }) }, + { + op: 'addClip', + trackId: 'v0', + clip: mediaClip({ id: 'ca', mediaId: M, sourceOutSec: 3, startSec: 0 }), + }, + { + op: 'addClip', + trackId: 'v0', + clip: mediaClip({ id: 'cb', mediaId: M, sourceOutSec: 3, startSec: 0 }), + }, + ]); + } + + it('removeClip of one of two equal-startSec siblings', () => { + expectRoundtrip(equalStartDoc(), { op: 'removeClip', clipId: 'ca' }); + }); + + it('moveClip (cross-track) of one of two equal-startSec siblings', () => { + expectRoundtrip(equalStartDoc(), { op: 'moveClip', clipId: 'ca', toTrackId: 'v1' }); + }); +}); diff --git a/tests/composition-op-schema.test.ts b/tests/composition-op-schema.test.ts new file mode 100644 index 0000000..dcc2b3d --- /dev/null +++ b/tests/composition-op-schema.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { + type CompositionOp, + type CompositionOpKind, + CompositionOpSchema, + mediaClip, + videoTrack, +} from '../src/timeline/ops.js'; +import type { MediaId } from '../src/timeline/schema.js'; + +const M = 'm_aaaaaaaaaaaa' as MediaId; + +// One valid sample per op kind. The `Record` type makes +// this exhaustive at COMPILE time: add a new op kind and this map fails to type +// until a sample exists, which the parse test below then exercises — so the +// runtime schema can never silently fall behind the hand-written union. +const SAMPLES: Record = { + setCanvas: { op: 'setCanvas', width: 1280, height: 720, fps: 24, background: 'white' }, + addTrack: { op: 'addTrack', track: videoTrack({ id: 'v0' }), index: 1 }, + removeTrack: { op: 'removeTrack', trackId: 'v0' }, + addClip: { + op: 'addClip', + trackId: 'v0', + clip: mediaClip({ id: 'c1', mediaId: M, sourceOutSec: 4, startSec: 0 }), + }, + removeClip: { op: 'removeClip', clipId: 'c1' }, + moveClip: { op: 'moveClip', clipId: 'c1', startSec: 2, toTrackId: 'v1' }, + setTrim: { op: 'setTrim', clipId: 'c1', sourceInSec: 1, sourceOutSec: 3 }, + setDuration: { op: 'setDuration', clipId: 't1', durationSec: 5 }, + splitClip: { op: 'splitClip', clipId: 'c1', atSec: 2, newClipId: 'c1b' }, + addEffect: { op: 'addEffect', clipId: 'c1', effect: { type: 'speed', factor: 2 }, index: 0 }, + removeEffect: { op: 'removeEffect', clipId: 'c1', index: 0 }, + setTransform: { op: 'setTransform', clipId: 'c1', transform: { scale: 2, x: 0.25 } }, + clearTransform: { op: 'clearTransform', clipId: 'c1' }, + addTransition: { + op: 'addTransition', + trackId: 'v0', + transition: { afterClipId: 'c1', kind: 'fade', durationSec: 1 }, + }, + removeTransition: { op: 'removeTransition', trackId: 'v0', afterClipId: 'c1' }, +}; + +describe('CompositionOpSchema', () => { + it.each(Object.entries(SAMPLES))('accepts a valid %s op byte-stably', (_kind, op) => { + expect(CompositionOpSchema.parse(op)).toEqual(op); + }); + + it('rejects an unknown op kind', () => { + expect(() => CompositionOpSchema.parse({ op: 'teleport', clipId: 'c1' })).toThrow(); + }); + + it('rejects a known op missing a required field', () => { + expect(() => CompositionOpSchema.parse({ op: 'removeClip' })).toThrow(); + }); + + it('rejects an op with a malformed nested payload', () => { + expect(() => + CompositionOpSchema.parse({ + op: 'addClip', + trackId: 'v0', + clip: { kind: 'media', id: 'c1', mediaId: 'not-a-media-id', sourceOutSec: 4, startSec: 0 }, + }), + ).toThrow(); + }); +}); diff --git a/tests/doc-op-log.test.ts b/tests/doc-op-log.test.ts new file mode 100644 index 0000000..8c7d7f3 --- /dev/null +++ b/tests/doc-op-log.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from 'vitest'; +import { emptyComposition } from '../src/timeline/composition.js'; +import { + canRedo, + canUndo, + type DocOpLogEntry, + emptyDocOpLog, + parseDocOpLog, + reconcile, + recordOps, + redo, + serializeDocOpLog, + undo, +} from '../src/timeline/doc-op-log.js'; +import { + applyOps, + applyOpsTracked, + type CompositionOp, + mediaClip, + videoTrack, +} from '../src/timeline/ops.js'; +import type { MediaId } from '../src/timeline/schema.js'; + +const M = 'm_aaaaaaaaaaaa' as MediaId; + +function entry(id: string, forward: CompositionOp[], inverse: CompositionOp[]): DocOpLogEntry { + return { id, label: forward.map((o) => o.op).join('+'), forward, inverse }; +} + +describe('applyOpsTracked', () => { + it('matches applyOps forward and returns an inverse that undoes the whole batch', () => { + const start = applyOps(emptyComposition(), [ + { op: 'addTrack', track: videoTrack({ id: 'v0' }) }, + ]); + const ops: CompositionOp[] = [ + { + op: 'addClip', + trackId: 'v0', + clip: mediaClip({ id: 'c1', mediaId: M, sourceOutSec: 4, startSec: 0 }), + }, + { op: 'splitClip', clipId: 'c1', atSec: 2, newClipId: 'c1b' }, + { op: 'setTransform', clipId: 'c1', transform: { scale: 2 } }, + ]; + const { doc, inverse } = applyOpsTracked(start, ops); + expect(doc).toEqual(applyOps(start, ops)); // same forward result + expect(applyOps(doc, inverse)).toEqual(start); // inverse undoes the batch + }); + + it('an empty batch is a no-op with an empty inverse', () => { + const start = emptyComposition(); + const { doc, inverse } = applyOpsTracked(start, []); + expect(doc).toEqual(start); + expect(inverse).toEqual([]); + }); +}); + +describe('doc-op-log algebra', () => { + it('recordOps appends entries and advances the cursor', () => { + let log = emptyDocOpLog(0); + log = recordOps( + log, + entry('dop_1', [{ op: 'setCanvas', width: 1 }], [{ op: 'setCanvas', width: 2 }]), + ); + log = recordOps( + log, + entry('dop_2', [{ op: 'setCanvas', width: 3 }], [{ op: 'setCanvas', width: 1 }]), + ); + expect(log.entries.map((e) => e.id)).toEqual(['dop_1', 'dop_2']); + expect(log.cursor).toBe(2); + expect(canUndo(log)).toBe(true); + expect(canRedo(log)).toBe(false); + }); + + it('undo moves the cursor back and returns the entry to reverse', () => { + let log = emptyDocOpLog(); + log = recordOps( + log, + entry('dop_1', [{ op: 'setCanvas', width: 1 }], [{ op: 'setCanvas', width: 9 }]), + ); + const u = undo(log); + expect(u?.entry.id).toBe('dop_1'); + expect(u?.log.cursor).toBe(0); + expect(canRedo(u?.log ?? log)).toBe(true); + }); + + it('redo returns the forward entry and restores the cursor', () => { + let log = emptyDocOpLog(); + log = recordOps( + log, + entry('dop_1', [{ op: 'setCanvas', width: 1 }], [{ op: 'setCanvas', width: 9 }]), + ); + const afterUndo = undo(log)?.log ?? log; + const r = redo(afterUndo); + expect(r?.entry.id).toBe('dop_1'); + expect(r?.log.cursor).toBe(1); + }); + + it('undo at the bottom and redo at the top return null', () => { + expect(undo(emptyDocOpLog())).toBeNull(); + const log = recordOps(emptyDocOpLog(), entry('dop_1', [], [])); + expect(redo(log)).toBeNull(); // cursor already at the end + }); + + it('a fresh edit after undo truncates the redo tail', () => { + let log = emptyDocOpLog(); + log = recordOps(log, entry('a', [{ op: 'setCanvas', width: 1 }], [])); + log = recordOps(log, entry('b', [{ op: 'setCanvas', width: 2 }], [])); + log = undo(log)?.log ?? log; // cursor 1; 'b' is now redoable + log = recordOps(log, entry('c', [{ op: 'setCanvas', width: 3 }], [])); // truncates 'b' + expect(log.entries.map((e) => e.id)).toEqual(['a', 'c']); + expect(log.cursor).toBe(2); + expect(canRedo(log)).toBe(false); + }); +}); + +describe('reconcile (crash-desync safety)', () => { + it('trusts the log when the revs match', () => { + const log = recordOps(emptyDocOpLog(5), entry('dop_1', [], [])); + expect(reconcile(log, 5)).toBe(log); + }); + + it('drops history (keeps an empty log at the doc rev) when revs disagree', () => { + const log = recordOps(emptyDocOpLog(5), entry('dop_1', [], [])); + const r = reconcile(log, 6); + expect(r.entries).toEqual([]); + expect(r.cursor).toBe(0); + expect(r.rev).toBe(6); + }); +}); + +describe('persistence (parse / serialize)', () => { + it('round-trips through serialize + parse', () => { + const log = recordOps( + emptyDocOpLog(3), + entry( + 'dop_1', + [ + { + op: 'addClip', + trackId: 'v0', + clip: mediaClip({ id: 'c1', mediaId: M, sourceOutSec: 4, startSec: 0 }), + }, + ], + [{ op: 'removeClip', clipId: 'c1' }], + ), + ); + expect(parseDocOpLog(JSON.parse(serializeDocOpLog(log)))).toEqual(log); + }); + + it('loads a rev-less / cursor-less legacy log with defaults', () => { + expect(parseDocOpLog({ version: 1, entries: [] })).toEqual({ + version: 1, + rev: 0, + cursor: 0, + entries: [], + }); + }); + + it('rejects a log carrying a malformed op', () => { + expect(() => + parseDocOpLog({ + version: 1, + rev: 0, + cursor: 0, + entries: [{ id: 'x', label: 'bad', forward: [{ op: 'nope' }], inverse: [] }], + }), + ).toThrow(); + }); +}); diff --git a/tests/document-store-oplog.test.ts b/tests/document-store-oplog.test.ts new file mode 100644 index 0000000..96a8e9b --- /dev/null +++ b/tests/document-store-oplog.test.ts @@ -0,0 +1,174 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { emptyComposition } from '../src/timeline/composition.js'; +import { + compositionOpsPath, + compositionPath, + mutateComposition, + overwriteComposition, + readComposition, + readDocOpLog, + redoDocOp, + resetComposition, + undoLastDocOp, + writeCompositionIfUnchanged, +} from '../src/timeline/document-store.js'; +import { mediaClip, videoTrack } from '../src/timeline/ops.js'; +import type { MediaId } from '../src/timeline/schema.js'; + +const M = 'm_aaaaaaaaaaaa' as MediaId; + +let workspace: string; +let saved: string | undefined; + +beforeEach(async () => { + workspace = await mkdtemp(join(tmpdir(), 'mmc-oplog-test-')); + saved = process.env.MAKEMYCLIP_WORKSPACE; + process.env.MAKEMYCLIP_WORKSPACE = workspace; +}); + +afterEach(async () => { + if (saved === undefined) delete process.env.MAKEMYCLIP_WORKSPACE; + else process.env.MAKEMYCLIP_WORKSPACE = saved; + await rm(workspace, { recursive: true, force: true }); +}); + +describe('op-log integration — undo / redo', () => { + it('records each mutation and exposes undo/redo state', async () => { + await mutateComposition([{ op: 'addTrack', track: videoTrack({ id: 'v0' }) }]); + const log = await readDocOpLog(); + expect(log.entries).toHaveLength(1); + expect(log.cursor).toBe(1); + expect(log.entries[0]?.label).toBe('addTrack'); + }); + + it('undo reverts the document and redo re-applies it', async () => { + await mutateComposition([{ op: 'addTrack', track: videoTrack({ id: 'v0' }) }]); + const before = await readComposition(); + + expect((await undoLastDocOp()).undone).toBe(true); + expect((await readComposition()).tracks).toEqual([]); + + expect((await redoDocOp()).redone).toBe(true); + const after = await readComposition(); + expect(after.tracks.map((t) => t.id)).toEqual(['v0']); + // Same content as before the undo (rev advances — undo/redo are writes). + expect({ ...after, rev: 0 }).toEqual({ ...before, rev: 0 }); + }); + + it('undo at the bottom and redo at the top are no-ops', async () => { + expect((await undoLastDocOp()).undone).toBe(false); + await mutateComposition([{ op: 'setCanvas', width: 1280 }]); + expect((await redoDocOp()).redone).toBe(false); + }); + + it('a fresh edit after undo truncates the redo tail', async () => { + await mutateComposition([{ op: 'addTrack', track: videoTrack({ id: 'v0' }) }]); + await mutateComposition([{ op: 'addTrack', track: videoTrack({ id: 'v1' }) }]); + await undoLastDocOp(); // undo v1; it is now redoable + await mutateComposition([{ op: 'addTrack', track: videoTrack({ id: 'v2' }) }]); // truncates v1 + + const log = await readDocOpLog(); + expect(log.entries.map((e) => e.label)).toEqual(['addTrack', 'addTrack']); + expect(log.cursor).toBe(2); + expect((await redoDocOp()).redone).toBe(false); + expect((await readComposition()).tracks.map((t) => t.id)).toEqual(['v0', 'v2']); + }); + + it('doc rev and log rev stay in lockstep across mutate / undo / redo', async () => { + await mutateComposition([{ op: 'setCanvas', width: 1280 }]); + expect((await readDocOpLog()).rev).toBe((await readComposition()).rev); + await mutateComposition([{ op: 'setCanvas', height: 720 }]); + expect((await readDocOpLog()).rev).toBe((await readComposition()).rev); + await undoLastDocOp(); + expect((await readDocOpLog()).rev).toBe((await readComposition()).rev); + await redoDocOp(); + expect((await readDocOpLog()).rev).toBe((await readComposition()).rev); + }); + + it('multi-step undo then redo returns to the exact prior state', async () => { + await mutateComposition([{ op: 'addTrack', track: videoTrack({ id: 'v0' }) }]); + await mutateComposition([ + { + op: 'addClip', + trackId: 'v0', + clip: mediaClip({ id: 'c1', mediaId: M, sourceOutSec: 4, startSec: 0 }), + }, + ]); + const final = await readComposition(); + + await undoLastDocOp(); + await undoLastDocOp(); + expect((await readComposition()).tracks).toEqual([]); + + await redoDocOp(); + await redoDocOp(); + expect({ ...(await readComposition()), rev: 0 }).toEqual({ ...final, rev: 0 }); + }); + + it('drops undo history when the op-log file is corrupt — the document survives', async () => { + await mutateComposition([{ op: 'addTrack', track: videoTrack({ id: 'v0' }) }]); + await writeFile(compositionOpsPath(), 'not valid json'); + + const log = await readDocOpLog(); + expect(log.entries).toEqual([]); // history dropped, no throw + expect((await readComposition()).tracks.map((t) => t.id)).toEqual(['v0']); // doc intact + expect((await undoLastDocOp()).undone).toBe(false); + }); + + it('resetComposition (clip timeline new) clears the op-log', async () => { + await mutateComposition([{ op: 'addTrack', track: videoTrack({ id: 'v0' }) }]); + await resetComposition(emptyComposition()); + expect((await readDocOpLog()).entries).toEqual([]); + expect((await undoLastDocOp()).undone).toBe(false); + }); + + it('an empty op batch is a true no-op (no rev bump, no log entry)', async () => { + await mutateComposition([{ op: 'setCanvas', width: 1280 }]); // rev 1 + const before = await readComposition(); + await mutateComposition([]); + expect((await readComposition()).rev).toBe(before.rev); + expect((await readDocOpLog()).entries).toHaveLength(1); + }); +}); + +describe('op-log lockstep across non-recording doc writers (regressions)', () => { + it('overwriteComposition over a corrupt doc resets history — undo does NOT replay a stale inverse', async () => { + // The colliding-rev case: mutate once (doc rev 1, log rev 1 with an addTrack + // entry), corrupt the doc, then recover. A naive overwrite resets the doc to + // rev 1 while leaving the log at rev 1, so reconcile MATCHES the stale log and + // undo applies removeTrack to a doc with no such track — which throws. + await mutateComposition([{ op: 'addTrack', track: videoTrack({ id: 'v0' }) }]); + await writeFile(compositionPath(), 'not valid json'); + await overwriteComposition(emptyComposition()); + + const log = await readDocOpLog(); + expect(log.entries).toEqual([]); + expect(log.rev).toBe((await readComposition()).rev); // lockstep + expect((await undoLastDocOp()).undone).toBe(false); // a clean no-op, not a throw + }); + + it('writeCompositionIfUnchanged resets undo history and keeps revs in lockstep', async () => { + await mutateComposition([{ op: 'addTrack', track: videoTrack({ id: 'v0' }) }]); + await mutateComposition([{ op: 'addTrack', track: videoTrack({ id: 'v1' }) }]); + const current = await readComposition(); + + await writeCompositionIfUnchanged({ ...current, background: '#111' }, current.rev); + + const log = await readDocOpLog(); + expect(log.entries).toEqual([]); // a raw CAS write is not recorded → history reset + expect(log.rev).toBe((await readComposition()).rev); // lockstep, not a silent desync + expect((await undoLastDocOp()).undone).toBe(false); + }); + + it('resetComposition advances rev past the previous one (never reuses a rev)', async () => { + await mutateComposition([{ op: 'setCanvas', width: 1280 }]); // rev 1 + await mutateComposition([{ op: 'setCanvas', height: 720 }]); // rev 2 + const reset = await resetComposition(emptyComposition()); + expect(reset.rev).toBeGreaterThan(2); + expect((await readComposition()).rev).toBe(reset.rev); + expect((await readDocOpLog()).rev).toBe(reset.rev); + }); +}); diff --git a/tests/document-store.test.ts b/tests/document-store.test.ts index 7aa9c69..1a6c647 100644 --- a/tests/document-store.test.ts +++ b/tests/document-store.test.ts @@ -4,12 +4,15 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { emptyComposition } from '../src/timeline/composition.js'; import { + CompositionConflictError, CompositionCorruptError, compositionPath, mutateComposition, + overwriteComposition, readComposition, resetComposition, writeComposition, + writeCompositionIfUnchanged, } from '../src/timeline/document-store.js'; import { mediaClip, videoTrack } from '../src/timeline/ops.js'; import type { MediaId } from '../src/timeline/schema.js'; @@ -72,3 +75,69 @@ describe('document-store', () => { expect(onDisk).toEqual(comp); }); }); + +describe('optimistic concurrency (rev)', () => { + it('loads a rev-less legacy composition as rev 0 (back-compat)', async () => { + // Documents written before `rev` existed must still parse. + await writeFile(compositionPath(), JSON.stringify({ version: 1, tracks: [] })); + expect((await readComposition()).rev).toBe(0); + }); + + it('bumps rev monotonically and persists it across mutations', async () => { + expect((await readComposition()).rev).toBe(0); + await mutateComposition([{ op: 'setCanvas', width: 1280 }]); + expect((await readComposition()).rev).toBe(1); + await mutateComposition([{ op: 'setCanvas', height: 720 }]); + expect((await readComposition()).rev).toBe(2); + }); + + it('writeCompositionIfUnchanged commits and bumps rev when the base is current', async () => { + await mutateComposition([{ op: 'addTrack', track: videoTrack({ id: 'v0' }) }]); // rev 1 + const current = await readComposition(); + const next = await writeCompositionIfUnchanged(current, current.rev); + expect(next.rev).toBe(current.rev + 1); + expect((await readComposition()).rev).toBe(current.rev + 1); + }); + + it('rejects a stale CAS write whose base rev was superseded, without clobbering', async () => { + await mutateComposition([{ op: 'addTrack', track: videoTrack({ id: 'v0' }) }]); // rev 1 + const stale = await readComposition(); // captured at rev 1 + await mutateComposition([{ op: 'addTrack', track: videoTrack({ id: 'v1' }) }]); // winner → rev 2 + + await expect(writeCompositionIfUnchanged(stale, stale.rev)).rejects.toBeInstanceOf( + CompositionConflictError, + ); + // The winner survived; the rejected write did not overwrite it. + const final = await readComposition(); + expect(final.rev).toBe(2); + expect(final.tracks.map((t) => t.id)).toEqual(['v0', 'v1']); + }); + + it('serializes concurrent in-process mutations — no lost update', async () => { + // The lost-update bug the CAS store fixes: a lock-free read-apply-write let + // two concurrent callers clobber each other. Both ops must land, rev = 2. + await Promise.all([ + mutateComposition([{ op: 'addTrack', track: videoTrack({ id: 'v0' }) }]), + mutateComposition([{ op: 'addTrack', track: videoTrack({ id: 'v1' }) }]), + ]); + const comp = await readComposition(); + expect(comp.rev).toBe(2); + expect(new Set(comp.tracks.map((t) => t.id))).toEqual(new Set(['v0', 'v1'])); + }); +}); + +describe('overwriteComposition (recovery primitive)', () => { + it('replaces composition.json WITHOUT parsing it, even when corrupt', async () => { + await mutateComposition([{ op: 'setCanvas', width: 1280 }]); // rev 1 + await writeFile(compositionPath(), 'not valid json'); // live doc now corrupt + await expect(readComposition()).rejects.toBeInstanceOf(CompositionCorruptError); + + const next = await overwriteComposition(emptyComposition({ fps: 24 })); + expect(next.rev).toBe(1); // corrupt base → rev 0 → 1 + expect(next.fps).toBe(24); + + const reread = await readComposition(); + expect(reread.fps).toBe(24); + expect(reread.rev).toBe(1); + }); +}); diff --git a/tests/timeline-introspect.test.ts b/tests/timeline-introspect.test.ts new file mode 100644 index 0000000..cb7bbbc --- /dev/null +++ b/tests/timeline-introspect.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from 'vitest'; +import { buildFrameAtPlan, CompileError, type MediaInfo } from '../src/timeline/compile.js'; +import { clipsAtTime, emptyComposition } from '../src/timeline/composition.js'; +import { applyOps, mediaClip, textClip, videoTrack } from '../src/timeline/ops.js'; +import type { MediaId } from '../src/timeline/schema.js'; + +const M = 'm_aaaaaaaaaaaa' as MediaId; +const MEDIA = new Map([[M, { path: '/in.mp4', hasAudio: true }]]); +const CTX = (output: string) => ({ media: MEDIA, dir: '/ws', output }); + +describe('clipsAtTime', () => { + const doc = applyOps(emptyComposition(), [ + { op: 'addTrack', track: videoTrack({ id: 'v0' }) }, + { op: 'addTrack', track: videoTrack({ id: 'v1' }) }, + { + op: 'addClip', + trackId: 'v0', + clip: mediaClip({ id: 'a', mediaId: M, sourceOutSec: 5, startSec: 0 }), + }, + { + op: 'addClip', + trackId: 'v0', + clip: mediaClip({ id: 'b', mediaId: M, sourceOutSec: 4, startSec: 5 }), + }, + { + op: 'addClip', + trackId: 'v1', + clip: textClip({ id: 't', text: 'hi', durationSec: 3, startSec: 0 }), + }, + ]); + + it('is half-open: the boundary belongs to the later clip', () => { + expect( + clipsAtTime(doc, 0) + .map((h) => h.clip.id) + .sort(), + ).toEqual(['a', 't']); + expect(clipsAtTime(doc, 4.999).map((h) => h.clip.id)).toContain('a'); + // At exactly 5, clip 'a' ([0,5)) ends and 'b' ([5,9)) begins → 'b', not 'a'. + expect(clipsAtTime(doc, 5).map((h) => h.clip.id)).not.toContain('a'); + expect(clipsAtTime(doc, 5).map((h) => h.clip.id)).toContain('b'); + }); + + it('returns every track live at the time, with the offset into each clip', () => { + const hits = clipsAtTime(doc, 2); + expect(hits.map((h) => h.clip.id).sort()).toEqual(['a', 't']); + expect(hits.find((h) => h.clip.id === 'a')?.localOffsetSec).toBe(2); + }); + + it('returns nothing in a gap or past the end', () => { + expect(clipsAtTime(doc, 9)).toEqual([]); // 'b' ends at 9 (half-open) + expect(clipsAtTime(doc, 100)).toEqual([]); + }); +}); + +describe('buildFrameAtPlan', () => { + function oneClip() { + return applyOps(emptyComposition(), [ + { op: 'addTrack', track: videoTrack({ id: 'v0' }) }, + { + op: 'addClip', + trackId: 'v0', + clip: mediaClip({ id: 'c1', mediaId: M, sourceOutSec: 8, startSec: 0 }), + }, + ]); + } + + it('encodes the clip segment then extracts the frame at the doc-local offset', () => { + const plan = buildFrameAtPlan(oneClip(), CTX('/ws/frame.jpg'), 3); + expect(plan.steps).toHaveLength(2); + const [seg, frame] = plan.steps; + expect(seg?.label).toBe('segment:c1'); + expect(frame?.label).toContain('frame:c1'); + expect(frame?.output).toBe('/ws/frame.jpg'); + // -ss -i + expect(frame?.args.slice(0, 2)).toEqual(['-y', '-ss']); + expect(Number(frame?.args[2])).toBeCloseTo(3); + expect(frame?.args[4]).toBe(seg?.output); // reads the encoded segment + expect(frame?.args.at(-1)).toBe('/ws/frame.jpg'); + }); + + it('selects the second clip and offsets into it for a time in the second clip', () => { + const doc = applyOps(oneClip(), [ + { + op: 'addClip', + trackId: 'v0', + clip: mediaClip({ id: 'c2', mediaId: M, sourceOutSec: 4, startSec: 8 }), + }, + ]); + const plan = buildFrameAtPlan(doc, CTX('/ws/f.jpg'), 10); // 2s into c2 ([8,12)) + expect(plan.steps[0]?.label).toBe('segment:c2'); + expect(Number(plan.steps[1]?.args[2])).toBeCloseTo(2); + }); + + it('maps doc-local time to the post-speed segment timebase for a speed clip', () => { + // 8s source at 2x → segment is 4s; doc-local 4s maps to segment time 2s. + const doc = applyOps(emptyComposition(), [ + { op: 'addTrack', track: videoTrack({ id: 'v0' }) }, + { + op: 'addClip', + trackId: 'v0', + clip: mediaClip({ + id: 'c1', + mediaId: M, + sourceOutSec: 8, + startSec: 0, + effects: [{ type: 'speed', factor: 2 }], + }), + }, + ]); + const plan = buildFrameAtPlan(doc, CTX('/ws/f.jpg'), 4); + expect(Number(plan.steps[1]?.args[2])).toBeCloseTo(2); + }); + + it('works on a text clip (no media required)', () => { + const doc = applyOps(emptyComposition(), [ + { op: 'addTrack', track: videoTrack({ id: 'v0' }) }, + { + op: 'addClip', + trackId: 'v0', + clip: textClip({ id: 't1', text: 'hi', durationSec: 4, startSec: 0 }), + }, + ]); + const plan = buildFrameAtPlan(doc, CTX('/ws/f.jpg'), 1); + expect(plan.steps[0]?.label).toBe('segment:t1'); + expect(Number(plan.steps[1]?.args[2])).toBeCloseTo(1); + }); + + it('throws past the end', () => { + expect(() => buildFrameAtPlan(oneClip(), CTX('/ws/f.jpg'), 9)).toThrow(CompileError); + }); + + it('throws in a gap', () => { + const doc = applyOps(oneClip(), [ + { + op: 'addClip', + trackId: 'v0', + clip: mediaClip({ id: 'c2', mediaId: M, sourceOutSec: 4, startSec: 12 }), + }, + ]); + // c1 is [0,8), c2 is [12,16); 10s is in the gap. + expect(() => buildFrameAtPlan(doc, CTX('/ws/f.jpg'), 10)).toThrow(CompileError); + }); + + it('throws before the start', () => { + expect(() => buildFrameAtPlan(oneClip(), CTX('/ws/f.jpg'), -1)).toThrow(CompileError); + }); + + it('throws on an overlapping (unexportable) timeline instead of guessing a clip', () => { + const doc = applyOps(oneClip(), [ + { + op: 'addClip', + trackId: 'v0', + clip: mediaClip({ id: 'c2', mediaId: M, sourceOutSec: 6, startSec: 4 }), + }, + ]); + // c1 [0,8) and c2 [4,10) overlap on one track — export rejects this, so frame must too. + expect(() => buildFrameAtPlan(doc, CTX('/ws/f.jpg'), 5)).toThrow(CompileError); + }); +}); diff --git a/tests/timeline-verbs.test.ts b/tests/timeline-verbs.test.ts new file mode 100644 index 0000000..526b89a --- /dev/null +++ b/tests/timeline-verbs.test.ts @@ -0,0 +1,230 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { emptyComposition } from '../src/timeline/composition.js'; +import { + applyVerbs, + CompositionConflictError, + mutateComposition, + readComposition, + readDocOpLog, + undoLastDocOp, +} from '../src/timeline/document-store.js'; +import { applyOps, type CompositionOp, videoTrack } from '../src/timeline/ops.js'; +import type { MediaId } from '../src/timeline/schema.js'; +import { lowerVerb, lowerVerbs, type VerbContext } from '../src/timeline/verbs.js'; + +const M = 'm_aaaaaaaaaaaa' as MediaId; +const ctx = (durationSec = 10): VerbContext => ({ + ingest: async () => ({ mediaId: M, durationSec }), + defaultTrack: 'v0', +}); + +type AddClipOp = Extract; +const addClips = (ops: CompositionOp[]): AddClipOp[] => + ops.filter((o): o is AddClipOp => o.op === 'addClip'); + +describe('lowerVerb', () => { + const empty = emptyComposition(); + + it('add_media ingests, creates the default track, and appends a media clip', async () => { + const ops = await lowerVerb(empty, { verb: 'add_media', path: '/a.mp4' }, ctx()); + expect(ops).toMatchObject([ + { op: 'addTrack', track: { id: 'v0' } }, + { + op: 'addClip', + trackId: 'v0', + clip: { kind: 'media', mediaId: M, sourceInSec: 0, sourceOutSec: 10, startSec: 0 }, + }, + ]); + }); + + it('add_media honors explicit start/trim/track and skips addTrack when it exists', async () => { + const doc = applyOps(empty, [{ op: 'addTrack', track: videoTrack({ id: 'v0' }) }]); + const ops = await lowerVerb( + doc, + { + verb: 'add_media', + path: '/a.mp4', + track: 'v0', + startSec: 5, + sourceInSec: 1, + sourceOutSec: 4, + }, + ctx(), + ); + expect(ops).toMatchObject([ + { op: 'addClip', clip: { sourceInSec: 1, sourceOutSec: 4, startSec: 5 } }, + ]); + expect(ops.some((o) => o.op === 'addTrack')).toBe(false); + }); + + it('add_text lowers style and appends after the last clip by default', async () => { + const ops = await lowerVerb( + empty, + { verb: 'add_text', text: 'hi', durationSec: 3, color: 'red' }, + ctx(), + ); + expect(ops).toMatchObject([ + { op: 'addTrack', track: { id: 'v0' } }, + { + op: 'addClip', + clip: { kind: 'text', text: 'hi', durationSec: 3, style: { color: 'red' } }, + }, + ]); + }); + + it('edit verbs lower 1:1 to ops', async () => { + expect( + await lowerVerb( + empty, + { verb: 'trim', clipId: 'c1', sourceInSec: 1, sourceOutSec: 3 }, + ctx(), + ), + ).toEqual([{ op: 'setTrim', clipId: 'c1', sourceInSec: 1, sourceOutSec: 3 }]); + expect( + await lowerVerb(empty, { verb: 'move', clipId: 'c1', startSec: 2, toTrack: 'v1' }, ctx()), + ).toEqual([{ op: 'moveClip', clipId: 'c1', startSec: 2, toTrackId: 'v1' }]); + expect(await lowerVerb(empty, { verb: 'remove', clipId: 'c1' }, ctx())).toEqual([ + { op: 'removeClip', clipId: 'c1' }, + ]); + expect(await lowerVerb(empty, { verb: 'transition', afterClipId: 'c1' }, ctx())).toEqual([ + { + op: 'addTransition', + trackId: 'v0', + transition: { afterClipId: 'c1', kind: 'fade', durationSec: 1 }, + }, + ]); + expect( + await lowerVerb( + empty, + { verb: 'set_transform', clipId: 'c1', scale: 2, opacity: 0.5 }, + ctx(), + ), + ).toEqual([{ op: 'setTransform', clipId: 'c1', transform: { scale: 2, opacity: 0.5 } }]); + const split = await lowerVerb(empty, { verb: 'split', clipId: 'c1', atSec: 2 }, ctx()); + expect(split[0]).toMatchObject({ op: 'splitClip', clipId: 'c1', atSec: 2 }); + }); +}); + +describe('lowerVerbs (state threads between verbs)', () => { + it('the default append point shifts as earlier verbs add clips', async () => { + const ops = await lowerVerbs( + emptyComposition(), + [ + { verb: 'add_media', path: '/a.mp4' }, // [0,5) + { verb: 'add_color', durationSec: 2 }, // should append at 5 + ], + ctx(5), + ); + const color = addClips(ops).find((o) => o.clip.kind === 'color'); + expect(color?.clip.startSec).toBe(5); + }); +}); + +describe('intent-free verbs lower to no ops (no spurious undo entry)', () => { + it('trim/move with no value fields, and an empty set_transform, return []', async () => { + const c = ctx(); + expect(await lowerVerb(emptyComposition(), { verb: 'trim', clipId: 'c1' }, c)).toEqual([]); + expect(await lowerVerb(emptyComposition(), { verb: 'move', clipId: 'c1' }, c)).toEqual([]); + expect(await lowerVerb(emptyComposition(), { verb: 'set_transform', clipId: 'c1' }, c)).toEqual( + [], + ); + }); +}); + +describe('optional clip id (mid-batch references)', () => { + it('add_* honor a caller-supplied id so a later verb in the batch can target it', async () => { + const ops = await lowerVerbs( + emptyComposition(), + [ + { verb: 'add_color', id: 'card', durationSec: 2 }, + { verb: 'set_transform', clipId: 'card', opacity: 0.5 }, + ], + ctx(), + ); + expect(addClips(ops)[0]?.clip.id).toBe('card'); + expect(ops.some((o) => o.op === 'setTransform' && o.clipId === 'card')).toBe(true); + }); +}); + +describe('applyVerbs (op-aware + undoable)', () => { + let workspace: string; + let saved: string | undefined; + beforeEach(async () => { + workspace = await mkdtemp(join(tmpdir(), 'mmc-verbs-test-')); + saved = process.env.MAKEMYCLIP_WORKSPACE; + process.env.MAKEMYCLIP_WORKSPACE = workspace; + }); + afterEach(async () => { + if (saved === undefined) delete process.env.MAKEMYCLIP_WORKSPACE; + else process.env.MAKEMYCLIP_WORKSPACE = saved; + await rm(workspace, { recursive: true, force: true }); + }); + + it('applies verbs through mutateComposition and records ONE undoable entry', async () => { + const { doc, ops } = await applyVerbs([{ verb: 'add_media', path: '/a.mp4' }], ctx(6)); + expect(doc.tracks[0]?.clips[0]?.kind).toBe('media'); + expect(ops.length).toBeGreaterThan(0); + + const log = await readDocOpLog(); + expect(log.entries).toHaveLength(1); + + const undo = await undoLastDocOp(); + expect(undo.undone).toBe(true); + expect((await readComposition()).tracks).toEqual([]); + }); + + it('mutateComposition rejects a stale expectedBaseRev (the guard applyVerbs retries on)', async () => { + await mutateComposition([{ op: 'setCanvas', width: 1280 }]); // rev 1 + await expect( + mutateComposition([{ op: 'setCanvas', height: 720 }], { expectedBaseRev: 0 }), + ).rejects.toBeInstanceOf(CompositionConflictError); + const doc = await mutateComposition([{ op: 'setCanvas', height: 720 }], { expectedBaseRev: 1 }); + expect(doc.rev).toBe(2); + }); + + it('a burst of concurrent applyVerbs all land — none lost to the retry cap', async () => { + // All N writers read the same base rev (held at a barrier in `ingest` until + // every writer has lowered), then race the commit. Each conflicts on the + // stale expectedBaseRev and re-lowers, so writer k commits on attempt k — + // forcing up to N attempts. N=6 exceeds the old hardcoded cap of 4 (which + // dropped the 5th/6th with a CompositionConflictError) and stays within + // MAX_COMMIT_ATTEMPTS, so the fix must land all six. + const N = 6; + const DUR = 5; + await mutateComposition([{ op: 'addTrack', track: videoTrack({ id: 'v0' }) }]); + + let arrived = 0; + let openGate!: () => void; + const gate = new Promise((resolve) => { + openGate = resolve; + }); + const barrierCtx: VerbContext = { + defaultTrack: 'v0', + ingest: async () => { + if (arrived < N) { + arrived++; + if (arrived === N) openGate(); + } + await gate; + return { mediaId: M, durationSec: DUR }; + }, + }; + + const results = await Promise.all( + Array.from({ length: N }, () => + applyVerbs([{ verb: 'add_media', path: '/a.mp4' }], barrierCtx), + ), + ); + expect(results).toHaveLength(N); + + const doc = await readComposition(); + const starts = (doc.tracks[0]?.clips ?? []).map((c) => c.startSec).sort((a, b) => a - b); + // All six landed, abutting with no overlap or gap (proves no edit was lost). + expect(starts).toEqual([0, 1, 2, 3, 4, 5].map((i) => i * DUR)); + // One undoable entry per landed edit, plus the seeding addTrack. + expect((await readDocOpLog()).entries).toHaveLength(N + 1); + }); +}); diff --git a/tests/workspace.test.ts b/tests/workspace.test.ts new file mode 100644 index 0000000..f2f80be --- /dev/null +++ b/tests/workspace.test.ts @@ -0,0 +1,144 @@ +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { makeVerbContext } from '../src/ui/timeline-tools.js'; +import { TOOL_REGISTRY } from '../src/ui/tool-registry.js'; +import { resolveInput, resolveInWorkspace, WorkspaceBoundaryError } from '../src/workspace.js'; + +let workspace: string; +let saved: string | undefined; + +beforeEach(async () => { + workspace = await mkdtemp(join(tmpdir(), 'mmc-ws-test-')); + saved = process.env.MAKEMYCLIP_WORKSPACE; + process.env.MAKEMYCLIP_WORKSPACE = workspace; +}); + +afterEach(async () => { + if (saved === undefined) delete process.env.MAKEMYCLIP_WORKSPACE; + else process.env.MAKEMYCLIP_WORKSPACE = saved; + await rm(workspace, { recursive: true, force: true }); +}); + +describe('resolveInWorkspace (untrusted-input confinement)', () => { + it('accepts an absolute path inside the workspace', () => { + const p = resolve(workspace, 'imports/clip.mp4'); + expect(resolveInWorkspace(p)).toBe(p); + }); + + it('resolves a relative path against the workspace', () => { + expect(resolveInWorkspace('imports/clip.mp4')).toBe(resolve(workspace, 'imports/clip.mp4')); + }); + + it('rejects parent-traversal paths', () => { + expect(() => resolveInWorkspace('../escape.mp4')).toThrow(WorkspaceBoundaryError); + expect(() => resolveInWorkspace(resolve(workspace, '../sibling/secret.mov'))).toThrow( + WorkspaceBoundaryError, + ); + }); + + it('rejects an absolute path outside the workspace', () => { + expect(() => resolveInWorkspace('/etc/passwd')).toThrow(WorkspaceBoundaryError); + }); + + it('rejects the workspace directory itself (not a file)', () => { + expect(() => resolveInWorkspace(workspace)).toThrow(WorkspaceBoundaryError); + }); + + it('does not confuse a file named "..foo" with traversal', () => { + const p = resolve(workspace, '..foo.mp4'); + expect(resolveInWorkspace(p)).toBe(p); + }); + + it('the CLI resolver stays UNCONFINED (a user-typed path is consent)', () => { + expect(resolveInput('/etc/passwd')).toBe('/etc/passwd'); + }); +}); + +describe('the agent/UI ingest surfaces are gated', () => { + it('the registry-dispatched ingest tool rejects out-of-workspace paths before probing', async () => { + await expect(TOOL_REGISTRY.ingest.fn({ path: '/etc/passwd' })).rejects.toBeInstanceOf( + WorkspaceBoundaryError, + ); + }); + + it("makeVerbContext().ingest (the add_media verb's path) rejects out-of-workspace paths", async () => { + await expect(makeVerbContext().ingest('/etc/passwd')).rejects.toBeInstanceOf( + WorkspaceBoundaryError, + ); + }); +}); + +describe('every path-bearing registry tool is confined, not just ingest', () => { + // The other registry tools (render/trim/overlay/…) are reachable from the same + // untrusted surfaces (POST /api/tools/:name, the agent's tool calls). Each reads + // its source path(s) with FFmpeg, so each must reject out-of-workspace input — + // otherwise it's an arbitrary-file read (the rendered output is served back). + + it('a single-input tool (render) rejects an out-of-workspace input before probing', async () => { + await expect(TOOL_REGISTRY.render.fn({ input: '/etc/passwd' })).rejects.toBeInstanceOf( + WorkspaceBoundaryError, + ); + }); + + it('trim rejects an out-of-workspace input', async () => { + await expect( + TOOL_REGISTRY.trim.fn({ input: '/etc/passwd', startSec: 0, endSec: 1 }), + ).rejects.toBeInstanceOf(WorkspaceBoundaryError); + }); + + it('confines EVERY path field — a valid input with an escaping second path is rejected', async () => { + const inside = resolve(workspace, 'imports/clip.mp4'); + // add_audio confines both `input` and `audio`; the escape is in the 2nd field. + await expect( + TOOL_REGISTRY.add_audio.fn({ input: inside, audio: '/etc/passwd' }), + ).rejects.toBeInstanceOf(WorkspaceBoundaryError); + }); + + it('confines array-of-path inputs (concat) element-wise', async () => { + const inside = resolve(workspace, 'imports/a.mp4'); + await expect( + TOOL_REGISTRY.concat.fn({ inputs: [inside, '/etc/passwd'] }), + ).rejects.toBeInstanceOf(WorkspaceBoundaryError); + }); +}); + +describe('resolveInWorkspace follows symlinks (no in-workspace symlink escape)', () => { + it('rejects a path through an in-workspace symlink that points OUT of the tree', async () => { + const outside = await mkdtemp(join(tmpdir(), 'mmc-outside-')); + try { + await writeFile(join(outside, 'secret.txt'), 'secret'); + await mkdir(join(workspace, 'imports')); + // imports/escape -> , so imports/escape/secret.txt is really outside. + await symlink(outside, join(workspace, 'imports', 'escape')); + expect(() => resolveInWorkspace('imports/escape/secret.txt')).toThrow(WorkspaceBoundaryError); + // the symlinked directory itself resolves outside too + expect(() => resolveInWorkspace('imports/escape')).toThrow(WorkspaceBoundaryError); + } finally { + await rm(outside, { recursive: true, force: true }); + } + }); + + it('allows a path through an in-workspace symlink that stays inside the tree', async () => { + await mkdir(join(workspace, 'real')); + await symlink(join(workspace, 'real'), join(workspace, 'inside-link')); + // inside-link -> real (both in-workspace), so the read stays contained. + expect(resolveInWorkspace('inside-link/clip.mp4')).toBe( + resolve(workspace, 'inside-link/clip.mp4'), + ); + }); + + it('rejects a DANGLING in-workspace symlink whose target is outside (created later)', async () => { + const outside = await mkdtemp(join(tmpdir(), 'mmc-outside-')); + try { + await mkdir(join(workspace, 'imports')); + // The symlink exists but its target does not yet — the link must still be + // followed to its out-of-tree destination, not treated as an in-ws leaf. + await symlink(join(outside, 'not-there-yet.txt'), join(workspace, 'imports', 'dangling')); + expect(() => resolveInWorkspace('imports/dangling')).toThrow(WorkspaceBoundaryError); + } finally { + await rm(outside, { recursive: true, force: true }); + } + }); +});