Skip to content

Commit 685472b

Browse files
fix(text): store styles[].range in UTF-16 code units, not bytes (#85) (#87)
A text material's content.styles[].range holds UTF-16 code units — plain JS string indices. This CLI wrote UTF-16LE bytes, so every stored offset was doubled. A single full-span range survived that, because [0, 2n] clamps back to the end of the text when CapCut opens the draft, which is why add-text looked correct and the premise went unchallenged. A multi-range highlight did not: on the 17 code-unit string "อย่าไปซื้อ Claude", text-ranges wrote the emphasised span at [22, 34] and CapCut painted nothing. text-ranges, caption --karaoke, --highlight-words and presets carrying text_ranges were all affected. The premise was this repo's own, asserted in docs/draft-schema and never measured. @hillimited measured it: across 38 app-authored drafts, 211 text materials from CapCut International 7.9.0 and 8.9.1 store code units and none store bytes, and CLI-written drafts read back as code units once the app has re-saved them. Eight sites shared the assumption, so flipping the writer alone would have broken the readers. All of them now go through src/text-offsets.ts, which is also the one place to branch if a JianYing draft ever proves different: - decorators setTextRanges (writer) - caption addCaptionSegment (full-span block) - preset capture and rangeless-preset collapse - lint --fix re-wrap (newline shift is 1 code unit, not 2 bytes) - export-srt karaoke word matching - draft updateTextContent, factory buildTextContent / text override - docs/draft-schema/02-materials.md In the code-unit domain range is the JS string index, so the BMP assumption that came with the byte conversion is gone and an emoji highlight is correct by construction. Adds lint text-range-doubled (fixable): the doubled form is identifiable with certainty — every offset even, inside [0, 2n], last ending at exactly 2n, which is out of bounds as code units and precisely where the old writer's trailing block landed — so lint --fix halves them and converges. export-srt repairs the same form on read, so old karaoke drafts still collapse per word. The canonical test fixture carried the doubled form too; corrected. 700 tests pass (8 new).
1 parent aa14f3f commit 685472b

23 files changed

Lines changed: 527 additions & 114 deletions

‎CHANGELOG.md‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,67 @@ All notable changes to capcut-cli are documented here. The format follows [Keep
44

55
## [Unreleased]
66

7+
## [0.19.1] — 2026-08-16
8+
9+
One bug, reported with the measurement that settled it, plus the repair for
10+
drafts already written. No command was removed and no existing flag changed
11+
meaning.
12+
13+
### Fixed
14+
15+
- **Every multi-range text highlight was written past the end of its text**
16+
([#85](https://github.com/renezander030/capcut-cli/issues/85)). A text
17+
material's `content.styles[].range` holds UTF-16 **code units** — plain JS
18+
string indices — and this CLI wrote UTF-16LE **bytes**, so every stored
19+
offset was doubled. A single full-span range survived the mistake, because
20+
`[0, 2n]` clamps back to the end of the text when CapCut opens the draft,
21+
which is exactly why ordinary `add-text` looked correct and the premise went
22+
unchallenged. A multi-range highlight did not: on the 17 code-unit string
23+
`อย่าไปซื้อ Claude`, `text-ranges --styles '[{"start":11,"end":17,…}]'` wrote
24+
the emphasised span at `[22, 34]`, entirely past the end, and CapCut painted
25+
nothing. `text-ranges`, `caption --karaoke`, `--highlight-words` and any
26+
preset carrying `text_ranges` were all affected.
27+
28+
The premise came from this repo's own schema notes and had never been
29+
measured. @hillimited measured it: across 38 app-authored drafts on one
30+
machine, 211 text materials from CapCut International 7.9.0 and 8.9.1 store
31+
code units and none store bytes — and CLI-written drafts read back as code
32+
units once the app has re-saved them, the app parsing the doubled values,
33+
clamping them, and writing its own interpretation back.
34+
35+
Eight sites shared the assumption — the writer, preset capture, the
36+
`lint --fix` re-wrap, `export-srt`'s karaoke word matching, `edit`'s text
37+
replacement, the text factory and the schema doc — so flipping the writer
38+
alone would have broken the readers. All of them now go through one module,
39+
`text-offsets.ts`, which is also the single place to branch should a
40+
JianYing draft ever turn out to store something else. Side effect worth
41+
having: in the code-unit domain `range` *is* the JS string index, so the BMP
42+
assumption that came with the byte conversion is gone and an emoji highlight
43+
is correct by construction.
44+
45+
- **`export-srt --granularity word` still collapses old karaoke drafts.** It
46+
matches highlight ranges against word offsets, so it now reads the doubled
47+
form as well and lines the words up either way.
48+
49+
### Added
50+
51+
- **`lint` reports and repairs the doubled ranges earlier versions wrote**
52+
(`text-range-doubled`, fixable). The doubled form is identifiable with
53+
certainty rather than by guess — every offset even, every range inside
54+
`[0, 2n]`, and the last one ending at exactly `2n`, which is out of bounds
55+
as code units and precisely where the old writer's trailing block landed —
56+
so `lint --fix` halves them. App-authored drafts, drafts written from 0.19.1
57+
on, and drafts already repaired all end at `n` and are never touched.
58+
59+
- `extractCodeUnitStyleRanges` and the `text-offsets` helpers are exported
60+
from the library entry for anyone reading `styles[].range` themselves.
61+
62+
### Documentation
63+
64+
- `docs/draft-schema/02-materials.md` said "UTF-16 BYTE offsets, not character
65+
indices". It now says code units, shows the measurement behind that, and
66+
points at the repair for drafts written by earlier versions.
67+
768
## [0.19.0] — 2026-08-14
869

970
Nine items from an opportunity-mining pass over this repo's own issues and the

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@ JSON in, JSON out: every command reads and writes the local draft store directly
2828
- **Library** — `import { loadDraft, lintDraft, saveDraft } from "capcut-cli"` (typed, zero-dep)
2929
- **Queue runner** — `capcut serve` reads JSONL jobs from stdin, for [n8n / Make / Coze](./examples/serve-automation.md)
3030

31-
> **New in v0.19.0:** a long `render` no longer fails with `ERR_CHILD_PROCESS_STDIO_MAXBUFFER` on a draft that was fine, and a render that does fail now names the missing decoder, encoder or filter instead of dumping ffmpeg's output; `render --progress` streams progress so a ten-minute job stops looking hung. `lint` gained three checks — caption reading speed (`--max-cps`), vertical safe-area (`--safe-area`) and speed consistency — and `lint --fix` can finally re-wrap CJK captions without disturbing per-character styling. Plus `export-timeline` no longer rounds a sub-half-frame clip to a zero-length OTIO clip ([#82](https://github.com/renezander030/capcut-cli/issues/82)), and the keyframe docs now name the `property_type` the code actually writes ([#80](https://github.com/renezander030/capcut-cli/issues/80)). No command was removed and no existing flag changed meaning. Full details in the [changelog](./CHANGELOG.md).
31+
> **New in v0.19.1:** every multi-range text highlight was being written past the end of the text it styled. `styles[].range` holds UTF-16 code units, not UTF-16LE bytes, so `text-ranges`, `caption --karaoke`, `--highlight-words` and any preset carrying `text_ranges` stored offsets twice as large as they should be — a plain `add-text` looked fine only because a full-span range clamps back to the end of the text ([#85](https://github.com/renezander030/capcut-cli/issues/85), measured by [@hillimited](https://github.com/hillimited) across 38 app-authored drafts). Fixed everywhere those offsets are read or written, and `lint --fix` repairs drafts written by earlier versions (`text-range-doubled`). Full details in the [changelog](./CHANGELOG.md).
3232
33-
> **New in v0.18.0:** `capcut fixture` now redacts the device identifiers CapCut stamps into every draft, so a bundle is finally safe to attach to a public issue (#59); `init` warns when the bundled template predates the CapCut version that owns the target drafts folder, instead of letting the app refuse the draft with a misleading "unusual path" error (#67); the nested `Timelines/` guidance is gated on detected app version, so 8.5.0 users stop seeing a 7.x warning their own evidence contradicts (#68); and the schema docs record CapCut's frame quantisation of durations on first open (#69). Full details in the [changelog](./CHANGELOG.md).
33+
> **New in v0.19.0:** a long `render` no longer fails with `ERR_CHILD_PROCESS_STDIO_MAXBUFFER` on a draft that was fine, and a render that does fail now names the missing decoder, encoder or filter instead of dumping ffmpeg's output; `render --progress` streams progress so a ten-minute job stops looking hung. `lint` gained three checks — caption reading speed (`--max-cps`), vertical safe-area (`--safe-area`) and speed consistency — and `lint --fix` can finally re-wrap CJK captions without disturbing per-character styling. Plus `export-timeline` no longer rounds a sub-half-frame clip to a zero-length OTIO clip ([#82](https://github.com/renezander030/capcut-cli/issues/82)), and the keyframe docs now name the `property_type` the code actually writes ([#80](https://github.com/renezander030/capcut-cli/issues/80)). No command was removed and no existing flag changed meaning. Full details in the [changelog](./CHANGELOG.md).
3434
3535
## Install
3636

‎README.zh-CN.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@ JSON 进、JSON 出:每个命令都直接读写本地草稿存储,不用 MCP
2828
- **库(Library)** —— `import { loadDraft, lintDraft, saveDraft } from "capcut-cli"`(带类型、零依赖)
2929
- **队列执行器** —— `capcut serve` 从 stdin 读取 JSONL 任务,对接 [n8n / Make / Coze](./examples/serve-automation.md)
3030

31-
> **v0.19.0 新增:** 长时间的 `render` 不再在本身正常的草稿上以 `ERR_CHILD_PROCESS_STDIO_MAXBUFFER` 失败;渲染确实失败时,现在会直接指出缺失的解码器、编码器或滤镜,而不是抛出一整段 ffmpeg 输出;`render --progress` 会实时输出进度,十分钟的任务不再看起来像卡死。`lint` 新增三项检查——字幕阅读速度(`--max-cps`)、竖屏安全区(`--safe-area`)与变速一致性;`lint --fix` 终于可以在不破坏逐字样式的前提下为中日韩字幕换行。此外,`export-timeline` 不再把不足半帧的片段导出为零长度的 OTIO 片段([#82](https://github.com/renezander030/capcut-cli/issues/82)),关键帧文档也改为标注代码实际写入的 `property_type`([#80](https://github.com/renezander030/capcut-cli/issues/80))。没有删除任何命令,现有参数含义均未改变。详见[更新日志](./CHANGELOG.md)。
31+
> **v0.19.1 新增:** 此前每一处多区间文字高亮都被写到了文本末尾之外。`styles[].range` 存的是 UTF-16 码元(code unit)而非 UTF-16LE 字节,因此 `text-ranges`、`caption --karaoke`、`--highlight-words` 以及任何带 `text_ranges` 的预设,写入的偏移量都是应有值的两倍——普通的 `add-text` 看起来正常,只是因为整段区间会被裁回文本末尾([#85](https://github.com/renezander030/capcut-cli/issues/85),由 [@hillimited](https://github.com/hillimited) 在 38 个由 App 创建的草稿上实测得出)。所有读写该偏移量的位置均已修正,`lint --fix` 可修复旧版本写出的草稿(`text-range-doubled`)。详见[更新日志](./CHANGELOG.md)。
3232
33-
> **v0.18.0 新增:** `capcut fixture` 现在会脱敏 CapCut 写入每个草稿的设备标识符,分享包终于可以安全地附到公开 issue 上(#59);当内置模板早于目标草稿目录所属的 CapCut 版本时,`init` 会给出警告,而不是让应用以误导性的“路径异常”错误拒绝打开草稿(#67);嵌套 `Timelines/` 提示改为按检测到的应用版本区分,8.5.0 用户不再看到与自身证据相悖的 7.x 警告(#68);schema 文档记录了 CapCut 首次打开草稿时对时长的帧对齐量化(#69)。详见[更新日志](./CHANGELOG.md)。
33+
> **v0.19.0 新增:** 长时间的 `render` 不再在本身正常的草稿上以 `ERR_CHILD_PROCESS_STDIO_MAXBUFFER` 失败;渲染确实失败时,现在会直接指出缺失的解码器、编码器或滤镜,而不是抛出一整段 ffmpeg 输出;`render --progress` 会实时输出进度,十分钟的任务不再看起来像卡死。`lint` 新增三项检查——字幕阅读速度(`--max-cps`)、竖屏安全区(`--safe-area`)与变速一致性;`lint --fix` 终于可以在不破坏逐字样式的前提下为中日韩字幕换行。此外,`export-timeline` 不再把不足半帧的片段导出为零长度的 OTIO 片段([#82](https://github.com/renezander030/capcut-cli/issues/82)),关键帧文档也改为标注代码实际写入的 `property_type`([#80](https://github.com/renezander030/capcut-cli/issues/80))。没有删除任何命令,现有参数含义均未改变。详见[更新日志](./CHANGELOG.md)。
3434
3535
## 安装
3636

‎docs/draft-schema/02-materials.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ The top-level fields (`text_color`, `border_width`, `has_shadow`, …) are the "
117117
"text": "Hello world", // the actual rendered characters
118118
"styles": [
119119
{
120-
"range": [0, 10], // UTF-16 BYTE offsets, not character indices
120+
"range": [0, 11], // UTF-16 code-unit offsets (JS string indices)
121121
"fill": { "content": { "solid": { "color": [1.0, 0.84, 0.0] } } }, // [r,g,b] 0..1
122122
"font": { "id": "...", "path": "..." },
123123
"size": 18,
@@ -131,7 +131,9 @@ The top-level fields (`text_color`, `border_width`, `has_shadow`, …) are the "
131131
}
132132
```
133133

134-
**The `range` array is in UTF-16 little-endian byte offsets.** This trips up everyone the first time. For ASCII text, byte index = character index. For Chinese / emoji, each char is 2 bytes (BMP) or 4 bytes (non-BMP). `capcut-cli text-ranges` handles the conversion.
134+
**The `range` array is in UTF-16 code units** — the same indices `"abc".slice(start, end)` takes in JavaScript, or `text[start:end]` in Python 3. One BMP character (Latin, Chinese, Thai) is one unit; one emoji or other astral character is two, because UTF-16 encodes it as a surrogate pair.
135+
136+
This is worth stating plainly because this repo got it wrong. Until v0.19.1 `capcut-cli` wrote these offsets as UTF-16LE **bytes** — every value doubled ([#85](https://github.com/renezander030/capcut-cli/issues/85)). A single full-span range survived the mistake, since `[0, 2n]` clamps back to the end of the text when CapCut opens the draft, which is exactly why it went unnoticed; a multi-range highlight did not. A scan of 38 app-authored drafts from CapCut International 7.9.0 and 8.9.1 settled it: 211 text materials in code units, none in bytes. `capcut lint --fix` repairs drafts written by the older versions (`text-range-doubled`).
135137

136138
`capcut-cli` exposes:
137139

‎docs/version-support.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Run `capcut version <project>` for schema flags and `capcut diagnose <project> -
1818
|---|---|---|---|
1919
| 6.2.8 | fixture-tested | supported | Canonical fixture in `test/draft_content.json`; full command suite. |
2020
| 6.5–8.0 | expected-compatible | unverified | No committed app-created fixtures. Enum/schema changes appear additive. |
21+
| 7.9 / 8.9 (International, macOS) | reported | one field measured | A scan of 38 app-authored drafts on one machine established that `materials.texts[].content` → `styles[].range` holds UTF-16 code units, not UTF-16LE bytes: 211 text materials in code units, none in bytes ([#85](https://github.com/renezander030/capcut-cli/issues/85)). That settled a real writer bug, fixed in 0.19.1. It is a measurement of one field, not a suite run — no sanitized app-created folder from either version is committed yet, so these versions stay off "fixture-tested". |
2122
| 8.7 Windows | reported + synthetic-tested | adapter shipped, real validation pending | Issue #35 reports that `draft_content.json` edits may be ignored in favour of `template-2.tmp` / `draft_meta_info.json`. v0.11 discovers nested/string JSON timeline envelopes, selects modern storage, synchronizes every readable target, and provides `diagnose --bundle` and `fixture --out` (one-command sanitized bundle). v0.13 adds `sync-timelines` to reconcile an already-drifted mirror (plan by default, `--apply` to write); `diagnose` names it as the remedy. A reporter-provided real folder is still required before marking this fixture-tested. |
2223
| 9.x | expected-compatible | unverified | `common_masks` may coexist with legacy mask fields. Use `version`, `diagnose`, and `migrate`; do not treat this row as desktop-app verification. |
2324
| 10.x (Mac and Windows) | reported | write-guarded | New builds are reported to reject tool-written drafts as corrupted ("内容已损坏"; pyJianYingDraft#177, #194 for the JianYing 10.8 counterpart; Mac primary file reported as `draft_info.json`, Jianying-CapCut2XML#4). No fixture exists; mutating commands refuse without `--force-write`. Fixture wanted — see the write guard section below. |

‎package-lock.json‎

Lines changed: 2 additions & 14 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "capcut-cli",
3-
"version": "0.19.0",
3+
"version": "0.19.1",
44
"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.",
55
"type": "module",
66
"bin": {
@@ -26,6 +26,7 @@
2626
"dist/lint.d.ts",
2727
"dist/runner.d.ts",
2828
"dist/store.d.ts",
29+
"dist/text-offsets.d.ts",
2930
"dist/time.d.ts",
3031
"dist/version.d.ts",
3132
"docs",

‎src/caption.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type { Draft, Segment, Track } from "./draft.js";
1414
import { findSegment } from "./draft.js";
1515
import { captionStyleFromPreset, type TextStylePreset } from "./preset.js";
1616
import { parseSrt } from "./srt.js";
17+
import { storedTextLength } from "./text-offsets.js";
1718

1819
export interface CaptionOptions {
1920
audio?: string; // path to audio file; if absent, derived from --from-segment
@@ -206,7 +207,7 @@ function addCaptionSegment(
206207
const matId = uuid();
207208
const content = JSON.stringify({
208209
text,
209-
styles: [{ ...baseStyle, range: [0, Buffer.from(text, "utf16le").length] }],
210+
styles: [{ ...baseStyle, range: [0, storedTextLength(text)] }],
210211
});
211212
draft.materials.texts.push({
212213
id: matId,

0 commit comments

Comments
 (0)