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

## [Unreleased]

### Changed

- `skills/capcut-edit/scripts/anim.sh` derives the effect-cache base per platform instead of hardcoding the macOS container path, which on Windows wrote a path the app cannot resolve into the animation material: `%LOCALAPPDATA%\CapCut\User Data\Cache\effect` (JianYing: `%LOCALAPPDATA%\JianyingPro\...`), the macOS container on darwin, `~/.local/share/CapCut/Cache/effect` elsewhere. `CAPCUT_EFFECT_CACHE` overrides all three.
- `skills/capcut-edit/scripts/{ken-burns,stamp-cta,long-to-short}.sh` no longer assume they sit inside a `capcut-cli` checkout. They resolve the CLI by priority `$CAPCUT_CLI` → the repo build → an installed `node_modules/capcut-cli/dist/index.js` (walking up from the script) → `capcut-cli`/`capcut` on `PATH`, so the skill works when it is copied out of the repo. `SKILL.md` documents the resolution order, the two environment variables and the JianYing draft-store path.

### Fixed

- `caption` and `doctor` resolve the Whisper binary through one resolver: an explicit `--whisper-cmd` first, then the first of `whisper` / `whisper-cli` / `faster-whisper` actually on `PATH`. `doctor` had always probed all three while `caption` only ever spawned `whisper` and defaulted its dialect to openai, so on a host that installs whisper.cpp — a `whisper-cli` binary and no `whisper`, the name that build has shipped since v1.7.4 — `doctor` reported the check as ok and the first `caption` run failed with ENOENT while hinting at the wrong dialect. Resolving also feeds dialect detection, so a resolved `whisper-cli` selects the whisper-cpp invocation instead of the openai one. `--whisper-cmd` and `--whisper-engine` still win.
- `segments` no longer prints `NaN:00NaN` / `undefinedx` for a segment whose `target_timerange` omits `start` (its 0 default is simply not written) or that carries no `speed`. A draft that the app has edited can hold such a segment while the app itself shows it normally; `start_us`, `duration_us` and `speed` now fall back to `0`, `0` and `1` in the JSON result and in the `-H` tables.

## [0.29.0] — 2026-10-09

### Added
Expand Down
3 changes: 3 additions & 0 deletions skills/capcut-edit/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ CLI for editing CapCut / JianYing drafts through a version-aware store. v0.11 de

- **macOS**: `~/Movies/CapCut/User Data/Projects/com.lveditor.draft/<project>/`
- **Windows**: `%LOCALAPPDATA%\CapCut\User Data\Projects\com.lveditor.draft\<project>\`
- **Windows (JianYing / 剪映)**: `%LOCALAPPDATA%\JianyingPro\User Data\Projects\com.lveditor.draft\<project>\`

## Core principle — deterministic scripts

Expand Down Expand Up @@ -64,6 +65,8 @@ Batch is transactional: one failing operation writes nothing. Use `--continue-on
- `_test.sh` — run every wrapper against the fixture; run after any change here.
- **`assets/examples/`** — raw JSON snippets for hand-editing.

Scripts resolve the `capcut` entry point themselves, so the skill also works when it is installed outside a `capcut-cli` checkout: `$CAPCUT_CLI` (explicit path to `dist/index.js`) wins, then the repo build, then an installed `node_modules/capcut-cli`, then `capcut-cli`/`capcut` on `PATH`. `anim.sh` resolves the CapCut effect-cache directory per platform; `$CAPCUT_EFFECT_CACHE` overrides it.

## Invariants

- Close the project in CapCut before editing; reopen after.
Expand Down
29 changes: 25 additions & 4 deletions skills/capcut-edit/scripts/anim.sh
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,31 @@ const crypto = require("crypto");

const [, projectArg, segPrefix, slug, durOverride, cataloguePath] = process.argv;

const EFFECT_CACHE_BASE = path.join(
process.env.HOME,
"Library/Containers/com.lemon.lvoverseas/Data/Movies/CapCut/User Data/Cache/effect"
);
// The effect cache lives in a per-platform app data directory. Resolve it at
// runtime instead of hardcoding the macOS container path, which on Windows
// wrote a path the app cannot resolve into the animation material.
// CAPCUT_EFFECT_CACHE overrides both.
function defaultEffectCacheBase() {
const override = process.env.CAPCUT_EFFECT_CACHE;
if (override) return override;
if (process.platform === "win32") {
const local =
process.env.LOCALAPPDATA || path.join(process.env.USERPROFILE || process.env.HOME || "", "AppData", "Local");
for (const app of ["CapCut", "JianyingPro"]) {
const candidate = path.join(local, app, "User Data", "Cache", "effect");
if (fs.existsSync(candidate)) return candidate;
}
return path.join(local, "CapCut", "User Data", "Cache", "effect");
}
if (process.platform === "darwin") {
return path.join(
process.env.HOME,
"Library/Containers/com.lemon.lvoverseas/Data/Movies/CapCut/User Data/Cache/effect"
);
}
return path.join(process.env.HOME || "", ".local/share/CapCut/Cache/effect");
}
const EFFECT_CACHE_BASE = defaultEffectCacheBase();

function parseTime(input) {
const neg = input.startsWith("-");
Expand Down
38 changes: 36 additions & 2 deletions skills/capcut-edit/scripts/ken-burns.sh
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,44 @@ duration="$9"
easing="${10:-ease-out}"

script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cli="$script_dir/../../../dist/index.js"
# Resolve the CLI entry, so this script also works when the skill is used
# outside a capcut-cli checkout. Priority:
# 1. $CAPCUT_CLI (explicit override)
# 2. <repo>/dist/index.js (this checkout)
# 3. <dir>/node_modules/capcut-cli/dist/index.js, walking up from here
# 4. capcut-cli / capcut on PATH
resolve_cli() {
if [[ -n "${CAPCUT_CLI:-}" && -f "${CAPCUT_CLI}" ]]; then
printf '%s\n' "$CAPCUT_CLI"
return 0
fi
local repo_cli="$script_dir/../../../dist/index.js"
if [[ -f "$repo_cli" ]]; then
printf '%s\n' "$repo_cli"
return 0
fi
local dir="$script_dir" i
for i in 1 2 3 4 5 6; do
dir="$(dirname "$dir")"
if [[ -f "$dir/node_modules/capcut-cli/dist/index.js" ]]; then
printf '%s\n' "$dir/node_modules/capcut-cli/dist/index.js"
return 0
fi
done
if command -v capcut-cli >/dev/null 2>&1; then
command -v capcut-cli
return 0
fi
if command -v capcut >/dev/null 2>&1; then
command -v capcut
return 0
fi
printf '%s\n' "$repo_cli"
}
cli="$(resolve_cli)"

if [[ ! -f "$cli" ]]; then
echo "capcut CLI not found at $cli — run \`npm run build\` in capcut-cli/" >&2
echo "capcut CLI not found — set \$CAPCUT_CLI to its dist/index.js, install it (npm install -g capcut-cli), or run \`npm run build\` in capcut-cli/" >&2
exit 1
fi

Expand Down
38 changes: 36 additions & 2 deletions skills/capcut-edit/scripts/long-to-short.sh
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,44 @@ title="$5"
cta="$6"

script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cli="$script_dir/../../../dist/index.js"
# Resolve the CLI entry, so this script also works when the skill is used
# outside a capcut-cli checkout. Priority:
# 1. $CAPCUT_CLI (explicit override)
# 2. <repo>/dist/index.js (this checkout)
# 3. <dir>/node_modules/capcut-cli/dist/index.js, walking up from here
# 4. capcut-cli / capcut on PATH
resolve_cli() {
if [[ -n "${CAPCUT_CLI:-}" && -f "${CAPCUT_CLI}" ]]; then
printf '%s\n' "$CAPCUT_CLI"
return 0
fi
local repo_cli="$script_dir/../../../dist/index.js"
if [[ -f "$repo_cli" ]]; then
printf '%s\n' "$repo_cli"
return 0
fi
local dir="$script_dir" i
for i in 1 2 3 4 5 6; do
dir="$(dirname "$dir")"
if [[ -f "$dir/node_modules/capcut-cli/dist/index.js" ]]; then
printf '%s\n' "$dir/node_modules/capcut-cli/dist/index.js"
return 0
fi
done
if command -v capcut-cli >/dev/null 2>&1; then
command -v capcut-cli
return 0
fi
if command -v capcut >/dev/null 2>&1; then
command -v capcut
return 0
fi
printf '%s\n' "$repo_cli"
}
cli="$(resolve_cli)"

if [[ ! -f "$cli" ]]; then
echo "capcut CLI not found at $cli — run \`npm run build\` in capcut-cli/" >&2
echo "capcut CLI not found — set \$CAPCUT_CLI to its dist/index.js, install it (npm install -g capcut-cli), or run \`npm run build\` in capcut-cli/" >&2
exit 1
fi

Expand Down
38 changes: 36 additions & 2 deletions skills/capcut-edit/scripts/stamp-cta.sh
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,44 @@ duration="$4"
text="${5:-}"

script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cli="$script_dir/../../../dist/index.js"
# Resolve the CLI entry, so this script also works when the skill is used
# outside a capcut-cli checkout. Priority:
# 1. $CAPCUT_CLI (explicit override)
# 2. <repo>/dist/index.js (this checkout)
# 3. <dir>/node_modules/capcut-cli/dist/index.js, walking up from here
# 4. capcut-cli / capcut on PATH
resolve_cli() {
if [[ -n "${CAPCUT_CLI:-}" && -f "${CAPCUT_CLI}" ]]; then
printf '%s\n' "$CAPCUT_CLI"
return 0
fi
local repo_cli="$script_dir/../../../dist/index.js"
if [[ -f "$repo_cli" ]]; then
printf '%s\n' "$repo_cli"
return 0
fi
local dir="$script_dir" i
for i in 1 2 3 4 5 6; do
dir="$(dirname "$dir")"
if [[ -f "$dir/node_modules/capcut-cli/dist/index.js" ]]; then
printf '%s\n' "$dir/node_modules/capcut-cli/dist/index.js"
return 0
fi
done
if command -v capcut-cli >/dev/null 2>&1; then
command -v capcut-cli
return 0
fi
if command -v capcut >/dev/null 2>&1; then
command -v capcut
return 0
fi
printf '%s\n' "$repo_cli"
}
cli="$(resolve_cli)"

if [[ ! -f "$cli" ]]; then
echo "capcut CLI not found at $cli — run \`npm run build\` in capcut-cli/" >&2
echo "capcut CLI not found — set \$CAPCUT_CLI to its dist/index.js, install it (npm install -g capcut-cli), or run \`npm run build\` in capcut-cli/" >&2
exit 1
fi

Expand Down
18 changes: 15 additions & 3 deletions src/caption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { captionStyleFromPreset, type TextStylePreset } from "./preset.js";
import { type CaptionScript, captionScript, groupingDefaults, wordSeparator } from "./script.js";
import { parseSrt } from "./srt.js";
import { storedTextLength } from "./text-offsets.js";
import { resolveWhisperCommand } from "./whisper.js";

export interface CaptionOptions {
/**
Expand Down Expand Up @@ -569,9 +570,20 @@ export function parseWordTimings(raw: string): ParsedWordTimings {
return { format, words, skipped };
}

function detectEngine(opts: CaptionOptions): TranscriptionResult["engine"] {
/**
* The Whisper binary to run: the caller's `--whisper-cmd` wins, otherwise the
* first dialect actually installed on PATH, otherwise the historical default.
* `doctor` has always probed all three names; `caption` used to spawn only
* `whisper`, so on a host with whisper.cpp (`whisper-cli`) installed and no
* `whisper`, doctor said ok and caption died with ENOENT.
*/
export function whisperCommand(opts: CaptionOptions): string {
return opts.whisperCmd ?? resolveWhisperCommand() ?? "whisper";
}

export function detectEngine(opts: CaptionOptions): TranscriptionResult["engine"] {
if (opts.whisperEngine && opts.whisperEngine !== "auto") return opts.whisperEngine;
const command = (opts.whisperCmd ?? "whisper").toLowerCase();
const command = whisperCommand(opts).toLowerCase();
if (command.includes("faster-whisper")) return "faster-whisper";
if (command.includes("whisper-cli") || /(^|[/\\])main(?:\.exe)?$/.test(command)) return "whisper-cpp";
return "openai";
Expand Down Expand Up @@ -612,7 +624,7 @@ export function buildWhisperInvocation(
}

function runWhisper(audio: string, opts: CaptionOptions): TranscriptionResult {
const cmd = opts.whisperCmd ?? "whisper";
const cmd = whisperCommand(opts);
const model = opts.whisperModel ?? "base";
const language = opts.language ?? "auto";
const engine = detectEngine(opts);
Expand Down
17 changes: 2 additions & 15 deletions src/doctor.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { existsSync } from "node:fs";
import { platform, release } from "node:os";
import { delimiter, join } from "node:path";
import { appVersionsPath, scanTrackedStores } from "./app-versions.js";
import { scanStore } from "./factory.js";
import { probeFfmpegCapabilities } from "./render.js";
import { draftDirCandidates } from "./store.js";
import { onPath, resolveWhisperCommand } from "./whisper.js";

export type CheckStatus = "ok" | "warn" | "missing";

Expand All @@ -25,19 +25,6 @@ export interface DoctorReport {
checks: DoctorCheck[];
}

/** Minimal cross-platform PATH lookup — no `which`/`where` shell-out. */
function onPath(cmd: string): string | null {
const dirs = (process.env.PATH ?? "").split(delimiter).filter(Boolean);
const exts = platform() === "win32" ? ["", ".exe", ".cmd", ".bat"] : [""];
for (const dir of dirs) {
for (const ext of exts) {
const full = join(dir, cmd + ext);
if (existsSync(full)) return full;
}
}
return null;
}

function nodeMajor(): number {
return Number.parseInt(process.versions.node.split(".")[0] ?? "0", 10);
}
Expand Down Expand Up @@ -127,7 +114,7 @@ export function runDoctor(options: DoctorOptions = {}): DoctorReport {
});

// whisper — needed by `caption`.
const whisper = onPath("whisper") ?? onPath("whisper-cli") ?? onPath("faster-whisper");
const whisper = resolveWhisperCommand();
checks.push({
name: "whisper",
status: whisper ? "ok" : "warn",
Expand Down
18 changes: 13 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import type {
TextStyleOptions,
} from "./decorators.js";
import type { DoctorCheck } from "./doctor.js";
import type { Draft, MaterialText, Segment, Track } from "./draft.js";
import type { Draft, MaterialText, Segment, Timerange, Track } from "./draft.js";
import {
assertTargetsUnchangedOnDisk,
commitDraftTargets,
Expand Down Expand Up @@ -1776,7 +1776,15 @@ function cmdTracks(draft: Draft, flags: Flags): void {
}

function segmentData(draft: Draft, track: Track, seg: Segment) {
const t = seg.target_timerange;
// Read the timing fields defensively. A segment the app itself wrote can
// carry a `target_timerange` without `start` (its 0 default is omitted) and
// no `speed`; both were typed as required, so an honest cast documents that
// the on-disk shape is looser than the type. Without this the tables below
// printed `NaN:00NaN` and `undefinedx` for a segment the app opens fine.
const t = seg.target_timerange as Partial<Timerange> | undefined;
const startUs = t?.start ?? 0;
const durationUs = t?.duration ?? 0;
const speed = typeof seg.speed === "number" ? seg.speed : 1;
let label = "";
if (track.type === "text") {
const mat = findMaterial(draft.materials.texts, seg.material_id);
Expand All @@ -1791,9 +1799,9 @@ function segmentData(draft: Draft, track: Track, seg: Segment) {
return {
id: seg.id,
type: track.type,
start_us: t.start,
duration_us: t.duration,
speed: seg.speed,
start_us: startUs,
duration_us: durationUs,
speed,
volume: seg.volume,
opacity: seg.clip?.alpha ?? 1,
label,
Expand Down
41 changes: 41 additions & 0 deletions src/whisper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { existsSync } from "node:fs";
import { delimiter, join } from "node:path";

/** Minimal cross-platform PATH lookup — no `which`/`where` shell-out. */
export function onPath(cmd: string): string | null {
const dirs = (process.env.PATH ?? "").split(delimiter).filter(Boolean);
const exts = process.platform === "win32" ? ["", ".exe", ".cmd", ".bat"] : [""];
for (const dir of dirs) {
for (const ext of exts) {
const full = join(dir, cmd + ext);
if (existsSync(full)) return full;
}
}
return null;
}

/** A PATH lookup, injectable so the resolution below is testable without a
* real whisper install on the test host. */
export type PathProbe = (cmd: string) => string | null;

/**
* Whisper CLI dialects this project knows how to drive, in preference order:
* openai-whisper's `whisper`, then the name whisper.cpp has shipped since
* v1.7.4 (`whisper-cli`), then `faster-whisper`.
*
* `doctor` has always probed all three, but `caption` only ever spawned
* `whisper` and defaulted its dialect to openai — so on a host that installs
* whisper.cpp (a `whisper-cli` binary and no `whisper`), doctor reported the
* check as ok and the first `caption` run died with ENOENT, hinting at the
* wrong dialect. One resolver, used by both.
*/
export const WHISPER_COMMANDS = ["whisper", "whisper-cli", "faster-whisper"] as const;

/** First installed Whisper dialect, as an absolute path, or null. */
export function resolveWhisperCommand(probe: PathProbe = onPath): string | null {
for (const cmd of WHISPER_COMMANDS) {
const found = probe(cmd);
if (found) return found;
}
return null;
}
Loading