v0.13.0: special week double release (6 features)#45
Merged
Conversation
…ontent.json (#39) On CapCut >= 8.7 the app can drive the timeline from template-2.tmp / draft_info.json instead of draft_content.json, so a CLI edit that only the canonical file carries is silently ignored (#35). diagnose detects the condition but had no repair path. capcut sync-timelines <project> compares every readable timeline target against draft_content.json (always canonical for this command; direction is never inferred from mtimes) and prints a plan of what drifted, including the pre-open mirror case where draft_info.json still carries a stale draft GUID. --apply performs the write by reusing loadDraft/saveDraft, so all targets are rewritten transactionally inside their own envelopes with .bak backups and the existing concurrency checks. It refuses to write while the editor is running unless --force-write, no-ops with exit 0 when targets agree, and reports a mirror that exists but holds no readable timeline (binary/encrypted template-2.tmp) as unreconcilable with the fixture-bundle workaround instead of pretending success. diagnose's next_actions now point at sync-timelines as the remedy instead of deferring to issue #35.
…fect-slug rule Follow-up to #42. Extends FIXABLE_CODES with two mechanically-safe repairs and adds one report-only rule. line-too-long (fix pass 4): greedy word wrap that only swaps spaces for newlines 1:1, so text length — and therefore the UTF-16LE byte offsets in the content styles[] ranges — never shifts. Existing line breaks are kept; lines already within the limit are untouched; words longer than maxCharsPerLine are never split mid-word and stay reported. caption-gap-too-small (fix pass 3): pulls the earlier caption's end back until the gap reaches minGapBetweenCaptionsUs — the same mutation direction as the existing overlap fix (pass 2), so it can never move a segment start, create a new overlap, or go negative. When the shrink would zero out the caption it is skipped and stays reported. missing-material and missing-file stay report-only, deliberately: - missing-material: a segment whose material_id resolves nowhere is dangling timeline content. The only mechanical "fix" is deleting the segment, i.e. auto-deleting user content; the right repair is usually re-adding the material. `prune` is the inverse direction (materials no segment references) and does not apply. - missing-file: paths are host-dependent (the test fixture itself ships Windows C:\ paths) and the file may exist on the machine the draft is destined for. Deleting the material would destroy content over an environment difference; `relink`/`replace` are the intended repairs. unknown-effect-slug (report-only, warning): validates effect_id / resource_id on materials.video_effects entries (add-effect, add-filter) and materials.material_animations animations (text-anim, image-anim) against the bundled enum table — enums.json across both namespaces plus the inline knossos-verified starter catalogues, now exposed via effectCatalogue()/imageAnimCatalogue(). Ecosystem tools apply stale effect ids that CapCut silently ignores (GuanYixuan/pyCapCut#12); lint now surfaces them before the app eats them. Not fixable: a repair would mean guessing which resource the author meant. Warning severity keeps the 0/1/2 CI exit-code contract intact. Tests: 220 -> 224 (fix round-trips for both new fixable codes; bogus and known-id cases for unknown-effect-slug).
export-srt gains --granularity <line|word> (default line; existing phrase-level SRT output stays byte-identical) and --format <srt|vtt> (default srt). Word timings are exported directly where the draft actually stores them: caption --karaoke writes one full-phrase segment per word, word-timed, with that word's style range highlighted. A run of such segments is collapsed back into a single word-timed cue. Plain phrase captions (import-srt, add-text) store no per-word timing, so timings are interpolated within each cue, weighted by word character length; the help text states this rather than implying stored precision. SRT word granularity emits one cue per word. WebVTT word granularity emits one cue per phrase with inline <00:00:01.400> word timestamps, the karaoke form players understand. Line-granularity SRT still round-trips through parseSrt. Cross-repo demand: hyperaudio/hyperaudio-lite-editor#387 (word-level VTT for karaoke burn-in), dudarenok-maker/Castwright#975 (word/ sentence/line granularity SRT+VTT export).
Add --easing <linear|ease-in|ease-out|ease-in-out> to `capcut keyframe` (single-shot and --batch, plus a per-line "easing" override on batch JSONL), an optional easing field on the compile keyframe op, and an optional [easing] argument on ken-burns.sh. CapCut UI easing presets are not stored as named curveType values: the UI writes curveType "FreeCurveInOut" plus bezier control handles on both keyframes of the eased segment. Handle x is a fixed ratio of the interval in microseconds (ease-in +0.42, ease-out +0.32/-0.4, ease-in-out +/-0.42); handle y is 0 except for ease-out, whose outgoing handle is round(0.94 x delta-value, 6). Encoding, ratios, and rounding follow the CapCut UI oracle capture validated in Davidb-2107/capcut-cli-david (cubic-out-triplet-frame-aligned.json): control.x byte-identical on frame-aligned intervals, within 1us otherwise, control.y within 1 ULP. Inserting an eased keyframe retro-updates the facing handles of both neighbours so the easing applies to both adjacent segments, matching UI behaviour. ken-burns.sh now DEFAULTS to ease-out instead of linear. The CapCut UI applies "Cubic Out" to a ken-burns zoom, so the old linear output never matched what the app itself produces; matching the app is the bug fix. Pass "linear" as the optional 10th argument for the previous motion. Keyframes written without easing (or with --easing linear) still emit the exact "Line" encoding as before, locked by a back-compat test.
… long-form-to-shorts splits
Adds 'capcut detect-scenes <video>' — runs ffmpeg's scene-change score
(select='gt(scene,T)' + metadata=print, parsing pts_time and
lavfi.scene_score from stderr) over a raw video and prints cut points
(seconds + hh:mm:ss.mmm + score) plus the resulting segment list in both
seconds and draft-native microseconds, ready to feed into compile/cut.
No AI, no new deps; the external-binary handling mirrors render/probe
(pure, unit-testable parsing + an actionable error when ffmpeg is
missing or the file does not exist).
--threshold <0..1> scene score a cut must exceed (default 0.4)
--min-gap <s> merge cuts closer than the gap, keeping the
strongest (default 2)
--limit <n> keep only the N strongest cuts
--ffmpeg-cmd <p> ffmpeg binary override
--json force JSON (the repo default; overrides -H)
Detection only — draft mutation stays with compile/cut/quickstart.
Ecosystem ask: GuanYixuan/pyJianYingDraft#191 (content-aware cut points).
…y via --preset
Power users hand-tune caption styling once and want to reuse it across
drafts. 'capcut make-preset <project> <text-segment-id> --out <preset.json>'
extracts a text segment's full re-appliable styling into a versioned,
portable JSON ({"capcutCliPreset": 1}): font identity (name/path/resource
id), fill/stroke/background colors, style flags, alignment + position
transform, bubble reference, and per-range karaoke styling when present.
Apply side: --preset <file> on add-text and text-style (material fields +
content styles[0] + transform + bubble + text ranges) and on caption (base
cue style, same coverage as --style-ref). Explicit CLI flags always
override preset values; a wrong or missing schema fails with a clear
validation error. Only properties the CLI can already re-apply are
captured (factory STYLE_FIELDS + TextStyleOptions + add-text options);
animations are excluded because they attach via enum slugs and
segment-relative timings that don't survive a portable preset.
Prior art: Davidb-2107/capcut-cli-david v1.14.0 (make-preset --out /
restyle --preset flag semantics), implemented natively on upstream's
save-template/apply-template pattern. Ecosystem asks:
GuanYixuan/pyJianYingDraft#192 (cannot programmatically set fonts),
Hommy-master/capcut-mate#57 (custom fonts require code edits).
…timelines fixture Raw NUL/SOH/STX bytes made git flag the test file as binary, which would hide its diff from review. Behavior is identical: the JS string still contains the same three control characters at runtime.
…ion, per-instance fixable stamps, unknown-effect-slug to info Fixes four confirmed v0.13.0 code-review findings: - caption-gap-too-small --fix crushed captions to sub-frame slivers (e.g. 50,003us -> 1us) and reported FIXED. Pass 3 now never shrinks a caption below MIN_CAPTION_DURATION_US (100ms = three frames at the 30fps draft default); unclearable gap issues are skipped and stamped fixable:false. - wrapLine kept a multi-space run's surplus spaces on the broken line, so the fixed line could still exceed --max-chars: --fix then either never converged (identical issue survived every run, draft never saved) or degraded trailing-space lines into stacked blank caption lines. The break now replaces a space chosen so no emitted line exceeds the cap; re-running the wrap is a no-op, so --fix converges. - line-too-long was stamped fixable:true on instances pass 4 provably cannot fix (raw-JSON fallback content without a text field, space-less CJK text, single words over the cap). The stamp is now computed per instance: only when the fixer reads the same string as the checker and re-wrapping clears every over-cap line. - unknown-effect-slug false-positived on UI-authored drafts using store-downloaded effects (unbounded catalogue the bundled enum table cannot cover) and flipped lint exit codes 0 -> 1 in CI. Downgraded to info severity: still reported for CLI-authored drafts, but never affects the exit code. Updates lint --help (fixable codes, 100ms floor, info-level exit-code note) and adds regression tests mirroring the review reproductions.
- Direction hazard: plan now reports each target's mtime plus canonical_stale/newer_mirrors, warns when draft_content.json is older than a drifted mirror (CapCut >= 8.7 writes mirrors on save), and --apply refuses that direction unless --force-write. diagnose's divergence advice recommends the plan form with a back-up caution instead of a destructive --apply one-liner. - Custom-file scope: input is restricted to a project directory or its draft_content.json path; any other explicitly named draft file exits 1 with a pointer to the directory form, symmetrically for plan and --apply, so the plan and the write always cover the same target set. - Write set: --apply writes exactly the drifted mirrors via the factored commitDraftTargets/assertTargetsUnchangedOnDisk helpers (atomic temp+rename, .bak per file written, changed-on-disk guard between plan read and write). draft_content.json is never rewritten (no sortTracks reorder, no canonical .bak overwrite) and in-sync mirrors stay untouched; JSON reconciled/backups list only files actually written. - --apply --dry-run no longer prints 'Reconciled ...' on stderr; it states plan-only and reports would_reconcile with reconciled: []. - Unreconcilable honesty: apply and the in-sync report now compute ok/in_sync the same way and exit 2 while a readable-by-CapCut mirror cannot be reconciled, so re-runs on unchanged state repeat the verdict.
…ebVTT output - collapseKaraokeRuns grouped runs purely by text equality, so a karaoke phrase repeated back-to-back merged into one 2N-segment run, failed word tiling, and word mode exploded each word segment into the full phrase (8 cues instead of 4 in the reviewed repro). Runs now require word-tiling validity: highlight ranges must tile the phrase words in order exactly once, and a range-cycle restart begins a new run, so each repetition collapses into its own cue with real word timings. - Karaoke-shaped segments that fail run validation (out-of-order starts, partial runs) but highlight exactly one word keep that word with the segment's own timing; word mode never re-interpolates the full phrase into a single word's timeslot. Lone captions that merely emphasize a word still interpolate as before. - srtTime rounded the sub-second fraction to milliseconds without carrying, emitting invalid timestamps like 00:00:01,1000 (and vttTime 00:00:01.1000, which WebVTT parsers reject) for fractions >= 999.5ms. Rounding now happens on total milliseconds so the carry propagates through seconds, minutes and hours. - Inline WebVTT karaoke timestamps are now strictly increasing and strictly greater than the cue start: a word whose start collides with the previous timestamp (coincident Whisper words) joins the previous timestamp group instead of emitting a spec-invalid duplicate tag. - WebVTT cue text escapes & < > (line and word mode) so captions containing markup characters are no longer swallowed as tags; SRT output stays raw. export-srt help notes the escaping. Findings addressed: 1. Karaoke collapse merges adjacent identical phrases, then word mode explodes each word segment into the full phrase (N^2 wrong cues) 2. vttTime/srtTime millisecond rollover emits invalid timestamps like 00:00:01.1000 (WebVTT parsers drop the cue) 3. karaokeWords accepts equal word start times, producing inline VTT timestamps equal to the cue start (invalid per WebVTT spec) 4. renderVtt does not escape '&' and '<' in cue text 5. Word-level export silently garbles karaoke captions when a phrase repeats back-to-back (same root cause as 1)
…rame semantics Fixes four confirmed v0.13.0 code-review findings: - Easing validation bypassed by inherited prototype names: resolveEasing used 'easing in EASING_PROFILES', so Object.prototype members (hasOwnProperty, toString, constructor, ...) passed validation and applyEasing read undefined ratios, NaN-corrupting the draft with "x": null control points at exit 0. Now Object.hasOwn; same hardening applied to the PROPERTY_MAP lookups in parseKeyframeValue/addKeyframes. - Linear keyframe insert between an eased pair left stale out-of-range bezier handles: the neighbours' facing handles were computed against the old prev->next interval and could reach past the inserted keyframe (overshoot + snap-back in CapCut). A linear insert now clears both neighbours' facing handles and drops a neighbour back to curveType Line when it is left with no handles at all; far-side eased segments are untouched. - compile --check accepted an invalid keyframe easing and the real compile failed only after initDraft had seeded the draft directory, leaving an orphan half-built draft. validateSpec now pre-flights the easing field with the same resolveEasing check the write path uses, so --check rejects it and compile fails before anything is written. - Single-keyframe --easing silently stamped curveType FreeCurveInOut with zero-length handles. A lone eased keyframe now stays Line, exits 0 with a warning on stderr (and a warnings field in the JSON output), and picks up the curve when its pair keyframe is added with an easing; compile surfaces the same warning in its warnings array. Help text and the keyframe --easing option description updated accordingly.
- detect-scenes: derive the final segment end from the VIDEO stream's
duration via ffprobe (probeMedia) instead of the container Duration
header (the longest stream). With an audio track outlasting the video,
segments no longer point past the last video frame. Falls back to the
container duration only when ffprobe is unavailable; new
duration_source field ("video-stream" | "container") and human output
note say which was used. New --ffprobe-cmd option (matches
add-video/compile); cuts past the known end are now dropped from
cuts[] too, so cuts[] and segments[] agree.
- detect-scenes: distinguish spawnSync failure modes. ETIMEDOUT now
reports "scene detection timed out after Ns" and ENOBUFS reports
"output exceeded the N MiB buffer" instead of falsely claiming
"ffmpeg is unavailable - install ffmpeg" when ffmpeg actually ran.
Timeout/buffer caps exposed as SceneDetectOptions (defaults
unchanged: 600s / 64 MiB).
- timecode(): round to whole milliseconds first so round-up carries
through seconds/minutes/hours; 59.9996 now formats as "00:01:00.000"
instead of the out-of-range "00:00:60.000".
- probe: parseMediaProbe/MediaProbe expose videoDurationUs (the video
stream's own duration) alongside the container durationUs.
Findings addressed:
1. Final segment end uses container Duration (longest stream), so
segments extend past the end of the video track (src/scenes.ts).
2. ffmpeg timeout (600s) or maxBuffer overflow is misreported as
"ffmpeg is unavailable - install ffmpeg" (src/scenes.ts).
3. timecode() emits invalid timestamps like "00:00:60.000" when
milliseconds round up to a full minute (src/scenes.ts).
- Explicit --color/--font-size now also override preset text_ranges, so
karaoke/highlight blocks no longer re-stamp their captured colours/sizes
over the flag values (documented "flags override preset" contract).
- parsePreset validates transform/bubble/text_ranges shapes and style field
value types, rejecting malformed presets with a clear error instead of
writing garbage (e.g. transform:{}) into the draft.
- Applying a rangeless preset onto a multi-range segment now collapses the
leftover range blocks to the single uniform preset style.
- make-preset gates its writeFileSync on isDryRun() and reports `written`.
…-run Regression tests for the four preset findings: flags overriding captured text_ranges (and preservation without flags), rejection of malformed transform/style/text_ranges/bubble shapes (plus a valid one still applying), rangeless-preset collapse of a multi-range segment (and a ranged preset preserving blocks), and make-preset honoring --dry-run.
parseFlags is a single global pass; the flags added this release (--threshold, --min-gap, --limit, --json, --granularity, --format, --easing, --preset, --apply) were consumed on every command, silently stripping flag-like tokens from free-text positionals (e.g. an add-text body containing '--limit 5'). Scope each of these flags to the commands that declare them via command-specs; on any other command they fall through to the positional stream verbatim, restoring pre-release behaviour. Flags that master already parsed globally are unchanged. Adds regression tests both directions: free-text preservation on non-declaring commands and continued parsing/validation on the declaring command.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
v0.13.0: special week double release
Six features in one bundle, the top of the opportunity backlog by rank (Impact x Confidence), each mined from real user pain across this repo's issues, divergent forks, and the wider CapCut/JianYing tooling ecosystem.
What's in it
sync-timelines <project-dir> [--apply] [--force-write]: repair drifted CapCut >= 8.7 timeline mirrors, plan-first with mtime direction guardlint --fixnow repairsline-too-long(style-range-safe rewrap) andcaption-gap-too-small(100ms floor, never crushes captions); newunknown-effect-sluginfo ruledetect-scenes <video>: deterministic ffmpeg scene-cut detection with merge/limit and draft-native microsecond segmentsmake-preset <project> <seg-id> --out p.json+--preseton add-text/text-style/caption: portable text-style presets, explicit flags winPrior art for sync-timelines, make-preset, and the easing encodings comes from the capcut-cli-david fork; implemented natively for this codebase and credited in the CHANGELOG. Thanks @Davidb-2107. (Context: #36.)
Quality process
--color/--font-sizenow override a preset's capturedtext_ranges(not just the base style), presets are schema-validated on load and rejected before anything is written into the draft, a rangeless preset onto a multi-range segment now deterministically collapses the leftover ranges, andmake-preset --dry-runwrites nothing (written:false).--threshold,--min-gap,--limit,--json,--granularity,--format,--easing,--preset,--apply) are now scoped to the commands that declare them, so a free-text positional containing a flag-like substring (e.g.add-text ... New Year --limit 5 drinks) survives verbatim; flags earlier releases already parsed globally are untouched.Not in this release
docs/jianying-encryption.md).Review notes