From d5d8a695470d93a2953b62374a46c463ccf5b7b8 Mon Sep 17 00:00:00 2001
From: Slava Yultyyev
Date: Wed, 17 Jun 2026 14:53:25 -0700
Subject: [PATCH 01/11] feat(timeline): add rev CAS to CompositionDoc via a
shared revisioned-store
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Extract the session store's in-process write serialization + rev
compare-and-swap into a generic `createRevisionedStore` factory, and have
both the session log and the CompositionDoc consume it so the two stores
can't drift. The doc store previously did a lock-free read-apply-write,
which silently loses updates the moment the agent and `clip ui` co-edit
composition.json — the same bug class the session store already fixed.
- src/storage/revisioned-store.ts: shared serialization + CAS retry + CAS
write + parse-free overwrite (independent write-chain per store).
- session/store.ts: refactored onto the factory; public API + behavior
byte-identical (tests/session.test.ts unchanged and green).
- timeline/composition.ts: add `rev` (default 0, back-compat) to CompositionSchema.
- timeline/document-store.ts: mutateComposition now serialized + CAS; add
writeCompositionIfUnchanged, overwriteComposition, CompositionConflictError.
applyOp's return type is unchanged — the reversible op-log / {doc, opsApplied}
change is the next PR.
Co-Authored-By: Claude Opus 4.8
---
src/index.ts | 10 +++
src/session/store.ts | 95 ++++++--------------
src/storage/revisioned-store.ts | 154 ++++++++++++++++++++++++++++++++
src/timeline/composition.ts | 8 ++
src/timeline/document-store.ts | 83 +++++++++++++++--
tests/document-store.test.ts | 69 ++++++++++++++
6 files changed, 345 insertions(+), 74 deletions(-)
create mode 100644 src/storage/revisioned-store.ts
diff --git a/src/index.ts b/src/index.ts
index 2b793eb..2df29d1 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -78,6 +78,13 @@ export {
SessionEntrySchema,
SessionSchema,
} from './session/types.js';
+export {
+ createRevisionedStore,
+ RevisionConflictError,
+ type Revisioned,
+ type RevisionedStore,
+ type RevisionedStoreConfig,
+} from './storage/revisioned-store.js';
export {
type CompileContext,
CompileError,
@@ -124,12 +131,15 @@ export {
TransitionSchema,
} from './timeline/composition.js';
export {
+ CompositionConflictError,
CompositionCorruptError,
compositionPath,
mutateComposition,
+ overwriteComposition,
readComposition,
resetComposition,
writeComposition,
+ writeCompositionIfUnchanged,
} from './timeline/document-store.js';
export { buildMediaMap } from './timeline/media-registry.js';
export {
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/composition.ts b/src/timeline/composition.ts
index deb3e8d..4579c26 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),
diff --git a/src/timeline/document-store.ts b/src/timeline/document-store.ts
index 3697d63..7ddc463 100644
--- a/src/timeline/document-store.ts
+++ b/src/timeline/document-store.ts
@@ -1,6 +1,7 @@
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';
@@ -26,6 +27,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,16 +67,64 @@ 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),
+});
+
/**
- * 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.
+ * Read → apply ops → write, returning the new composition. Now serialized + rev
+ * compare-and-swap via the shared revisioned store, so the agent and the UI can
+ * co-edit composition.json without lost updates (writes were already atomic).
+ * `applyOp`'s return type is unchanged in this slice — the reversible op-log /
+ * `{ doc, opsApplied }` change lands in the next PR.
*/
export async function mutateComposition(ops: CompositionOp[]): Promise {
- const next = applyOps(await readComposition(), ops);
- await writeComposition(next);
- return next;
+ const { state } = await docStore.mutate((current) => {
+ const next = applyOps(current, ops);
+ return { next, result: next };
+ });
+ return state;
+}
+
+/**
+ * Compare-and-swap write: persist `comp` only if the on-disk `rev` still equals
+ * `expectedRev`, then bump to `expectedRev + 1`. Throws `CompositionConflictError`
+ * if another writer committed first — the optimistic-concurrency primitive the
+ * co-editing UI builds on.
+ */
+export function writeCompositionIfUnchanged(
+ comp: Composition,
+ expectedRev: number,
+): Promise {
+ return docStore.writeIfUnchanged(comp, expectedRev);
+}
+
+/**
+ * Recovery primitive: replace composition.json atomically and WITHOUT trusting
+ * the current file to parse (so it works precisely when the live file is the
+ * corrupt one being recovered), advancing `rev` past the last readable revision.
+ */
+export function overwriteComposition(comp: Composition = emptyComposition()): Promise {
+ return docStore.overwrite(comp);
}
export async function resetComposition(
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);
+ });
+});
From 452617805b5edb6bd53dd547c26efad170d400c7 Mon Sep 17 00:00:00 2001
From: Slava Yultyyev
Date: Wed, 17 Jun 2026 15:23:27 -0700
Subject: [PATCH 02/11] feat(timeline): add invertOp (exact per-op inverses) +
canonical doc order
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Foundation for reversible editing (the op-log/undo lands next): a pure
`invertOp(comp, op): CompositionOp[]` that, given the pre-state, returns the
op(s) that undo `op` exactly. `applyOp`/`applyOps` keep their
`(comp) => Composition` signature, so no existing caller changes.
Two op-vocabulary additions are required for exact reversibility:
- `addTrack` gains an optional `index` (insert at z-order position) so a
`removeTrack` can be undone at the original index.
- a new `clearTransform` op — the inverse of a `setTransform` that created a
transform on a clip that had none.
`applyOp` now also canonicalizes track order on every output — clips by
(startSec, id), transitions by afterClipId — so two documents with identical
content are deep-equal regardless of the op history that built them. Array
order is semantically irrelevant (the compiler keys transitions by afterClipId
and rejects overlapping clips), and canonical order is what lets undo land on a
byte-identical document.
Verified by a round-trip property — applyOps(applyOp(pre, op), invertOp(pre, op))
deep-equals pre — across every op kind plus a LIFO sequence-undo test, including
clips carrying effects+transforms, multi-transition tracks, and equal-startSec
siblings (41 tests).
Co-Authored-By: Claude Opus 4.8
---
src/index.ts | 1 +
src/timeline/ops.ts | 205 ++++++++++++++++-
tests/composition-invert.test.ts | 376 +++++++++++++++++++++++++++++++
3 files changed, 580 insertions(+), 2 deletions(-)
create mode 100644 tests/composition-invert.test.ts
diff --git a/src/index.ts b/src/index.ts
index 2df29d1..5bd5206 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -150,6 +150,7 @@ export {
CompositionOpError,
type CompositionOpKind,
colorClip,
+ invertOp,
mediaClip,
textClip,
videoTrack,
diff --git a/src/timeline/ops.ts b/src/timeline/ops.ts
index 9807a5e..9a100a0 100644
--- a/src/timeline/ops.ts
+++ b/src/timeline/ops.ts
@@ -32,7 +32,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,6 +43,7 @@ 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 };
@@ -101,7 +102,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 +207,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 +237,201 @@ 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)}`);
+ }
+ }
+}
+
// ─── internals ───────────────────────────────────────────────────────────────
function locate(comp: Composition, clipId: string): boolean {
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' });
+ });
+});
From 0a21563230624a58e0db71b1313497ac66a04783 Mon Sep 17 00:00:00 2001
From: Slava Yultyyev
Date: Wed, 17 Jun 2026 15:43:44 -0700
Subject: [PATCH 03/11] =?UTF-8?q?feat(timeline):=20op-log=20data=20layer?=
=?UTF-8?q?=20=E2=80=94=20op=20schema,=20batch=20inverse,=20undo-stack=20a?=
=?UTF-8?q?lgebra?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The pure data layer for undo/redo (the I/O wiring into mutateComposition + the
CLI lands next). No behavioural change yet — nothing reads or writes the log.
- `CompositionOpSchema` (ops.ts): a runtime validator for a CompositionOp, used
to validate ops read back from the persisted op-log. An exhaustive
`Record` sample test parses every kind byte-stably so
the schema can't drift from the hand-written union. Uses a defaults-free
`PartialTransformSchema` for setTransform — `TransformSchema.partial()` would
re-inflate omitted fields to defaults, turning a stored partial `{ scale: 2 }`
into a full transform that overwrites x/y/opacity on redo.
- `applyOpsTracked` (ops.ts): applies a batch, threading state so each op's
inverse is captured at the pre-state it saw, returning the new doc plus a
single inverse op-list that undoes the whole batch.
- `doc-op-log.ts` (new): the undo-stack value type + pure algebra
(record/undo/redo/canUndo/canRedo), `reconcile` (drops history on a doc/log
rev mismatch — the crash-between-writes case — rather than risk a stale undo),
and parse/serialize for the persisted `composition-ops.json`.
Verified: applyOpsTracked round-trips (apply batch then inverse = identity); the
stack algebra (truncate-redo-tail, cursor bounds); reconcile both ways;
parse/serialize round-trip + malformed-op rejection. 488 tests.
Co-Authored-By: Claude Opus 4.8
---
src/index.ts | 3 +
src/timeline/doc-op-log.ts | 114 +++++++++++++++++++
src/timeline/ops.ts | 107 ++++++++++++++++++
tests/composition-op-schema.test.ts | 65 +++++++++++
tests/doc-op-log.test.ts | 169 ++++++++++++++++++++++++++++
5 files changed, 458 insertions(+)
create mode 100644 src/timeline/doc-op-log.ts
create mode 100644 tests/composition-op-schema.test.ts
create mode 100644 tests/doc-op-log.test.ts
diff --git a/src/index.ts b/src/index.ts
index 5bd5206..7fc7ccd 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -145,13 +145,16 @@ 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';
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/ops.ts b/src/timeline/ops.ts
index 9a100a0..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,
@@ -49,6 +52,82 @@ export type CompositionOp =
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) {
@@ -432,6 +511,34 @@ export function invertOp(comp: Composition, op: CompositionOp): CompositionOp[]
}
}
+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/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();
+ });
+});
From dc097b4812c1c4e47a4942c98bdbc3cb87a947ce Mon Sep 17 00:00:00 2001
From: Slava Yultyyev
Date: Wed, 17 Jun 2026 16:17:07 -0700
Subject: [PATCH 04/11] =?UTF-8?q?feat(timeline):=20persist=20the=20op-log?=
=?UTF-8?q?=20=E2=80=94=20working=20undo/redo=20for=20the=20timeline?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Wires the op-log data layer into document-store so timeline edits are
undoable end-to-end. The undo stack lives in composition-ops.json, separate
from the composition.json render doc.
- mutateComposition now records each batch (with its computed inverse) to the
op-log and returns the new doc. Empty batches are a true no-op.
- undoLastDocOp / redoDocOp apply an entry's inverse / forward ops and move the
log cursor; clip timeline undo|redo|log expose them.
- commitDocAndLog couples the doc + log writes in one serialized section,
log-first so a crash leaves the log "ahead" (reconcile discards it) rather
than the doc ahead (which could replay a stale inverse). doc.rev and log.rev
advance together so reconcile can spot the crash window.
Hardening from an adversarial review of the diff (all latent — no live caller
yet, but fixed before the UI builds on these primitives):
- overwriteComposition / resetComposition now reset the op-log inside one
exclusive section and advance rev past the last readable one, so a recovery
that lands on a colliding rev can't match a stale log and replay an inverse
that throws; resetComposition no longer regresses rev to 0.
- writeCompositionIfUnchanged resets undo history explicitly (a raw whole-doc
CAS write has no recorded ops); undoable co-editing goes through
mutateComposition.
- readDocOpLog reads both files inside the exclusive section so an interleaved
in-process commit can't make a consistent log momentarily read as empty.
Co-Authored-By: Claude Opus 4.8
---
src/cli-timeline.ts | 48 +++++-
src/index.ts | 4 +
src/timeline/document-store.ts | 230 +++++++++++++++++++++++++----
tests/document-store-oplog.test.ts | 174 ++++++++++++++++++++++
4 files changed, 430 insertions(+), 26 deletions(-)
create mode 100644 tests/document-store-oplog.test.ts
diff --git a/src/cli-timeline.ts b/src/cli-timeline.ts
index 8e90bb7..18f6998 100644
--- a/src/cli-timeline.ts
+++ b/src/cli-timeline.ts
@@ -7,7 +7,14 @@ import {
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';
@@ -27,6 +34,9 @@ const TIMELINE_HELP = `clip timeline — build and export a non-destructive comp
clip timeline split
clip timeline remove
clip timeline show
+ clip timeline undo
+ clip timeline redo
+ clip timeline log
clip timeline export [
@@ -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.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'