Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
d5d8a69
feat(timeline): add rev CAS to CompositionDoc via a shared revisioned…
yultyyev Jun 17, 2026
da1cbbf
Merge pull request #26 from MakeMyClip/feat/timeline-revisioned-store
yultyyev Jun 17, 2026
4526178
feat(timeline): add invertOp (exact per-op inverses) + canonical doc …
yultyyev Jun 17, 2026
b6d78d7
Merge pull request #27 from MakeMyClip/feat/timeline-op-inverses
yultyyev Jun 17, 2026
0a21563
feat(timeline): op-log data layer — op schema, batch inverse, undo-st…
yultyyev Jun 17, 2026
6ec4926
Merge pull request #28 from MakeMyClip/feat/timeline-op-log
yultyyev Jun 17, 2026
dc097b4
feat(timeline): persist the op-log — working undo/redo for the timeline
yultyyev Jun 17, 2026
f2b2668
Merge pull request #29 from MakeMyClip/feat/timeline-op-log-io
yultyyev Jun 17, 2026
b1e38b0
feat(timeline): read-only introspection — show / at / frame (the agen…
yultyyev Jun 17, 2026
9b53882
Merge pull request #30 from MakeMyClip/feat/timeline-introspect
yultyyev Jun 18, 2026
ad3819c
feat(timeline): unify the agent + UI onto the op-aware, undoable edit…
yultyyev Jun 18, 2026
d8af646
Merge pull request #31 from MakeMyClip/feat/timeline-unify-mutation
yultyyev Jun 18, 2026
f4dc04c
fix(ui): confine agent/UI media paths to the workspace
yultyyev Jun 18, 2026
f5a7c4e
Merge pull request #32 from MakeMyClip/fix/confine-ingest-paths
yultyyev Jun 18, 2026
4a2ff19
fix(timeline): follow symlinks when confining agent/UI paths to the w…
yultyyev Jun 18, 2026
e3a3226
fix(timeline): widen the applyVerbs retry to the commit-attempt cap
yultyyev Jun 18, 2026
adced00
fix(timeline): drop per-clip fades only on real transition boundaries
yultyyev Jun 18, 2026
330615c
docs: document the timeline workflow, refresh counts, drop brand names
yultyyev Jun 18, 2026
8745b9e
Merge pull request #33 from MakeMyClip/chore/v0.2.0-release-polish
yultyyev Jun 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 25 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ The open-source, local-first AI video editor — chat, CLI, or browser timeline.
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License: MIT"></a>
<a href="./package.json"><img src="https://img.shields.io/badge/node-%E2%89%A524-brightgreen" alt="Node 24+"></a>
<a href="https://www.npmjs.com/package/@makemyclip/editor"><img src="https://img.shields.io/npm/v/@makemyclip/editor.svg" alt="npm"></a>
<img src="https://img.shields.io/badge/tests-356%20passing-brightgreen" alt="Tests: 356 passing">
<img src="https://img.shields.io/badge/tests-539%20passing-brightgreen" alt="Tests: 539 passing">
<img src="https://img.shields.io/badge/telemetry-none-brightgreen" alt="Telemetry: none">
</p>

Expand Down Expand Up @@ -143,26 +143,42 @@ Legend: ✅ great fit · ⚠️ works but not the best · ❌ doesn't fit.
</details>

<details>
<summary><b>Architecture</b> — three surfaces, one registry, one append-only op log</summary>
<summary><b>Architecture</b> — three surfaces, one composition document, one append-only op log</summary>

```
Claude Code → skill triggers → npx -y @makemyclip/editor <tool>
Claude Code → skill triggers → npx -y @makemyclip/editor <tool | timeline …>
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)
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 <afterClipId> 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 <sec>` / `frame <sec>` 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)
Expand Down Expand Up @@ -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.
Expand Down
16 changes: 15 additions & 1 deletion SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <afterClipId> 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 <sec>` / `timeline frame <sec>` 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
Expand Down
2 changes: 1 addition & 1 deletion llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
158 changes: 140 additions & 18 deletions src/cli-timeline.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,28 @@
import { resolve } from 'node:path';
import { appendOp } from './session/store.js';
import { compileTimeline } from './timeline/compile.js';
import { buildFrameAtPlan, CompileError, compileTimeline } from './timeline/compile.js';
import {
type Clip,
type Composition,
clipDuration,
clipEndSec,
clipsAtTime,
compositionDuration,
emptyComposition,
makeClipId,
} from './timeline/composition.js';
import { mutateComposition, readComposition, resetComposition } from './timeline/document-store.js';
import {
mutateComposition,
readComposition,
readDocOpLog,
redoDocOp,
resetComposition,
undoLastDocOp,
} from './timeline/document-store.js';
import { buildMediaMap } from './timeline/media-registry.js';
import type { CompositionOp } from './timeline/ops.js';
import { colorClip, mediaClip, textClip, videoTrack } from './timeline/ops.js';
import { colorClip, mediaClip, textClip } from './timeline/ops.js';
import { runPlan } from './timeline/run-plan.js';
import { DEFAULT_VERB_TRACK as DEFAULT_TRACK, ensureTrack, trackEnd } from './timeline/verbs.js';
import { ingest } from './tools/ingest.js';
import { getWorkspace, newOutputPath, resolveInput } from './workspace.js';

Expand All @@ -27,6 +38,11 @@ const TIMELINE_HELP = `clip timeline — build and export a non-destructive comp
clip timeline split <clipId> <atSec>
clip timeline remove <clipId>
clip timeline show
clip timeline at <atSec>
clip timeline frame <atSec> [<output>]
clip timeline undo
clip timeline redo
clip timeline log
clip timeline export [<output>]
`;

Expand Down Expand Up @@ -61,20 +77,35 @@ function num(value: string | undefined, fallback: number): number {
return n;
}

const DEFAULT_TRACK = 'v0';

/** Ops to create the named video track if it doesn't exist yet. */
function ensureTrack(comp: Composition, trackId: string): CompositionOp[] {
return comp.tracks.some((t) => t.id === trackId)
? []
: [{ op: 'addTrack', track: videoTrack({ id: trackId }) }];
/** A short, human-readable label for a clip (for `show` / `at`). */
function clipLabel(clip: Clip): string {
switch (clip.kind) {
case 'media':
return clip.mediaId;
case 'text':
return clip.text.length > 30 ? `${clip.text.slice(0, 30)}…` : clip.text;
case 'color':
return clip.color;
}
}

/** End time of the last clip on a track (0 if empty/missing) — the append point. */
function trackEnd(comp: Composition, trackId: string): number {
const track = comp.tracks.find((t) => t.id === trackId);
if (!track) return 0;
return track.clips.reduce((end, c) => Math.max(end, clipEndSec(c)), 0);
/** Dry-run the export compiler (pure — no FFmpeg runs) to report whether the
* document can export and, if not, the first blocker. */
function checkExportable(
comp: Composition,
media: Awaited<ReturnType<typeof buildMediaMap>>,
): { exportable: boolean; blockers: string[] } {
try {
compileTimeline(comp, {
media,
dir: getWorkspace(),
output: resolve(getWorkspace(), '.probe.mp4'),
});
return { exportable: true, blockers: [] };
} catch (err) {
if (err instanceof CompileError) return { exportable: false, blockers: [err.message] };
throw err;
}
}

export async function runTimeline(args: string[]): Promise<void> {
Expand Down Expand Up @@ -232,11 +263,102 @@ export async function runTimeline(args: string[]): Promise<void> {

case 'show': {
const comp = await readComposition();
const { exportable, blockers } = checkExportable(comp, await buildMediaMap());
out({
rev: comp.rev,
durationSec: compositionDuration(comp),
canvas: { width: comp.width, height: comp.height, fps: comp.fps },
tracks: comp.tracks.map((t) => ({ id: t.id, kind: t.kind, clips: t.clips.length })),
composition: comp,
exportable,
blockers,
tracks: comp.tracks.map((t) => ({
id: t.id,
kind: t.kind,
muted: t.muted,
clips: t.clips.map((c) => ({
id: c.id,
kind: c.kind,
startSec: c.startSec,
endSec: clipEndSec(c),
durationSec: clipDuration(c),
label: clipLabel(c),
})),
transitions: t.transitions.map((tr) => ({
afterClipId: tr.afterClipId,
kind: tr.kind,
durationSec: tr.durationSec,
})),
})),
});
return;
}

case 'at': {
const [at] = positional;
if (!at) throw new Error('Usage: clip timeline at <atSec>');
const atSec = num(at, 0);
const comp = await readComposition();
out({
atSec,
clips: clipsAtTime(comp, atSec).map((h) => ({
track: h.track.id,
clipId: h.clip.id,
kind: h.clip.kind,
localOffsetSec: h.localOffsetSec,
label: clipLabel(h.clip),
})),
});
return;
}

case 'frame': {
const [at, output] = positional;
if (!at) throw new Error('Usage: clip timeline frame <atSec> [<output>]');
const atSec = num(at, 0);
const comp = await readComposition();
const media = await buildMediaMap();
const finalOutput = output ? resolveInput(output) : newOutputPath('timeline-frame', 'jpg');
const plan = buildFrameAtPlan(
comp,
{ media, dir: getWorkspace(), output: finalOutput },
atSec,
);
const result = await runPlan(plan);
out({ frame: result.output, atSec });
return;
}

case 'undo': {
const { undone, doc, label } = await undoLastDocOp();
out(
undone
? { undone: true, label, rev: doc.rev }
: { undone: false, message: 'Nothing to undo.' },
);
return;
}

case 'redo': {
const { redone, doc, label } = await redoDocOp();
out(
redone
? { redone: true, label, rev: doc.rev }
: { redone: false, message: 'Nothing to redo.' },
);
return;
}

case 'log': {
const log = await readDocOpLog();
out({
rev: log.rev,
cursor: log.cursor,
canUndo: log.cursor > 0,
canRedo: log.cursor < log.entries.length,
entries: log.entries.map((e, i) => ({
id: e.id,
label: e.label,
state: i < log.cursor ? 'applied' : 'undone',
})),
});
return;
}
Expand Down
5 changes: 5 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ Specialty:
clip stabilize <input> [<shakiness>] [<smoothing>] [<accuracy>] [<zoom>]
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 <subcommand> 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.
Expand Down
6 changes: 3 additions & 3 deletions src/ffmpeg/args/transition.ts
Original file line number Diff line number Diff line change
@@ -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=<name>` 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=<name>` value.
*/
export type TransitionKind =
| 'fade'
Expand Down
Loading
Loading