From 06ac3c5f42e7c310f0bce55222b09836e7b3d6bc Mon Sep 17 00:00:00 2001 From: Phelim Date: Mon, 12 Oct 2026 17:22:07 +0800 Subject: [PATCH 1/3] fix(skills): make the recipe scripts work outside a repo checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four skill scripts assumed they lived inside a capcut-cli checkout: - `anim.sh` hardcoded the macOS effect-cache container path. On Windows that string is written into the animation material's `path`, where the app cannot resolve it, so the animation has no effect cache. Resolve the base per platform instead — `%LOCALAPPDATA%\CapCut\User Data\Cache\effect` (JianYing: `%LOCALAPPDATA%\JianyingPro\...`) on win32, the container on darwin, `~/.local/share/CapCut/Cache/effect` elsewhere — with `CAPCUT_EFFECT_CACHE` as an override. - `ken-burns.sh`, `stamp-cta.sh` and `long-to-short.sh` computed the CLI entry as `$script_dir/../../../dist/index.js`. That is only correct inside a checkout: a skill installed as a plugin (or copied anywhere else) failed with "capcut CLI not found". They now resolve 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`, keeping the repo-relative build as the fallback so existing checkouts behave exactly as before. Tests cover the resolution order (a stub CLI proves `$CAPCUT_CLI` wins, and that a missing `$CAPCUT_CLI` falls through to the repo build) and the effect-cache base (`CAPCUT_EFFECT_CACHE` is honoured; on win32 the written path is a Windows cache base, never the macOS container). SKILL.md documents the resolution order, both environment variables and the JianYing Windows draft store. --- CHANGELOG.md | 5 ++ skills/capcut-edit/SKILL.md | 3 + skills/capcut-edit/scripts/anim.sh | 29 ++++++-- skills/capcut-edit/scripts/ken-burns.sh | 38 ++++++++++- skills/capcut-edit/scripts/long-to-short.sh | 38 ++++++++++- skills/capcut-edit/scripts/stamp-cta.sh | 38 ++++++++++- test/anim-effect-cache.test.mjs | 56 +++++++++++++++ test/skill-cli-resolution.test.mjs | 75 +++++++++++++++++++++ 8 files changed, 272 insertions(+), 10 deletions(-) create mode 100644 test/anim-effect-cache.test.mjs create mode 100644 test/skill-cli-resolution.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index ec02070..0f1494d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ 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. + ## [0.29.0] — 2026-10-09 ### Added diff --git a/skills/capcut-edit/SKILL.md b/skills/capcut-edit/SKILL.md index 01680ac..1a05a85 100644 --- a/skills/capcut-edit/SKILL.md +++ b/skills/capcut-edit/SKILL.md @@ -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//` - **Windows**: `%LOCALAPPDATA%\CapCut\User Data\Projects\com.lveditor.draft\\` +- **Windows (JianYing / 剪映)**: `%LOCALAPPDATA%\JianyingPro\User Data\Projects\com.lveditor.draft\\` ## Core principle — deterministic scripts @@ -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. diff --git a/skills/capcut-edit/scripts/anim.sh b/skills/capcut-edit/scripts/anim.sh index f3a8982..a4d0ff2 100755 --- a/skills/capcut-edit/scripts/anim.sh +++ b/skills/capcut-edit/scripts/anim.sh @@ -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("-"); diff --git a/skills/capcut-edit/scripts/ken-burns.sh b/skills/capcut-edit/scripts/ken-burns.sh index 37d4850..27dabd8 100755 --- a/skills/capcut-edit/scripts/ken-burns.sh +++ b/skills/capcut-edit/scripts/ken-burns.sh @@ -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. /dist/index.js (this checkout) +# 3. /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 diff --git a/skills/capcut-edit/scripts/long-to-short.sh b/skills/capcut-edit/scripts/long-to-short.sh index 3904f53..ddad3ad 100755 --- a/skills/capcut-edit/scripts/long-to-short.sh +++ b/skills/capcut-edit/scripts/long-to-short.sh @@ -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. /dist/index.js (this checkout) +# 3. /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 diff --git a/skills/capcut-edit/scripts/stamp-cta.sh b/skills/capcut-edit/scripts/stamp-cta.sh index 1915712..707b063 100755 --- a/skills/capcut-edit/scripts/stamp-cta.sh +++ b/skills/capcut-edit/scripts/stamp-cta.sh @@ -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. /dist/index.js (this checkout) +# 3. /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 diff --git a/test/anim-effect-cache.test.mjs b/test/anim-effect-cache.test.mjs new file mode 100644 index 0000000..f963f4c --- /dev/null +++ b/test/anim-effect-cache.test.mjs @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { after, describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; +import { tmpDir, tmpDraft } from "./helpers/tmp-draft.mjs"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SKILL = join(__dirname, "..", "skills", "capcut-edit"); +const ANIM = join(SKILL, "scripts", "anim.sh"); +const CATALOGUE = join(SKILL, "assets", "animations.json"); + +const SEG = "aaaaaa01"; + +function runAnim(draftPath, cacheDir) { + return spawnSync("bash", [ANIM, draftPath, SEG, "fade-in"], { + encoding: "utf-8", + env: { ...process.env, CAPCUT_EFFECT_CACHE: cacheDir }, + }); +} + +/** The effect `path` anim.sh wrote into the animation material. */ +function writtenEffectPath(draftPath) { + const draft = JSON.parse(readFileSync(draftPath, "utf-8")); + const anims = draft.materials.material_animations ?? []; + assert.equal(anims.length, 1, "one animation material should have been added"); + return anims[0].animations[0].path; +} + +describe("anim.sh effect cache base", () => { + it("honours CAPCUT_EFFECT_CACHE and writes a resolvable effect path", () => { + const fix = tmpDraft(); + const cache = tmpDir(); + after(() => { + fix.cleanup(); + cache.cleanup(); + }); + + const r = runAnim(fix.path, cache.dir); + assert.equal(r.status, 0, `stderr: ${r.stderr}`); + const spec = JSON.parse(readFileSync(CATALOGUE, "utf-8"))["fade-in"]; + assert.equal(writtenEffectPath(fix.path), join(cache.dir, spec.effect_id, spec.md5)); + }); + + it("does not hardcode the macOS container path on Windows", () => { + if (process.platform !== "win32") return; + const fix = tmpDraft(); + after(() => fix.cleanup()); + const r = spawnSync("bash", [ANIM, fix.path, SEG, "fade-in"], { encoding: "utf-8" }); + assert.equal(r.status, 0, `stderr: ${r.stderr}`); + const written = writtenEffectPath(fix.path); + assert.ok(!written.includes("Library/Containers"), `effect path still points at the macOS container: ${written}`); + assert.match(written, /Cache[\\/]effect/, `expected a Windows cache base, got ${written}`); + }); +}); diff --git a/test/skill-cli-resolution.test.mjs b/test/skill-cli-resolution.test.mjs new file mode 100644 index 0000000..67d058e --- /dev/null +++ b/test/skill-cli-resolution.test.mjs @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { after, describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; +import { tmpDir, tmpDraft } from "./helpers/tmp-draft.mjs"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SCRIPTS = join(__dirname, "..", "skills", "capcut-edit", "scripts"); +const KEN_BURNS = join(SCRIPTS, "ken-burns.sh"); + +const SEG = "aaaaaa01"; + +// ken-burns.sh +// [duration] [easing] +const KEN_BURNS_ARGS = (draftPath) => [KEN_BURNS, draftPath, SEG, "1.0", "1.2", "0", "-0.1", "0", "-0.05", "3s"]; + +function runKenBurns(draftPath, env) { + return spawnSync("bash", KEN_BURNS_ARGS(draftPath), { + encoding: "utf-8", + // Isolate the app-version tripwire state, as test/keyframe-easing.test.mjs does. + env: { ...process.env, ...env }, + }); +} + +/** A stand-in for the CLI that reports the argv it was invoked with. */ +function stubCli(dir) { + const stub = join(dir, "stub-cli.mjs"); + writeFileSync(stub, "console.log(JSON.stringify({ ok: true, stub_argv: process.argv.slice(2) }));\n", "utf-8"); + return stub; +} + +describe("skill scripts: CLI resolution", () => { + it("uses $CAPCUT_CLI when it points at an existing entry", () => { + const fix = tmpDraft(); + const tmp = tmpDir(); + after(() => { + fix.cleanup(); + tmp.cleanup(); + }); + const r = runKenBurns(fix.path, { CAPCUT_CLI: stubCli(tmp.dir), CAPCUT_CLI_APP_VERSIONS: join(tmp.dir, "v.json") }); + assert.equal(r.status, 0, `stderr: ${r.stderr}`); + assert.ok(!/capcut CLI not found/.test(r.stderr), `unexpected resolution failure: ${r.stderr}`); + const out = JSON.parse(r.stdout.trim().split("\n").pop()); + assert.equal(out.ok, true); + assert.deepEqual(out.stub_argv.slice(0, 2), ["keyframe", fix.path]); + }); + + it("falls through to the repo build when $CAPCUT_CLI does not exist", () => { + const fix = tmpDraft(); + after(() => fix.cleanup()); + const r = runKenBurns(fix.path, { CAPCUT_CLI: join(fix.dir, "does-not-exist.js") }); + assert.equal(r.status, 0, `stderr: ${r.stderr}`); + assert.ok(!/capcut CLI not found/.test(r.stderr)); + // The real CLI wrote the keyframes. + const draft = JSON.parse(readFileSync(fix.path, "utf-8")); + const seg = draft.tracks.flatMap((t) => t.segments).find((s) => s.id.startsWith(SEG)); + assert.ok((seg.common_keyframes ?? []).length > 0, "expected keyframes on the segment"); + }); + + it("keeps the repo-relative build working with no override at all", () => { + const fix = tmpDraft(); + const tmp = tmpDir(); + after(() => { + fix.cleanup(); + tmp.cleanup(); + }); + const env = { ...process.env, CAPCUT_CLI_APP_VERSIONS: join(tmp.dir, "v.json") }; + delete env.CAPCUT_CLI; + const r = runKenBurns(fix.path, env); + assert.equal(r.status, 0, `stderr: ${r.stderr}`); + assert.ok(!/capcut CLI not found/.test(r.stderr)); + }); +}); From f183eebbdda5a7b8f72ccfd7ccfc05a4c5633ace Mon Sep 17 00:00:00 2001 From: Phelim Date: Mon, 12 Oct 2026 17:22:28 +0800 Subject: [PATCH 2/3] fix(caption): resolve the Whisper binary the same way doctor does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `doctor` probed three dialect names — `whisper`, `whisper-cli`, `faster-whisper` — but `caption` only ever spawned `whisper`, and when the command was not given explicitly it also assumed the openai dialect. Reproduced on a Windows host whose only Whisper is whisper.cpp's `whisper-cli.exe` (the name that build has shipped since v1.7.4, and the one Homebrew's whisper-cpp installs): `capcut doctor` reported `[✓] whisper`, and the next `caption` run died with ENOENT — with a hint suggesting --whisper-engine openai|whisper-cpp|faster-whisper, i.e. leaving the reader to work out that the default command name was the problem. Both now share `resolveWhisperCommand()` in the new `src/whisper.ts`, which also owns the PATH lookup `doctor` used to define privately. An explicit `--whisper-cmd` and an explicit `--whisper-engine` still win over detection. Dialect detection reads the resolved command, so a resolved `whisper-cli` selects the whisper-cpp invocation instead of the openai one — the second half of the same bug. The resolver takes an injectable PATH probe, so the test asserts the preference order, the whisper.cpp-only case, the faster-whisper fallback and the nothing-installed case without depending on what the test host has installed, and pins that `--whisper-cmd` still wins for dialect detection. --- CHANGELOG.md | 4 +++ src/caption.ts | 18 ++++++++-- src/doctor.ts | 17 ++------- src/whisper.ts | 41 +++++++++++++++++++++ test/whisper-resolution.test.mjs | 61 ++++++++++++++++++++++++++++++++ 5 files changed, 123 insertions(+), 18 deletions(-) create mode 100644 src/whisper.ts create mode 100644 test/whisper-resolution.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f1494d..8dfd51e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ All notable changes to capcut-cli are documented here. The format follows [Keep - `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. + ## [0.29.0] — 2026-10-09 ### Added diff --git a/src/caption.ts b/src/caption.ts index 3e2e1a6..b42441e 100644 --- a/src/caption.ts +++ b/src/caption.ts @@ -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 { /** @@ -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"; @@ -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); diff --git a/src/doctor.ts b/src/doctor.ts index 1f83a6d..1866b08 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -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"; @@ -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); } @@ -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", diff --git a/src/whisper.ts b/src/whisper.ts new file mode 100644 index 0000000..c0719cc --- /dev/null +++ b/src/whisper.ts @@ -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; +} diff --git a/test/whisper-resolution.test.mjs b/test/whisper-resolution.test.mjs new file mode 100644 index 0000000..999cd96 --- /dev/null +++ b/test/whisper-resolution.test.mjs @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { detectEngine, whisperCommand } from "../dist/caption.js"; +import { resolveWhisperCommand, WHISPER_COMMANDS } from "../dist/whisper.js"; + +/** + * `doctor` has always probed openai-whisper (`whisper`), whisper.cpp + * (`whisper-cli`) and `faster-whisper`; `caption` only ever spawned `whisper` + * and defaulted its dialect to openai. 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. + * + * The resolver is exercised through an injected PATH probe so the assertions + * do not depend on what the test host happens to have installed. + */ +describe("whisper command resolution", () => { + it("prefers the dialects in WHISPER_COMMANDS order", () => { + const r = resolveWhisperCommand((cmd) => `/${cmd}`); + assert.equal(r, `/${WHISPER_COMMANDS[0]}`); + }); + + it("finds a whisper.cpp install that ships only whisper-cli", () => { + const r = resolveWhisperCommand((cmd) => (cmd === "whisper-cli" ? "/usr/local/bin/whisper-cli" : null)); + assert.equal(r, "/usr/local/bin/whisper-cli"); + }); + + it("falls back to faster-whisper", () => { + const r = resolveWhisperCommand((cmd) => (cmd === "faster-whisper" ? "/opt/fw/faster-whisper" : null)); + assert.equal(r, "/opt/fw/faster-whisper"); + }); + + it("returns null when nothing is installed", () => { + assert.equal( + resolveWhisperCommand(() => null), + null, + ); + }); +}); + +describe("caption dialect detection follows the resolved binary", () => { + it("an explicit --whisper-cmd still wins", () => { + assert.equal(detectEngine({ whisperCmd: "/opt/openai/whisper" }), "openai"); + }); + + it("a whisper.cpp path selects the whisper-cpp invocation", () => { + assert.equal(detectEngine({ whisperCmd: "/usr/local/bin/whisper-cli" }), "whisper-cpp"); + }); + + it("an explicit --whisper-engine still wins", () => { + assert.equal(detectEngine({ whisperCmd: "/usr/local/bin/whisper-cli", whisperEngine: "openai" }), "openai"); + }); + + it("whisperCommand() returns the caller's command unchanged", () => { + assert.equal(whisperCommand({ whisperCmd: "/custom/whisper" }), "/custom/whisper"); + }); + + it("whisperCommand() always resolves to something runnable", () => { + // Host-dependent which binary is found, but never an empty string. + assert.ok(whisperCommand({}).length > 0); + }); +}); From 761e44405578c51512013373d0a97f82466d80fc Mon Sep 17 00:00:00 2001 From: Phelim Date: Mon, 12 Oct 2026 17:22:51 +0800 Subject: [PATCH 3/3] fix(segments): tolerate a segment without target_timerange.start or speed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `TargetSegment.start` and `.duration` are typed as required, so `segmentData` read them directly and `formatTime(undefined)` rendered `NaN`. Measured on a JianYing 9.0.0 (Windows) project that the app opens and edits normally, where `capcut segments -H` printed: 62a041c7 text NaN:00NaN-NaN:00NaN 3.00s undefinedx smoke-test for a segment whose `target_timerange` is `{"duration":3000000}` and which has no `speed` — the app drops the 0 default, and `speed` is absent from segments of that shape. The same `undefined` also reached the JSON result as `"start_us": null`. Both fields now fall back to their 0 / 1 defaults in `segmentData`, which is the single source for the `segments` table, the JSON result and the text-only table. The fix is deliberately on the read side: the two writers reachable in isolation were checked and both write `start` — `add-text` at `start=0` writes `{"start":0,...}`, and `init` seeds an empty draft — so nothing here guesses at a writer that omitted it. The test drives the fixture through `spawnCli`, deleting `start` and `speed` first, and asserts the human table contains no `NaN`/`undefined` while the JSON result reports the defaults. --- CHANGELOG.md | 1 + src/index.ts | 18 +++++--- test/segment-table-missing-fields.test.mjs | 51 ++++++++++++++++++++++ 3 files changed, 65 insertions(+), 5 deletions(-) create mode 100644 test/segment-table-missing-fields.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dfd51e..e0203e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ All notable changes to capcut-cli are documented here. The format follows [Keep ### 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 diff --git a/src/index.ts b/src/index.ts index 40e3178..99c5a90 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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, @@ -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 | 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); @@ -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, diff --git a/test/segment-table-missing-fields.test.mjs b/test/segment-table-missing-fields.test.mjs new file mode 100644 index 0000000..277c658 --- /dev/null +++ b/test/segment-table-missing-fields.test.mjs @@ -0,0 +1,51 @@ +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"; + +const SEG = "aaaaaa01"; + +/** + * The app can write a segment whose `target_timerange` omits `start` (its 0 + * default) and that carries no `speed`. Both were typed as required, so the + * tables printed `NaN:00NaN` / `undefinedx` for a segment the app opens fine. + * Measured on a JianYing 9.0.0 (Windows) project: `capcut segments

-H` + * rendered `62a041c7 text NaN:00NaN-NaN:00NaN 3.00s undefinedx`. + */ +function draftWithoutTimingFields() { + const fix = tmpDraft(); + const draft = JSON.parse(readFileSync(fix.path, "utf-8")); + const seg = draft.tracks.flatMap((t) => t.segments).find((s) => s.id.startsWith(SEG)); + assert.ok(seg, "fixture segment missing"); + delete seg.target_timerange.start; + delete seg.speed; + writeFileSync(fix.path, JSON.stringify(draft), "utf-8"); + return fix; +} + +describe("segments table: missing target_timerange.start / speed", () => { + it("renders a partial segment without NaN or undefined", () => { + const fix = draftWithoutTimingFields(); + after(() => fix.cleanup()); + + const r = spawnCli(["segments", fix.path, "-H"]); + assert.equal(r.status, 0, `stderr: ${r.stderr}`); + assert.ok(!/NaN/.test(r.stdout), `human table printed NaN:\n${r.stdout}`); + assert.ok(!/undefined/.test(r.stdout), `human table printed undefined:\n${r.stdout}`); + assert.match(r.stdout, /aaaaaa01/, "the segment should still be listed"); + }); + + it("reports the missing timing fields as their defaults in JSON", () => { + const fix = draftWithoutTimingFields(); + after(() => fix.cleanup()); + + const r = spawnCli(["segments", fix.path]); + assert.equal(r.status, 0, `stderr: ${r.stderr}`); + const rows = Array.isArray(r.json) ? r.json : (r.json.segments ?? []); + const seg = rows.find((s) => s.id.startsWith(SEG)); + assert.equal(seg.start_us, 0); + assert.equal(seg.speed, 1); + assert.ok(Number.isFinite(seg.duration_us), `duration should stay numeric, got ${seg.duration_us}`); + }); +});