Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@ All notable changes to capcut-cli are documented here. The format follows [Keep

## [Unreleased]

## [0.25.0] — 2026-09-18

### Added

- `caption` follows the transcript's script. Whisper's "words" for Chinese and Japanese are single characters or short tokens, so the Latin defaults (four words per cue, joined with spaces) produced fragments with spaces between the characters. Cues in Chinese and Japanese are now joined without spaces and bounded by characters alone, at the width `lint` holds captions to (16 zh, 13 ja; Korean keeps spaces and four words at 16); karaoke ranges follow the new offsets. The result reports `caption_script` (`latin` | `zh` | `ja` | `ko`). An explicit `--max-words` / `--max-chars` applies as given. Library: `groupWords` takes a `separator`; `wordSeparator`, `groupingDefaults` and `GroupingDefaults` are exported; the script detection moved to `script.ts` (still exported from the lint entry points).
- `caption --max-chars` is documented in the command reference alongside `--max-words`.
- The Chinese README notes the feature in 科技爱好者周刊 issue 413.

### Unchanged

- `--script` alignment still tokenizes the script file on whitespace; a Chinese script line is one token to it.

## [0.24.0] — 2026-09-18

### Added
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,9 @@ The host reads a draft and passes its JSON as tool input. The component itself h

## Release notes

> **New in v0.24.0:** captions in Chinese, Japanese and Korean are held to their own limits — `lint` flags a 32-character Chinese line and a 15 chars/s cue that the Latin defaults (42, 20) let through, and `--fix` re-wraps between characters (zh 16/9, ja 13/4, ko 16/12; an explicit `--max-chars` / `--max-cps` still applies everywhere). On a JianYing 6.0+ drafts folder, where every app-written project is encrypted, `init` / `quickstart` / `compile` now say that none could seed the new draft (`template.store`, a WARNING) and `lint` reports `template-unverified-store` instead of nothing. Plus a one-command agent install: `npx skills add renezander030/capcut-cli`. Full details in the [changelog](./CHANGELOG.md).
> **New in v0.25.0:** `caption` follows the transcript's script. Whisper's "words" for Chinese and Japanese are single characters or short tokens, so the Latin defaults (four words per cue, joined with spaces) produced fragments with spaces between the characters; cues are now joined without spaces and bounded by characters alone, at the width `lint` holds captions to (zh 16, ja 13, ko 16), and the result reports `caption_script`. An explicit `--max-words` / `--max-chars` still wins. Full details in the [changelog](./CHANGELOG.md).

> **New in v0.23.0:** drafts that open on the CapCut you actually have. A draft built from the bundled 6.5.0 template is refused by CapCut 8.4+, 8.7 Windows and 9.3 as "from an unusual path" ([#67](https://github.com/renezander030/capcut-cli/issues/67), [#111](https://github.com/renezander030/capcut-cli/issues/111) — the real 8.7 Windows round-trip, negative with the bundled template and positive with one captured from the installed app). `init`, `quickstart` and `compile` now seed new drafts from the newest app-authored project in your drafts folder by default (its version markers and settings, none of its content, never its `Timelines/` mirrors); `migrate --from-store` restamps drafts built earlier, and `lint` reports the stale signature as `template-stale`. Media gets its `local_material_id` link to `draft_materials` at add time — the key JianYing 5.9+ and CapCut 9.3 resolve local clips by ([JmsLdrn/capcut-mcp#1](https://github.com/JmsLdrn/capcut-mcp/issues/1)) — and `lint --fix` writes it for existing drafts (`media-unlinked`). Plus `source-range-exceeds-material`, a `compile --check` that names flat `text-style` keys ([#110](https://github.com/renezander030/capcut-cli/issues/110)), the macOS permission hint on `media-outside-draft`, and `init` stamping both timeline mirrors so `register` accepts its own drafts. No command was removed and no existing output changed shape. Full details in the [changelog](./CHANGELOG.md).
> **New in v0.24.0:** captions in Chinese, Japanese and Korean are held to their own limits — `lint` flags a 32-character Chinese line and a 15 chars/s cue that the Latin defaults (42, 20) let through, and `--fix` re-wraps between characters (zh 16/9, ja 13/4, ko 16/12; an explicit `--max-chars` / `--max-cps` still applies everywhere). On a JianYing 6.0+ drafts folder, where every app-written project is encrypted, `init` / `quickstart` / `compile` now say that none could seed the new draft (`template.store`, a WARNING) and `lint` reports `template-unverified-store` instead of nothing. Plus a one-command agent install: `npx skills add renezander030/capcut-cli`. Full details in the [changelog](./CHANGELOG.md).

## Built with capcut-cli

Expand Down
6 changes: 4 additions & 2 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ capcut info ./my-first/ -H

有用的话,[给 capcut-cli 加个 Star](https://github.com/renezander030/capcut-cli),帮助更多剪辑师和 Agent 开发者发现它。

入选[《科技爱好者周刊》第 413 期](https://github.com/ruanyf/weekly/blob/master/docs/issue-413.md)。

想了解更多实用的 AI Agent 工具,从视频自动化到交付前的检查,[在 GitHub 上关注 René](https://github.com/renezander030)。

也可以从源码构建:`git clone https://github.com/renezander030/capcut-cli && cd capcut-cli && npm install && npm run build`(然后用 `npm link` 暴露出 `capcut`)。或者不安装,直接运行任意命令:`npx capcut-cli <command>`。
Expand Down Expand Up @@ -78,9 +80,9 @@ Claude Code 也可以把它作为插件加载:

## 发布说明

> **v0.24.0 新增:** 中文、日文、韩文字幕按各自的规范检查 —— `lint` 会指出 32 字的中文单行和每秒 15 字的字幕(拉丁默认的 42 字 / 每秒 20 字会放过它们),`--fix` 按字重新折行(zh 16/9、ja 13/4、ko 16/12;显式传入 `--max-chars` / `--max-cps` 仍对所有文字生效)。在剪映 6.0+ 的草稿目录里(应用写出的项目全部加密),`init` / `quickstart` / `compile` 现在会明确说明没有任何项目可作为种子(`template.store` 与 WARNING),`lint` 会报告 `template-unverified-store` 而不是沉默。另外,一条命令即可把它装进 Agent:`npx skills add renezander030/capcut-cli`。完整说明见[更新日志](./CHANGELOG.md)。
> **v0.25.0 新增:** `caption` 按转写文本的文字来分句。Whisper 对中文、日文给出的"词"是单个字或很短的片段,按拉丁默认(每句 4 词、用空格连接)会生成字与字之间带空格的碎片;现在中日文按字直接连接、只按字数上限分句,上限就是 `lint` 对字幕的行宽(zh 16、ja 13、ko 16),结果里会报告 `caption_script`。显式传入的 `--max-words` / `--max-chars` 仍然优先。完整说明见[更新日志](./CHANGELOG.md)。

> **v0.23.0 新增:** 生成的草稿能在你实际安装的 CapCut 里打开。用内置 6.5.0 模板生成的草稿会被 CapCut 8.4+、8.7 Windows 和 9.3 以"项目来自异常路径"拒绝([#67](https://github.com/renezander030/capcut-cli/issues/67)、[#111](https://github.com/renezander030/capcut-cli/issues/111)——这是等待已久的 8.7 Windows 真机验证:内置模板失败,从已安装应用捕获的模板成功)。`init`、`quickstart` 与 `compile` 现在默认以草稿目录中最新的应用生成项目为种子(保留其版本标记与设置,不带任何内容,绝不复制其 `Timelines/` 镜像);`migrate --from-store` 为旧版本生成的草稿重新盖上标记,`lint` 以 `template-stale` 报告过期签名。素材在添加时即写入 `draft_materials` 并回填 `local_material_id`——剪映 5.9+ 与 CapCut 9.3 正是靠这个键定位本地素材([JmsLdrn/capcut-mcp#1](https://github.com/JmsLdrn/capcut-mcp/issues/1)),已有草稿可用 `lint --fix` 补链(`media-unlinked`)。另有 `source-range-exceeds-material` 检查、能指出扁平 `text-style` 键的 `compile --check`([#110](https://github.com/renezander030/capcut-cli/issues/110))、`media-outside-draft` 的 macOS 权限提示,以及 `init` 同时盖章两份时间线镜像,使 `register` 接受自己生成的草稿。没有删除任何命令,现有输出结构均未改变。详见[更新日志](./CHANGELOG.md)。
> **v0.24.0 新增:** 中文、日文、韩文字幕按各自的规范检查 —— `lint` 会指出 32 字的中文单行和每秒 15 字的字幕(拉丁默认的 42 字 / 每秒 20 字会放过它们),`--fix` 按字重新折行(zh 16/9、ja 13/4、ko 16/12;显式传入 `--max-chars` / `--max-cps` 仍对所有文字生效)。在剪映 6.0+ 的草稿目录里(应用写出的项目全部加密),`init` / `quickstart` / `compile` 现在会明确说明没有任何项目可作为种子(`template.store` 与 WARNING),`lint` 会报告 `template-unverified-store` 而不是沉默。另外,一条命令即可把它装进 Agent:`npx skills add renezander030/capcut-cli`。完整说明见[更新日志](./CHANGELOG.md)。


## 常用命令
Expand Down
13 changes: 11 additions & 2 deletions docs/command-reference.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "capcut-cli",
"version": "0.24.0",
"version": "0.25.0",
"schema_version": 2,
"description": "Edit CapCut/JianYing draft_content.json directly. JSON in, JSON out.",
"global_flags": [
Expand Down Expand Up @@ -3659,7 +3659,16 @@
],
"type": "number",
"required": false,
"description": "Maximum words per cue."
"description": "Maximum words per karaoke cue. Unset: 4; for Chinese and Japanese transcripts unlimited (the character cap bounds the cue)."
},
{
"name": "max_chars",
"flags": [
"--max-chars"
],
"type": "number",
"required": false,
"description": "Maximum characters per cue (karaoke) or per script line. Unset: 28 / 42; Chinese 16 / 16, Japanese 13 / 13, Korean 16 / 16."
},
{
"name": "max_gap_ms",
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

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

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "capcut-cli",
"version": "0.24.0",
"version": "0.25.0",
"description": "Independent, unofficial CLI to create and edit CapCut projects — build drafts from scratch, add video/audio/text, subtitles, timing, speed, volume, templates, cut long-form to shorts. No API needed. Not affiliated with ByteDance.",
"type": "module",
"bin": {
Expand All @@ -25,6 +25,7 @@
"dist/draft.d.ts",
"dist/lint.d.ts",
"dist/runner.d.ts",
"dist/script.d.ts",
"dist/store.d.ts",
"dist/text-offsets.d.ts",
"dist/time.d.ts",
Expand Down
64 changes: 56 additions & 8 deletions src/caption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
import type { Draft, Segment, Track } from "./draft.js";
import { findSegment } from "./draft.js";
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";

Expand Down Expand Up @@ -72,6 +73,9 @@ export interface CaptionResult {
color_cycle?: number;
/** --script alignment quality (only when a script was given). */
script?: AlignmentReport;
/** The script the transcript is written in, which chose the word separator
* and the --max-words / --max-chars defaults (see script.ts). */
caption_script: CaptionScript;
}

/**
Expand All @@ -95,6 +99,15 @@ export function captionDraft(draft: Draft, opts: CaptionOptions): CaptionResult
const audio = resolveAudio(draft, opts);
const transcription = runWhisper(audio, opts);
const recognizedWords = transcription.words.length > 0 ? transcription.words : wordsFromCues(transcription.cues);
// The transcript's script decides how words join into a cue (no space inside
// Chinese or Japanese) and how many of them make one when the caller set no
// --max-words / --max-chars; the script file's wording when one was given,
// since that is the text the cues will carry.
const textScript = captionScript(
opts.scriptText !== undefined ? opts.scriptText : recognizedWords.map((word) => word.word).join(""),
);
const separator = wordSeparator(textScript);
const grouping = groupingDefaults(textScript);
let scriptReport: AlignmentReport | undefined;
let cues: CaptionCue[];
if (opts.scriptText !== undefined) {
Expand All @@ -106,15 +119,33 @@ export function captionDraft(draft: Draft, opts: CaptionOptions): CaptionResult
const aligned = alignScript(lines, recognizedWords);
scriptReport = aligned.report;
cues = opts.karaoke
? groupWords(aligned.words, opts.maxWords ?? 4, opts.maxChars ?? 28, (opts.maxGapMs ?? 500) * 1000)
? groupWords(
aligned.words,
opts.maxWords ?? grouping.karaokeMaxWords,
opts.maxChars ?? grouping.karaokeMaxChars,
(opts.maxGapMs ?? 500) * 1000,
separator,
)
: // One cue per script line — the author's chunking — split only when a
// line outgrows --max-chars (words and gaps never split a line).
aligned.lines.flatMap((line) =>
groupWords(line, Number.POSITIVE_INFINITY, opts.maxChars ?? 42, Number.POSITIVE_INFINITY),
groupWords(
line,
Number.POSITIVE_INFINITY,
opts.maxChars ?? grouping.lineMaxChars,
Number.POSITIVE_INFINITY,
separator,
),
);
} else {
cues = opts.karaoke
? groupWords(recognizedWords, opts.maxWords ?? 4, opts.maxChars ?? 28, (opts.maxGapMs ?? 500) * 1000)
? groupWords(
recognizedWords,
opts.maxWords ?? grouping.karaokeMaxWords,
opts.maxChars ?? grouping.karaokeMaxChars,
(opts.maxGapMs ?? 500) * 1000,
separator,
)
: transcription.cues;
}
if (cues.length === 0) {
Expand Down Expand Up @@ -166,7 +197,7 @@ export function captionDraft(draft: Draft, opts: CaptionOptions): CaptionResult
presetRanges,
});
if (opts.karaoke && cue.words && cue.words.length > 0) {
const fullText = cue.words.map((word) => word.word).join(" ");
const fullText = cue.words.map((word) => word.word).join(separator);
let cursor = 0;
let cueMatches = 0;
for (const word of cue.words) {
Expand All @@ -188,7 +219,7 @@ export function captionDraft(draft: Draft, opts: CaptionOptions): CaptionResult
} else {
setTextRanges(draft, segmentId, [karaokeRange]);
}
cursor = end + 1;
cursor = end + separator.length;
created++;
}
keywordMatches += cueMatches;
Expand All @@ -212,6 +243,7 @@ export function captionDraft(draft: Draft, opts: CaptionOptions): CaptionResult
source_audio: audio,
engine: opts.whisperCmd ? "shell" : "whisper-cli",
engine_name: transcription.engine,
caption_script: textScript,
words: transcription.words.length,
karaoke: opts.karaoke ?? false,
// undefined when the flags are off, so JSON output stays byte-identical.
Expand Down Expand Up @@ -432,15 +464,29 @@ export function wordsFromCues(cues: CaptionCue[]): CaptionWord[] {
return words;
}

export function groupWords(words: CaptionWord[], maxWords = 4, maxChars = 28, maxGapUs = 500_000): CaptionCue[] {
/**
* Group timed words into cues: a cue closes when the next word would exceed
* maxWords, would push the joined text past maxChars, or starts after a gap
* longer than maxGapUs. `separator` is what joins the words into the cue's
* text — a space for Latin and Korean, nothing for Chinese and Japanese
* (wordSeparator in script.ts) — and counts towards maxChars like any other
* character.
*/
export function groupWords(
words: CaptionWord[],
maxWords = 4,
maxChars = 28,
maxGapUs = 500_000,
separator = " ",
): CaptionCue[] {
const cues: CaptionCue[] = [];
let group: CaptionWord[] = [];
const flush = () => {
if (group.length === 0) return;
cues.push({
startUs: group[0].startUs,
endUs: group[group.length - 1].endUs,
text: group.map((word) => word.word).join(" "),
text: group.map((word) => word.word).join(separator),
words: group,
});
group = [];
Expand All @@ -450,7 +496,9 @@ export function groupWords(words: CaptionWord[], maxWords = 4, maxChars = 28, ma
const gap = group.length === 0 ? 0 : word.startUs - group[group.length - 1].endUs;
if (
group.length > 0 &&
(candidate.length > maxWords || candidate.map((item) => item.word).join(" ").length > maxChars || gap > maxGapUs)
(candidate.length > maxWords ||
candidate.map((item) => item.word).join(separator).length > maxChars ||
gap > maxGapUs)
) {
flush();
}
Expand Down
13 changes: 12 additions & 1 deletion src/command-specs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -538,7 +538,18 @@ const optionsByCommand: Record<string, OptionSpec[]> = {
option("whisper_model", ["--whisper-model"], "string", "Whisper model."),
option("language", ["--language"], "string", "Language code."),
option("karaoke", ["--karaoke"], "boolean", "Create word-highlight caption ranges."),
option("max_words", ["--max-words"], "number", "Maximum words per cue."),
option(
"max_words",
["--max-words"],
"number",
"Maximum words per karaoke cue. Unset: 4; for Chinese and Japanese transcripts unlimited (the character cap bounds the cue).",
),
option(
"max_chars",
["--max-chars"],
"number",
"Maximum characters per cue (karaoke) or per script line. Unset: 28 / 42; Chinese 16 / 16, Japanese 13 / 13, Korean 16 / 16.",
),
option("max_gap_ms", ["--max-gap-ms"], "number", "Maximum gap inside a karaoke cue."),
TRACK_NAME,
STYLE_REF,
Expand Down
2 changes: 2 additions & 0 deletions src/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ export {
} from "./lint.js";
export type { RunCommandRequest, RunCommandResult } from "./runner.js";
export { runCommand } from "./runner.js";
export type { GroupingDefaults } from "./script.js";
export { groupingDefaults, wordSeparator } from "./script.js";
export {
fromStoredOffset,
rangesLookDoubled,
Expand Down
Loading
Loading