# Changelog 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.16.0] — 2026-07-31 Six features in one release — the next slice of the opportunity backlog, bundled. The headline is version-compat: the draft_info-primary Mac layout becomes first-class instead of edit-only, and masks land in the array variant the installed app build actually reads. Behaviour changes are called out inline; the ones to know: `sync-timelines`/`register` now *work* on Mac-layout projects where they previously refused, `mask` on a version-marked JianYing draft targets the correct variant array (that is the fix), `migrate` also consolidates `common_mask[]`, `lint` probes existing local media by default (`--no-probe` opts out) and gains a warning-severity dangling-ref check — a draft carrying those now exits 1 (run `lint --fix`). Markerless and CapCut drafts write byte-identically to v0.15.0 with no new flags in play. ### Added - **First-class draft_info-primary layout** — newer Mac builds drive a project from `draft_info.json` with no `draft_content.json` beside it, and every ecosystem tool breaks there (jianying-mcp#5, pyJianYingDraft#177, #194). `discoverDraftStore` now reports a `layout` (`content-primary` | `info-primary` | `unknown`), surfaced by `diagnose` (plus a next_action naming the layout and the fixture CTA). **Behaviour change:** `sync-timelines` on such a project previously refused outright; it now promotes `draft_info.json` to the sync canonical and reconciles mirrors from it — the plan carries `layout` and a `canonical_note` naming the promotion and the synthetic-only evidence. `register` previously errored; it now derives id/name/duration from `draft_info.json` when `draft_content.json` is absent (plan gains `identity_source`, and the written sidecar's `draft_json_file` points at the real timeline file). Canonical filenames in both commands' output are no longer hardcoded. Round-trip evidence for this layout is synthetic-only, so the fixture CTA ships on every surface — if a Mac project opens fine in your app, `capcut fixture --out ` moves it to fixture-tested. - **Version-aware mask writes** — three mask array variants exist in the wild (`masks` legacy, `common_masks` JianYing 9.6+, `common_mask` the CapCut-verified struct this CLI writes), the app reads exactly one, and masks in the wrong one silently never appear (pyJianYingDraft#160). `mask` now picks its target: explicit `--mask-field ` wins, a version-marked JianYing draft follows its version evidence (a populated-but-wrong variant is deliberately not trusted — it may be exactly the #160 failure), otherwise an already-populated variant (newest era first), else `common_mask`. Markerless CLI-created drafts write byte-identically to before. `mask --off` and the one-mask-per-segment guard now cover all three variants; the JSON result gains `field`. `migrate` consolidates `common_mask[]` into the target array on both directions of the 9.6 jump (**behaviour change:** those entries were previously left behind). New info-severity, report-only lint check `mask-field-mismatch` flags masks split across variants (any app) and the wrong-array-for-this-JianYing-version case, naming the exact `migrate` call. `capcut version`'s `mask_field` gains the `common_mask` value, and `both` now means any two-plus populated variants. - `export-timeline [--out ]` — the cut as OpenTimelineIO JSON, the exit ramp when an app build rejects a draft (Ersiter/Jianying-CapCut2XML#4): clip order, trims, per-track gaps, speed as `LinearTimeWarp` (timeline duration = source range / time_scalar — exactly CapCut's source/target relationship), media paths with available ranges, and segment/material ids under `metadata.capcut`. Emits the stable OTIO schema set (`Timeline.1`/`Stack.1`/`Track.1`/`Clip.1`) that every reader accepts — DaVinci Resolve imports `.otio` natively. Raw document on stdout (pipe-able, like `export-srt`); `--out` writes the file and prints a JSON summary. Read-only and deterministic. Text tracks are skipped with a pointer to `export-srt`, sticker/effect/filter tracks with a no-portable-equivalent note — always reported, never silent. - `harvest-enums [--apply] [--catalogue ]` — learn store resource ids from a draft the app itself authored, instead of guessing at app internals (GuanYixuan/pyCapCut#12: newer store effects missing from every ecosystem table). Every harvested `effect_id`/`resource_id` joins lint's known-id set, so app-authored effects stop flagging `unknown-effect-slug`/`unknown-font-id`; named entries from cleanly-mapped kinds (effects, filters, transitions, masks, sound effects) become writable slugs through the normal `findEnum` path — harvest a draft that uses "Snowfly" once and `add-effect snowfly` works from then on. Ambiguous kinds stay id-only, never guessed: animations (intro/outro/combo indistinguishable in a draft), text-shape bubbles (would resolve as the wrong material shape), nameless font ids. The catalogue lives at `~/.config/capcut-cli/user-enums.json` (`--catalogue` or `$CAPCUT_CLI_USER_ENUMS` override); the bundled table wins slug collisions; a hand-broken catalogue reads as empty everywhere, is surfaced with a WARNING, and `--apply` refuses to clobber it. Plan by default; `--apply` writes the catalogue — never the draft. - **Media compatibility probe in `lint`** — two best-effort info checks on local media files that exist, only when ffprobe runs (a host without ffprobe lints exactly as before; `--no-probe` and `--ffprobe-cmd` are on `lint`): `vfr-media` flags variable-frame-rate video (avg vs base frame rate diverging >1% — the screen-recording/phone-capture class that drifts preview/render timing and breaks frame-based pipelines, 0xsline/OpenChatCut#1) with the exact `ffmpeg -fps_mode cfr` normalize line, and `media-unreadable` flags a file that exists but ffprobe cannot parse. `probe.ts` now reports `avgFps`/`baseFps` separately plus an `isVfr` helper. - **`dangling-companion-ref` + remediation hints** — new warning-severity lint check for `extra_material_refs` entries that resolve to no material (the leftover of a partial edit); always safely fixable, and `lint --fix` drops the ref — never a segment, never a material. **Behaviour change:** a draft carrying dangling refs now lints exit 1. `missing-material` and `missing-file` stay deliberately report-only (the only mechanical repair would delete timeline content or guess a path) and now carry a `suggested_command` (`capcut remove ` / `capcut relink --dir `) in JSON and human output. `lint`'s command spec now declares `--fix`, `--no-probe`, and `--ffprobe-cmd`. ### Fixed - `capcut version` no longer claims the mask migration is unshipped — the old `common_masks` note said "use `capcut migrate --to common_masks` once shipped" although `migrate` has carried that jump since v0.4, and misdescribed the write target as the legacy field. The note now describes the version-aware write behaviour, and a split-across-variants draft gets its own consolidation note. ## [0.15.0] — 2026-07-24 Four features in one release — the next slice of the opportunity backlog, bundled. Two of them deliberately change existing behaviour: mutating commands now refuse to write drafts beyond the collected version evidence (override with `--force-write`), and `--intensity` on `add-filter`/`add-effect` is now applied instead of silently ignored. With none of the new flags in play, every written draft stays byte-identical to v0.14.0 (the `version` and `diagnose` reports gain additive fields and honest evidence labels — see Fixed). ### Added - Write-time version guard — every mutating save (`saveDraft`, and `sync-timelines --apply`, which writes outside it) now assesses the draft's version markers before writing and refuses when the draft is beyond collected evidence or known-broken: JianYing ≥ 6.0 (the encrypted era), CapCut beyond the 9.x evidence ceiling, and top-level schema integers newer than the known 360000 generation. The refusal is actionable and ends with the fixture-collection CTA; `--force-write` overrides with a stderr WARNING (the global flag's description now names version-boundary checks alongside editor-running and changed-on-disk). The effective version is the max of `platform`, `last_modified_platform`, and the newest readable sibling timeline, so a newer-app mirror trips the guard too; markerless CLI-created drafts never trigger. **Behaviour change:** these drafts previously saved without complaint. Companion surfaces: `capcut version` reports `schema_int` / `evidence` / `beyond_known_range` / `write_guard`, `diagnose` gains a version-boundary `next_action` plus a `write_guard` field, and `sync-timelines` names the draft_info-primary Mac layout when `draft_content.json` is absent. - `remove [--keep-track] [--keep-materials]` — delete a segment in place. The segment leaves its track; a track the removal empties is dropped (`--keep-track` keeps it with `segments: []`); the materials only that segment referenced — its source material plus every `extra_material_refs` companion (speed, canvas, sound_channel_mapping, vocal_separation, ...) — are garbage-collected with the same sweep `prune` uses, so pre-existing orphans go in the same pass and a material another segment still references is never deleted; `--keep-materials` skips the sweep (run `prune` later). The draft duration is recomputed as the max segment end across ALL tracks — removing every segment leaves `tracks: []` and duration 0 — and the removal is mirrored into readable sibling timeline files (`draft_info.json`). Unknown or missing segment id: exit 1, no write, no `.bak`. Mutating, atomic save with `.bak` + history snapshot, honors `--dry-run`; `restore` undoes a remove byte-for-byte. `--keep-track`/`--keep-materials` are release-scoped, so the tokens stay verbatim free text on every other command. - `add-filter` / `add-effect` grow a raw-store escape hatch and range ergonomics — usage is now ` ( | --full)`: - `--resource-id ` applies a raw catalogue/store resource id, skipping slug lookup entirely — the positional becomes the display name; `--effect-id ` sets a distinct effect id when the store entry carries one (defaults to `--resource-id`, rejected without it). Unknown-slug errors keep their helpful hint. - `--intensity <0..1>` writes the strength as the material `value` (default 1). **Behaviour change:** previously the flag was silently ignored and the material was always written at full strength; out-of-range or non-numeric values now exit 1. - `--full` spans the whole timeline (start 0, duration = draft duration) without the ` ` positionals, wins when both are given, and exits 1 on a draft without a usable duration. - `--bind ` (`add-effect` only, **experimental**) attaches the effect to one segment instead of the whole frame — `apply_target_type` 0 plus `bind_segment_id`; short id prefixes resolve, and an unknown id exits 1 leaving the draft untouched. - On the plain slug path the output stays byte-compatible (`value` 1, `source_platform` 0); the new flags are release-scoped like their v0.13/v0.14 siblings. - `lint` unknown-slug coverage now spans every effect-shaped material array the CLI writes — previously only `materials.video_effects` was checked: transitions, masks (`resource_id` only — mask materials carry no `effect_id`), audio effects (`sfx`), and `materials.filters` (colour filters plus the text-shape bubbles that share the array), with the bubble catalogue added to the known-id table; CLI-written decorations never self-flag. New `unknown-font-id` info check on text materials (pyJianYingDraft#192): a font resource id CapCut doesn't know is silently replaced with the default font, so ids from `font_id` / `font_resource_id` / embedded content styles are checked against the bundled font table, and a resolvable on-disk font path silences the check because CapCut loads the file regardless of id. Info-severity and report-only by design — a repair would guess the author's font — and `--fix` leaves both codes untouched. ### Fixed - The JianYing "6.0.0+" version-registry match was dead code — a literal prefix comparison that could never fire — so encrypted-era JianYing drafts were reported as merely unknown. A structured ≥ 6.0 matcher replaces it and reports the era as known-broken. Registry claims are aligned with the evidence labels in [docs/version-support.md](./docs/version-support.md): 6.2.8 fixture-tested, 8.7.0 synthetic-tested, 6.5–9.0 expected-compatible instead of a blanket tested claim. ## [0.14.0] — 2026-07-17 Five features in one release — the next slice of the opportunity backlog, bundled. Two build on prior art from the [capcut-cli-david](https://github.com/Davidb-2107/capcut-cli-david) fork (thanks @Davidb-2107). The sixth backlog item, keyframeable mask geometry (#44), is deliberately **not** in this release: no public ground truth exists for the on-disk encoding, and a guessed encoding would silently no-op in the app — it ships once a real app-authored mask-keyframe draft is captured. ### Added - `duplicate [--track ] [--new-track]` — duplicate a segment at the SAME timeline position and duration onto a track that renders above the source: the PIP local-retouch flow from #44 (copy the clip above itself, then `mask` the copy) without hand-editing JSON. By default — and with the explicit `--new-track` — the copy goes onto a fresh track of the same type inserted directly after the source track in the tracks array: `sortTracks` is stable within a type and a later same-type track renders above, so the copy sits exactly on top of its source (a second copy gets a unique `-copy-2` track name). `--track ` places the copy onto that existing same-type track instead, and exits 1 with a clear error when the target range is occupied there, when the track is missing, or when its type does not match the source segment's; `--track` and `--new-track` together are rejected. ID hygiene: the new segment gets a fresh id; the source material entry — media included — is cloned with a fresh id (the media FILE on disk stays shared), so material-level edits on the copy (`crop`, `mix-mode`, `replace-media`) never leak to the source segment underneath it; every per-segment companion referenced via `extra_material_refs` (speed, placeholder_info, sound_channel_mapping, vocal_separation, canvas, material_color, masks, animations, ...) is cloned the same way — the app treats those as per-segment instances, so two segments never share one. Embedded keyframe list/entry ids are re-minted on the copy. The timeline duration is unchanged. Prints `{ new_segment_id, track_name, cloned_materials }` plus the copy's `material_id`, `track_id`, and `new_track`; mutating, atomic save with `.bak` + history snapshot, honors `--dry-run`. - `register [--apply] [--drafts ]` — the meta-repair sidecar for EXISTING drafts. `init` registers only the drafts it creates, so an existing folder missing its `draft_meta_info.json` sidecar or its entry in the store's `root_meta_info.json` is invisible to the CapCut app with no repair path (`doctor` checks the environment, `diagnose` is read-only). `register` derives id/name/duration from `draft_content.json` — a read-only source that is **never** written — and reports per target (`needs_repair`, per-target `state`/`action`/`detail`/`stale_fields`). Accepts the project directory or its `draft_content.json` path (any other filename exits 1). Plan-only by default (always exit 0); `--apply` recreates a missing/corrupt sidecar and inserts/updates the index entry — new entries clone the shape of an existing entry so they match the installed CapCut version, updates repair only the stale identifying fields (`draft_id`, `draft_fold_path`, `draft_json_file`, `draft_root_path`, `tm_duration`) in place and preserve everything else, including a non-empty `draft_name` (CapCut's display name is user data). Writes are atomic (temp+fsync+rename) with a `.bak` per file that already existed; `applied` / `backups` list exactly the files written. Idempotent: a re-run writes nothing and reports `applied: []`, exit 0. The store root is the draft's parent directory and must be *known* — a `root_meta_info.json` beside the draft folder, a managed `com.lveditor.draft` path (init's default location), or an explicit `--drafts ` (which, like `init`, creates the index on a fresh store); a draft outside any known store root is reported explicitly and nothing is written. An unreadable `root_meta_info.json` is never rewritten (it lists every draft) and is reported blocked instead. `--apply` refuses while the editor is running and when a target changed on disk since the plan read, unless `--force-write`; `--apply --dry-run` previews (`would_apply`, `applied: []`). Exits 2 on `--apply` when a target stays blocked (unknown store root, unreadable index). `diagnose` now recommends the `register` plan form when it sees `draft_meta_info.json` missing. - `crop [--ratio | --rect | --reset]` — read and set the crop on a video/photo segment's source material: the 8-corner normalized struct CapCut stores on `materials.videos[]`, which the factory wrote full-frame at creation and no command could read or edit before. With no flags the command is read-only: it prints the material's crop struct as JSON plus the stored source `width`/`height` (`crop` is `null` when the material carries none) and writes nothing. `--ratio ` computes the centered maximal crop of that aspect against the source dimensions stored in the draft — when the dims are missing or zero it exits 1 with an error that points at `--rect` (`free` restores the full frame without needing dims). `--rect ` sets an explicit normalized rect, all values 0..1 fractions of the source frame, validated as `x,y >= 0`, `w,h > 0`, `x+w <= 1`, `y+h <= 1` (a float-ulp tolerance keeps sums like `0.3 + 0.7` valid, and the written corners are clamped to 1); `--rect` overrides `--ratio` when both are given. `--reset` restores the full frame. The corner mapping matches the factory default exactly — y grows downward: `upper_left = (x, y)`, `upper_right = (x+w, y)`, `lower_left = (x, y+h)`, `lower_right = (x+w, y+h)`. When the material carries a `crop_ratio` field it is stamped `"free"` — CapCut's preset enum values are not published, so the app recomputes from the corner points; stated in `--help`. Mutating command: registered in the mutating set, writes atomically with a `.bak` snapshot like its siblings, honors `--dry-run`, and prints the resulting crop JSON on write. The new `--ratio`/`--rect`/`--reset` flags are release-scoped to `crop`, so free-text positionals of other commands containing those substrings survive verbatim. - `caption` / `import-srt` — per-word keyword emphasis and per-cue colour cycling as ergonomic flags, replacing hand-written `--styles` JSON for the viral-caption workflow (prior art: capcut-cli-david `--keyword-size` v1.15 and `import-captions --color-cycle`): - `--highlight-words ` — case-insensitive **whole-word** matches per cue get an emphasis text range; `@file` reads one word/phrase per line (phrases match across spaces). Word boundaries are Unicode-aware, so `für` matches in `Grüße für alle` but never inside `fürs`, and `cap` never matches inside `capcut`. Overlapping matches (e.g. `New York` + `York`) keep the earlier one. - `--keyword-color <#RRGGBB>` — emphasis colour; defaults to `#FFD700`, the same gold `caption --karaoke` paints the active word with (now the shared `KARAOKE_HIGHLIGHT_COLOR` constant). Requires `--highlight-words`. - `--keyword-size ` — emphasis size as a multiplier on the **cue's base font size** (style-ref/preset/`--font-size` aware), default 1.2 when `--highlight-words` is present. Validated: must be > 0 and <= 10. Requires `--highlight-words`. - `--color-cycle <#hex1,#hex2,...>` — rotates the BASE text colour per cue in list order, wrapping around; an independent axis from keyword emphasis. Precedence: explicit `--color` still sets the base colour for all cues unless `--color-cycle` is given (then the cycle wins per cue). - **Precedence contract (documented in `--help`):** keyword emphasis ranges sit on top of base/karaoke styling and override the matched words' colour/size; with `--karaoke`, karaoke ranges are built first and keyword matches override those words while inheriting their bold — the v0.13 "explicit flags beat preset ranges" spirit. - **One offset scheme.** Emphasis ranges are computed in the exact code-unit → UTF-16LE-byte scheme `text-ranges`/`setTextRanges` and the karaoke writer already use — correct for multibyte text (umlauts, CJK); no second offset scheme was introduced. - The four flags are release-scoped like the v0.13 parser additions: on commands that don't declare them (everything except `caption` and `import-srt`) the tokens fall through to free-text positionals verbatim. - JSON output gains `keyword_matches` / `color_cycle` **only when the flags are used**; with no flags, behaviour and output are byte-identical to v0.13. ### Fixed - Every user-supplied text/JSON read now tolerates a leading UTF-8 BOM (`U+FEFF`), the byte prefix Windows PowerShell's `Set-Content` (and some editors) writes — previously the draft failed to load with a JSON parse error, `.capcutrc` was silently ignored, and the SRT/ASS/JSONL parsers misread the first token. Covered paths: `draft_content.json` / `draft_meta_info.json` / `root_meta_info.json` and every other draft store candidate, `--preset` files, `@file` arguments (e.g. `text-ranges --styles @ranges.json`, `--highlight-words @words.txt`), stdin (`import-srt -`, `import-ass -`, `batch`, `keyframe --batch` JSONL), subtitle files (`import-srt`, `import-ass`, `quickstart --srt`, `compile` captions ops), `compile` specs, template files, and `.capcutrc`. The CLI never writes a BOM: saving a BOM'd draft drops it (atomic write, `.bak` and history snapshots preserve the loaded content), the concurrent-change guard no longer reports a BOM-only difference as "changed on disk", and `fixture` bundles are emitted BOM-free. Output for BOM-free files is byte-identical to before. Prior art: capcut-cli-david `eb2f0e0` (thanks @Davidb-2107). ## [0.13.2] — 2026-07-08 ### Documentation - Wider README hero / `media/og-card.png` banner (1280x640, 2:1) replacing the 0.13.1 card. Docs-only release; no code changes. ## [0.13.1] — 2026-07-08 ### Documentation - Refreshed the README hero / `media/og-card.png` social card (EN + zh reference the same asset). Docs-only release; no code changes since 0.13.0. ## [0.13.0] — 2026-07-08 Six features in one release — the top of the opportunity backlog, bundled. Two build on prior art from the [capcut-cli-david](https://github.com/Davidb-2107/capcut-cli-david) fork (thanks @Davidb-2107); see #36. ### Added - `sync-timelines [--apply] [--force-write]` — reconcile a CapCut >= 8.7 draft whose `template-2.tmp` / `draft_info.json` timeline mirror has drifted from `draft_content.json`, so CLI edits are honored by the app instead of silently ignored. `draft_content.json` is always canonical and is treated read-only. Accepts only a project directory or its `draft_content.json` (any other filename exits 1). Plan-only by default: the per-target drift report includes each mirror's mtime plus `newer_mirrors` / `canonical_stale`, and warns when `draft_content.json` is older than a drifted mirror (you would overwrite newer edits). `--apply` refuses that direction unless you add `--force-write` (exit 1), then rewrites **only** the drifted mirrors — the canonical file is never re-sorted or backed up, and in-sync mirrors are left untouched; `reconciled` / `backups` list exactly the files written. No-ops with exit 0 when all targets already agree. An unreadable (binary/encrypted) mirror is reported unreconcilable (`ok:false`, `in_sync:false`, exit 2) and stays idempotent on re-run instead of pretending success. `--apply --dry-run` reports the plan only (`would_reconcile`, `reconciled: []`). `diagnose` now recommends the plan form (with a back-up caution) instead of deferring to issue #35. Closes #39. - `lint --fix` — auto-repair mechanically-fixable draft defects, now four codes: `cue-too-long` (trims over-long captions to the configured cap), `caption-overlap` (shortens overlapping pairs so each ends where the next begins), `line-too-long` (greedy word wrap that swaps spaces for newlines 1:1, keeping styled-range byte offsets valid; never splits words — instances the wrap cannot actually fix, i.e. space-less/CJK text and over-cap single words, are stamped `fixable:false`), and `caption-gap-too-small` (pulls the earlier caption's end back to restore the minimum gap; never moves starts, never creates a new overlap). The gap repair honors a hard floor — exported `MIN_CAPTION_DURATION_US = 100_000` (100ms) — so a shrink that would land a caption below the floor is skipped and stamped `fixable:false` for that instance instead of collapsing it. Writes atomically with a `.bak` snapshot; combine with `--dry-run` to preview. `missing-material` and `missing-file` stay report-only deliberately: the only mechanical repairs would delete user timeline content or act on host-dependent paths. Closes #40. - `lint` — new report-only rule `unknown-effect-slug` (**info** severity, exit 0): flags effect/filter/animation resource ids in the draft that are not in the bundled enum table, surfacing them before CapCut silently drops them (the silent-failure mode reported across ecosystem tools, e.g. GuanYixuan/pyCapCut#12). Info rather than warning because store-downloaded effects on app-authored drafts are legitimate; only CLI-written stale slugs are at risk. - `export-srt [--granularity line|word] [--format srt|vtt]` — word-level caption export. Captions created by `caption --karaoke` carry real per-word timing and export it exactly; plain captions interpolate word timing proportionally by word length (stated in `--help`). SRT + word emits one cue per word; VTT + word emits one cue per phrase with inline `` karaoke timestamps for burn-in pipelines. Defaults (`line`, `srt`) reproduce the previous output byte-identically. - `keyframe ... --easing ` — CapCut-native easing curves, also accepted per-line (`easing` key) in `keyframe --batch` JSONL and in the `compile` spec's keyframe op. The app does not store named curve types: the UI writes `FreeCurveInOut` bezier control handles on both keyframes of the eased segment, and the emitted encodings are locked against a UI-oracle capture (prior art: capcut-cli-david). The `ken-burns` skill default changed linear → ease-out to match what the CapCut UI itself produces. - `detect-scenes