Changelog
August 16, 2026 · View on GitHub
All notable changes to capcut-cli are documented here. The format follows Keep a Changelog; the project follows Semantic Versioning.
[Unreleased]
[0.19.1] — 2026-08-16
One bug, reported with the measurement that settled it, plus the repair for drafts already written. No command was removed and no existing flag changed meaning.
Fixed
-
Every multi-range text highlight was written past the end of its text (#85). A text material's
content.styles[].rangeholds UTF-16 code units — plain JS string indices — and this CLI wrote UTF-16LE bytes, so every stored offset was doubled. A single full-span range survived the mistake, because[0, 2n]clamps back to the end of the text when CapCut opens the draft, which is exactly why ordinaryadd-textlooked correct and the premise went unchallenged. A multi-range highlight did not: on the 17 code-unit stringอย่าไปซื้อ Claude,text-ranges --styles '[{"start":11,"end":17,…}]'wrote the emphasised span at[22, 34], entirely past the end, and CapCut painted nothing.text-ranges,caption --karaoke,--highlight-wordsand any preset carryingtext_rangeswere all affected.The premise came from this repo's own schema notes and had never been measured. @hillimited measured it: across 38 app-authored drafts on one machine, 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, the app parsing the doubled values, clamping them, and writing its own interpretation back.
Eight sites shared the assumption — the writer, preset capture, the
lint --fixre-wrap,export-srt's karaoke word matching,edit's text replacement, the text factory and the schema doc — so flipping the writer alone would have broken the readers. All of them now go through one module,text-offsets.ts, which is also the single place to branch should a JianYing draft ever turn out to store something else. Side effect worth having: in the code-unit domainrangeis the JS string index, so the BMP assumption that came with the byte conversion is gone and an emoji highlight is correct by construction. -
export-srt --granularity wordstill collapses old karaoke drafts. It matches highlight ranges against word offsets, so it now reads the doubled form as well and lines the words up either way.
Added
-
lintreports and repairs the doubled ranges earlier versions wrote (text-range-doubled, fixable). The doubled form is identifiable with certainty rather than by guess — every offset even, every range inside[0, 2n], and the last one ending at exactly2n, which is out of bounds as code units and precisely where the old writer's trailing block landed — solint --fixhalves them. App-authored drafts, drafts written from 0.19.1 on, and drafts already repaired all end atnand are never touched. -
extractCodeUnitStyleRangesand thetext-offsetshelpers are exported from the library entry for anyone readingstyles[].rangethemselves.
Documentation
docs/draft-schema/02-materials.mdsaid "UTF-16 BYTE offsets, not character indices". It now says code units, shows the measurement behind that, and points at the repair for drafts written by earlier versions.
[0.19.0] — 2026-08-14
Nine items from an opportunity-mining pass over this repo's own issues and the surrounding ecosystem (pyJianYingDraft, capcut-mate, auto-subs, ffsubsync, moviepy, video-subtitle-extractor and the vertical-video tools). Two clusters carried the signal: ffmpeg robustness, where this CLI had one unguarded spawn left, and caption quality, where the lint rules measured length but never readability. No command was removed and no existing flag changed meaning.
Fixed
- A long
rendercould fail on a draft that was perfectly fine. The ffmpeg spawn inrenderwas the only media spawn in the codebase with nomaxBuffer, so it kept Node's 1 MiB default. ffmpeg writes one stats line per frame to stderr — roughly 18k lines for a ten-minute 30fps render — and once that overran the cap,spawnSyncreported it throughr.errorwith codeENOBUFSrather than by throwing. The old code folded that into its generic failure branch and told the user to install ffmpeg: advice that was wrong twice over, since ffmpeg was installed and had already rendered most of the file. The cap is now 64 MiB, andENOBUFSandETIMEDOUTeach get their own message that says ffmpeg ran.probe.ts,scenes.tsandprobeFfmpegCapabilitiesalready did this;rendernow matches them. export-timelinecould emit a zero-length OTIO clip (#82).draftToOtiocarried its ownMath.round((us / 1e6) * rate)alongside the one intime.ts, and the two disagreed in exactly the cases that reach the file: a clip shorter than half a frame rounded to zero frames, which an NLE either drops or refuses, and a small negative gap rounded to-0, which serialises into the JSON as-0. It now callsframesFor, so there is a single frame grid. A sub-half-frame clip exports as one frame instead of none.
Added
lintnow measures reading speed, not just length (caption-too-fast).cue-too-longcaps how long a caption may stay up; nothing capped how fast it goes by, so 45 characters in 1.2s — 37.5 chars/s, roughly double what anyone can read — passed every rule. The ceiling is 20 chars/s by default (--max-cps,0disables), counted on visible characters so whitespace does not inflate the rate. Report-only: the repair is either more screen time, which moves every later caption, or fewer words, which is an authoring decision. The suggested command names the segment and the duration that would clear it.lint --fixcan finally re-wrap CJK captions. The re-wrapper only ever swapped a space for a newline, becausestyles[]ranges are UTF-16LE byte offsets and a length-neutral edit keeps them valid. Space-less scripts have no space to swap, so a Chinese or Japanese caption trippedline-too-longforever with no way to clear it — on a tool whose other namespace is JianYing. Breaks are now inserted between characters, with every later style boundary shifted by exactly 2 bytes per insertion, so per-range styling (karaoke highlights above all) stays on its characters. Line-start punctuation is respected: a break never strands。,、or a closing bracket at the head of a line. Over-long Latin words are still never split and stay reported.lintflags captions that land under the platform UI (caption-outside-safe-area). On a vertical canvas, a caption parked near either edge sits where TikTok, Reels and Shorts draw their own controls. The rule only runs when the canvas is taller than it is wide and is deliberately direction-agnostic — both bands are unsafe — so it needs no assumption about which way CapCut'stransform.ypoints.--safe-areatunes the fraction (default0.85).lintcatches segments whose speed contradicts itself (speed-timerange-mismatch,speed-material-mismatch).capcut speedmaintains two things at once: the segment'sspeed, and the source span it consumes. A draft that has been through another tool can carry aspeedthat disagrees with its own timeranges, or with the linked speed material the app actually reads. The app then plays the clip at one rate while every UI surface reports another, and anything aligned to it — captions above all — drifts with no visible cause. A 1% tolerance keeps ordinary sub-frame rounding quiet.render --progressstreams ffmpeg's own output to stderr instead of capturing it. A 600s render otherwise prints nothing at all, so a working job and a hung one look identical. It doubles as the escape hatch for a render whose output would outgrow the buffer, since inherited output is never buffered by this process — which is what theENOBUFSmessage now points at.- A failed
renderexplains itself.explainFfmpegFailuremaps ffmpeg's stderr onto one actionable line: a missing decoder is named (the AV1/HEVC case), a missing encoder points atcapcut doctor, a missing filter names the flag that needs it, a truncated container points atlint, and a missing input points atrelink. It returns nothing rather than inventing a hint for a diagnostic it does not recognise, and is pure and exported so the mapping is tested without ffmpeg. - Frame-grid helpers let library callers match CapCut's duration rounding
(#76).
quantizeToFrame(us, fps)returns the nearest on-grid microsecond duration, whileframesFor(us, fps)exposes the frame count. Both fall back to 30 fps for missing or invalid rates, floor positive durations at one frame, and keep negative durations signed; existing commands remain unchanged and callers opt in through the public library API.draftToOtiois now the first in-tree caller, which is what closed #82 above.
Documentation
- The keyframe schema documented a
property_typethe code never writes (#80).docs/draft-schema/03-keyframes-and-animations.mdlistedKFTypeUniformScale;PROPERTY_MAPwrites the bareUNIFORM_SCALE, the one property that breaks theKFTypepattern. A keyframe hand-built from the table was silently ignored by the app. The table and the example now match the code, the asymmetry is called out as CapCut's rather than a typo, and a test fails if the two drift apart again. - The mask-keyframe section records what the encoding search has already ruled out (#44).
docs/draft-schema/03-keyframes-and-animations.mdsaid no capture exists in the neighbouring ecosystem tools without naming what had been checked, so anyone picking the issue up starts that search from zero. It now names the negative result:pyJianYingDraft'sKeyframePropertyenum — the upstreamsrc/enums.jsonis extracted from — carries the same eleven properties this CLI exposes and nothing for mask geometry, so there is no encoding to borrow and the ground truth has to come from an app-authored capture. No behaviour change; the CLI still declines to write mask keyframes.
[0.18.0] — 2026-08-09
Upgrade if you have ever run capcut fixture. Bundles produced by earlier versions carry your device identifiers, and the command's documented flow is to attach one to a public issue.
Security
capcut fixtureno longer leaks device identifiers (#59, reported by @scornik). The redactor handled home paths and email addresses only, so thedevice_id,mac_addressandhard_disk_idCapCut stamps into everyplatformandlast_modified_platformblock were copied verbatim intodraft_info.json,template-2.tmpand every nested timeline document.SANITIZE_REPORT.jsoncompounded it by writingsource_dirandout_dirraw, reintroducing the username the timeline files had just had scrubbed. Anyone following the documented "runcapcut fixtureand attach the bundle" flow published a stable device ID and MAC address while the filename and the report both said the bundle was sanitised. The new redactor keys on the field name rather than the value shape —device_idandmac_addressare plain 32-hex, and a bare hex pattern would also blank legitimate material and segment UUIDs — handles the escaped-quote formtemplate-2.tmpuses for its string-JSON, and matches only non-empty values so an already-blankhard_disk_idis not reported as removed. Keys are kept with empty values, because the on-disk shape is the point of a fixture.app_idis left alone: it identifies the app, not the machine. Bundles generated before this release should be treated as unsanitised.
Added
initwarns when the bundled template predates the target store's CapCut (#67, reported by @scornik). The template declaresapp_version 6.5.0and carries none of the schema markers a modern draft has; dropped into a materially newer store, CapCut lists the draft at 00:00 and then refuses to open it, reporting "Current project is from an unusual path and cannot be used currently" — which is wrong about the cause and sends people hunting the path.initnow reads the newestplatform.app_versionacross the projects already in the drafts folder, before copying the template, and warns when the store is a major version ahead, naming the real reason and pointing at--template. Warn, never refuse: the evidence is a single 8.5.0 report and--templateis a working escape hatch.
Changed
- The nested-
Timelines/guidance is gated on detected app version (#68, reported by @scornik). It fired on layout alone while its text is explicitly about CapCut 7.x and cites #50. On 8.5.0 the reported behaviour is the opposite: after a CLI-written draft was opened and closed, the nested document and the project-root file were byte-identical, so the app regenerated the mirror from the tool's content. 8.5.0+ now gets that wording; 7.x and unknown versions keep the cautious text, since the 8.5.0 reporter did not test 7.x. Three call sites carried a copy of this guidance — the write guard, theversioncommand's support notes, andsync-timelines, which rewrites mirrors outsidesaveDraft. - Builds against TypeScript 7, which no longer implicitly includes packages under
node_modules/@types;compilerOptions.typesnow namesnodeexplicitly. No source change was needed and no runtime behaviour differs.
Documentation
- Frame quantisation is documented (#69, reported by @scornik). CapCut snaps every duration to the project's frame grid the first time it opens a draft, so what you read back is not what you wrote.
docs/draft-schema/01-tracks-and-segments.mdnow records the measured 30fps behaviour, that drift stays sub-frame and the timeline contiguous, that sub-frame distinctions are lost (866ms and 867ms both land on 26 frames) while sub-frame segments survive, and the pre-quantisation formula for callers who need what they write to equal what the app stores.
[0.17.2] — 2026-08-08
Documentation only. No source changed, so the shipped dist/ is identical to 0.17.1 and there is nothing to gain by upgrading from it — this release exists so the npm page carries the security notice, because npm renders the README from the published tarball rather than from the repository.
Changed
- The README leads with the 0.17.1 security notice, in both languages — anyone arriving from npm now sees, above everything else, that versions up to and including 0.17.0 build the
export --batchautomation script by pasting the draft folder's name into it, what else 0.17.1 closed alongside it (thedrawtextcolour injection, thecompilespec name escaping the draft store, predictable temp files, credential values inserve's echoed args), that both injection paths need a draft folder or file the user did not author and are therefore local rather than remote, and the one command that fixes it. Versions below 0.17.1 are deprecated on npm with the same pointer, so an install of an affected version warns on the way past. The release-highlight blockquotes are trimmed to the two most recent versions as usual; a documentation release adds no highlight line of its own.
[0.17.1] — 2026-08-07
A maintenance release: nothing was added to the command surface, which is identical to 0.17.0 flag for flag. What changed is what the same commands cost and what they let a hostile draft do.
Security is the part to read. export --batch built the AppleScript and PowerShell it runs by splicing the draft folder's name into a quoted string literal, so a folder named with the right characters could break out and run arbitrary commands; both now pass the path as an argument or a provably complete escape. Alongside those: render --burn-captions validated a draft's caption colour before it reaches the ffmpeg filter, compile refuses a spec name that escapes the draft store, draft writes stage through an unpredictable exclusively-created temp file, serve masks credential values in the args it echoes, and Wikimedia downloads take only the last component of a File: title.
Performance was measured, not estimated — base and branch interleaved run-for-run. On a 4000-caption / 2.9 MB project lint --fix drops 3193 ms to 596 ms and lint 1465 ms to 367 ms, with texts, segments, export-srt, shift, set-text and restore between 41% and 53% faster; the CLI now compiles 7 modules at startup instead of 32, so every invocation begins 22-27% sooner. The package is 7% smaller unpacked and ships 28 fewer files.
Behaviour changes, all narrow: a caption colour that is neither a hex form nor a known ffmpeg colour name now falls back to the default instead of reaching the filter; a compile spec whose name leaves the draft store is rejected; serve's echoed args carry a mask where a credential value used to sit; and history snapshots inherit the .bak's 0600 rather than the umask's 0644. Drafts written are byte-identical to 0.17.0 — held to a 72-command oracle covering every add/edit/read command, both preset paths and 22 error paths, plus per-command file comparison on a synthetic project and the committed capcut-8.7-windows fixture.
Changed
-
Cold start: the CLI compiles 7 modules on startup instead of 32, so every invocation begins 22-27% sooner —
index.tsstatically imported every command module, so the whole module graph was compiled and evaluated beforemain()ran its first line.capcut --versiontherefore paid for the whisper caption path, the OTIO importer/exporter, the renderer, the compile-spec engine, the scene detector, and the queue server, on the way to printing a version string. Those 25 modules are nowawait import()ed at their dispatch sites. What stays static is only what every invocation genuinely reaches:command-specs(flag parsing,--help,describe, the shell completions),drafttogether with the core it pulls in itself (app-versions,bom,store,version), andtimefor the read commands' formatting. Nothing was reachable at module scope to begin with —COMMANDS,HELP,SUMMARIES, andENUM_FLAG_MAPare literals, so no imported value is read before a command has been chosen, which is what makes the whole move mechanical rather than a redesign.describeand the completions are unaffected by construction: both are generated fromCOMMANDSplusSUMMARIESthroughcommand-specs, which imports no command module and so never knew about the modules that became lazy; the full 80-command surface is emitted without loading any of them.lib.ts, the published library entry, resolves its exports directly rather than throughindex.ts, so every value and type a library consumer imports is untouched. The 35 command entry points that had to becomeasyncreturn their promise to amain()that was alreadyasyncand already awaited four commands, and everyprocess.exitstill runs after the awaited call has returned, so exit codes and flush ordering are unchanged. Measured over 15 interleaved repetitions per command, baseline and branch alternating run-for-run (medians):command before after texts <project>126 ms 91 ms −27% completions bash113 ms 83 ms −26% tracks <project>130 ms 95 ms −26% --version119 ms 89 ms −25% describe109 ms 82 ms −24% --help108 ms 84 ms −22% info <project>114 ms 88 ms −22% enums --masks118 ms 108 ms −8% enumsgains least because its own cost is dominated by parsing the 727 KB enum table rather than by the module graph. The saving is per-process, so it compounds wherever the CLI is invoked in a loop: the test suite spawns it 549 times andnpm run test:fastdrops from 47.8 s to 41.4 s over four interleaved pairs. Behaviour was held to an output-level check rather than assumed: 88 invocations spanning the spec surface, all 14enumsflags in both namespaces, ten read commands, and 31 error paths were captured on both builds and compared on stdout, stderr, and exit code — zero differences; and 25 mutating commands were each run against an identical copy of one draft on both builds, with the writtendraft_content.jsoncompared byte-for-byte after normalizing only the freshly minted ids (which differ between two runs of the same build too). A 25-step lifecycle —init, fouradd-*,text-style,save-template,apply-template,make-preset,prune,relink,quickstart,diff,concat,harvest-enums— was then run end to end in a fresh draft store per build, and all 13 files each produced (drafts, sidecar metadata, the root store index, the template and the preset) plus every command's output matched, the sole difference being a wall-clockmtimeinprojects.lib.tswas exercised separately on both builds: all 19 exports resolve, the load/lint/save round trip agrees, andrunCommand— which spawns the CLI, so it also covers the lazy dispatch from the library side — returns identical results including its unknown-command error. The one class of mistake the compiler cannot catch here is a droppedawaiton a call whose result is discarded — a mutating command would then race its ownprocess.exit— so the 27 such call sites were found by walking the file for unawaited calls to a now-asyncfunction rather than by reading, and the walk is clean. A failure raised inside one of these modules still surfaces exactly as before — it reaches the samemain().catch, so the same{"error": ...}line and exit 1 — verified by making a lazily-loaded module throw on both builds. The one difference anywhere in this change is a module that cannot be parsed: that used to fault in the loader beforemain()existed and printed Node's own stack trace, and now rejects the dynamic import and prints the standard JSON error instead. Exit code 1 either way, and it takes a corrupt install (a syntax error in a shipped file) to reach at all.test/cold-start.test.mjspins the result going forward: the static-import allow-list fails the build if a module quietly rejoins the startup graph, every commanddescribelists is checked to still have a dispatch site, and the commands that exit early are checked to still print — the shape a droppedawaittakes at runtime. Each of those assertions was mutation-tested against a deliberately broken build. -
Hot-path pass: large projects are 2-5x faster, and every byte written is unchanged — seven independent changes (each its own entry below) removed work that no command's output depended on: two quadratic scans in
lint, an eagerffprobespawn, a whole-timeline hash taken on every discovery, a second full store discovery on every write, a duplicated pre-write of the backup content, and a deep clone on every load. Measured end to end on a 4000-caption / 2.9 MB project with three readable siblings, baseline and branch interleaved run-for-run over nine repetitions (medians):command before after lint --fix3193 ms 596 ms −81% lint1465 ms 367 ms −75% texts640 ms 300 ms −53% segments648 ms 325 ms −50% export-srt619 ms 341 ms −45% shift791 ms 444 ms −44% set-text763 ms 434 ms −43% restore1197 ms 708 ms −41% info434 ms 315 ms −27% diagnoseandsync-timelinesare the two commands that read every timeline hash, so deferring that hash cannot help them; they do the same work as before and are unchanged within this machine's run-to-run spread. Behaviour was held to a byte-level check throughout: for each of eleven commands, exit code, stdout, stderr, all three timeline files, all three.bakfiles, and every history snapshot (by name and by content) were fingerprinted on both builds and compared — zero differences. -
loadDrafthands back the parsed timeline instead of a deep copy of it — every load ranstructuredCloneover the whole draft so the store cached alongside it kept a pristine copy. Nothing consulted that copy. The storeloadDraftcaches never leavesdraft.ts—loadContextsis module-private andsaveDraftis its only reader — and everythingsaveDrafttakes from it is either fixed at discovery time (version,layout,projectDir) or a string snapshot of the file on disk (raw,path,envelopePath); none of it is re-derived fromcanonical.draft. Every call site was re-checked on this branch, including the store consumers v0.17 added:concat,import-timeline,restore's mirror re-sync,compile, andquickstartall take the draft and never reach for the store, anddiagnose,sync-timelines,fixture, and the app-version tripwire run their own discovery rather than this one. The clone was therefore pure cost — tens of milliseconds on a multi-megabyte project, paid by read commands too.test/load-draft-aliasing.test.mjspins the invariant that makes the removal safe: an edit made after the load never reaches the pre-write bytes the.bakis built from, repeated saves of the same object keep the write set correct, and two loads of one project still get independent drafts. The comment onloadDraftstates the rule for anything added tosaveDraftlater — re-read the file, do not reach forstore.canonical.draft. Measured on a 4000-caption / 2.9 MB draft:info364 ms → 285 ms,lint435 ms → 354 ms,set-text503 ms → 422 ms. -
The pre-write content is written once and published under both recovery names — every write saves the bytes it is about to replace twice: once to the single
.bakthatrestorereads, and again, byte-identically, to the rolling history snapshot thatrestore --step 1reads. On a multi-megabyte project with three readable siblings that is a second full multi-megabyte write per target, of bytes already on disk. The snapshot is now hard-linked to the.bakinstead, so both recovery names refer to one file. Nothing in the write path ever edits a file in place —.bakis replaced by temp-and-rename, snapshots are only ever created or unlinked — so the two names cannot drift: the next write gives.baka fresh file and leaves the older snapshot holding exactly what it held. Filesystems that refuse hard links (FAT/exFAT sticks, some network shares) fall back to the full write, so the snapshot always exists whatever the draft folder sits on. Two side effects, both in the safe direction: the history directory now costs one copy per write instead of two, and a snapshot inherits the.bak's0600mode rather than the process umask's0644— history snapshots are draft content, and the.bakbeside them was already0600. Measured on a 4000-caption / 2.9 MB three-file project:set-text528 ms → 503 ms,lint --fix669 ms → 617 ms.test/backup-snapshot-pair.test.mjspins the shared-bytes contract end to end: the.bakand step 1 agree, each snapshot stays frozen at its own write across a three-write run, replacing the.baknever reaches back into the snapshot that shared its bytes, andrestoreandrestore --step 1land on identical bytes. -
A write rolls its store forward instead of re-discovering the project —
saveDraftkeeps a per-path store so a library caller can save the same loaded draft more than once without tripping its own changed-on-disk guard, and it refreshed that store by callingdiscoverDraftStorea second time: re-reading, re-parsing, and re-hashing every sibling from disk to establish something the write already knew — each target now holds exactly the bytes just handed to it.commitDraftTargetsnow returns the content it committed per target path, and the store is rebuilt from that. Everything a re-discovery would recompute still is:sizeandmtimecome from astatof the file just renamed into place;version(and with itmodernStorageandlayout) is re-derived from the timelines the targets now hold, which is how a mirror that used to raise the store version stops raising it once overwritten;divergedcollapses to false on its own because every written target now exposes the same timeline; and a candidate the write did not touch — an unreadabletemplate-2.tmp, say — is carried over untouched, error text included. Candidate order is preserved as well: the re-discovery was keyed on the canonical file rather than the project directory (so an explicitly addressedA.jsonis not lost), which puts the canonical first, and that is the order targets are written in and named in on a changed-on-disk report.test/store-after-write.test.mjsasserts field-by-field equality between the rolled-forward store and a real re-discovery across a single-file project, an enveloped mirror, a version drop, a divergence collapse, and an unreadable sibling. Measured on a 4000-caption project:set-text593 ms → 528 ms,lint --fix715 ms → 669 ms. Written drafts stay byte-identical. -
Store discovery no longer hashes every sibling timeline up front —
parseCandidatestamped each readable candidate withtimelineHash, a sha256 over a fullJSON.stringifyof that file's whole timeline, anddiscoverDraftStorethen folded those into thedivergedflag. Discovery runs on every command, on every readable sibling — soinfo,lint,segment, and every mutating write paid for a stringify-plus-hash of the entire draft per file, several megabytes of work each, for a value only three surfaces ever read:diverged,sync-timelines' plan, anddiagnose's candidate table.timelineHashis now computed on first access and memoized, anddivergedbecame a getter so it does not force the hashes it compares. The value is unchanged — still sha256 overJSON.stringifyof the timeline the candidate exposes, which is why two files that hold the same timeline behind different envelopes and different indentation still agree on it while their rawsha256differs — and a candidate is a discovery snapshot that nothing mutates between discovery and use.diagnoseandsync-timelinesread the hash and therefore still pay for it, unchanged. Measured on a 4000-caption / 2.9 MB three-file project:info419 ms → 331 ms,lint513 ms → 426 ms,set-text783 ms → 593 ms,lint --fix943 ms → 715 ms. Written drafts stay byte-identical. -
findMaterialresolves through a per-array id index instead of a fresh scan — the helper wasarr.find((m) => m.id === id), which is fine for a one-shot lookup and quadratic for the callers that run it per segment:lintresolvesmaterials.textsonce per caption both when checking line length and when re-wrapping under--fix. It now keeps an id → materialMapper materials array in aWeakMap, so the first lookup on an array builds the index and every later id on that array is a hash lookup. The array itself is the cache key and its length is the validity check, which covers how membership actually changes here: adding a material pushes onto the array (length moves), dropping one replaces the array with a filtered copy (a new key, so a new index), andmigratemoves entries between arrays (both lengths move). A material mutated in place —--fixrewriting a text material'scontent— needs no invalidation at all, because the index holds the array's own objects. The signature, the return value, and theundefinedfor a miss are unchanged; a repeated id still resolves to the first entry, and a cache miss still answers from the originalarr.find, so a malformed draft fails with the identicalTypeError.test/find-material-index.test.mjspins each of those cases, plus an end-to-endbatchrun that adds a caption and then edits it twice in the same process. Measured on a 4000-caption draft:lint634 ms → 513 ms,lint --fix1296 ms → 943 ms. Written drafts stay byte-identical. -
lintonly shells out toffprobeonce it has a file to probe — the local-path pass resolvedffprobeAvailable(probeCmd)before the material loop, and that call spawnsffprobe -version. Everylinton a project with local-path checking on therefore paid for a process spawn, including the large class of drafts that have nothing to probe at all: caption-only projects, projects whose media is all remote URLs, and projects whose media files are missing (those are reported asmissing-fileand skipped before any probe). The availability check is now resolved on the first material that reaches the probe step.probe.tsalready memoizes the answer per command string, so the spawn still happens at most once per process and cannot change mid-run;probeMedia: falseshort-circuits without spawning exactly as it did. Measured on a 4000-caption draft with no local media:lint761 ms → 634 ms,lint --fix1455 ms → 1296 ms. Locked bytest/lint-hot-path.test.mjs, which pointsffprobeCmdat a shim that logs its own invocations and asserts the log stays absent for an unprobeable draft and appears for a draft whose media is on disk. -
lintresolves material ids from one precomputed set instead of rescanning every materials array — same report, no longer quadratic —lintDraftasks "does this material exist anywhere?" once per segment (formaterial_id) and once per entry inextra_material_refs, andfixDraft's dangling-ref sweep asks it again for every surviving ref. Each of those questions used to walk every array undermaterials.*until it found a hit or ran out, so a draft with S references and M materials did O(S × M) work: on a 4000-caption draft that is roughly 32 million entry visits per lint, and--fixpays it three times (a lint before, the sweep, a lint after). The membership rule is unchanged — anymaterials.*array, string ids only, so a material whose id is not a string still cannot answer a string lookup — it is now collected in a single pass and probed as aSet. The set is built lazily on the first lookup that needs it, not up front, because a draft with nomaterialsobject at all must still fail at exactly the reference the scan failed at, andlintDraft's first loop skips those segments before ever asking. Nothing between the build and the last lookup adds or drops a material:fixDraft's passes move timeranges, rewrap caption text, and rewrite media paths, but never touch materials membership. Measured on a 4000-caption / 8000-material draft:lint1537 ms → 761 ms,lint --fix3091 ms → 1455 ms. Written drafts, stdout, stderr, and exit codes are byte-for-byte identical across every case in the harness. -
Dead code removed — no user-visible behaviour change — a re-audit after the v0.17.0 feature bundle confirmed a set of symbols that nothing calls, on any surface:
probe.ts's legacy dimension pipeline (parseProbeStreams,displayDimensions,probeVideoDimensions, theProbedDimensionsinterface), superseded byparseMediaProbe/probeMediaand referenced only by its own tests;decorators.ts'stransitionSlugs()(theenums --transitionslisting readslistEnum, never this wrapper);time.ts'susToSeconds(); andmigrate.ts'sSchemaVersiontype. None is re-exported fromlib.ts, none is reachable through thedescribe/completionsdynamic surfaces, and the package exposes no deep imports, so the library's public API is unchanged.normalizeRotationstays — both the surviving parser and the rotation math use it. Coverage that the deleted tests provided for surviving behaviour was re-homed ontoparseMediaProberather than dropped: the Display-Matrixside_data_listrotation path, the no-usable-stream null, and the malformed-JSON null are now asserted against the function that still implements them, and the add-video ffprobe test reads its expected dimensions throughprobeMedia. Written drafts stay byte-identical. -
--fontis no longer stored after parsing — the flag was parsed into aflags.fontfield that no command ever read. The field and its assignment are gone; the flag itself still consumes its value token exactly as before, because it predates the release-scoped-flag mechanism and is therefore parsed globally on every command, where swallowing the pair is observable in the positional stream (add-text ... hello --font Arial worldyields the texthello world, unchanged). That behaviour is now pinned by a regression test intest/flag-scoping.test.mjs— it had none. -
bin/capcutremoved — a two-line launcher that nothing referenced:package.json'sbinentries both point atdist/index.js, thefileswhitelist never includedbin/, so it was absent from the published package, and neither the Dockerfile noraction.ymlinvoked it. -
Ten duplicated blocks folded into shared helpers — no user-visible behaviour change — copy-paste that had accumulated across the add-* and text commands, consolidated only where the copies were provably identical. Empty tracks are now built by one
makeTrack(type, name, isDefaultName)indraft.tsinstead of nine hand-written object literals (eight infactory.ts, one insfx.ts);addEffect/addFiltershare their id-minting + find-or-create-track prologue (effectTrackSlot) and their 27-line material-push + segment + return tail (pushEffectSegment), with the material object the only parameter that differs;setMixModestops re-implementingfindCropMaterial's video-material lookup and calls it, the differing error wording carried by alabelargument;setTextStyle,setTextRanges,setBubble,extractTextPreset, andapplyTextPresetshare onerequireTextMaterial(draft, segmentId, label?)whose optional label preserves each caller's exact track-type message (bubble-text/make-preset/--preset); text and image animations shareensureAnimContainer;preset.tsimportshexToRgb01fromdecorators.tsrather than keeping a byte-identical copy; and inindex.tsthe text-styling flag block (two sites), theimport.meta.url-relative template-dir resolution (five sites —init,quickstart,compile,compile --data,import-timeline --out), and the Wikimedia licence payload (two sites, where the audio key set is a strict prefix of the video one) each became one function. Key order was treated as load-bearing throughout, because it is the order these objects serialize into the draft and into command JSON: every helper builds its object in the order the literals did, and the Wikimedia builder appendswidth/height/mimeafter the six shared keys rather than interleaving them. Verified by a 72-command oracle — every add/edit/read command, both preset paths, all five template-resolution sites, and 22 error paths — captured before and after and compared byte-for-byte after normalizing only UUIDs, temp paths, and wall-clock stamps: the six drafts it writes, all stdout, all stderr, and all exit codes are identical. The Wikimedia payloads were additionally diffed against live Commons fetches on bothadd-videoandadd-audio. Three guard messages that no test covered —mix-mode/crop's "only applies to video/photo materials" and "Text material not found for segment" — are now pinned verbatim intest/material-guard-messages.test.mjs, since the refactor moved the code that emits them. Net 139 lines removed fromsrc/. -
dist/enums.jsonships minified — same table, 65 KB smaller — the build step re-serializessrc/enums.jsonthroughJSON.parse/JSON.stringifyinstead of copying it verbatim, dropping the two-space indentation that only ever existed to keep the generated source diff-readable. The checked-insrc/enums.jsonis untouched, soextract-enumsand its git diffs are unaffected. Nothing reads the file as text —enums.ts'sload()is the single consumer and it parses immediately — and the round trip was verified lossless for values and key order (the table carries no integer-like keys, which are the one thing a JS object would reorder). All 56enumsinvocations (14 flags × both namespaces × JSON and-H) were captured before and after and are byte-for-byte identical. 793,645 → 726,782 bytes. -
@types/nodepinned to the oldest runtime the package supports — the dev dependency sat on^25.xwhileenginesdeclaresnode >=18and CI runs 18/20/22, sotscwas type-checking against a standard library far newer than the floor: an API added after Node 18 compiled cleanly and would then throw at runtime on a supported version, with CI catching it only if a test happened to exercise that line. Pinned to^18.19.130so the compiler enforces the floor the package advertises. No source change was needed —tsc --noEmitexits 0 as-is, confirming nothing had already drifted past Node 18 — and the emitteddistis byte-for-byte identical under both type lines, so the published output is unchanged. The pin is load-bearing rather than cosmetic: a probe importing Node 22'sfs.globcompiles under the old types and is now rejected. -
Unreachable type declarations dropped from the tarball —
tscemits a.d.tsfor all 36 modules, butexportsexposes exactly one entry (./dist/lib.d.ts), so a consumer can only resolve the declarations reachable from it; the other 28 were unresolvable by any import and shipped as dead weight.filesnow dropsdist/*.d.tsand re-adds the reachable eight —lib,command-specs,doctor,draft,lint,runner,store,version— wherestoreis reachable only transitively, which is why the set is computed rather than hand-listed. Every compiled.jsstill ships, so the CLI and the runtime library are untouched. Verified end-to-end: the packed tarball was installed into a clean project that imports all 19 exported values and all 24 exported types, andtsc --noEmitwithskipLibCheck: falseexits 0.test/packaging.test.mjsre-runs the reachability walk on every build and fails if the shipped set and the reachable set diverge, so adding a module to the public entry cannot silently strip its types.
Security
export --batchno longer interpolates the draft folder name into the AppleScript it runs (macOS) — the draft directory was pasted into a double-quoted AppleScript literal (open POSIX file "<dir>/draft_content.json") and the whole string handed toosascript -e, so a folder whose name contained a double quote closed the literal early and the rest of the name was compiled as AppleScript —do shell scriptincluded, which is arbitrary command execution as the user, triggered by nothing more than pointingexport --batchat a directory someone else created. The path is no longer part of the script: the script declareson run argvand reads the path back out ofitem 1 of argv, and the path travels as its ownosascriptargument, where AppleScript never parses it. Quoting a folder name can no longer reach the compiler, so this is closed by construction rather than by filtering the characters that happen to be dangerous today. The generated argument list is now built by the exported, I/O-freemacosExportArgs(draftDir, app)— thewindowsExportScriptpattern — so the property is asserted off-macOS intest/export-macos.test.mjs. Every legitimate folder name opens exactly the same draft file as before.export --batchescapes the draft folder name for the PowerShell literal it runs (Windows) — the same shape on the Windows path: the draft directory went raw into the single-quotedStart-Process -FilePath '<dir>\draft_content.json'string thatwindowsExportScriptbuilds forpowershell -NoProfile -Command, so a folder name containing an apostrophe closed the literal and the remainder ran as PowerShell statements. Every single-quote character is now doubled, which is the complete escape for that context — and deliberately all four characters PowerShell's tokenizer accepts as a single quote ('plus the Unicode curly variantsU+2018/U+2019/U+201A/U+201B), since any one of them closes a literal opened with any other. Because PowerShell decodes a doubled quote back to the character itself, this also fixes a latent bug for legitimate paths: a folder likeC:\Users\Rene's Draftspreviously produced a broken script and now opens correctly. Round-tripping the literal back through PowerShell's own rule is pinned intest/export-windows.test.mjs; a path with no quote in it generates a byte-identical script to before, verified across ASCII, spaced, UNC, umlaut, and CJK paths.- A
compilespec'snamecan no longer name a path out of the draft store — without--out, the output directory isresolve(<draft store>, spec.name), and withcompile --dataevery row derives its own name from row data through the same resolve. Neither the spec nor the rows are the CLI's own input — a spec is exactly the artifact an agent or an upstream pipeline hands over — yet nothing checked the name's shape, so"name": "../../elsewhere"built a full draft outside the store, and a JSONL row could do the same for any name it templated.validateSpecnow refuses a name that is empty/whitespace,.or.., carries either OS's path separator, or opens with a drive prefix — the same refusalrenamealready applies to a folder name, plus theX:prefix (C:namecarries no separator yet still resolves against that drive's own working directory on Windows). Containment then follows from the shape rather than from a filter: a single component with no separator and no drive prefix can only ever resolve to a child of the directory it is resolved against. The check runs on the single-draft path and on every per-row derived name, and because it lives in the spec validator it fires undercompile --checktoo — beforeinitDraftseeds any directory, so a rejected spec never leaves a half-built draft behind.--outis unaffected: it names the directory explicitly and always did. Every name that was legal before still builds to exactly the same path, byte-identically. - Draft writes are staged in an unpredictable temp file that is created exclusively — every mutating command writes through temp+fsync+rename, and the temp path was
<target>.capcut-cli-<pid>-<now>.tmpopened with a plain"w". That is both guessable — pid and clock are the only inputs — and symlink-following, so anyone able to create files in the draft folder could pre-place a symlink at the path the next save would pick and turn every draft save into a write to a file of their choosing, with the victim's own permissions. Not a theoretical folder either: a draft store is routinely a synced or shared directory, and the CLI is built to run unattended. The name now carries a crypto random component and the open is exclusive ("wx", i.e.O_EXCL), which refuses a path that already exists — a symlink included — rather than following it; a collision simply draws another name, up to eight attempts. Both stagers, the multi-file draft save and thewriteAtomicused for metadata sidecars and.bakfiles, now share the one helper. The bytes written and the rename that commits them are unchanged, and the added cost is one 8-byterandomBytescall (~5 µs) against a write path that is fsync-bound at ~1.3 ms — measured at +1.3% median over nine paired runs, inside the run-to-run noise. serveno longer echoes credential values into its result lines — every job's fully resolved argv was echoed back in the JSON result line, and those lines areserve's stdout: for the n8n/Make/Coze/cron callers the command exists for, that is a log file, a workflow execution record, or a CI artifact. A job passing--api-keytherefore wrote the Anthropic key verbatim into all of them. The echoed args now carry***in place of the value of a credential-bearing flag —--api-keyis the CLI's only one today — in both the--api-key VALUEform the CLI parses and the--api-key=VALUEform it does not (a caller who typed it still put a key on the wire). The spawned child receives the real argv unchanged, so no job behaves differently, and every non-credential token is still echoed exactly as before.- Wikimedia downloads take the last component of the
File:title, never a path —capcut add-video/add-audioaccept a Wikimedia URL and stage the asset into the draft'sassets/folder under a filename derived from the URL's ownFile:title. Percent-encoded separators survivedecodeURIComponent(File:..%2F..%2Fx.jpgdecodes to../../x.jpg), and the result was joined onto the destination directory unchecked, so a crafted Commons URL could place a downloaded file outside the draft. Latent rather than live — the title also has to resolve through the Commons imageinfo API for the download to happen at all — and fixed as defence in depth: the title now contributes its basename only, and the resolved path is asserted to sit inside the destination directory before anything is written, which additionally covers a caller-supplieddestFilename. Ordinary titles save to exactly the path they always did. render --burn-captionsvalidates a draft's caption colour before writing it into the ffmpeg filter — a text material'stext_colorwas interpolated straight intodrawtext'sfontcolor=option.drawtextoptions are colon-separated, so a draft could end the colour with a:and append options of its own;textfile=reads an arbitrary local file and burns its contents into the rendered video, turning a shared or downloaded draft into a local-file-disclosure channel. The colour is now accepted only in ffmpeg's two colour spellings —0xRRGGBB[AA]hex (what a#rrggbbvalue becomes) or a bare colour name, each with an optional@alpha— and anything else falls back to the existingwhitedefault, so a caption still burns in rather than the render failing. Colour names are checked on shape rather than against ffmpeg's ~140-entry list: the charset is what makes the value safe (it admits no:and no backslash, so a matching value provably cannot leave the option it is written into), and passing names through keeps an unknown one failing in ffmpeg the way it does today instead of being silently repaired. Verified byte-identical filter graphs across every hex form, named colour,@alphaform, and the non-string fallback; the only values whose output changes are ones ffmpeg already rejected outright ("",#,#zzz, names carrying digits), which never produced a render.tsxrefreshed so the dev toolchain pulls a patchedesbuild—npm auditreported one low-severity advisory, GHSA-g7r4-m6w7-qqqr (esbuild's development server allows arbitrary file read on Windows), reaching the tree only astsx's transitiveesbuild@0.27.7.npm audit fixcould not clear it:tsx4.21.x pinsesbuild ~0.27.0, and the advisory covers that entire line.tsx4.22.0 moved toesbuild ~0.28.0, so refreshing within the existing major resolvestsx@4.23.10/esbuild@0.28.1andnpm auditnow reports 0 vulnerabilities. The declared floor moves^4.0.0→^4.23.10so a lockfile-less install cannot resolve back onto the affected line; this stays insidetsx4.x, so no tooling major changes. Nothing shipped is affected either way —tsxis a devDependency used only bynpm run dev, and the advisory concerns a dev server this project never starts.
[0.17.0] — 2026-08-07
Eight features in one release — the next slice of the opportunity backlog, bundled — plus the honest slice of the ninth: keyframeable mask geometry (#44) stays evidence-gated because no credible on-disk encoding ground truth exists anywhere public, so what ships is the fixture evidence harvest that unblocks it, not a guessed encoding the app would silently no-op. The headline is pipeline round-trips: import-timeline brings OpenTimelineIO documents back into the draft format (the inverse of export-timeline), and compile --data turns one spec plus N JSONL rows into N built-and-registered drafts. Behaviour changes are called out inline; the ones to know: lint gains the warning-severity main-track-gap check — a draft whose only finding is a main-track gap linted exit 0 before and exits 1 now (lint --fix closes gaps up where safe) — while the new media-outside-draft check is info-severity and changes no exit codes; and mutating writes now WARN on stderr in two new cases — a CapCut 7.x nested Timelines/ layout whose root-mirror edit the app may discard, and an app auto-upgrade since the last CLI write — warn only, never a refusal. With none of the new commands or flags in play, written drafts stay byte-identical to v0.16.1.
Added
- CapCut 7.x nested
Timelines/layout — detected, named, and warned about (previously silent) — a real CapCut 7.7 project is reported to keep its live document atTimelines/<main_timeline_id>/draft_info.json(pointer:Timelines/project.json), with the project-rootdraft_info.jsona mirror the app regenerates on open (#50). Store discovery only ever considered root candidates, so a CLI edit reported success, rewrote the root mirror, and the app discarded the edit on the next open — without one word from the CLI. The structure is now detected:discoverDraftStorereports layouttimelines-nested(pre-8.7 stores only; CapCut >= 8.7 keeps itstemplate-2.tmpselection and layout value untouched) plus the nested document paths,diagnosenames the layout with a next_action carrying the discard risk and the fixture CTA (JSON gainsnested_timelines),versioncarries the same note, and every mutating write on such a project — thesaveDraftpath plussync-timelines --apply— prints a stderr WARNING that the root-mirror edit may be discarded by the app. Warn only, no canonical flip: reads and writes still target the project-root files byte-identically to v0.16.1, covered by test — promoting the nested file to canonical (#51) was rejected because no field artifact backs the claim yet. That artifact is exactly whatcapcut fixturenow collects:Timelines/project.jsonand the nested timeline documents ride along in the redacted bundle (relative paths preserved), so a reporter can finally attach the evidence #50 is blocked on. import-timeline <file.otio> (--out <new-project> | --into <project>)— OpenTimelineIO in, the inverse ofexport-timeline— the ecosystem has an export story but no road back INTO the draft format from standard NLE interchange: OTIO/XML-adjacent import is a standing ask across the neighbouring tools (OpenCut-app/OpenCut#719, Ersiter/Jianying-CapCut2XML#4, ArcReel/ArcReel#1657). Reads the exact schema setexport-timelineemits —Timeline.1/Stack.1/Track.1/Clip.1/Gap.1,ExternalReference/MissingReference,LinearTimeWarp— and builds the cut through the same factory functions theadd-*commands use. Clips become video/audio segments with their source ranges; gaps become timeline offsets; aLinearTimeWarpinverts the exporter's documented relationship exactly (timeline duration = source range / time_scalar, so speed = time_scalar and the segment's target duration is recomputed from its source duration);metadata.capcut.volumerides back onto the segment; times convert from frames at each RationalTime's own rate.ExternalReferencemedia that exists on disk is staged into the draft'sassets/video//assets/audio/through the same collision-safe copyadd-video/add-audiouse; a reference whose file is absent keeps itstarget_urlverbatim (pointrelinkat the media folder), and aMissingReferencebecomes an empty-path placeholder material — both are placeholder clips in thereplace-mediasense, listed in the JSON result'splaceholdersand named on stderr with the swap command.--out <new-project>builds a fresh draft (folder name from the path, display name from the timeline name, draft fps from the document's rate, so a re-export converts at the same frame rate);--into <project>appends the timeline onto an existing draft as NEW tracks — imported clips never land on an existing track, so existing segments can never be overlapped or re-timed, and a track-name collision de-collides with a numeric suffix. Unsupported OTIO features — non-Track stack children, unknown track kinds, transitions and other non-Clip/Gap items, effects beyond a single positive-scalar LinearTimeWarp, generator/other references, markers, a non-zeroglobal_start_time— are reported in the result'sskippedarray and on stderr, never silently dropped (theexport-timelinehouse rule). Round-trip covered by test: export a draft, import the document into a fresh one, and track/segment counts, target ranges, source ranges, and speeds reproduce exactly. With the command not in play every write path,add-video/add-audioincluded, stays byte-identical to v0.16.1.compile --data <rows.jsonl|->— one spec + N JSONL rows = N built-and-registered drafts — mass-production pipelines (one video per product, quote, or listing) hand-loop the CLI today, invokingcompileonce per output with a freshly rewritten spec each time (ArcReel/ArcReel#923, Gardene-el/Coze2JianYing#85).--datatakes a JSONL file (or-for stdin) and builds one draft per row through the exact single-draft compile path — same validation, same factory functions, same store registration. The templating rule is deliberately minimal: a{{key}}placeholder inside a STRING value of the spec (nested objects and arrays included, so the draftnametoo) is replaced with the row's value for that key — strings, numbers, and booleans; anything else is a row error — and nothing cleverer: no expressions, no defaults, no nested lookups. Non-string spec values are never templated (keep numeric fields numeric), and a placeholder with no matching row key is a row error rather than a draft that silently ships reading{{title}}. Row errors mirrorbatch's per-line contract exactly: by default every row is validated up front (JSON shape, spec validation, media pre-flight, name/directory collisions) and the first bad row aborts with its 1-based row number before any draft is written; with--continue-on-errorthe rows that validate are built, failures are reported per row, and the exit code is 1 when any row failed. Output is a summary JSON array —row,ok, thenname+draft_pathorerrorper row. Each row names its own draft, so--out(a single directory) is rejected with--data— use--drafts <dir>— and duplicate post-substitution names are a row error. Without--data,compilebehaves exactly as before: placeholder-looking text in a spec compiles verbatim, and written drafts stay byte-identical to v0.16.1.lint:media-outside-draftdetection +--fixstage-in — flags video/audio materials whosepathpoints outside the draft folder. Externally-referenced media plays fine on the machine that authored the draft and breaks on any move: copy the draft to another machine, reorganize the media folder, or open it on a sandboxed macOS build that cannot read outside the draft, and the app shows the black-screen/missing-media class (sun-guannan/VectCutAPI#48, #65; luoluoluo22/jianying-editor-skill#16). Severity info, deliberately not warning: app-authored drafts routinely reference local imports wherever they live on disk, so a warning would flip exit codes (0 -> 1) on a huge installed base of perfectly valid drafts — theunknown-effect-slugtrust model. No exit code changes for any draft.--fixstages the file into the draft'sassets/video//assets/audio/through the same collision-safe copyadd-video/add-audiouse — a basename already present with different content de-collides under a content-hashed name, re-staging identical content is a no-op — and rewrites the material path (plus its display-name fields, thereplace-mediaconvention) to the staged copy; the rewritten path is regenerated from the draft folder, so wrong-OS separators in the old value disappear by construction, never by string conversion. A missing source file stays report-only (fixable:false) with therelinkrepair assuggested_command— there is nothing on disk to stage, and repointing the material first is the deliberate step. Only absolute paths are judged (a relative or placeholder path resolves against the draft folder, and both separator styles count as inside), URLs are skipped,--no-check-pathsdisables the check, and--fix --dry-runpreviews without copying — a file copy is a side effect a discarded draft write cannot roll back. Drafts whose media already lives inside the folder write byte-identically to v0.16.1.- App auto-upgrade tripwire — the ecosystem's hottest standing pain: the desktop app updates itself, rewrites the drafts it opens, and a pipeline validated against one version quietly crosses a support boundary with nothing in the toolchain saying so until writes start misbehaving (GuanYixuan/pyJianYingDraft#115 — 33 comments of exactly this — and #178). The CLI now remembers the last version evidence it saw per draft store — in its own config area,
~/.config/capcut-cli/app-versions.json(XDG_CONFIG_HOMErespected,CAPCUT_CLI_APP_VERSIONSoverride; following theharvest-enumscatalogue pattern, state never lands inside a draft) — and every mutating write (thesaveDraftpath plussync-timelines --apply) compares the current evidence, the same effective tuple the v0.15 write guard detects, against that record. On a difference the command prints a stderr WARNING naming old -> new (app version 8.7.0 -> 10.5.0; app-source and schema-generation moves likewise) and its JSON result gainsapp_version_drift(store_dir,fromwith itsseen_at,to,changes), then the record is updated. Warn only — the tripwire never refuses: beyond-evidence writes stay gated by the existing--force-writeguard; the tripwire exists so the first write after an unnoticed upgrade says so, not the tenth. First sighting records silently; markerless CLI-created drafts carry no evidence and are never tracked; a corrupt state file reads as empty with a WARNING and the next mutating write rebuilds it (theuser-enums.jsonrobustness rule).capcut versionreportsapp_version_driftread-only (it never updates the record, so the drift stays visible until a mutating write acknowledges it) plus anApp drift:line under-H;doctorgains anapp-upgradecheck that re-inspects every tracked store and reports drift as warn, never a failure. docs/version-support.md gains the tripwire's contract and a "Pinning app updates" section with per-OS (Windows/macOS) guidance built from conservative, verifiable statements — including that no supported permanent update opt-out is documented for either app. Written drafts stay byte-identical to v0.16.1 with or without tripwire state in play. - zh-CN documentation: translated command reference + JianYing-first quickstart — the JianYing half of the user base reads Chinese first: the Chinese README is one of this repo's most-viewed paths (the demand signal already recorded in docs/jianying-encryption.md), and the largest neighbouring tools (GuanYixuan/pyJianYingDraft, sun-guannan/VectCutAPI) document in Chinese first — yet beyond the translated README every page here was English-only.
docs/command-reference.zh-CN.mdis a full Simplified-Chinese translation of the generated command reference — all 80 commands as of this release,import-timeline,compile --data, andrenameincluded — using the app's own terminology (剪映/草稿/轨道/片段/关键帧/蒙版/字幕/特效/转场/滤镜) with command names and usage strings kept verbatim; the English file stays the generated source of truth (npm run docs:commands) and the translation states that contract in its header.docs/quickstart.zh-CN.mdis a quickstart written FOR JianYing users rather than translated from the CapCut one: it leads with the version reality (5.9.x as the last plaintext line, 6.0+ encryption detected but never decrypted, the write guard refusing the 6.0+ era without--force-write), then the JianYing draft-store paths per OS, the three-command first draft, the--jianyingenum namespace with Chinese-name lookup ("_3D空间"), and a JianYing-feature → command map. Both READMEs and docs/README.md link the new pages. Docs only — no command, flag, or write-path behaviour changes anywhere. lint:main-track-gapdetection +--fixclose-up — flags gaps between consecutive segments on the main video track (the firsttype: "video"track — the bottom layer). CapCut's main track is magnetic: the app closes such gaps the moment the draft is opened, silently pulling every later segment left (sun-guannan/VectCutAPI#54), so a generated draft that looks correct in JSON re-times itself on open and captions, overlays, and audio aligned to the shifted content drift out of sync. Severity warning — exit-code change: a draft whose only finding is a main-track gap linted exit 0 before and exits 1 now, so CI gates oncapcut lintstart failing on drafts that were always going to shift in the app.--fixcloses a gap up (shifts the later main-track segments left — the same motion the app performs, so the written timing matches what CapCut will show) only where that is mechanically safe: no other track may have content playing at or after the gap, because then nothing can desync. Where other tracks are aligned to the segments that would move, the instance stays report-only (fixable:false) with asuggested_command— re-timing the dependent tracks in lockstep is a deliberate, content-touching repair the CLI won't run on its own. Gaps on overlay video tracks are never flagged; only the first video track is magnetic.rename <project> <new-name>— give a draft a new name after creation, which no tool in the ecosystem offers (sun-guannan/VectCutAPI#45 asked; the working answer to date is recreating the whole draft under the new name). Renames the draft folder on disk and rewrites the name and every self-referential path recorded about it —draft_nameplus any field pointing at or under the old folder (draft_fold_path,draft_json_file, and any other absolute path an app build stores, e.g. an absolutedraft_cover) — indraft_meta_info.jsonand in the draft's entry in the store'sroot_meta_info.json, withtm_draft_modifiedbumped, as one transaction: the same temp+fsync+rename writesregisteruses, a.bakper rewritten file, and a failed step restores the already-rewritten files and puts the folder back under its old name. Refuses when the target folder already exists, when the name is empty or contains a path separator, when either metadata file exists but does not parse (rename never renames around a file it cannot update — repair the sidecar withregister --apply, restore the index from a backup), and while the editor is running unless--force-write. A missing sidecar or index entry is only reported intargetsand the folder is renamed anyway —register --applyrecreates them afterwards; store-root discovery matchesregister(parentroot_meta_info.json, managedcom.lveditor.draftpath, or--drafts <dir>). Timeline files (draft_content.json/draft_info.json) are never touched, so absolute media references under the old folder path go stale: they are counted (stale_media_refs) and the exactrelinkrepair command is printed. The JSON result carries the old/new names, the old/new folder paths, andupdated— every file path rewritten;--dry-runpreviews the same plan without moving anything.- Mask-keyframe evidence harvest in
fixture— #44 stays evidence-gated instead of guessed — keyframeable mask geometry (the #43 stroke-following local-retouch flow) needs ground truth that still does not exist publicly: the desktop app can keyframe mask position/size/rotation/feather, but no app-authored draft carrying that encoding has been captured in this repo or the neighbouring ecosystem, the sample draft offered in #43 was never attached, and an invented encoding would save without error and silently no-op in the app — the pyJianYingDraft#160 failure class. So the feature does not ship on guesswork; the harvest that unblocks it does: everyfixturebundle now includesmask-keyframe-report.json, mapping each mask material across all three variant arrays (masks/common_mask/common_masks) — JSON path, config keys, keys beyond the CLI's own write set, keyframe-shaped nodes inside the entry — plus everyproperty_typein the draft split known/unknown and the segments carrying both a mask ref and keyframes;template-2.tmp-style embedded string-JSON documents are walked too. The report's verdict names a draft that actually contains mask-keyframe structures, and the bundle README, the JSON result (newmask_keyframe_evidencefield), and the notes point the reporter straight at #44 when it does — animate a mask in the app, runfixture, attach the bundle, and the encoding is harvested. Extraction runs on the redacted text only (nothing scrubbed can re-enter through the report),fixturestays read-only — covered by a byte-identical source-draft test — and the gap plus both unverified candidate shapes (segment-levelKFTypeMask*family vs a container inside the mask material) are now documented indocs/draft-schema/03-keyframes-and-animations.md. Written drafts everywhere stay byte-identical to v0.16.1.
[0.16.1] — 2026-08-03
Fixed
init,quickstart, andcompile(without--out) resolved their default draft store from a hardcoded macOS path built on$HOME, on every platform.doctoralready knew the per-OS stores, but nothing else used them, so on Windows — whereHOMEis normally unset — the default collapsed to a literal~/Movies/CapCut/User Data/Projects/com.lveditor.draftfolder created next to the working directory. CapCut refuses a draft there with "this draft comes from an unconventional path and is temporarily unsupported" (#52), on any app version. The per-OS candidate list now lives instore.tsasdraftDirCandidates()(doctordelegates to it) and draft-creating commands resolve throughdefaultDraftsDir():CAPCUT_DRAFT_DIRwins, then the first store that exists on disk, then the first candidate for the platform. Windows derives the store fromLOCALAPPDATA(falling back toUSERPROFILE), never fromHOME. On a platform with no known store (Linux) the commands now exit 1 naming--draftsandCAPCUT_DRAFT_DIRinstead of silently writing a draft the editor will not open.compile --outnever touches the store. Existing drafts already stranded in a wrong location are repaired withregister --apply.
[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.jsonwith nodraft_content.jsonbeside it, and every ecosystem tool breaks there (jianying-mcp#5, pyJianYingDraft#177, #194).discoverDraftStorenow reports alayout(content-primary|info-primary|unknown), surfaced bydiagnose(plus a next_action naming the layout and the fixture CTA). Behaviour change:sync-timelineson such a project previously refused outright; it now promotesdraft_info.jsonto the sync canonical and reconciles mirrors from it — the plan carrieslayoutand acanonical_notenaming the promotion and the synthetic-only evidence.registerpreviously errored; it now derives id/name/duration fromdraft_info.jsonwhendraft_content.jsonis absent (plan gainsidentity_source, and the written sidecar'sdraft_json_filepoints 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 <project> --out <dir>moves it to fixture-tested. - Version-aware mask writes — three mask array variants exist in the wild (
maskslegacy,common_masksJianYing 9.6+,common_maskthe CapCut-verified struct this CLI writes), the app reads exactly one, and masks in the wrong one silently never appear (pyJianYingDraft#160).masknow picks its target: explicit--mask-field <masks|common_mask|common_masks>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), elsecommon_mask. Markerless CLI-created drafts write byte-identically to before.mask --offand the one-mask-per-segment guard now cover all three variants; the JSON result gainsfield.migrateconsolidatescommon_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 checkmask-field-mismatchflags masks split across variants (any app) and the wrong-array-for-this-JianYing-version case, naming the exactmigratecall.capcut version'smask_fieldgains thecommon_maskvalue, andbothnow means any two-plus populated variants. export-timeline <project> [--out <file.otio>]— 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 asLinearTimeWarp(timeline duration = source range / time_scalar — exactly CapCut's source/target relationship), media paths with available ranges, and segment/material ids undermetadata.capcut. Emits the stable OTIO schema set (Timeline.1/Stack.1/Track.1/Clip.1) that every reader accepts — DaVinci Resolve imports.otionatively. Raw document on stdout (pipe-able, likeexport-srt);--outwrites the file and prints a JSON summary. Read-only and deterministic. Text tracks are skipped with a pointer toexport-srt, sticker/effect/filter tracks with a no-portable-equivalent note — always reported, never silent.harvest-enums <project> [--apply] [--catalogue <path>]— 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 harvestedeffect_id/resource_idjoins lint's known-id set, so app-authored effects stop flaggingunknown-effect-slug/unknown-font-id; named entries from cleanly-mapped kinds (effects, filters, transitions, masks, sound effects) become writable slugs through the normalfindEnumpath — harvest a draft that uses "Snowfly" once andadd-effect snowflyworks 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(--catalogueor$CAPCUT_CLI_USER_ENUMSoverride); the bundled table wins slug collisions; a hand-broken catalogue reads as empty everywhere, is surfaced with a WARNING, and--applyrefuses to clobber it. Plan by default;--applywrites 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-probeand--ffprobe-cmdare onlint):vfr-mediaflags 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 exactffmpeg -fps_mode cfrnormalize line, andmedia-unreadableflags a file that exists but ffprobe cannot parse.probe.tsnow reportsavgFps/baseFpsseparately plus anisVfrhelper. dangling-companion-ref+ remediation hints — new warning-severity lint check forextra_material_refsentries that resolve to no material (the leftover of a partial edit); always safely fixable, andlint --fixdrops the ref — never a segment, never a material. Behaviour change: a draft carrying dangling refs now lints exit 1.missing-materialandmissing-filestay deliberately report-only (the only mechanical repair would delete timeline content or guess a path) and now carry asuggested_command(capcut remove <project> <segment>/capcut relink <project> --dir <dir>) in JSON and human output.lint's command spec now declares--fix,--no-probe, and--ffprobe-cmd.
Fixed
capcut versionno longer claims the mask migration is unshipped — the oldcommon_masksnote said "usecapcut migrate --to common_masksonce shipped" althoughmigratehas 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, andsync-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-writeoverrides 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 ofplatform,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 versionreportsschema_int/evidence/beyond_known_range/write_guard,diagnosegains a version-boundarynext_actionplus awrite_guardfield, andsync-timelinesnames the draft_info-primary Mac layout whendraft_content.jsonis absent. remove <project> <segment-id> [--keep-track] [--keep-materials]— delete a segment in place. The segment leaves its track; a track the removal empties is dropped (--keep-trackkeeps it withsegments: []); the materials only that segment referenced — its source material plus everyextra_material_refscompanion (speed, canvas, sound_channel_mapping, vocal_separation, ...) — are garbage-collected with the same sweeppruneuses, so pre-existing orphans go in the same pass and a material another segment still references is never deleted;--keep-materialsskips the sweep (runprunelater). The draft duration is recomputed as the max segment end across ALL tracks — removing every segment leavestracks: []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;restoreundoes a remove byte-for-byte.--keep-track/--keep-materialsare release-scoped, so the tokens stay verbatim free text on every other command.add-filter/add-effectgrow a raw-store escape hatch and range ergonomics — usage is now<slug-or-name> (<start> <duration> | --full):--resource-id <id>applies a raw catalogue/store resource id, skipping slug lookup entirely — the positional becomes the display name;--effect-id <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 materialvalue(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.--fullspans the whole timeline (start 0, duration = draft duration) without the<start> <duration>positionals, wins when both are given, and exits 1 on a draft without a usable duration.--bind <segment-id>(add-effectonly, experimental) attaches the effect to one segment instead of the whole frame —apply_target_type0 plusbind_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 (
value1,source_platform0); the new flags are release-scoped like their v0.13/v0.14 siblings.
lintunknown-slug coverage now spans every effect-shaped material array the CLI writes — previously onlymaterials.video_effectswas checked: transitions, masks (resource_idonly — mask materials carry noeffect_id), audio effects (sfx), andmaterials.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. Newunknown-font-idinfo check on text materials (pyJianYingDraft#192): a font resource id CapCut doesn't know is silently replaced with the default font, so ids fromfont_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--fixleaves 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: 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 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 <project> <segment-id> [--track <track-name>] [--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, thenmaskthe 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:sortTracksis 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<name>-copy-2track name).--track <track-name>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;--trackand--new-tracktogether 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 viaextra_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'smaterial_id,track_id, andnew_track; mutating, atomic save with.bak+ history snapshot, honors--dry-run.register <project-dir> [--apply] [--drafts <dir>]— the meta-repair sidecar for EXISTING drafts.initregisters only the drafts it creates, so an existing folder missing itsdraft_meta_info.jsonsidecar or its entry in the store'sroot_meta_info.jsonis invisible to the CapCut app with no repair path (doctorchecks the environment,diagnoseis read-only).registerderives id/name/duration fromdraft_content.json— a read-only source that is never written — and reports per target (needs_repair, per-targetstate/action/detail/stale_fields). Accepts the project directory or itsdraft_content.jsonpath (any other filename exits 1). Plan-only by default (always exit 0);--applyrecreates 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-emptydraft_name(CapCut's display name is user data). Writes are atomic (temp+fsync+rename) with a.bakper file that already existed;applied/backupslist exactly the files written. Idempotent: a re-run writes nothing and reportsapplied: [], exit 0. The store root is the draft's parent directory and must be known — aroot_meta_info.jsonbeside the draft folder, a managedcom.lveditor.draftpath (init's default location), or an explicit--drafts <dir>(which, likeinit, creates the index on a fresh store); a draft outside any known store root is reported explicitly and nothing is written. An unreadableroot_meta_info.jsonis never rewritten (it lists every draft) and is reported blocked instead.--applyrefuses while the editor is running and when a target changed on disk since the plan read, unless--force-write;--apply --dry-runpreviews (would_apply,applied: []). Exits 2 on--applywhen a target stays blocked (unknown store root, unreadable index).diagnosenow recommends theregisterplan form when it seesdraft_meta_info.jsonmissing.crop <project> <segment-id> [--ratio <r> | --rect <x,y,w,h> | --reset]— read and set the crop on a video/photo segment's source material: the 8-corner normalized struct CapCut stores onmaterials.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 sourcewidth/height(cropisnullwhen the material carries none) and writes nothing.--ratio <free|1:1|16:9|9:16|4:3|3:4>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(freerestores the full frame without needing dims).--rect <x,y,w,h>sets an explicit normalized rect, all values 0..1 fractions of the source frame, validated asx,y >= 0,w,h > 0,x+w <= 1,y+h <= 1(a float-ulp tolerance keeps sums like0.3 + 0.7valid, and the written corners are clamped to 1);--rectoverrides--ratiowhen both are given.--resetrestores 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 acrop_ratiofield 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.baksnapshot like its siblings, honors--dry-run, and prints the resulting crop JSON on write. The new--ratio/--rect/--resetflags are release-scoped tocrop, 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--stylesJSON for the viral-caption workflow (prior art: capcut-cli-david--keyword-sizev1.15 andimport-captions --color-cycle):--highlight-words <w1,w2,...|@file>— case-insensitive whole-word matches per cue get an emphasis text range;@filereads one word/phrase per line (phrases match across spaces). Word boundaries are Unicode-aware, sofürmatches inGrüße für allebut never insidefürs, andcapnever matches insidecapcut. Overlapping matches (e.g.New York+York) keep the earlier one.--keyword-color <#RRGGBB>— emphasis colour; defaults to#FFD700, the same goldcaption --karaokepaints the active word with (now the sharedKARAOKE_HIGHLIGHT_COLORconstant). Requires--highlight-words.--keyword-size <multiplier>— emphasis size as a multiplier on the cue's base font size (style-ref/preset/--font-sizeaware), default 1.2 when--highlight-wordsis 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--colorstill sets the base colour for all cues unless--color-cycleis 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/setTextRangesand 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
captionandimport-srt) the tokens fall through to free-text positionals verbatim. - JSON output gains
keyword_matches/color_cycleonly 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'sSet-Content(and some editors) writes — previously the draft failed to load with a JSON parse error,.capcutrcwas silently ignored, and the SRT/ASS/JSONL parsers misread the first token. Covered paths:draft_content.json/draft_meta_info.json/root_meta_info.jsonand every other draft store candidate,--presetfiles,@filearguments (e.g.text-ranges --styles @ranges.json,--highlight-words @words.txt), stdin (import-srt -,import-ass -,batch,keyframe --batchJSONL), subtitle files (import-srt,import-ass,quickstart --srt,compilecaptions ops),compilespecs, template files, and.capcutrc. The CLI never writes a BOM: saving a BOM'd draft drops it (atomic write,.bakand history snapshots preserve the loaded content), the concurrent-change guard no longer reports a BOM-only difference as "changed on disk", andfixturebundles are emitted BOM-free. Output for BOM-free files is byte-identical to before. Prior art: capcut-cli-davideb2f0e0(thanks @Davidb-2107).
[0.13.2] — 2026-07-08
Documentation
- Wider README hero /
media/og-card.pngbanner (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.pngsocial 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 fork (thanks @Davidb-2107); see #36.
Added
sync-timelines <project-dir> [--apply] [--force-write]— reconcile a CapCut >= 8.7 draft whosetemplate-2.tmp/draft_info.jsontimeline mirror has drifted fromdraft_content.json, so CLI edits are honored by the app instead of silently ignored.draft_content.jsonis always canonical and is treated read-only. Accepts only a project directory or itsdraft_content.json(any other filename exits 1). Plan-only by default: the per-target drift report includes each mirror's mtime plusnewer_mirrors/canonical_stale, and warns whendraft_content.jsonis older than a drifted mirror (you would overwrite newer edits).--applyrefuses 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/backupslist 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-runreports the plan only (would_reconcile,reconciled: []).diagnosenow recommends the plan form (with a back-up caution) instead of deferring to issue #35. Closes #39.lint <project> --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 stampedfixable:false), andcaption-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 — exportedMIN_CAPTION_DURATION_US = 100_000(100ms) — so a shrink that would land a caption below the floor is skipped and stampedfixable:falsefor that instance instead of collapsing it. Writes atomically with a.baksnapshot; combine with--dry-runto preview.missing-materialandmissing-filestay report-only deliberately: the only mechanical repairs would delete user timeline content or act on host-dependent paths. Closes #40.lint— new report-only ruleunknown-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 <project> [--granularity line|word] [--format srt|vtt]— word-level caption export. Captions created bycaption --karaokecarry 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<hh:mm:ss.mmm>karaoke timestamps for burn-in pipelines. Defaults (line,srt) reproduce the previous output byte-identically.keyframe ... --easing <linear|ease-in|ease-out|ease-in-out>— CapCut-native easing curves, also accepted per-line (easingkey) inkeyframe --batchJSONL and in thecompilespec's keyframe op. The app does not store named curve types: the UI writesFreeCurveInOutbezier 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). Theken-burnsskill default changed linear → ease-out to match what the CapCut UI itself produces.detect-scenes <video> [--threshold <0..1>] [--min-gap <s>] [--limit <n>] [--json]— deterministic ffmpeg scene-cut detection (no AI, zero new dependencies) to seed the long-form → shorts flow: prints detected cut points (seconds,hh:mm:ss.mmmtimecode, scene score) plus a ready-to-use contiguous segment list in seconds and draft-native microseconds. Follows theprobe/renderexternal-binary pattern, including a clear actionable error when ffmpeg is missing.make-preset <project> <text-segment-id> --out <preset.json>— extract a hand-tuned text style (font, colors, style flags, alignment/transform, bubble, karaoke/multi-style ranges) from an existing draft into a versioned, portable preset file; apply it with the new--preset <file>flag onadd-text,text-style, andcaption. Addresses the recurring ecosystem ask for programmatic font/style reuse (GuanYixuan/pyJianYingDraft#192, Hommy-master/capcut-mate#57). Contract:- Explicit flags beat the preset — including its ranges.
--color/--font-sizeoverride every capturedtext_rangesblock over the covered span, not just the base style, so a karaoke/highlight preset re-renders in the flag color/size; applying the same preset without those flags preserves the per-range styling. - Presets are schema-validated on load.
transformmust be an object with finite numericx/y,bubblemust carry non-emptyeffect_id/resource_id,text_rangesmust be an array of objects with integerstart/endwhereend > startand correctly typed style fields. A malformed preset is rejected with a clear error and is never written into the draft. - Rangeless preset onto a multi-range segment collapses the ranges. Applying a preset that carries no
text_rangesonto a segment that still holds multiple range blocks (leftover karaoke/highlight) collapses them to the single uniform preset style spanning the whole text — consistent with the "applies the full preset" contract. A preset that itself carries ranges applies them unchanged. Documented in--help. make-preset --dry-runwrites nothing — the--outfile is neither created nor overwritten, and the JSON output reportsdryRun:truewithwritten:false(a normal run reportswritten:true).
- Explicit flags beat the preset — including its ranges.
- Parser: the value-consuming flags introduced in this release (
--threshold,--min-gap,--limit,--json,--granularity,--format,--easing,--preset,--apply) are scoped to the commands that declare them instead of being consumed globally, so free-text positionals that contain a flag-like substring survive verbatim (e.g.add-text ... New Year --limit 5 drinksstores the literal text). Flags that earlier releases already parsed globally are unchanged.
Documentation
docs/version-support.md— the CapCut 8.7 row now namessync-timelinesas the repair path for drifted mirrors.docs/draft-schema/03-keyframes-and-animations.md— documents theFreeCurveInOutbezier-handle easing encoding.
[0.12.0] — 2026-06-27
Added
quickstart <name> [--video <f>] [--audio <f>] [--srt <f>]— the one-command path from a single file to an editable draft. Creates the draft, adds the input (durations from ffprobe when available, a 5s placeholder otherwise), lints it with the same checks aslint, inspects the storage layout likediagnose, and prints the exact open-in-CapCut step. Exit 0 when created and lint-clean, 2 when created with lint errors. Reduces first-run friction for a CLI that now has 50+ commands.fixture <project> --out <dir>— build a shareable, redacted compatibility bundle. Copies only the timeline JSON (neverassets/media), redacts user home paths and email addresses, and writes a reporter README plus a diagnose report. Automates the "attach a sanitized project folder" step in the version-support flow so reporters can safely contribute the real CapCut 8.7 (issue #35) fixtures the storage adapter still needs.replace-media <project> <segment-id> <new-file> [--retime]— swap a segment's source clip in place (placeholder/proxy > final render) while preserving its timeline position, timing, effects, and keyframes. Copies the file intoassets/and refreshes intrinsic duration/dimensions via ffprobe. Distinct fromrelink, which only repairs broken paths by basename. Warns when the new clip is shorter than the segment uses;--retimefits the segment to the new clip. Honors--dry-run(no write, no copy). This is the assemble-with-placeholders-then-swap-in-finals workflow that fits the CLI's local, deterministic, agent-drivable positioning.
Documentation
docs/jianying-encryption.md— decision record for JianYing 6.0+ draft encryption: detect, do not decrypt, with the rationale (legal posture, algorithm in flux) and the tripwires that would reopen the decision.capcut decryptnow links to it.docs/version-support.md— the reporting flow and the CapCut 8.7 row now referencecapcut fixturefor one-command sanitized bundles.
[0.11.3] — 2026-06-20
Documentation
- Synchronized the English and Chinese READMEs with the shipped v0.11 surface: version-aware storage, v0.11.2 Windows fixes, six templates, 13 enum categories, 205 tests, the full cross-platform CI matrix, and the current GitHub Action reference.
[0.11.2] — 2026-06-20
Fixed
- Windows now resolves the bundled template and spawned
serveCLI through proper filesystem paths instead of URL pathnames, fixinginit,compile, and queued jobs on drive-letter paths. - ESM test imports use
file:URLs on Windows, and a single-filerestorepreserves the backup's exact bytes. - The full Node 20 suite now passes on Windows, macOS, and Linux in GitHub Actions.
[0.11.1] — 2026-06-20
Fixed
- GitHub Actions on Windows now uses Node's built-in test discovery instead of relying on POSIX shell expansion of
test/*.test.mjs.
[0.11.0] — 2026-06-20
A reliability and automation release spanning the full draft lifecycle. It closes the highest-value gaps found in the v0.10.1 repository audit while preserving the zero-runtime-dependency core.
Added
- CapCut 8.7+ draft store — version-aware discovery of
draft_content.json,draft_info.json,draft_meta_info.json, andtemplate-2.tmp, including nested/string JSON envelopes. Every readable timeline target is synchronized on write. diagnose— redacted storage report with canonical-file selection, hashes, timeline divergence, editor-process detection, and--bundle <report.json>output for compatibility reports.- Command contract v2 —
describenow exposes usage, typed positionals/options, defaults/enums, mutability, prerequisites, output form, and exit codes for every command. Help, completions, generated docs, and the typedrunCommand()library API consume the registry. compilev2 — stable item refs, source timing, speed/volume/opacity/transforms, transitions, filters, effects, keyframes, audio fades, templates, SRT captions, text styles/ranges, plus--check/--planvalidation without writes.- Caption adapters + karaoke — explicit OpenAI Whisper, whisper.cpp, and faster-whisper dialects; word-timestamp parsing/grouping;
--karaoke,--max-words,--max-chars, and--max-gap-msgenerate time-varying highlighted caption segments. - Full media probing — ffprobe duration, FPS, display rotation, dimensions, codecs, audio presence/channels, and a path+mtime cache.
add-video,add-audio, andcompilecan infer omitted durations. - Higher-fidelity proxy rendering — optional
--all-video-trackscomposition with transforms/opacity, audio fades, draft caption colour/size/position, explicit skipped-feature reports, and FFmpeg capability detection/fallbacks. - Reliable
serverunner — bounded async workers, per-project serialization, stable job-ID deduplication, retry/backoff, configurable timeout/output limits, and safe capture for outputs larger than 64 KiB. - Cross-platform CI smoke matrix — Node 20 tests on Ubuntu, macOS, and Windows in addition to the existing Node 18/20/22 Linux matrix.
Changed
- Conflict-safe atomic persistence — writes are prepared and fsynced before same-directory rename, every synchronized target receives a backup/history snapshot, changed-on-disk drafts are refused, and managed drafts are protected while CapCut/JianYing is running.
--force-writeis the explicit override. - Transactional
batch— all operations validate against cloned state and commit once. Any failure writes nothing by default;--continue-on-errorintentionally commits only successful operations and exits non-zero. doctor— now reports ffprobe and detailed FFmpeg filter/encoder capabilities alongside Whisper and project-directory checks.- Lint gate — warnings now fail
npm run lint; the existing lint debt was removed.
Fixed
- Large JianYing enum and
serveresults no longer truncate at the macOS 64 KiB synchronous pipe boundary. - Proxy-render tests no longer assume every installed FFmpeg build includes
drawtext; caption burn falls back cleanly when it is absent. - Stale roadmap, version-support, Chinese README, skill reference, test-count, and release metadata claims were synchronized with the shipped surface.
[0.10.0] — 2026-06-08
Two commands that close the two biggest gaps in a headless CapCut workflow: seeing the result, and authoring a whole draft in one shot. No breaking changes; still zero npm-dep and JSON-by-default. Both shell out to ffmpeg only when actually rendering, the same opt-in external-binary pattern caption uses for whisper.
Added
render— a low-res ffmpeg proxy preview of a draft, so you can watch an edit without opening CapCut. Flattens the main video track (per-segment source trim + speed), scales to a proxy size (--scale, default 0.5), mixes every audio-track segment, and optionally burns the text segments in with--burn-captions. It is explicitly a preview, not CapCut's final render (no multi-track video compositing, no effects/transitions). The ffmpeg command is built by a pure, deterministicbuildRenderPlanthat is unit-tested without invoking ffmpeg;--dry-runprints that plan instead of executing (and needs no ffmpeg). Read-only — never mutates the draft.compile— builds a whole draft from a declarative JSON spec (the inverse ofdescribe): instead of chaining dozens of mutatingadd-*commands, an agent emits one spec andcompileconstructs the draft atomically via the same proven factory functions the imperative commands use. Times are in seconds (converted to CapCut's microseconds); media paths resolve relative to the spec file. The full spec is validated — and every media file checked to exist — before anything is written, so a bad spec fails clean. Writes bothdraft_content.jsonanddraft_info.jsonso every downstream command reads the same data.
[0.9.0] — 2026-06-03
Ten new commands/capabilities across inspection, maintenance, composition, and agent-integration. No breaking changes; still zero-dep, JSON-by-default, pipeable.
Added
describe— emits the full command surface as JSON (name, version, global flags, every command + summary) so LLM/agent callers get a tool spec instead of scraping--help. A test enforces that every command has a summary, so nothing ships undescribed.prune— removes materials no segment references. The referenced set is the union of every segment'smaterial_idandextra_material_refs[], so masks/effects/animations/fades referenced indirectly are never wrongly deleted. Pairs with--dry-run.relink— repairs broken media paths.--dir <folder>repoints each missing material to a same-basename file in the folder;--from <p> --to <q>prefix-replaces paths. Reports relinked / still-missing / present counts. Pairs with--dry-run.timeline— shows the track/segment layout. JSON default returns lanes with computed columns;-Hrenders ASCII bars (--cols N, default 60). Makes layout/track-order issues diagnosable without opening CapCut.projects— lists CapCut/JianYing draft folders on disk (scans the per-OS default dirs or--drafts <dir>), with an optional name-substring filter and--namesto read each draft's title. No more pasting 40-char UUID paths.- Multi-step undo — every write now also keeps a rolling snapshot history under
<draftdir>/.capcut-cli-history/(capped at 20).restore --step Nrolls back N writes (step 1 == the.bak);restore --listshows the history. Plainrestoreis unchanged. diff— compare two drafts: segments added/removed/changed (start/duration/material/speed/volume), and materials added/removed/changed (a text edit mutates the material in place, so this is whereset-textshows up). Read-only.concat— append one draft onto another's timeline: B's segments are time-shifted by A's duration, and any B material/segment id that collides with A is reassigned a fresh uuid (with references rewritten) so the merge stays valid. Writes to--outor in place.config— defaults (draftsdir,jianying,cols) can be set in a.capcutrc(cwd, then home; CLI flags win).capcut configprints the resolved file and effective values.- Windows
export --batch— the Windows path now ships: PowerShell opens each draft and sends CapCut's export shortcut (Ctrl+E). Same experimental UI-automation caveat as macOS. (Live render is host-dependent; the script generation is unit-tested.)
[0.8.0] — 2026-06-03
Safety, discoverability, and a long-overdue track-order fix. No breaking changes; everything stays zero-dep, JSON-by-default, and pipeable.
Added
- Global
--dry-run(#15) — any draft-mutating command now honors--dry-run: it computes and prints the normal JSON result with"dryRun":trueadded, but leaves the draft and its.bakuntouched. Gated centrally insaveDraft, so it covers every write command at once.translate/export --batchkeep their existing dry-run behavior. restorecommand (#16) —capcut restore <project>undoes the last write by copying<draft>.bakback over the draft. Single-step (only one backup generation is kept); exits non-zero with a clear message when no.bakexists. Honors--dry-run.- Shell completions (#18, #19, #20) —
capcut completions <bash|zsh|fish>generates a completion script for command names and global flags.
Fixed
- Track order scrambled on import (#21) — tracks were written in the order edit commands ran, but CapCut lays out the timeline from the tracks-array order, not from per-segment
render_index, so building a draft incrementally produced a jumbled timeline.saveDraftnow normalizes the tracks array to the canonical bottom→top layer order (video → audio → sticker → effect → filter → text) on every save; the sort is stable so same-type tracks keep their authored order. Also exported assortTracksfrom the library entry point.
Documentation
- README — added a from-source install path and a consolidated Prerequisites note (Node ≥ 18, whisper for
caption,ANTHROPIC_API_KEYfortranslate); a worked-example block for the v0.4/v0.5 commands that had none (mix-mode,audio-fade,add-filter,bubble-text,add-cover,add-sfx,chroma,import-ass);--dry-run/restoreusage; and a Troubleshooting table covering the CapCut-must-be-closed footgun, track-order normalization,.bakrecovery, whisper/API-key setup, and the--fade-outflag. CONTRIBUTING.md— build / test / lint commands, thenpm testpre-commit gate, and PR conventions.
Internal
- Pre-commit hook rebuilds
dist/before tests (#23) — the hook rantest:fast(no build step), so it could pass-or-fail against a staledist/. It now runsnpm test, which builds first.
[0.7.0] — 2026-05-31
Added
templatescommand (#13) —capcut templateslists the bundled templates (slug + description). JSON by default,-Hfor a table.- Global
--version/-vflag (#12) — print the installed CLI version without a subcommand.
Documentation
- Independent / non-affiliation disclaimer + trademark notice — README and metadata clarify the project is unofficial and not affiliated with ByteDance; "CapCut" / "JianYing" are used nominatively.
Internal
- Pinned Biome to 2.4.15 (#14) and cleared auto-fixable lint debt.
[0.6.0] — 2026-05-29
Distribution and integration release. No breaking changes to existing commands; everything stays zero-dep, JSON-by-default, and pipeable.
Added
capcut doctor— environment preflight that inspects the machine, not a draft: Node version (hard requirement, ≥ 18), a whisper binary onPATH(forcaption),ANTHROPIC_API_KEY(fortranslate), and the default per-OS CapCut/JianYing project directory. JSON by default,-Hfor a human checklist. Exits1only on a hard failure.- Importable Node library —
import { loadDraft, saveDraft, findSegment, findMaterial, getTracksByType, extractText, updateTextContent, lintDraft, detectVersion, runDoctor } from "capcut-cli", with types. Newsrc/lib.tsentry point;package.jsonexports/main/typesmap todist/lib.js;tsconfignow emits.d.ts. Importing the package no longer executes the CLI. - Dockerfile +
.dockerignore— zero-dep multi-stage build; the final image is Node +dist/+templates/. Drafts mount at/work. Also runsserveover a stdin pipe. - GitHub Action (
action.yml) — composite action wrappingcapcut lintso drafts can be gated in CI;lintexit code2(errors) fails the job.uses: renezander030/capcut-cli@v0.6. - Three new shipped templates —
caption-pop(bold white center subtitle),lower-third(handle/name attribution),hook-question(large top-of-frame hook). Catalogue grows 3 → 6, all validated by the roundtrip suite. serve-automation.mdexample — JSONL job/result contract and four integration paths (local pipe, n8n Execute Command, cloud builders via webhook→queue-file, Docker).
CI / Quality
- GitHub Actions CI — test matrix across Node 18 / 20 / 22 plus a Biome lint job, on every push and pull request.
- Fuzz / injection test suite — 12 malformed
draft_content.jsoninputs (non-JSON, truncated, wrong-shape, prototype-pollution attempts, deep nesting) across six read commands assert graceful failure: no hang, no leaked stack trace, single-line JSON error on stderr. Plus a prototype-pollution non-regression check. - Test suite grew to 113 passing tests (doctor, fuzz, library, and the three new templates added their own coverage).
[0.5.0] — 2026-05-25
Six new commands voted in from Discussion #1, shipped as a single release. All keep the zero-dep, JSON-by-default, pipeable design.
Added
capcut mix-mode <project> <segment-id> <mode>— set blend mode on a video segment. Writesmix_modeon the video material (not the segment) since CapCut keys blend modes offmaterials.videos[]. 12 modes:normal,multiply,screen,overlay,soft-light,hard-light,color-dodge,color-burn,darken,lighten,difference,exclusion. Rejects non-video/photo segments.capcut audio-fade <project> <segment-id> [--in <sec>] [--fade-out <sec>]— fade-in / fade-out on an audio segment via a realmaterials.audio_fades[]entry ({id, fade_in_duration, fade_out_duration, fade_type, type:audio_fade}), referenced fromsegment.extra_material_refs. Re-applying replaces the existing fade instead of stacking. Rejects on non-audio segments. (Note:--outcollides with the global output-path flag, so this command uses--fade-out.)capcut add-cover <project> <image-path> [--time <ms>]— set the draft's cover frame (thumbnail) to a local image. Writes a populated object on the draft root'scoverfield (wasnullin every template). Shape includespath,type:image,time,time_ms(both — CapCut versions disagree on the unit), and acustom_cover_iduuid. Validates the image path exists.--timedefaults to 0.capcut add-filter <project> <slug> <start> <duration>+capcut enums --filters— colour-filter track separate fromadd-effect. Samematerials.video_effects[]storage buttype:filterandcategory_name:Filterso CapCut shows it in the filter rail. 10-slug starter catalogue for the CapCut namespace (vintage,warm,cool,bw,sepia,vivid,contrast,faded,dramatic,soft); JianYing namespace delegates to the 468 entries inenums.jsonvia--jianying.capcut bubble-text <project> <text-segment-id> --bubble <slug>+capcut enums --bubbles— speech-bubble shape on a text segment. Writes amaterials.filters[]entry (type:text_shape, matching pyJianYingDraft'sTextBubble.export_json) plus stampsbubble_effect_id/bubble_resource_idon the text material — some CapCut versions read from the material directly, others fromfilters[]. 7-slug starter catalogue (rectangle,rounded,cloud,oval,star,heart,burst) plus--effect-id/--resource-idpassthrough for users with custom ids.capcut import-ass <project> <ass-path-or-->— ASS / SSA subtitle import alongsideimport-srt. Zero-dep parser (src/ass.ts) reads[Events]/Dialoguelines, honours theFormatheader, strips inline overrides ({\\b1\\an8}) and\\Nline breaks. Time formatH:MM:SS.cc(centiseconds → microseconds). Shares the cue-to-segments pipeline withimport-srt— same--track-name,--style-ref,--time-offset, and text-style flag surface.
Fixed
readFileSync("/dev/stdin", ...)→readFileSync(0, ...)in three call sites (keyframe --batch,import-srt,servequeue). FixesENXIO: no such device or addresswhen the CLI was invoked with a piped stdin viachild_process.spawn. The/dev/stdindevice node fails to open in that mode on Linux; fd-0 always works.capcut initfalls back to a bundled template attemplates/_init/when the upstream../CapCutAPI/templatedirectory isn't present. Previously broke on every machine that didn't have the Python project cloned alongside.
Misc
- Test suite grew from 60 → 91 passing tests across 53 suites (six new test files, one per shipped command).
- Husky pre-commit gate stayed green throughout the v0.5 cycle — every feature commit includes its tests and passes before being pushed.
0.3.2 — 2026-05-15
Added — README polish for discoverability
- Workflow diagram (Mermaid) at the top of both READMEs showing how
capcut-clifits into a viral-shorts pipeline (long video → cut → LLM hook → CLI edits → CapCut render → publish). GitHub renders Mermaid natively; no committed image. Mirrored inREADME.zh-CN.mdwith 小红书 / 抖音 / 视频号 labels. - Comparison table vs
pyJianYingDraft(Python, JianYing-only),CapCutAPI(Python + HTTP server), andcutcli(Go, closed-source). Shows the unique positioning: onlycapcut-cliis zero-dep Node + cross-namespace (CapCut + JianYing) + has a shipped schema reference + ships built-in templates. - Feature checklist — categorized list of every shipped command with ✅ / ⬜ / 🚫 status and anchor links to the relevant docs section. 10 categories: Project I/O, Add content, Edit, Decorators, Templates, Import & discovery, Source materials, Cross-platform, Output, Quality, Roadmap. Mirrors the structure that drove
pyJianYingDraftto 3,266 stars.
No code changes; CLI surface is bit-for-bit identical to v0.3.1.
0.3.1 — 2026-05-15
Added
docs/draft-schema/— 7-file reference for the CapCut / JianYing project JSON: overview, tracks-and-segments, materials, keyframes-and-animations, effects-filters-stickers-masks-transitions, CapCut↔JianYing version differences. Practical, field-level, derived from real drafts +pyJianYingDraft. Closes the most-asked question for anyone writing tooling against the format: "what's the JSON shape?"node:testfixture-backed test suite — 36 tests across 5 test files (inspect,edit,create,template,decorators) covering the major CLI surface against the canonicaltest/draft_content.jsonfixture. ~1 second total runtime.- Husky pre-commit hook + Biome lint — every commit runs
lint-staged(Biome check/format on staged files only) followed by the fullnode:testsuite. Cheap (<10s on a clean tree), catches regressions before they hit npm. Skipping with--no-verifyshould be rare. npm run test/test:fast/lint/lint:fix/formatscripts inpackage.json.
Changed
- Test runner: shell-based
scripts/_test.sh(which tests skill wrappers) remains, but the canonical CLI test suite is nowtest/*.test.mjsvianode --test. CI-friendly, parallel, cross-platform.
0.3.0 — 2026-05-15
Five phases of new commands ported from the upstream Python project (sun-guannan/VectCutAPI / CapCutAPI), all keeping the original zero-dep, local-only, JSON-by-default, pipeable design. No new runtime, no network beyond the Wikimedia gate, no Python at runtime.
Added — Phase 1: decorators on existing segments
capcut keyframe— add keyframe(s) to a segment forposition_x,position_y,rotation,scale_x,scale_y,uniform_scale,alpha,saturation,contrast,brightness,volume. Single-shot and--batch(JSONL on stdin) modes. Value parsing accepts"50%","+0.5","45deg". Writes tocommon_keyframeson the segment, appends to existing per-property lists, sorted by time offset.capcut transition— attach a transition between segments. Starter catalogue:dissolve,rgb-glitch,radial-blur,horizontal-blur,vertical-blur-ii,twinkle-zoom,urban-glitch,shake-3.--duration <s>override.capcut mask— attach a mask:linear | mirror | circle | rectangle | heart | star. Flags:--center-x,--center-y,--size,--rotation,--feather,--invert,--rect-width,--round-corner.capcut mask <project> <id> --offremoves all masks.capcut bg-blur— background blur level 1–4 (light → maximum, mapping to0.0625 / 0.375 / 0.75 / 1.0).--offto clear.capcut text-style— rich text styling on an existing text segment:--alpha,--vertical,--fixed-width/-height,--shadow(+--shadow-alpha/-angle/-color/-distance/-smoothing),--border-width/-color/-alpha,--bg-color/-alpha/-style/-round-radius/-width/-height/-h-offset/-v-offset.capcut text-anim— text intro/outro animations. Slugs:fade-in,fade-out,typewriter,pop-up,throw-out,blur-text-in,zoom-in-text. Per-side duration overrides.
Added — Phase 2: new track types
capcut add-sticker— create a sticker track + segment from a CapCut resource id, with--x/-y/-scale/-rotation/-track-nametransforms.capcut add-effect— scene/character effect on its own effect track. Starter catalogue (CapCut namespace):shake,vhs,cinematic,light-leak,film-grain,chromatic,vignette.--params <json-array>of 0–100 effect parameters.capcut image-anim— intro/outro/combo animations on video / image segments. Slugs:fade-in,flash-in,pulsing-zooms,scroll-up,stripe-merge,zoom-out,fade-out,blur-out,smoke.
Added — Phase 3: import + enum discovery
capcut import-srt— parse an SRT file and create one text segment per cue. Accepts a file path or-for stdin. Flags:--track-name,--time-offset <s>,--style-ref <segment-id>(copy styling from an existing text segment), plus explicit text-style flags. Zero-dep parser; singlesaveDraftfor the whole file (fast on hundreds of cues).capcut enums— list valid enum values for AI agents:--transitions,--masks,--text-intros/-outros/-loop-anims,--image-intros/-outros/-combos,--scene-effects,--character-effects,--audio-effects,--fonts. Output is JSON by default (slug,member,name, effect/resource ids, md5, durations) or a human-readable table with-H. Reads from a committedenums.jsonextracted frompyJianYingDraft(13 categories × 2 namespaces, ~790 KB).
Added — Phase 4: multi-style text + JianYing namespace
capcut text-ranges— multi-style text. Different styling per character range in a single text segment.--styles @path.jsonor inline JSON:[{"start":0,"end":5,"font_color":"#FFD700","font_size":18,"bold":true},…]. Sorts + validates non-overlap, emits baseline-style fillers for gaps so CapCut renders the whole text. Unlocks word-level highlight captions.--jianyingglobal flag — threaded throughtransition,mask,text-anim,image-anim,add-effect, andenums. Selects the JianYing enum namespace (default is CapCut). Lookup falls back tomembername, socapcut transition <project> <id> "_3D空间" --jianyingworks.
Added — Phase 5: Wikimedia Commons input
add-video/add-audioaccept Wikimedia URLs —commons.wikimedia.org,*.wikipedia.org,upload.wikimedia.orgpage URLs and direct CDN URLs all resolve through the Commons imageinfo API to a canonicalFile:title.- License classifier + refusal gate —
permissive(CC*, PD, CC0, etc.),fair-use,restrictive(NC, ND, ©),unknown. Restrictive/unknown require--force-license. Fair-use downloads with a warning. Output JSON carries awikimediablock withartist,credit,description_url, license raw + class, dimensions, mime — drop-in attribution for YouTube descriptions. - Single on-disk copy — assets download directly into
<draft>/assets/<kind>/. No temp-dir churn;addVideo/addAudiocopyFileSyncbecomes a no-op.
Added — packaging
- Ready-made templates ship in
templates/:gold-title.json,end-card.json,subscribe-cta.json. Use directly viacapcut apply-template ./project ./node_modules/capcut-cli/templates/<name>.json <start> <duration>. .github/FUNDING.yml— enables GitHub Sponsors + Gumroad links on the repo sidebar.--helpfooter — everycapcut --helpnow ends with links to the full viral-shorts pipeline (Gumroad / Stripe), guides, Sponsors, and contact.
Skill + docs
skills/capcut-edit/reorganised intoreferences/+scripts/+assets/.SKILL.mdtrimmed;references/api-reference.mdcovers every command and flag;references/workflows.mddocuments whichscripts/*.shto call (not how to reconstruct them);references/pitfalls.mdcovers the gotchas (close-project-first,.bak,clip=nullon audio, etc.).- Wrapper scripts:
fade-in.sh,fade-out.sh,anim.sh,ken-burns.sh,long-to-short.sh,stamp-cta.sh. All covered byscripts/_test.sh(7/7 passing).
Changed
npm run buildnow doestsc && cp src/enums.json dist/enums.jsonso the runtime reads the dist copy viaimport.meta.url.npm run extract-enumsregeneratessrc/enums.jsonfrompyJianYingDraft.
Notes
- All five phases keep capcut-cli zero-dep at runtime — no Python, no FFmpeg, no network beyond the explicit Wikimedia opt-in (which is
fetch-based, no external deps). - HTTP server, MCP server, ffprobe-based duration probing, FFmpeg letterboxing, and cloud rendering remain explicitly out of scope per
PLAN.md.
0.2.2 — 2026-04-26
- README CTAs to Viral Story Shorts Blueprint (Gumroad).
0.2.1 — 2026-04-26
- npm tarball now includes
examples/and Chinese README.
0.2.0 — 2026-04-26
- Long-form videos to shorts, end to end.