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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ All notable changes to capcut-cli are documented here. The format follows [Keep

## [Unreleased]

### Added

- **Global `--dry-run`** ([#15](https://github.com/renezander030/capcut-cli/issues/15)) — any draft-mutating command now honors `--dry-run`: it computes and prints the normal JSON result with `"dryRun":true` added, but leaves the draft **and** its `.bak` untouched. Gated centrally in `saveDraft`, so it covers every write command at once. `translate` / `export --batch` keep their existing dry-run behavior.
- **`restore` command** ([#16](https://github.com/renezander030/capcut-cli/issues/16)) — `capcut restore <project>` undoes the last write by copying `<draft>.bak` back over the draft. Single-step (only one backup generation is kept); exits non-zero with a clear message when no `.bak` exists. Honors `--dry-run`.

### Documentation

- **README** — added a from-source install path and a consolidated Prerequisites note (Node ≥ 18, whisper for `caption`, `ANTHROPIC_API_KEY` for `translate`); a worked-example block for the v0.4/v0.5 commands that had none (`mix-mode`, `audio-fade`, `add-filter`, `bubble-text`, `add-cover`, `add-sfx`, `chroma`, `import-ass`); and a **Troubleshooting** table covering the CapCut-must-be-closed footgun, track-order normalization ([#21](https://github.com/renezander030/capcut-cli/issues/21)), `.bak` recovery, whisper/API-key setup, and the `--fade-out` flag.
Expand Down
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,13 @@ a1b2c3d4 0:00.50- 0:03.00 Welcome to the video
capcut set-text ./project a1b2c3 "New text" -q && echo "done"
```

**Dry run** (`--dry-run`) -- preview any mutating command. It prints the normal JSON result with `"dryRun":true` added, but leaves the draft **and** its `.bak` untouched. Preview, then commit:
```bash
capcut speed ./project a1b2c3 2.0 --dry-run
# {"ok":true,"id":"a1b2c3...","old_speed":1,"new_speed":2,"dryRun":true} ← nothing written
capcut speed ./project a1b2c3 2.0 # run for real
```

## Commands

### Overview (start here)
Expand Down Expand Up @@ -391,7 +398,7 @@ Options: `--font-size <n>`, `--color <hex>`, `--align <0|1|2>` (left/center/righ

### Edit

Every write command creates a `.bak` backup before modifying the file.
Every write command creates a `.bak` backup before modifying the file. Add `--dry-run` to any of them to preview without writing; `capcut restore` rolls the last write back.

```bash
capcut set-text ./project a1b2c3 "New subtitle"
Expand All @@ -403,6 +410,7 @@ capcut speed ./project a1b2c3 1.5
capcut volume ./project a1b2c3 0.8
capcut opacity ./project a1b2c3 0.5
capcut trim ./project a1b2c3 2s 5s
capcut restore ./project # undo the last write (single-step, from .bak)
```

### Templates
Expand Down Expand Up @@ -656,7 +664,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** | Every write leaves a `.bak` beside the draft. Roll back with `mv draft_content.json.bak draft_content.json`. |
| **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. |
| **`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
18 changes: 18 additions & 0 deletions src/draft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,25 @@ export function sortTracks(draft: Draft): void {
.map(({ track }) => track);
}

// --dry-run state. When set, saveDraft computes the in-memory change but skips
// both the on-disk write and the .bak snapshot, so the draft is left untouched.
// All ~35 mutating commands funnel through saveDraft, so gating here covers them.
let dryRun = false;

export function setDryRun(value: boolean): void {
dryRun = value;
}

export function isDryRun(): boolean {
return dryRun;
}

export function saveDraft(filePath: string, draft: Draft): void {
if (dryRun) {
// Normalize in memory (so any read-back is consistent) but write nothing.
sortTracks(draft);
return;
}
const bakPath = `${filePath}.bak`;
if (existsSync(filePath)) {
const original = rawOriginal ?? readFileSync(filePath, "utf-8");
Expand Down
41 changes: 38 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env node

import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { copyFileSync, existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { parseAss } from "./ass.js";
Expand Down Expand Up @@ -37,13 +37,16 @@ import { type DoctorCheck, runDoctor } from "./doctor.js";
import type { Draft, Segment, Track } from "./draft.js";
import {
extractText,
findDraft,
findMaterial,
findMaterialGlobal,
findSegment,
getMaterialTypes,
getTracksByType,
isDryRun,
loadDraft,
saveDraft,
setDryRun,
updateTextContent,
} from "./draft.js";
import { type Category, listEnum, type Namespace } from "./enums.js";
Expand Down Expand Up @@ -128,13 +131,14 @@ export const COMMANDS = [
"chroma",
"enums",
"doctor",
"restore",
"serve",
"decrypt",
"export",
"init",
] as const;

const GLOBAL_FLAGS = ["--jianying", "-H", "--human", "-q", "--quiet", "-v", "--version"] as const;
const GLOBAL_FLAGS = ["--jianying", "-H", "--human", "-q", "--quiet", "-v", "--version", "--dry-run"] as const;

const HELP = `capcut-cli -- fast edits to CapCut projects

Expand All @@ -146,6 +150,8 @@ Global flags:
-H, --human Human-readable table output (default: JSON)
-v, --version Print the installed CLI version
-q, --quiet No output on success, exit code only (write commands)
--dry-run Preview a mutating command: print the result (with
"dryRun":true) but leave the draft and its .bak untouched
--jianying Use JianYing enum namespace (default: CapCut) for
transition, mask, text-anim, image-anim, add-effect, enums

Expand Down Expand Up @@ -212,6 +218,7 @@ Edit:
opacity <project> <id> <alpha> Set opacity (0.0-1.0)
export-srt <project> Export subtitles to SRT
batch <project> Run multiple edits from stdin (JSONL)
restore <project> Undo the last write (restore from .bak, single-step)

Animate:
keyframe <project> <id> <property> <time> <value>
Expand Down Expand Up @@ -762,7 +769,13 @@ function parseFlags(args: string[]): { positional: string[]; flags: Flags } {

function out(data: unknown, flags: Flags): void {
if (flags.quiet) return;
process.stdout.write(`${JSON.stringify(data)}\n`);
// In --dry-run, stamp an object result with dryRun:true so callers can tell a
// preview from a committed write. Arrays (read commands) are left untouched.
let payload = data;
if (isDryRun() && data !== null && typeof data === "object" && !Array.isArray(data)) {
payload = { ...(data as Record<string, unknown>), dryRun: true };
}
process.stdout.write(`${JSON.stringify(payload)}\n`);
}

class CliError extends Error {
Expand Down Expand Up @@ -2077,6 +2090,19 @@ function getCliVersion(): string {
return pkg.version;
}

// `restore` undoes the last write by copying <draft>.bak back over the draft.
// Only one backup generation is kept, so this is a single-step undo.
function cmdRestore(projectPath: string | undefined, flags: Flags): void {
if (!projectPath) die("Missing project path. Usage: capcut restore <project>");
const filePath = findDraft(projectPath);
const bakPath = `${filePath}.bak`;
if (!existsSync(bakPath)) {
die(`No backup found at ${bakPath}. Nothing to restore (a .bak is written on the first edit).`);
}
if (!isDryRun()) copyFileSync(bakPath, filePath);
out({ ok: true, restored: filePath, from: bakPath }, flags);
}

// --- Main ---

async function main(): Promise<void> {
Expand All @@ -2088,6 +2114,9 @@ async function main(): Promise<void> {

const { positional, flags } = parseFlags(raw);

// Global --dry-run: gate every saveDraft write (see src/draft.ts).
setDryRun(flags.dryRun === true);

if (flags.version) {
console.log(getCliVersion());
process.exit(0);
Expand Down Expand Up @@ -2127,6 +2156,12 @@ async function main(): Promise<void> {
process.exit(cmdDoctor(flags) ? 0 : 1);
}

// `restore` copies <draft>.bak back over the draft — no loadDraft/parse needed.
if (cmd === "restore") {
cmdRestore(projectPath, flags);
process.exit(0);
}

// `serve` reads jobs from stdin/queue file — no project needed.
if (cmd === "serve") {
await cmdServe(flags);
Expand Down
35 changes: 35 additions & 0 deletions test/dry-run.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { after, describe, it } from "node:test";
import { spawnCli } from "./helpers/spawn-cli.mjs";
import { tmpDraft } from "./helpers/tmp-draft.mjs";

// #15: a global --dry-run that previews any mutating command without writing.
describe("global --dry-run (#15)", () => {
const segId = (path) => JSON.parse(readFileSync(path, "utf-8")).tracks.find((t) => t.type === "video").segments[0].id;

it("leaves the draft and its .bak untouched, and stamps dryRun:true", () => {
const fix = tmpDraft();
after(() => fix.cleanup());
const before = readFileSync(fix.path, "utf-8");

const r = spawnCli(["speed", fix.path, segId(fix.path), "2.0", "--dry-run"]);
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
assert.ok(r.json.ok);
assert.equal(r.json.dryRun, true);

assert.equal(readFileSync(fix.path, "utf-8"), before, "draft must be byte-identical after --dry-run");
assert.equal(existsSync(`${fix.path}.bak`), false, "no .bak should be written in --dry-run");
});

it("a real write changes the file and carries no dryRun marker", () => {
const fix = tmpDraft();
after(() => fix.cleanup());
const before = readFileSync(fix.path, "utf-8");

const r = spawnCli(["speed", fix.path, segId(fix.path), "1.25"]);
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
assert.equal(r.json.dryRun, undefined);
assert.notEqual(readFileSync(fix.path, "utf-8"), before, "a real write must change the file");
});
});
46 changes: 46 additions & 0 deletions test/restore.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { after, describe, it } from "node:test";
import { spawnCli } from "./helpers/spawn-cli.mjs";
import { tmpDraft } from "./helpers/tmp-draft.mjs";

// #16: `restore` undoes the last write by copying <draft>.bak back over the draft.
describe("restore (#16)", () => {
const segId = (path) => JSON.parse(readFileSync(path, "utf-8")).tracks.find((t) => t.type === "video").segments[0].id;

it("reverts the draft to its pre-write state", () => {
const fix = tmpDraft();
after(() => fix.cleanup());
const original = readFileSync(fix.path, "utf-8");

const w = spawnCli(["speed", fix.path, segId(fix.path), "2.0"]);
assert.equal(w.status, 0, `stderr: ${w.stderr}`);
assert.notEqual(readFileSync(fix.path, "utf-8"), original, "write should change the file first");

const r = spawnCli(["restore", fix.path]);
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
assert.ok(r.json.ok);
assert.equal(readFileSync(fix.path, "utf-8"), original, "restore should bring back the original bytes");
});

it("exits non-zero with a clear message when no .bak exists", () => {
const fix = tmpDraft();
after(() => fix.cleanup());

const r = spawnCli(["restore", fix.path]);
assert.equal(r.status, 1);
assert.match(`${r.stdout}${r.stderr}`, /No backup found/);
});

it("honors --dry-run: reports without restoring", () => {
const fix = tmpDraft();
after(() => fix.cleanup());
spawnCli(["speed", fix.path, segId(fix.path), "2.0"]);
const mutated = readFileSync(fix.path, "utf-8");

const r = spawnCli(["restore", fix.path, "--dry-run"]);
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
assert.equal(r.json.dryRun, true);
assert.equal(readFileSync(fix.path, "utf-8"), mutated, "--dry-run restore must not change the file");
});
});
Loading