From 4a2ff19a225e81dfc837f9db0a97eeb6f27526ca Mon Sep 17 00:00:00 2001
From: Slava Yultyyev
Date: Thu, 18 Jun 2026 10:40:57 -0700
Subject: [PATCH 1/4] fix(timeline): follow symlinks when confining agent/UI
paths to the workspace
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
resolveInWorkspace checked containment on the lexically-resolved path, so an
in-workspace symlink pointing out of the tree was followed at FFmpeg-open time —
the boundary it exists to enforce (AGENTS.md non-negotiable #3) leaked.
Canonicalize both the workspace and the resolved path before comparing: walk the
existing prefix with lstat, follow symlinks explicitly (so a DANGLING in-workspace
link resolves toward its real, possibly out-of-tree, target instead of looking
like an in-workspace leaf), and treat only a genuine ENOENT as a not-yet-created
tail — EACCES / symlink loops fail closed. A symlinked workspace root (macOS
/var -> /private/var) is collapsed too, so legitimate paths aren't rejected. The
lexical resolved path is still returned, so an in-workspace symlink to an
in-workspace target keeps working.
Residual (documented): an open-time TOCTOU race needs O_NOFOLLOW at the open
site, out of scope here.
---
src/workspace.ts | 71 ++++++++++++++++++++++++++++++++++++++---
tests/workspace.test.ts | 41 +++++++++++++++++++++++-
2 files changed, 106 insertions(+), 6 deletions(-)
diff --git a/src/workspace.ts b/src/workspace.ts
index 2192c04..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, relative, resolve, sep } from 'node:path';
+import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path';
const WORKSPACE_ENV = 'MAKEMYCLIP_WORKSPACE';
@@ -39,6 +40,49 @@ export class WorkspaceBoundaryError extends Error {
}
}
+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).
@@ -46,14 +90,31 @@ export class WorkspaceBoundaryError extends Error {
* 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 by resolved-path prefix; it does NOT follow symlinks, so a
- * symlink placed inside the workspace can still point out — hardening that is a
- * follow-up, but `..`-traversal and absolute out-of-tree paths are rejected here.
+ * 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);
- const rel = relative(workspace, resolved);
+ 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)) {
diff --git a/tests/workspace.test.ts b/tests/workspace.test.ts
index 0132bc0..f2f80be 100644
--- a/tests/workspace.test.ts
+++ b/tests/workspace.test.ts
@@ -1,4 +1,4 @@
-import { mkdtemp, rm } from 'node:fs/promises';
+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';
@@ -103,3 +103,42 @@ describe('every path-bearing registry tool is confined, not just ingest', () =>
).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 });
+ }
+ });
+});
From e3a322676392c0f392f73add8aae110bc36dbca8 Mon Sep 17 00:00:00 2001
From: Slava Yultyyev
Date: Thu, 18 Jun 2026 10:40:57 -0700
Subject: [PATCH 2/4] fix(timeline): widen the applyVerbs retry to the
commit-attempt cap
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
applyVerbs re-lowers and retries on a CompositionConflictError (the optimistic
expectedBaseRev guard) but capped the loop at a hardcoded 4, while the inner
commit loop allows MAX_COMMIT_ATTEMPTS (8). A burst of >4 concurrent edits against
the same base rev therefore dropped its tail: the 5th+ writer exhausted its
retries and lost the edit. Align the cap to MAX_COMMIT_ATTEMPTS so each concurrent
edit re-lowers against fresh state and lands.
Latent today (the shipping UI has no concurrent caller of the verbs path), but
closed before the API gains one. expectedBaseRev stays load-bearing — the guard
is what forces the re-lower. Adds a deterministic regression test that races a
burst through a barrier so each writer needs an increasing attempt count.
---
src/timeline/document-store.ts | 8 ++++---
tests/timeline-verbs.test.ts | 43 ++++++++++++++++++++++++++++++++++
2 files changed, 48 insertions(+), 3 deletions(-)
diff --git a/src/timeline/document-store.ts b/src/timeline/document-store.ts
index 19d1014..d3f71c1 100644
--- a/src/timeline/document-store.ts
+++ b/src/timeline/document-store.ts
@@ -253,15 +253,17 @@ export async function applyVerbs(
// 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.
- for (let attempt = 1; attempt <= 4; attempt++) {
+ // 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 < 4) continue;
+ if (err instanceof CompositionConflictError && attempt < MAX_COMMIT_ATTEMPTS) continue;
throw err;
}
}
diff --git a/tests/timeline-verbs.test.ts b/tests/timeline-verbs.test.ts
index f922bb9..526b89a 100644
--- a/tests/timeline-verbs.test.ts
+++ b/tests/timeline-verbs.test.ts
@@ -184,4 +184,47 @@ describe('applyVerbs (op-aware + undoable)', () => {
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);
+ });
});
From adced000afd33d8394c88ac131aa7c36dd692842 Mon Sep 17 00:00:00 2001
From: Slava Yultyyev
Date: Thu, 18 Jun 2026 10:40:57 -0700
Subject: [PATCH 3/4] fix(timeline): drop per-clip fades only on real
transition boundaries
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A per-clip fadeIn/fadeOut on the same cut as a transition double-darkened (the
xfade already blends that window). Suppress the fade on a transition boundary so
the xfade owns the blend — but only when the fold actually emits an xfade there.
A transition keyed to the LAST clip (e.g. left dangling after the next clip was
removed) blends nothing, so gate fadeOut suppression on a following clip existing;
otherwise a fade-to-black at the timeline end was silently dropped to a hard cut.
Outer-boundary fades and fades on plain cuts are untouched.
Adds regression tests for the matched-boundary case and the dangling-transition
(last-clip) case.
---
src/timeline/compile.ts | 50 +++++++++++++++++++++-------
tests/compile.test.ts | 72 +++++++++++++++++++++++++++++++++++++++++
2 files changed, 111 insertions(+), 11 deletions(-)
diff --git a/src/timeline/compile.ts b/src/timeline/compile.ts
index b089611..6392b16 100644
--- a/src/timeline/compile.ts
+++ b/src/timeline/compile.ts
@@ -30,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 {
@@ -151,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[] = [];
@@ -181,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}`);
@@ -231,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[] = [];
@@ -496,9 +513,20 @@ export function compileTimeline(comp: Composition, ctx: CompileContext): FfmpegP
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/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)', () => {
From 330615c382dc826e17b6d01ca30ed1118aef266a Mon Sep 17 00:00:00 2001
From: Slava Yultyyev
Date: Thu, 18 Jun 2026 10:41:10 -0700
Subject: [PATCH 4/4] docs: document the timeline workflow, refresh counts,
drop brand names
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Document the `clip timeline` composition workflow (new → add-media →
transition → show → export, with undo/redo and read-only show/at/frame) in
README, SKILL.md, and the top-level `clip --help` (it was reachable but unlisted).
- Reframe the source-of-truth narrative: the Composition document is canonical
for assembled edits; session.json is the append-only op log of tool calls
(was "the session is the source of truth", which contradicted the code and
AGENTS.md non-negotiable #2).
- Refresh the stale "356 tests" claim to 539 (README badge + FAQ, llms.txt).
- Remove competitor brand names from the TransitionKind JSDoc (it ships in
dist/index.d.ts); the README/llms comparison prose keeps its intentional naming.
- Drop the hand-maintained, drifting SKILL.md frontmatter version field.
---
README.md | 34 +++++++++++++++++++++++++---------
SKILL.md | 16 +++++++++++++++-
llms.txt | 2 +-
src/cli.ts | 5 +++++
src/ffmpeg/args/transition.ts | 6 +++---
5 files changed, 49 insertions(+), 14 deletions(-)
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.
-
+
@@ -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'