Skip to content

v0.13.0: special week double release (6 features)#45

Merged
renezander030 merged 30 commits into
masterfrom
release/v0.13.0
Jul 8, 2026
Merged

v0.13.0: special week double release (6 features)#45
renezander030 merged 30 commits into
masterfrom
release/v0.13.0

Conversation

@renezander030

Copy link
Copy Markdown
Owner

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

Feature Closes / motivated by
sync-timelines <project-dir> [--apply] [--force-write]: repair drifted CapCut >= 8.7 timeline mirrors, plan-first with mtime direction guard #39, #35
lint --fix now repairs line-too-long (style-range-safe rewrap) and caption-gap-too-small (100ms floor, never crushes captions); new unknown-effect-slug info rule #40, GuanYixuan/pyCapCut#12
`export-srt --granularity line word --format srt
`keyframe --easing linear ease-in
detect-scenes <video>: deterministic ffmpeg scene-cut detection with merge/limit and draft-native microsecond segments GuanYixuan/pyJianYingDraft#191
make-preset <project> <seg-id> --out p.json + --preset on add-text/text-style/caption: portable text-style presets, explicit flags win GuanYixuan/pyJianYingDraft#192, Hommy-master/capcut-mate#57

Prior 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

  • Built feature-per-worktree, each landing with the full suite green before integration.
  • Full adversarial review of the cumulative diff (multi-agent finders + independent refuters who reproduced findings against the built CLI): 27 confirmed findings, all fixed with regression tests mirroring the reproductions. Highlights: sync-timelines direction hazard (could have rolled back newer app edits), write-set scoping (canonical file now treated read-only), preset schema validation, karaoke repeated-phrase export, easing prototype-name validation bypass.
  • Preset fixes (4 findings): explicit --color/--font-size now override a preset's captured text_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, and make-preset --dry-run writes nothing (written:false).
  • Flag-scoping fix (1 finding): the value-consuming flags added this release (--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.
  • Tests: 220 -> 342 passing (node --test), biome clean, zero new runtime dependencies.

Not in this release

  • JianYing 6+ draft decryption stays detect-only per the standing decision record (docs/jianying-encryption.md).

Review notes

  • Best reviewed commit-by-commit: 6 feature merges, then review-fix merges, then the release commit.
  • npm publish happens only after this PR is approved and merged.

…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.
@renezander030 renezander030 merged commit dad52a1 into master Jul 8, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant