diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dc40c0..d5c12de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` repoints each missing material to a same-basename file in the folder; `--from

--to ` 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

`), 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 `/.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. diff --git a/README.md b/README.md index 5e6ffd6..90ed821 100644 --- a/README.md +++ b/README.md @@ -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 ` for progressive-disclosure drill-down; `export-srt`. +- **Inspect** — `info` · `tracks` · `materials` · `segments` · `texts`; `segment`/`material ` 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 `, [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 `, [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. @@ -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 ` — 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 ` reverts the last write. Earlier writes are recoverable too: `capcut restore --list` shows the snapshot history (kept in `/.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 `. | | **`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. | diff --git a/package-lock.json b/package-lock.json index 5a79900..845d4d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "capcut-cli", - "version": "0.8.0", + "version": "0.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "capcut-cli", - "version": "0.8.0", + "version": "0.9.0", "license": "MIT", "bin": { "capcut": "dist/index.js", diff --git a/package.json b/package.json index e29e741..1677621 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/doctor.ts b/src/doctor.ts index 03ce412..14b0a22 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -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 [ diff --git a/src/draft.ts b/src/draft.ts index af43a66..098922d 100644 --- a/src/draft.ts +++ b/src/draft.ts @@ -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; @@ -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 `/.capcut-cli-history/`, +// capped at HISTORY_MAX. `restore --step N` rolls back N writes; CapCut ignores +// the hidden dir. snapshots are named `.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. @@ -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 diff --git a/src/export-batch.ts b/src/export-batch.ts index 1e4ebdf..f6834f2 100644 --- a/src/export-batch.ts +++ b/src/export-batch.ts @@ -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 @@ -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" }; } diff --git a/src/index.ts b/src/index.ts index 7834939..d3ab96c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node -import { copyFileSync, existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { copyFileSync, existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { parseAss } from "./ass.js"; @@ -33,7 +34,7 @@ import { textAnimSlugs, } from "./decorators.js"; import { detectEncryption } from "./decrypt.js"; -import { type DoctorCheck, runDoctor } from "./doctor.js"; +import { type DoctorCheck, draftDirs, runDoctor } from "./doctor.js"; import type { Draft, Segment, Track } from "./draft.js"; import { extractText, @@ -44,6 +45,7 @@ import { getMaterialTypes, getTracksByType, isDryRun, + listSnapshots, loadDraft, saveDraft, setDryRun, @@ -72,6 +74,7 @@ import { setAudioFade, setCover, setMixMode, + uuid, } from "./factory.js"; import { DEFAULT_LINT_OPTIONS, type LintOptions, lintDraft, lintExitCode, summarize } from "./lint.js"; import { migrateDraft } from "./migrate.js"; @@ -129,6 +132,15 @@ export const COMMANDS = [ "migrate", "add-sfx", "chroma", + "prune", + "relink", + "timeline", + "projects", + "diff", + "concat", + "config", + "describe", + "completions", "enums", "doctor", "restore", @@ -218,7 +230,17 @@ Edit: opacity Set opacity (0.0-1.0) export-srt Export subtitles to SRT batch Run multiple edits from stdin (JSONL) - restore Undo the last write (restore from .bak, single-step) + restore [--step N | --list] Undo writes (latest .bak, or N writes back; --list history) + +Maintenance & inspection: + prune Remove materials no segment references + relink --dir | --from

--to Repair broken media paths + timeline [--cols N] Show track/segment layout (JSON, or -H ASCII bars) + projects [query] [--drafts

] [--names] List CapCut/JianYing draft folders on disk + diff Compare two drafts (added/removed/changed) + concat [--out

] Append draftB onto projectA's timeline (id-safe) + config Show resolved .capcutrc + effective defaults + describe Emit the full command surface as JSON (agent tool spec) Animate: keyframe

--to prefix-replace on every material path. +function cmdRelink(draft: Draft, filePath: string, flags: Flags): void { + if (!flags.dir && !(flags.from && flags.to)) { + die("Usage: capcut relink --dir | --from --to "); + } + const dirIndex = new Map(); + if (flags.dir) { + if (!existsSync(flags.dir)) die(`--dir not found: ${flags.dir}`); + for (const f of readdirSync(flags.dir)) dirIndex.set(path.basename(f), path.join(flags.dir as string, f)); + } + const relinked: Array<{ id: string; from: string; to: string }> = []; + let missing = 0; + let ok = 0; + for (const arr of Object.values(draft.materials)) { + if (!Array.isArray(arr)) continue; + for (const m of arr) { + const mat = m as { id?: string; path?: unknown }; + if (typeof mat.path !== "string" || mat.path === "") continue; + let p = mat.path; + let changed = false; + if (flags.from && flags.to && p.startsWith(flags.from)) { + p = flags.to + p.slice(flags.from.length); + changed = true; + } + if (!existsSync(p) && flags.dir) { + const hit = dirIndex.get(path.basename(p)); + if (hit) { + p = hit; + changed = true; + } + } + if (changed && p !== mat.path) { + relinked.push({ id: mat.id ?? "", from: mat.path, to: p }); + mat.path = p; + } + if (existsSync(p)) ok++; + else missing++; + } + } + if (relinked.length > 0) saveDraft(filePath, draft); + out({ ok: true, relinked: relinked.length, still_missing: missing, present: ok, changes: relinked }, flags); +} + +// `timeline` shows the track/segment layout. JSON default returns structured +// lanes (with computed columns); -H renders ASCII bars scaled to --cols (def 60). +function cmdTimeline(draft: Draft, flags: Flags): void { + const cols = flags.cols && flags.cols > 0 ? flags.cols : 60; + let span = 0; + for (const t of draft.tracks) + for (const s of t.segments) span = Math.max(span, s.target_timerange.start + s.target_timerange.duration); + span = Math.max(span, draft.duration, 1); + const scale = (us: number) => Math.round((us / span) * cols); + const tracks = draft.tracks.map((t) => ({ + type: t.type, + name: t.name, + segments: t.segments.map((s) => { + const startCol = scale(s.target_timerange.start); + const endCol = Math.max(startCol + 1, scale(s.target_timerange.start + s.target_timerange.duration)); + return { + id: s.id, + start_us: s.target_timerange.start, + duration_us: s.target_timerange.duration, + col_start: startCol, + col_end: endCol, + }; + }), + })); + + if (!flags.human) { + out({ ok: true, span_us: span, cols, tracks }, flags); + return; + } + const lines: string[] = []; + const label = (t: { type: string; name: string }) => `${t.type}${t.name ? `/${t.name}` : ""}`.padEnd(14).slice(0, 14); + for (const t of tracks) { + const row = Array.from({ length: cols }, () => " "); + for (const s of t.segments) { + for (let c = s.col_start; c < s.col_end && c < cols; c++) row[c] = "█"; + } + lines.push(`${label(t)} |${row.join("")}|`); + } + process.stdout.write(`${lines.join("\n")}\n`); +} + +// `projects` lists draft folders on disk. Scans --drafts

(or the per-OS +// CapCut/JianYing default dirs) for sub-folders containing a draft file. An +// optional query substring filters by folder name. --names also reads each +// draft's `name` field (one parse per project). +function cmdProjects(positional: string[], flags: Flags): void { + const query = positional[1]?.toLowerCase(); + const roots = flags.drafts ? [{ label: "custom", path: flags.drafts }] : draftDirs(); + const projects: Array<{ name?: string; folder: string; path: string; mtime: string; root: string }> = []; + for (const root of roots) { + if (!existsSync(root.path)) continue; + for (const entry of readdirSync(root.path)) { + const folder = path.join(root.path, entry); + let isDir = false; + try { + isDir = statSync(folder).isDirectory(); + } catch { + isDir = false; + } + if (!isDir) continue; + const draftFile = ["draft_content.json", "draft_info.json"] + .map((f) => path.join(folder, f)) + .find((p) => existsSync(p)); + if (!draftFile) continue; + if (query && !entry.toLowerCase().includes(query)) continue; + const rec: { name?: string; folder: string; path: string; mtime: string; root: string } = { + folder: entry, + path: draftFile, + mtime: statSync(draftFile).mtime.toISOString(), + root: root.label, + }; + if (flags.names) { + try { + const d = JSON.parse(readFileSync(draftFile, "utf-8")) as { name?: string }; + rec.name = d.name || undefined; + } catch { + /* unreadable draft — leave name undefined */ + } + } + projects.push(rec); + } + } + projects.sort((a, b) => b.mtime.localeCompare(a.mtime)); + if (flags.human) { + if (!projects.length) { + process.stdout.write("No projects found.\n"); + return; + } + const lines = projects.map((p) => `${p.mtime.slice(0, 10)} ${p.folder}${p.name ? ` (${p.name})` : ""}`); + process.stdout.write(`${lines.join("\n")}\n`); + return; + } + out({ ok: true, count: projects.length, projects }, flags); +} + +// One-line summary per command, keyed by the COMMANDS entries. `describe` +// serializes these into a machine-readable tool spec. The test asserts every +// COMMANDS name has an entry here, so a new command can't ship undescribed. +const SUMMARIES: Record = { + info: "Project overview + material summary.", + version: "Detect CapCut/JianYing version, schema flags, and support status.", + lint: "Schema-aware checks (overlaps, line length, missing files); exit 0/1/2 for CI.", + tracks: "List all tracks.", + segments: "List segments with timing; filter by --track .", + texts: "List all text/subtitle content.", + "set-text": "Change a text segment's content.", + shift: "Shift one segment's timing by an offset (e.g. +0.5s).", + "shift-all": "Shift all segments (optionally on one --track) by an offset.", + speed: "Set a segment's playback speed.", + volume: "Set a segment's volume (0.0-1.0).", + trim: "Trim a segment to a start/duration window.", + opacity: "Set a segment's opacity (0.0-1.0).", + "export-srt": "Export subtitles to SRT on stdout.", + materials: "List material types and counts; filter with --type.", + segment: "Full detail for one segment and its material.", + material: "Full detail for one material.", + "add-audio": "Add a local or Wikimedia audio file on an audio track.", + "add-video": "Add a local or Wikimedia video/image on a video track.", + "add-text": "Add a text segment with font/color/position options.", + cut: "Extract a time range into a new standalone draft.", + keyframe: "Add a keyframe (position/scale/rotation/alpha/volume); single or --batch.", + transition: "Add a transition between segments.", + mask: "Apply a mask (linear/circle/heart/...) with geometry flags, or --off.", + "bg-blur": "Set background blur level 1-4, or --off.", + "text-style": "Style text (alpha/shadow/border/background box).", + "text-anim": "Add intro/outro/combo text animation.", + "image-anim": "Add intro/outro/combo animation to an image/video segment.", + "add-sticker": "Add a sticker on its own track with transform.", + "mix-mode": "Set a video segment's blend mode.", + "audio-fade": "Add fade-in/fade-out to an audio segment (--in / --fade-out).", + "add-cover": "Set the project cover/thumbnail from a local image.", + "add-filter": "Add a colour filter on its own track.", + "bubble-text": "Apply a speech-bubble shape to a text segment.", + "add-effect": "Add a scene effect on its own track.", + "save-template": "Extract a segment as a reusable template JSON.", + "apply-template": "Stamp a template into a project with new timing/text.", + batch: "Run multiple edits from stdin (JSONL), one file write.", + "import-srt": "Import an SRT file/stdin as one text segment per cue.", + "import-ass": "Import an ASS/SSA subtitle file as text segments.", + "text-ranges": "Apply byte-accurate multi-style ranges to a text segment.", + caption: "Transcribe audio via whisper into real caption-track segments.", + translate: "Clone a draft into another language via the Anthropic API.", + migrate: "Apply known schema migrations across version boundaries.", + "add-sfx": "Add a sound effect on a dedicated track.", + chroma: "Green-screen / chroma key a video segment, or --off.", + enums: "List enum slugs (transitions, masks, effects, ...) by category.", + doctor: "Environment preflight (Node, whisper, API key, project dir).", + prune: "Remove materials no segment references.", + relink: "Repair broken media paths (--dir or --from/--to).", + timeline: "Show the track/segment layout (JSON, or -H ASCII bars).", + projects: "List CapCut/JianYing draft folders on disk.", + diff: "Compare two drafts (segments/materials/tracks added/removed/changed).", + concat: "Append one draft onto another's timeline (id-safe), write to --out or in place.", + config: "Show the resolved config (.capcutrc + effective defaults).", + describe: "Emit the full command surface as JSON (agent tool spec).", + completions: "Generate shell completions (bash|zsh|fish).", + restore: "Undo writes from .bak / snapshot history (--step N, --list).", + serve: "Run a stateless JSONL job queue from stdin/--queue.", + decrypt: "Detect JianYing 6.0+ encryption and explain the workaround.", + export: "EXPERIMENTAL UI-automated render queue (macOS).", + init: "Create a new empty draft from a template.", +}; + +const GLOBAL_FLAG_DOCS: Array<{ flag: string; description: string }> = [ + { flag: "-H, --human", description: "Human-readable output instead of JSON." }, + { flag: "-q, --quiet", description: "No stdout on success; exit code only." }, + { flag: "-v, --version", description: "Print the installed CLI version." }, + { flag: "--dry-run", description: "Preview a mutating command; write nothing." }, + { flag: "--jianying", description: "Use the JianYing enum namespace." }, +]; + +// `describe` emits a machine-readable tool spec for LLM/agent callers, so they +// don't have to scrape --help. Names come from COMMANDS (source of truth); +// summaries from SUMMARIES (test-enforced complete). +function cmdDescribe(flags: Flags): void { + const commands = COMMANDS.map((name) => ({ name, summary: SUMMARIES[name] ?? "" })); + out( + { + name: "capcut-cli", + version: getCliVersion(), + description: "Edit CapCut/JianYing draft_content.json directly. JSON in, JSON out.", + global_flags: GLOBAL_FLAG_DOCS, + commands, + }, + flags, + ); +} + +// --- Config (.capcutrc) --- + +interface CapcutConfig { + drafts?: string; + jianying?: boolean; + cols?: number; +} + +// Load .capcutrc from cwd, then home. cwd wins. Returns {} if none/invalid. +function loadConfig(): { path: string | null; config: CapcutConfig } { + for (const p of [path.join(process.cwd(), ".capcutrc"), path.join(homedir(), ".capcutrc")]) { + if (!existsSync(p)) continue; + try { + const cfg = JSON.parse(readFileSync(p, "utf-8")) as CapcutConfig; + return { path: p, config: cfg }; + } catch { + // Malformed config is ignored rather than crashing every command. + return { path: p, config: {} }; + } + } + return { path: null, config: {} }; +} + +// Apply config as defaults: a CLI flag always wins over the file. +function applyConfig(flags: Flags, config: CapcutConfig): void { + if (flags.drafts === undefined && typeof config.drafts === "string") flags.drafts = config.drafts; + if (flags.jianying === undefined && config.jianying === true) flags.jianying = true; + if (flags.cols === undefined && typeof config.cols === "number") flags.cols = config.cols; +} + +function cmdConfig(flags: Flags): void { + const { path: cfgPath, config } = loadConfig(); + out( + { + ok: true, + path: cfgPath, + config, + effective: { drafts: flags.drafts, jianying: !!flags.jianying, cols: flags.cols }, + }, + flags, + ); +} + +// --- diff / concat --- + +// Read a draft from disk without touching loadDraft's module state (so two can +// be loaded at once for diff/concat). +function readDraft(input: string): { draft: Draft; filePath: string } { + const filePath = findDraft(input); + return { draft: JSON.parse(readFileSync(filePath, "utf-8")) as Draft, filePath }; +} + +function indexSegments(draft: Draft): Map { + const m = new Map(); + for (const t of draft.tracks) for (const s of t.segments) m.set(s.id, { seg: s, track: t.type }); + return m; +} + +function indexMaterials(draft: Draft): Map { + const m = new Map(); + for (const [type, arr] of Object.entries(draft.materials)) { + if (!Array.isArray(arr)) continue; + for (const mat of arr) { + const id = (mat as { id?: unknown }).id; + if (typeof id === "string") m.set(id, type); + } + } + return m; +} + +// id -> serialized material, so diff can detect in-place content changes +// (a text edit mutates the material under the same id). +function indexMaterialContent(draft: Draft): Map { + const m = new Map(); + for (const arr of Object.values(draft.materials)) { + if (!Array.isArray(arr)) continue; + for (const mat of arr) { + const id = (mat as { id?: unknown }).id; + if (typeof id === "string") m.set(id, JSON.stringify(mat)); + } + } + return m; +} + +// `diff` reports what changed between two drafts: segments added/removed/changed +// and materials added/removed. Read-only. +function cmdDiff(positional: string[], flags: Flags): void { + const aPath = positional[1]; + const bPath = positional[2]; + if (!aPath || !bPath) die("Usage: capcut diff "); + const a = readDraft(aPath).draft; + const b = readDraft(bPath).draft; + + const aSeg = indexSegments(a); + const bSeg = indexSegments(b); + const segAdded: string[] = []; + const segRemoved: string[] = []; + const segChanged: Array<{ id: string; fields: string[] }> = []; + for (const [id, { seg }] of bSeg) { + if (!aSeg.has(id)) { + segAdded.push(id); + continue; + } + const prev = aSeg.get(id)?.seg as Segment; + const fields: string[] = []; + if (prev.target_timerange.start !== seg.target_timerange.start) fields.push("start"); + if (prev.target_timerange.duration !== seg.target_timerange.duration) fields.push("duration"); + if (prev.material_id !== seg.material_id) fields.push("material_id"); + if (JSON.stringify(prev.content ?? null) !== JSON.stringify(seg.content ?? null)) fields.push("content"); + if (prev.speed !== seg.speed) fields.push("speed"); + if (prev.volume !== seg.volume) fields.push("volume"); + if (fields.length) segChanged.push({ id, fields }); + } + for (const id of aSeg.keys()) if (!bSeg.has(id)) segRemoved.push(id); + + const aMat = indexMaterialContent(a); + const bMat = indexMaterialContent(b); + const matAdded = [...bMat.keys()].filter((id) => !aMat.has(id)); + const matRemoved = [...aMat.keys()].filter((id) => !bMat.has(id)); + // Same id in both but different serialized content — e.g. a text edit mutates + // the text material (not the segment), so this is where set-text shows up. + const matChanged = [...bMat.keys()].filter((id) => aMat.has(id) && aMat.get(id) !== bMat.get(id)); + + const changed = + segAdded.length + segRemoved.length + segChanged.length + matAdded.length + matRemoved.length + matChanged.length > + 0; + out( + { + ok: true, + changed, + tracks: { a: a.tracks.length, b: b.tracks.length }, + segments: { added: segAdded, removed: segRemoved, changed: segChanged }, + materials: { added: matAdded, removed: matRemoved, changed: matChanged }, + }, + flags, + ); +} + +// `concat` appends draftB onto draftA's timeline. B's segments are time-shifted +// by A's duration; any B material/segment id that collides with A is reassigned +// a fresh uuid (and references rewritten) so the merged draft stays valid. +function cmdConcat(positional: string[], flags: Flags): void { + const aInput = positional[1]; + const bInput = positional[2]; + if (!aInput || !bInput) die("Usage: capcut concat [--out ]"); + const { draft: a, filePath: aFile } = loadDraft(aInput); + const b = JSON.parse(readFileSync(findDraft(bInput), "utf-8")) as Draft; + + const offset = a.duration || 0; + const aSegIds = new Set(); + for (const t of a.tracks) for (const s of t.segments) aSegIds.add(s.id); + const aMatIds = new Set(indexMaterials(a).keys()); + + // 1. Reassign colliding material ids in B, build old->new map. + const matRemap = new Map(); + for (const [, arr] of Object.entries(b.materials)) { + if (!Array.isArray(arr)) continue; + for (const mat of arr) { + const m = mat as { id?: string }; + if (typeof m.id === "string" && aMatIds.has(m.id)) { + const fresh = uuid(); + matRemap.set(m.id, fresh); + m.id = fresh; + } + } + } + // 2. Fix B segments: remap material refs, reassign colliding segment ids, time-shift. + for (const t of b.tracks) { + for (const s of t.segments) { + if (matRemap.has(s.material_id)) s.material_id = matRemap.get(s.material_id) as string; + s.extra_material_refs = (s.extra_material_refs ?? []).map((r) => matRemap.get(r) ?? r); + if (aSegIds.has(s.id)) s.id = uuid(); + s.target_timerange = { ...s.target_timerange, start: s.target_timerange.start + offset }; + } + } + // 3. Merge B materials into A. + for (const [type, arr] of Object.entries(b.materials)) { + if (!Array.isArray(arr)) continue; + const dest = (a.materials as Record)[type]; + if (Array.isArray(dest)) dest.push(...arr); + else (a.materials as Record)[type] = [...arr]; + } + // 4. Merge B tracks into A: same type+name extends; otherwise appended. + for (const bt of b.tracks) { + const match = a.tracks.find((at) => at.type === bt.type && at.name === bt.name); + if (match) match.segments.push(...bt.segments); + else a.tracks.push(bt); + } + a.duration = offset + (b.duration || 0); + + if (flags.out) { + writeFileSync(flags.out, JSON.stringify(a, null, 2), "utf-8"); + out({ ok: true, out: flags.out, duration_us: a.duration, remapped_ids: matRemap.size }, flags); + } else { + saveDraft(aFile, a); + out({ ok: true, project: aFile, duration_us: a.duration, remapped_ids: matRemap.size }, flags); + } +} + // --- Main --- async function main(): Promise { @@ -2114,6 +2635,9 @@ async function main(): Promise { const { positional, flags } = parseFlags(raw); + // .capcutrc defaults fill in unset flags (CLI flags always win). + applyConfig(flags, loadConfig().config); + // Global --dry-run: gate every saveDraft write (see src/draft.ts). setDryRun(flags.dryRun === true); @@ -2156,12 +2680,40 @@ async function main(): Promise { process.exit(cmdDoctor(flags) ? 0 : 1); } - // `restore` copies .bak back over the draft — no loadDraft/parse needed. + // `restore` copies a backup/snapshot back over the draft — no loadDraft/parse needed. if (cmd === "restore") { cmdRestore(projectPath, flags); process.exit(0); } + // `describe` emits the tool spec — no project needed. + if (cmd === "describe") { + cmdDescribe(flags); + process.exit(0); + } + + // `projects` scans the disk for draft folders — no single project needed. + if (cmd === "projects") { + cmdProjects(positional, flags); + process.exit(0); + } + + // `diff` reads two drafts; `concat` reads two and writes one — handled directly. + if (cmd === "diff") { + cmdDiff(positional, flags); + process.exit(0); + } + if (cmd === "concat") { + cmdConcat(positional, flags); + process.exit(0); + } + + // `config` just reports the resolved .capcutrc — no project needed. + if (cmd === "config") { + cmdConfig(flags); + process.exit(0); + } + // `serve` reads jobs from stdin/queue file — no project needed. if (cmd === "serve") { await cmdServe(flags); @@ -2213,6 +2765,15 @@ async function main(): Promise { case "info": cmdInfo(draft, flags); break; + case "prune": + cmdPrune(draft, filePath, flags); + break; + case "relink": + cmdRelink(draft, filePath, flags); + break; + case "timeline": + cmdTimeline(draft, flags); + break; case "version": cmdVersion(draft, flags); break; diff --git a/test/concat.test.mjs b/test/concat.test.mjs new file mode 100644 index 0000000..6400337 --- /dev/null +++ b/test/concat.test.mjs @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import { copyFileSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { after, describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; +import { spawnCli } from "./helpers/spawn-cli.mjs"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const FIXTURE = join(__dirname, "draft_content.json"); + +const segIds = (d) => d.tracks.flatMap((t) => t.segments.map((s) => s.id)); +const matIds = (d) => + Object.values(d.materials) + .filter(Array.isArray) + .flat() + .filter((m) => m && typeof m.id === "string") + .map((m) => m.id); + +describe("concat", () => { + function setup() { + const dir = mkdtempSync(join(tmpdir(), "capcut-concat-")); + copyFileSync(FIXTURE, join(dir, "A.json")); + copyFileSync(FIXTURE, join(dir, "B.json")); + return { + dir, + a: join(dir, "A.json"), + b: join(dir, "B.json"), + out: join(dir, "AB.json"), + cleanup: () => rmSync(dir, { recursive: true, force: true }), + }; + } + + it("--out merges both timelines with no id collisions and shifted B segments", () => { + const s = setup(); + after(s.cleanup); + const a = JSON.parse(readFileSync(s.a, "utf-8")); + const b = JSON.parse(readFileSync(s.b, "utf-8")); + const aDur = a.duration; + + const r = spawnCli(["concat", s.a, s.b, "--out", s.out]); + assert.equal(r.status, 0, `stderr: ${r.stderr}`); + + const ab = JSON.parse(readFileSync(s.out, "utf-8")); + // every segment & material id is unique despite A and B sharing ids (worst case) + const sids = segIds(ab); + const mids = matIds(ab); + assert.equal(sids.length, segIds(a).length + segIds(b).length, "all segments from both drafts present"); + assert.equal(new Set(sids).size, sids.length, "segment ids must be unique after concat"); + assert.equal(new Set(mids).size, mids.length, "material ids must be unique after concat"); + assert.ok(r.json.remapped_ids > 0, "colliding ids should have been remapped"); + + // duration is the sum, and B's segments are shifted past A's end + assert.equal(ab.duration, aDur + b.duration); + const maxStart = Math.max(...ab.tracks.flatMap((t) => t.segments.map((seg) => seg.target_timerange.start))); + assert.ok(maxStart >= aDur, "B segments must be time-shifted past A's duration"); + + // --out leaves source A untouched (no merge written back, no extra segments) + assert.equal(JSON.parse(readFileSync(s.a, "utf-8")).duration, aDur, "source A duration unchanged"); + assert.equal( + segIds(JSON.parse(readFileSync(s.a, "utf-8"))).length, + segIds(a).length, + "source A segments unchanged", + ); + }); + + it("in-place concat writes A and keeps a .bak", () => { + const s = setup(); + after(s.cleanup); + const aDur = JSON.parse(readFileSync(s.a, "utf-8")).duration; + const bDur = JSON.parse(readFileSync(s.b, "utf-8")).duration; + const r = spawnCli(["concat", s.a, s.b]); + assert.equal(r.status, 0, `stderr: ${r.stderr}`); + assert.equal(JSON.parse(readFileSync(s.a, "utf-8")).duration, aDur + bDur); + }); + + it("errors without two arguments", () => { + const s = setup(); + after(s.cleanup); + const r = spawnCli(["concat", s.a]); + assert.equal(r.status, 1); + assert.match(`${r.stdout}${r.stderr}`, /Usage: capcut concat/); + }); +}); diff --git a/test/config.test.mjs b/test/config.test.mjs new file mode 100644 index 0000000..41ff27e --- /dev/null +++ b/test/config.test.mjs @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { after, describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; +import { spawnCli } from "./helpers/spawn-cli.mjs"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const FIXTURE = join(__dirname, "draft_content.json"); + +describe("config (.capcutrc)", () => { + function cwdWith(rc) { + const dir = mkdtempSync(join(tmpdir(), "capcut-cfg-")); + writeFileSync(join(dir, ".capcutrc"), JSON.stringify(rc)); + return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) }; + } + + it("config reports the resolved file and effective defaults", () => { + const c = cwdWith({ jianying: true, cols: 30, drafts: "/tmp/foo" }); + after(c.cleanup); + const r = spawnCli(["config"], { cwd: c.dir }); + assert.equal(r.status, 0, `stderr: ${r.stderr}`); + assert.ok(r.json.path?.endsWith(".capcutrc")); + assert.equal(r.json.effective.jianying, true); + assert.equal(r.json.effective.cols, 30); + assert.equal(r.json.effective.drafts, "/tmp/foo"); + }); + + it("a config 'drafts' default is used by `projects` when --drafts is absent", () => { + // a projects root with one valid project + const root = mkdtempSync(join(tmpdir(), "capcut-cfgroot-")); + mkdirSync(join(root, "projX")); + copyFileSync(FIXTURE, join(root, "projX", "draft_content.json")); + const c = cwdWith({ drafts: root }); + after(() => { + c.cleanup(); + rmSync(root, { recursive: true, force: true }); + }); + + const r = spawnCli(["projects"], { cwd: c.dir }); + assert.equal(r.status, 0, `stderr: ${r.stderr}`); + assert.equal(r.json.count, 1); + assert.equal(r.json.projects[0].folder, "projX"); + }); + + it("a CLI flag overrides the config default", () => { + const otherRoot = mkdtempSync(join(tmpdir(), "capcut-other-")); + const c = cwdWith({ drafts: "/nonexistent/from/config" }); + after(() => { + c.cleanup(); + rmSync(otherRoot, { recursive: true, force: true }); + }); + // --drafts points at an empty (but existing) dir -> count 0, proving config's path was overridden + const r = spawnCli(["projects", "--drafts", otherRoot], { cwd: c.dir }); + assert.equal(r.status, 0); + assert.equal(r.json.count, 0); + }); +}); diff --git a/test/describe.test.mjs b/test/describe.test.mjs new file mode 100644 index 0000000..4a4ac58 --- /dev/null +++ b/test/describe.test.mjs @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { spawnCli } from "./helpers/spawn-cli.mjs"; + +// `describe` emits a machine-readable tool spec for agent callers. +describe("describe", () => { + it("emits valid JSON with name, version, global_flags, and commands", () => { + const r = spawnCli(["describe"]); + assert.equal(r.status, 0, `stderr: ${r.stderr}`); + assert.ok(r.json, "stdout should be valid JSON"); + assert.equal(r.json.name, "capcut-cli"); + assert.match(r.json.version, /^\d+\.\d+\.\d+/); + assert.ok(Array.isArray(r.json.global_flags) && r.json.global_flags.length > 0); + assert.ok(Array.isArray(r.json.commands) && r.json.commands.length > 0); + }); + + it("describes every command with a non-empty summary (no undescribed commands)", () => { + const r = spawnCli(["describe"]); + const undescribed = r.json.commands.filter((c) => !c.name || !c.summary || c.summary.length === 0); + assert.deepEqual(undescribed, [], `commands missing a summary: ${undescribed.map((c) => c.name).join(", ")}`); + }); + + it("includes the new commands", () => { + const r = spawnCli(["describe"]); + const names = r.json.commands.map((c) => c.name); + for (const expected of ["prune", "relink", "timeline", "projects", "describe", "restore"]) { + assert.ok(names.includes(expected), `expected command "${expected}" in describe output`); + } + }); +}); diff --git a/test/diff.test.mjs b/test/diff.test.mjs new file mode 100644 index 0000000..a2a2f71 --- /dev/null +++ b/test/diff.test.mjs @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import { copyFileSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { after, describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; +import { spawnCli } from "./helpers/spawn-cli.mjs"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const FIXTURE = join(__dirname, "draft_content.json"); + +describe("diff", () => { + function pair() { + const dir = mkdtempSync(join(tmpdir(), "capcut-diff-")); + copyFileSync(FIXTURE, join(dir, "A.json")); + copyFileSync(FIXTURE, join(dir, "B.json")); + return { + dir, + a: join(dir, "A.json"), + b: join(dir, "B.json"), + cleanup: () => rmSync(dir, { recursive: true, force: true }), + }; + } + + it("reports no change for identical drafts", () => { + const p = pair(); + after(p.cleanup); + const r = spawnCli(["diff", p.a, p.b]); + assert.equal(r.status, 0, `stderr: ${r.stderr}`); + assert.equal(r.json.changed, false); + }); + + it("detects a text edit as a changed material", () => { + const p = pair(); + after(p.cleanup); + const id = spawnCli(["texts", p.b]).json[0].id; + spawnCli(["set-text", p.b, id, "EDITED", "-q"]); + const r = spawnCli(["diff", p.a, p.b]); + assert.equal(r.json.changed, true); + assert.equal(r.json.materials.changed.length, 1); + }); + + it("detects a segment timing change", () => { + const p = pair(); + after(p.cleanup); + const seg = spawnCli(["segments", p.b]).json[0]; + spawnCli(["shift", p.b, seg.id, "+1s", "-q"]); + const r = spawnCli(["diff", p.a, p.b]); + const changed = r.json.segments.changed.find((c) => seg.id.startsWith(c.id) || c.id === seg.id); + assert.ok(changed, "the shifted segment should appear in segments.changed"); + assert.ok(changed.fields.includes("start")); + }); + + it("detects an added material", () => { + const p = pair(); + after(p.cleanup); + const d = JSON.parse(readFileSync(p.b, "utf-8")); + d.materials.texts.push({ id: "NEW_MAT" }); + writeFileSync(p.b, JSON.stringify(d, null, 2)); + const r = spawnCli(["diff", p.a, p.b]); + assert.ok(r.json.materials.added.includes("NEW_MAT")); + }); +}); diff --git a/test/export-windows.test.mjs b/test/export-windows.test.mjs new file mode 100644 index 0000000..9b9607f --- /dev/null +++ b/test/export-windows.test.mjs @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import { dirname, join } from "node:path"; +import { describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const EXPORT_BATCH = join(__dirname, "..", "dist", "export-batch.js"); + +// Live Windows UI automation can't run on this (Linux) host, so we verify the +// generated PowerShell script — the part that is deterministic and testable. +describe("windows export script generation", () => { + it("opens the draft, targets the app, and sends the export shortcut", async () => { + const { windowsExportScript } = await import(EXPORT_BATCH); + const script = windowsExportScript("C:\\projects\\demo", "capcut"); + assert.match(script, /Start-Process/); + assert.ok(script.includes("C:\\projects\\demo\\draft_content.json"), "opens the project's draft file"); + assert.ok(script.includes("'CapCut'"), "targets the CapCut process"); + assert.ok(script.includes("SendKeys"), "uses SendKeys"); + assert.ok(script.includes("'^e'"), "sends Ctrl+E (export)"); + }); + + it("targets JianyingPro for the jianying app", async () => { + const { windowsExportScript } = await import(EXPORT_BATCH); + const script = windowsExportScript("C:\\p", "jianying"); + assert.ok(script.includes("'JianyingPro'")); + }); +}); diff --git a/test/projects.test.mjs b/test/projects.test.mjs new file mode 100644 index 0000000..eeba604 --- /dev/null +++ b/test/projects.test.mjs @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import { copyFileSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { after, describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; +import { spawnCli } from "./helpers/spawn-cli.mjs"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const FIXTURE = join(__dirname, "draft_content.json"); + +describe("projects", () => { + function makeRoot() { + const root = mkdtempSync(join(tmpdir(), "capcut-projroot-")); + for (const name of ["projA", "projB"]) { + mkdirSync(join(root, name)); + copyFileSync(FIXTURE, join(root, name, "draft_content.json")); + } + mkdirSync(join(root, "notAProject")); // no draft file -> excluded + return root; + } + + it("lists only folders that contain a draft file", () => { + const root = makeRoot(); + after(() => rmSync(root, { recursive: true, force: true })); + const r = spawnCli(["projects", "--drafts", root]); + assert.equal(r.status, 0, `stderr: ${r.stderr}`); + assert.equal(r.json.count, 2); + const folders = r.json.projects.map((p) => p.folder).sort(); + assert.deepEqual(folders, ["projA", "projB"]); + }); + + it("filters by name substring", () => { + const root = makeRoot(); + after(() => rmSync(root, { recursive: true, force: true })); + const r = spawnCli(["projects", "projA", "--drafts", root]); + assert.equal(r.json.count, 1); + assert.equal(r.json.projects[0].folder, "projA"); + }); + + it("--names reads each draft's name field", () => { + const root = makeRoot(); + after(() => rmSync(root, { recursive: true, force: true })); + const r = spawnCli(["projects", "--drafts", root, "--names"]); + assert.ok(r.json.projects.every((p) => Object.hasOwn(p, "name") || p.name === undefined)); + // the fixture has a name; at least one project should expose it + assert.ok(r.json.projects.some((p) => typeof p.name === "string" && p.name.length > 0)); + }); +}); diff --git a/test/prune.test.mjs b/test/prune.test.mjs new file mode 100644 index 0000000..8078701 --- /dev/null +++ b/test/prune.test.mjs @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import { readFileSync, writeFileSync } from "node:fs"; +import { after, describe, it } from "node:test"; +import { spawnCli } from "./helpers/spawn-cli.mjs"; +import { tmpDraft } from "./helpers/tmp-draft.mjs"; + +// `prune` removes only materials no segment references — and must NOT delete +// materials referenced indirectly via extra_material_refs. +describe("prune (#17)", () => { + it("removes orphans, keeps directly- and indirectly-referenced materials", () => { + const fix = tmpDraft(); + after(() => fix.cleanup()); + + const d = JSON.parse(readFileSync(fix.path, "utf-8")); + d.materials.texts.push({ id: "ORPHAN_TEXT" }); // referenced by nobody + d.materials.stickers = [{ id: "REFD_VIA_EXTRA" }]; // referenced only via extra_material_refs + d.tracks[0].segments[0].extra_material_refs.push("REFD_VIA_EXTRA"); + const directId = d.tracks[0].segments[0].material_id; // referenced via material_id + writeFileSync(fix.path, JSON.stringify(d, null, 2)); + + const r = spawnCli(["prune", fix.path]); + assert.equal(r.status, 0, `stderr: ${r.stderr}`); + assert.equal(r.json.removed, 1, "exactly the one orphan should be removed"); + + const after_ = JSON.parse(readFileSync(fix.path, "utf-8")); + assert.ok(!after_.materials.texts.some((m) => m.id === "ORPHAN_TEXT"), "orphan must be gone"); + assert.ok( + after_.materials.stickers.some((m) => m.id === "REFD_VIA_EXTRA"), + "extra_material_refs target must survive", + ); + assert.ok( + Object.values(after_.materials) + .flat() + .some((m) => m && m.id === directId), + "material_id-referenced material must survive", + ); + // segments untouched + assert.deepEqual(after_.tracks[0].segments.length, d.tracks[0].segments.length); + }); + + it("honors --dry-run (reports removals, writes nothing)", () => { + const fix = tmpDraft(); + after(() => fix.cleanup()); + const d = JSON.parse(readFileSync(fix.path, "utf-8")); + d.materials.texts.push({ id: "ORPHAN_X" }); + writeFileSync(fix.path, JSON.stringify(d, null, 2)); + const before = readFileSync(fix.path, "utf-8"); + + const r = spawnCli(["prune", fix.path, "--dry-run"]); + assert.equal(r.status, 0); + assert.equal(r.json.removed, 1); + assert.equal(r.json.dryRun, true); + assert.equal(readFileSync(fix.path, "utf-8"), before, "dry-run must not change the file"); + }); +}); diff --git a/test/relink.test.mjs b/test/relink.test.mjs new file mode 100644 index 0000000..989e677 --- /dev/null +++ b/test/relink.test.mjs @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, describe, it } from "node:test"; +import { spawnCli } from "./helpers/spawn-cli.mjs"; +import { tmpDraft } from "./helpers/tmp-draft.mjs"; + +describe("relink", () => { + it("--dir repoints a missing path to a same-basename file in the folder", () => { + const fix = tmpDraft(); + const media = mkdtempSync(join(tmpdir(), "capcut-media-")); + after(() => { + fix.cleanup(); + rmSync(media, { recursive: true, force: true }); + }); + writeFileSync(join(media, "clip.mp4"), "x"); + + const d = JSON.parse(readFileSync(fix.path, "utf-8")); + d.materials.videos = [{ id: "V1", path: "/nonexistent/old/clip.mp4" }]; + writeFileSync(fix.path, JSON.stringify(d, null, 2)); + + const r = spawnCli(["relink", fix.path, "--dir", media]); + assert.equal(r.status, 0, `stderr: ${r.stderr}`); + assert.equal(r.json.relinked, 1); + + const v = JSON.parse(readFileSync(fix.path, "utf-8")).materials.videos[0]; + assert.equal(v.path, join(media, "clip.mp4"), "path should point at the found file"); + }); + + it("--from/--to prefix-replaces material paths", () => { + const fix = tmpDraft(); + after(() => fix.cleanup()); + const d = JSON.parse(readFileSync(fix.path, "utf-8")); + d.materials.videos = [{ id: "V1", path: "/old/root/a/clip.mp4" }]; + writeFileSync(fix.path, JSON.stringify(d, null, 2)); + + const r = spawnCli(["relink", fix.path, "--from", "/old/root", "--to", "/new/place"]); + assert.equal(r.status, 0, `stderr: ${r.stderr}`); + assert.equal(r.json.relinked, 1); + const v = JSON.parse(readFileSync(fix.path, "utf-8")).materials.videos[0]; + assert.equal(v.path, "/new/place/a/clip.mp4"); + }); + + it("errors when neither --dir nor --from/--to is given", () => { + const fix = tmpDraft(); + after(() => fix.cleanup()); + const r = spawnCli(["relink", fix.path]); + assert.equal(r.status, 1); + assert.match(`${r.stdout}${r.stderr}`, /--dir|--from/); + }); + + it("honors --dry-run", () => { + const fix = tmpDraft(); + after(() => fix.cleanup()); + const d = JSON.parse(readFileSync(fix.path, "utf-8")); + d.materials.videos = [{ id: "V1", path: "/old/x.mp4" }]; + writeFileSync(fix.path, JSON.stringify(d, null, 2)); + const before = readFileSync(fix.path, "utf-8"); + + const r = spawnCli(["relink", fix.path, "--from", "/old", "--to", "/new", "--dry-run"]); + assert.equal(r.status, 0); + assert.equal(r.json.relinked, 1); + assert.equal(r.json.dryRun, true); + assert.equal(readFileSync(fix.path, "utf-8"), before, "dry-run must not change the file"); + }); +}); diff --git a/test/restore-history.test.mjs b/test/restore-history.test.mjs new file mode 100644 index 0000000..0992d79 --- /dev/null +++ b/test/restore-history.test.mjs @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import { after, describe, it } from "node:test"; +import { spawnCli } from "./helpers/spawn-cli.mjs"; +import { tmpDraft } from "./helpers/tmp-draft.mjs"; + +// Multi-step undo: every write snapshots the pre-write state into a rolling +// history. `restore --step N` rolls back N writes (step 1 == the .bak). +describe("restore --step / --list (snapshot history)", () => { + const textOf = (path, id) => { + const r = spawnCli(["texts", path]); + const seg = r.json.find((t) => t.id.startsWith(id)); + return seg ? seg.text : null; + }; + + it("rolls back to the exact state N writes ago", () => { + const fix = tmpDraft(); + after(() => fix.cleanup()); + + const first = spawnCli(["texts", fix.path]).json[0]; + const id = first.id; + const original = first.text; + + spawnCli(["set-text", fix.path, id, "T1", "-q"]); + spawnCli(["set-text", fix.path, id, "T2", "-q"]); + spawnCli(["set-text", fix.path, id, "T3", "-q"]); + assert.equal(textOf(fix.path, id), "T3", "sanity: latest write is T3"); + + // 3 writes => 3 snapshots, newest first: step1=pre-T3(T2), step2=pre-T2(T1), step3=pre-T1(original) + const list = spawnCli(["restore", fix.path, "--list"]); + assert.equal(list.json.count, 3); + + spawnCli(["restore", fix.path, "--step", "2"]); + assert.equal(textOf(fix.path, id), "T1", "--step 2 should restore the T1 state"); + + // snapshots are not consumed by a restore, so step 1 still yields T2 + spawnCli(["restore", fix.path, "--step", "1"]); + assert.equal(textOf(fix.path, id), "T2", "--step 1 should restore the T2 state (== .bak)"); + + spawnCli(["restore", fix.path, "--step", "3"]); + assert.equal(textOf(fix.path, id), original, "--step 3 should restore the original"); + }); + + it("plain restore equals --step 1", () => { + const fix = tmpDraft(); + after(() => fix.cleanup()); + const id = spawnCli(["texts", fix.path]).json[0].id; + spawnCli(["set-text", fix.path, id, "A", "-q"]); + spawnCli(["set-text", fix.path, id, "B", "-q"]); + spawnCli(["restore", fix.path]); // plain -> .bak -> pre-B == "A" + assert.equal(textOf(fix.path, id), "A"); + }); + + it("--step beyond history exits non-zero with a clear message", () => { + const fix = tmpDraft(); + after(() => fix.cleanup()); + const id = spawnCli(["texts", fix.path]).json[0].id; + spawnCli(["set-text", fix.path, id, "only", "-q"]); + const r = spawnCli(["restore", fix.path, "--step", "9"]); + assert.equal(r.status, 1); + assert.match(`${r.stdout}${r.stderr}`, /No snapshot at --step 9/); + }); +}); diff --git a/test/timeline.test.mjs b/test/timeline.test.mjs new file mode 100644 index 0000000..b70832d --- /dev/null +++ b/test/timeline.test.mjs @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import { after, describe, it } from "node:test"; +import { spawnCli } from "./helpers/spawn-cli.mjs"; +import { tmpDraft } from "./helpers/tmp-draft.mjs"; + +describe("timeline", () => { + const fix = tmpDraft(); + after(() => fix.cleanup()); + + it("JSON default returns lanes with computed columns", () => { + const r = spawnCli(["timeline", fix.path]); + assert.equal(r.status, 0, `stderr: ${r.stderr}`); + assert.ok(Array.isArray(r.json.tracks) && r.json.tracks.length > 0); + assert.equal(r.json.cols, 60); + for (const t of r.json.tracks) { + assert.ok(typeof t.type === "string"); + for (const s of t.segments) { + assert.ok(Number.isInteger(s.col_start) && Number.isInteger(s.col_end)); + assert.ok(s.col_end > s.col_start, "every segment must occupy at least one column"); + assert.ok(s.col_end <= r.json.cols); + } + } + }); + + it("respects --cols", () => { + const r = spawnCli(["timeline", fix.path, "--cols", "20"]); + assert.equal(r.json.cols, 20); + for (const t of r.json.tracks) for (const s of t.segments) assert.ok(s.col_end <= 20); + }); + + it("-H renders ASCII bars with one row per track", () => { + const r = spawnCli(["timeline", fix.path, "-H"]); + assert.equal(r.status, 0); + const rows = r.stdout.trimEnd().split("\n"); + assert.equal(rows.length, 3, "fixture has 3 tracks"); + assert.match(r.stdout, /█/, "should contain bar characters"); + assert.match(rows[0], /video/); + }); +});