Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,23 @@

All notable changes to capcut-cli are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.9.0] — 2026-06-03

Ten new commands/capabilities across inspection, maintenance, composition, and agent-integration. No breaking changes; still zero-dep, JSON-by-default, pipeable.

### Added

- **`describe`** — emits the full command surface as JSON (name, version, global flags, every command + summary) so LLM/agent callers get a tool spec instead of scraping `--help`. A test enforces that every command has a summary, so nothing ships undescribed.
- **`prune`** — removes materials no segment references. The referenced set is the union of every segment's `material_id` **and** `extra_material_refs[]`, so masks/effects/animations/fades referenced indirectly are never wrongly deleted. Pairs with `--dry-run`.
- **`relink`** — repairs broken media paths. `--dir <folder>` repoints each missing material to a same-basename file in the folder; `--from <p> --to <q>` prefix-replaces paths. Reports relinked / still-missing / present counts. Pairs with `--dry-run`.
- **`timeline`** — shows the track/segment layout. JSON default returns lanes with computed columns; `-H` renders ASCII bars (`--cols N`, default 60). Makes layout/track-order issues diagnosable without opening CapCut.
- **`projects`** — lists CapCut/JianYing draft folders on disk (scans the per-OS default dirs or `--drafts <dir>`), with an optional name-substring filter and `--names` to read each draft's title. No more pasting 40-char UUID paths.
- **Multi-step undo** — every write now also keeps a rolling snapshot history under `<draftdir>/.capcut-cli-history/` (capped at 20). `restore --step N` rolls back N writes (step 1 == the `.bak`); `restore --list` shows the history. Plain `restore` is unchanged.
- **`diff`** — compare two drafts: segments added/removed/changed (start/duration/material/speed/volume), and materials added/removed/**changed** (a text edit mutates the material in place, so this is where `set-text` shows up). Read-only.
- **`concat`** — append one draft onto another's timeline: B's segments are time-shifted by A's duration, and any B material/segment id that collides with A is reassigned a fresh uuid (with references rewritten) so the merge stays valid. Writes to `--out` or in place.
- **`config`** — defaults (`drafts` dir, `jianying`, `cols`) can be set in a `.capcutrc` (cwd, then home; CLI flags win). `capcut config` prints the resolved file and effective values.
- **Windows `export --batch`** — the Windows path now ships: PowerShell opens each draft and sends CapCut's export shortcut (Ctrl+E). Same experimental UI-automation caveat as macOS. (Live render is host-dependent; the script generation is unit-tested.)

## [0.8.0] — 2026-06-03

Safety, discoverability, and a long-overdue track-order fix. No breaking changes; everything stays zero-dep, JSON-by-default, and pipeable.
Expand Down
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,16 +68,17 @@ How `capcut-cli` differs from the other CapCut / JianYing tooling:

A capability map; see [Commands](#commands) for syntax.

- **Inspect** — `info` · `tracks` · `materials` · `segments` · `texts`; `segment`/`material <id>` for progressive-disclosure drill-down; `export-srt`.
- **Inspect** — `info` · `tracks` · `materials` · `segments` · `texts`; `segment`/`material <id>` for progressive-disclosure drill-down; `timeline` (ASCII layout); `export-srt`.
- **Build & add** — `init` a draft, then `add-video` · `add-audio` · `add-text` from local files or [Wikimedia Commons URLs](#wikimedia-commons-phase-5) (license-gated); `add-sticker`, `add-effect`.
- **Edit** — `set-text` · `shift` · `shift-all` · `speed` · `volume` · `opacity` · `trim`; `batch` (many edits, one write); `--dry-run` preview and `restore` undo on any write.
- **Edit** — `set-text` · `shift` · `shift-all` · `speed` · `volume` · `opacity` · `trim`; `batch` (many edits, one write); `--dry-run` preview, and `restore` undo (latest `.bak` or `--step N` through snapshot history).
- **Maintain & compose** — `prune` (drop unreferenced materials) · `relink` (repair broken media paths via `--dir` or `--from`/`--to`) · `projects` (list drafts on disk by name) · `diff` (compare two drafts) · `concat` (stitch drafts into one timeline, id-safe).
- **Decorate** — `keyframe` · `transition` · `mask` · `bg-blur` · `text-style` · `text-anim` · `image-anim` · `text-ranges` (word-level highlight captions); `mix-mode` · `audio-fade` · `add-filter` · `bubble-text` · `add-cover` · `add-sfx` · `chroma`.
- **Captions & translate** — `caption` (whisper → real caption objects, not text-segment mimics), `import-srt` / `import-ass`, `translate` (Anthropic-API multi-language clone, zero deps).
- **Templates** — `save-template` / `apply-template`; six ship in [`templates/`](./templates/) (`gold-title`, `end-card`, `subscribe-cta`, `hook-question`, `lower-third`, `caption-pop`).
- **Resilience** — `version` (support detection) · `lint` (schema-aware CI checks, exit 0/1/2) · `migrate` · `decrypt`; [schema reference](./docs/draft-schema/) + [version matrix](./docs/version-support.md).
- **Discover** — `enums` — 12 categories × 2 namespaces, no network.
- **Integrate** — Node [library](#use-as-a-node-library), [Dockerfile](./Dockerfile), [GitHub Action](#github-action--lint-drafts-in-ci), `serve` (stateless JSONL runner for n8n/Make/Coze), `export --batch` (experimental render queue), `completions <bash|zsh|fish>`, [Claude Code plugin](#claude-code-plugin).
- **Output** — JSON by default (pipe to `jq`), `-H` table, `-q` quiet.
- **Integrate** — `describe` (JSON tool spec for LLM/agent callers), Node [library](#use-as-a-node-library), [Dockerfile](./Dockerfile), [GitHub Action](#github-action--lint-drafts-in-ci), `serve` (stateless JSONL runner for n8n/Make/Coze), `export --batch` (experimental render queue), `completions <bash|zsh|fish>`, [Claude Code plugin](#claude-code-plugin).
- **Output** — JSON by default (pipe to `jq`), `-H` table, `-q` quiet. Defaults (`drafts` dir, `jianying`, `cols`) can live in a `.capcutrc`; `capcut config` shows the resolved values.

**Cross-platform:** CapCut **and** JianYing in one binary (`--jianying` switches the enum namespace); macOS · Windows · Linux; pure Node ≥ 18, zero runtime deps.

Expand Down Expand Up @@ -572,7 +573,7 @@ Close the project in CapCut before editing, reopen after. CapCut reads the JSON
|---|---|
| **Edits vanish / project looks unchanged** | CapCut was open. It keeps its own copy of the draft in memory and overwrites your file when it next saves. **Close the project in CapCut, run the CLI, then reopen.** This is the single most common gotcha. |
| **Track / layer order looks scrambled in CapCut** | Older builds wrote tracks in command-call order, but CapCut lays out the timeline from the tracks-array order. Recent builds normalize the array to the canonical layer order (video → audio → overlays → text) on every save. Update, re-run the edit, reopen. ([#21](https://github.com/renezander030/capcut-cli/issues/21)) |
| **Need to undo an edit** | Run `capcut restore <project>` — it copies the `.bak` back over the draft. Single-step (only the last write is kept). Preview any command first with `--dry-run` to avoid the round-trip. |
| **Need to undo an edit** | `capcut restore <project>` reverts the last write. Earlier writes are recoverable too: `capcut restore <project> --list` shows the snapshot history (kept in `<draftdir>/.capcut-cli-history/`, last 20), and `--step N` rolls back N writes. Preview any command first with `--dry-run` to avoid the round-trip. |
| **`caption` fails: whisper not found** | `caption` shells out to a whisper binary. Install one (`pip install openai-whisper`, `brew install whisper-cpp`, or faster-whisper) or pass `--whisper-cmd <path>`. |
| **`translate` fails: ANTHROPIC_API_KEY** | Set the env var (`export ANTHROPIC_API_KEY=…`) or pass `--api-key`. |
| **`audio-fade --out` seems ignored** | `--out` is the global output-path flag. Use `--fade-out` for the fade-out duration. |
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "capcut-cli",
"version": "0.8.0",
"version": "0.9.0",
"description": "Independent, unofficial CLI to create and edit CapCut projects — build drafts from scratch, add video/audio/text, subtitles, timing, speed, volume, templates, cut long-form to shorts. No API needed. Not affiliated with ByteDance.",
"type": "module",
"bin": {
Expand Down
2 changes: 1 addition & 1 deletion src/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ function nodeMajor(): number {
}

/** Default per-OS CapCut/JianYing project directories. */
function draftDirs(): { label: string; path: string }[] {
export function draftDirs(): { label: string; path: string }[] {
const home = homedir();
if (platform() === "darwin") {
return [
Expand Down
51 changes: 49 additions & 2 deletions src/draft.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { basename, dirname, join, resolve } from "node:path";

export interface Timerange {
start: number;
Expand Down Expand Up @@ -147,6 +147,52 @@ export function isDryRun(): boolean {
return dryRun;
}

// Multi-step undo history. Alongside the single `.bak`, every write also keeps
// a rolling stack of the pre-write content under `<draftdir>/.capcut-cli-history/`,
// capped at HISTORY_MAX. `restore --step N` rolls back N writes; CapCut ignores
// the hidden dir. snapshots are named `<draftbase>.NNNNNN.snap` (zero-padded,
// monotonically increasing) so the newest is the lexicographically last.
const HISTORY_DIR = ".capcut-cli-history";
const HISTORY_MAX = 20;

function historyDir(filePath: string): string {
return join(dirname(filePath), HISTORY_DIR);
}

function snapshotFiles(filePath: string): string[] {
const dir = historyDir(filePath);
if (!existsSync(dir)) return [];
const prefix = `${basename(filePath)}.`;
return readdirSync(dir)
.filter((f) => f.startsWith(prefix) && f.endsWith(".snap"))
.sort(); // zero-padded indices => lexicographic === numeric order, oldest first
}

function writeHistorySnapshot(filePath: string, content: string): void {
const dir = historyDir(filePath);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
const existing = snapshotFiles(filePath);
const last = existing[existing.length - 1];
const lastIndex = last ? Number.parseInt(last.match(/\.(\d+)\.snap$/)?.[1] ?? "0", 10) : 0;
const name = `${basename(filePath)}.${String(lastIndex + 1).padStart(6, "0")}.snap`;
writeFileSync(join(dir, name), content, "utf-8");
// Trim oldest beyond the cap.
const all = snapshotFiles(filePath);
while (all.length > HISTORY_MAX) {
const oldest = all.shift();
if (oldest) rmSync(join(dir, oldest));
}
}

// Snapshots newest-first, step 1 = most recent write (equivalent to `.bak`).
export function listSnapshots(filePath: string): Array<{ step: number; index: number; path: string }> {
const dir = historyDir(filePath);
return snapshotFiles(filePath)
.map((f) => ({ index: Number.parseInt(f.match(/\.(\d+)\.snap$/)?.[1] ?? "0", 10), path: join(dir, f) }))
.sort((a, b) => b.index - a.index)
.map((s, i) => ({ step: i + 1, index: s.index, path: s.path }));
}

export function saveDraft(filePath: string, draft: Draft): void {
if (dryRun) {
// Normalize in memory (so any read-back is consistent) but write nothing.
Expand All @@ -157,6 +203,7 @@ export function saveDraft(filePath: string, draft: Draft): void {
if (existsSync(filePath)) {
const original = rawOriginal ?? readFileSync(filePath, "utf-8");
writeFileSync(bakPath, original, "utf-8");
writeHistorySnapshot(filePath, original);
}
sortTracks(draft);
// Detect original indent: if first line after { starts with tab use tab, else count spaces
Expand Down
35 changes: 27 additions & 8 deletions src/export-batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export interface ExportBatchResult {
*
* CapCut/JianYing have no headless render CLI. This wraps OS-level automation:
* - macOS: AppleScript (`osascript`) opens each draft and triggers Export
* - Windows: PowerShell + SendKeys (sketched; needs CapCut window focus)
* - Windows: PowerShell + SendKeys (Ctrl+E export; needs CapCut window focus)
* - Linux: not supported — CapCut/JianYing don't run natively
*
* Reliability is bounded by the host UI not changing. We surface this clearly
Expand Down Expand Up @@ -109,11 +109,30 @@ function runMacOSExport(draftDir: string, app: "capcut" | "jianying"): { ok: boo
return { ok: true, message: "Export triggered via AppleScript; check your CapCut export queue" };
}

function runWindowsExport(_draftDir: string, _app: "capcut" | "jianying"): { ok: boolean; message: string } {
return {
ok: false,
message:
"Windows automation is sketched but not yet shipped — requires PowerShell + UI Automation framework. " +
"Workaround: use AutoHotkey externally. See docs/version-support.md.",
};
// Build the PowerShell automation for one draft. Pure (no I/O) so it can be
// unit-tested off-Windows: opens the project file, waits for the app window,
// then sends CapCut's export shortcut (Ctrl+E) via SendKeys.
export function windowsExportScript(draftDir: string, app: "capcut" | "jianying"): string {
const exe = app === "capcut" ? "CapCut" : "JianyingPro";
const draftFile = `${draftDir}\\draft_content.json`;
return [
"Add-Type -AssemblyName System.Windows.Forms;",
`Start-Process -FilePath '${draftFile}';`,
"Start-Sleep -Seconds 6;",
`$p = Get-Process '${exe}' -ErrorAction SilentlyContinue | Select-Object -First 1;`,
"if ($p) { [System.Windows.Forms.SendKeys]::SendWait('^e'); } else { exit 3 }",
].join("\n");
}

function runWindowsExport(draftDir: string, app: "capcut" | "jianying"): { ok: boolean; message: string } {
// Same reliability caveat as the macOS path: bounded by the host UI not moving.
const script = windowsExportScript(draftDir, app);
const r = spawnSync("powershell", ["-NoProfile", "-Command", script], { encoding: "utf-8", timeout: 30_000 });
if (r.status !== 0) {
return {
ok: false,
message: `powershell failed (status ${r.status}): ${r.stderr || r.stdout || "is CapCut installed and the window unobstructed?"}`,
};
}
return { ok: true, message: "Export triggered via PowerShell SendKeys (Ctrl+E); check your CapCut export queue" };
}
Loading
Loading