Skip to content

Release v0.33.0: caption coverage and cache, delivery gates, draft-change diff, shareable fixtures - #172

Merged
renezander030 merged 11 commits into
masterfrom
release/v0.33.0
Nov 6, 2026
Merged

renezander030 merged 11 commits into
masterfrom
release/v0.33.0

Conversation

@renezander030

Copy link
Copy Markdown
Owner

Nine changes for v0.33.0 on top of a behavior-preserving tidy-up, plus the version bump. The first commit is the tidy-up on its own, so it can be reviewed separately from the features.

Captions

  • caption checks that its cues cover the speech it transcribed. ffmpeg's silencedetect measures the non-silent spans of the transcribed audio, the cue spans are subtracted, and the result carries coverage with every uncaptioned stretch of at least --min-uncaptioned seconds (default 1.5). A [uncaptioned-speech] warning names them, so a transcription that stops before the end of the audio, or drops quiet speech, no longer passes unnoticed. Exit code unchanged; skipped with a note without ffmpeg; --no-coverage turns it off.
  • caption caches what it transcribed, keyed on the audio's content and the engine settings, so a run that only changes styling, grouping, --script or --translate does not transcribe again (and --cloud does not upload again). transcript_cache: hit | miss | off in the result; --no-cache bypasses it; CAPCUT_CLI_CACHE_DIR moves it.
  • render --burn-captions draws each caption in its text material's own font file instead of ffmpeg's default face, falling back as before when the file is missing.

Delivery checks

  • loudness --tolerance <LU> (with --target) and --max-true-peak <dBTP> gate a delivery: a miss exits 1 with ok: false, and gate carries the estimate and each verdict. Without the flags the result and exit code are unchanged.
  • lint --max-still-secs / --max-shot-secs report still-too-long and shot-too-long; lint --min-silent-secs reports silent-stretch where nothing audible plays. All opt-in, so lint's default result is unchanged.

Compatibility evidence

  • diff <project> --since-write shows what changed in a draft since capcut-cli last wrote it, such as what CapCut rewrote on open or save. The CLI keeps one record of its last write per draft file in its history directory; snapshots, restore and restore --list behave as before.
  • fixture --out requires a new or empty directory outside the source project, builds the bundle in a temporary sibling and publishes it only when complete.
  • fixture --anonymize builds a bundle that can be shared without showing what the draft is about: neutral media, folder and project names, consistent across every mirror, and text replaced character by character so lengths, scripts and style ranges behave the same.

Fixed

Tidy-up (first commit): the CapCut cloud clients share one request routine, CapCut's text colour format is written in one place, a language-lookup fallback that could never return is removed, and caption resolves, extracts and cleans up its audio in one helper. CLI help, describe, the library exports, shipped type files and the packed file list are identical before and after it.

Evidence

  • npm run build, npm test, npm run lint (biome, as CI runs it), python -m unittest discover -s python/tests, the packed-tarball install smoke and npm run wasm:verify pass on the final tree with Node 22 on macOS: 1,477 tests, 1,474 pass, 0 fail, 3 skipped (the new real-render font test skips where ffmpeg has no drawtext).
  • New tests: caption coverage (span math and generated tone/silence audio), transcript cache (hit, miss on model/language/audio change, --no-cache, corrupt entry, cloud hit without upload), caption fonts (resolution, escaping, fallbacks), loudness gate (tones at known levels, --apply, peak ceiling), lint pacing and silent stretches, fixture output isolation, fixture anonymization (all mirrors, CJK/Latin/kana/Hangul text, style ranges), and diff --since-write (track reorder, value change, added material, unchanged, no record, restore unaffected).
  • The real-render font test runs where ffmpeg has drawtext; the Linux/Windows matrix, Node 18/20 and the Wassette MCP smoke run in CI.
  • Base: 0f58b834a1188bd810cbf5325ac8cb6480134123 (master). Touched: caption, loudness, lint, render, fixture, diff/history write path, docs, CHANGELOG, README version note. Untouched: draft write format, store selection, editor guard, Python client.
  • The only other open pull request, chore(deps-dev): bump the dev-dependencies group with 2 updates #167 (dev-dependency group bump), touches package.json and package-lock.json dev-dependency lines; this branch changes only their version field.

Door

Two-way. Every change is additive or opt-in except two defaults: caption now adds a coverage field and a stderr warning, and it reuses a cached transcription for identical audio and settings (--no-cache restores the old behaviour). Reverting the release commit range restores 0.32 behaviour; the only stored artifacts are the transcript cache in the user cache directory and one last-write record per draft file in .capcut-cli-history/, both safe to delete.

Blast radius

caption (all engines), loudness, lint (only with the new flags), render --burn-captions, fixture, diff --since-write, and every CLI write, which now also refreshes its last-write record next to the existing history snapshots. Users who parse caption output strictly see two new fields (coverage, transcript_cache).

- The CapCut cloud clients share one request routine with retries.
- CapCut's text colour and fill format is written by one set of helpers.
- Language-code normalization drops a fallback loop that could never return.
- caption resolves, extracts and cleans up its audio in one helper.
loudness always answered ok:true and exited 0, so a script could not stop
an export whose mix misses its delivery target.

--tolerance <LU> (needs --target) fails the run when the estimated
loudness sits further than that from the target; --max-true-peak <dBTP>
fails it when the loudest clip's true peak, at its volume, is above the
ceiling. With --apply the mix after this run's gain is judged (the
--max-gain-db cap included), without it the mix as it stands. A miss
exits 1 with ok:false and one reason line on stderr, and the result
carries a gate object with the target, tolerance, estimate and each
verdict. With --apply the gain is still written, as lint --fix writes
its repairs before reporting what remains. Without either flag the
result and exit code are unchanged.

The verdict is computed by judgeLoudness beside measureLoudness, which checks
the new options before any clip is measured.
lint --max-still-secs <n> reports still-too-long (warning) for each
segment whose material is a still image (a photo) and plays longer than
n seconds on the timeline; --max-shot-secs <n> reports shot-too-long
(warning) for each video clip longer than n seconds, overlay tracks
included. Messages name the segment, its track, where it starts and how
long it plays. Neither is fixable, and neither runs unless its flag is
given, so the default lint result is unchanged. A threshold that is not
a positive number of seconds is refused instead of switching the check
off silently.
lint --min-silent-secs <n> reports silent-stretch (warning) for every
range of n seconds or longer, up to the end of the timeline, where
nothing audible plays: no audio-track clip above volume 0 and no video
clip that carries its own sound.

The check is conservative so it never reports silence that might not be
there. A clip counts as silent only when the draft says so: volume 0
without volume keyframes, a muted track (attribute bit 1), a photo, or a
video whose sound was separated to an audio track. A video clip whose
file may lack an audio stream, or whose material is missing, counts as
sound. The end of the range is the draft duration, clamped to the last
segment on any track. Not fixable, and off unless the flag is given.
After caption transcribes (local Whisper or --cloud), measure the
transcribed audio with ffmpeg silencedetect, subtract the cue spans in
the audio's own time and report coverage {speech_s, captioned_s, ratio,
uncaptioned, tail_uncaptioned_s} in the result. Uncovered speech of at
least --min-uncaptioned seconds (default 1.5) is listed and printed as
one [uncaptioned-speech] warning on stderr, so a transcription that
stops early or drops quiet speech becomes visible. The exit code is
unchanged. Without ffmpeg the block is {skipped: "ffmpeg not found"};
--no-coverage skips the check and --words runs have none.
…again

caption stores the raw transcription (cues, words, engine; before
--script, grouping, styling and --translate) keyed by the sha256 of the
exact audio bytes transcribed plus engine, whisper command, model,
language, word-timing mode, --cloud and a cache format version. A run
that only changes the later steps reads it back: no engine run, and
with --cloud no upload. The result reports transcript_cache hit, miss
or off.

Entries are one JSON file per key in CAPCUT_CLI_CACHE_DIR, else
$XDG_CACHE_HOME/capcut-cli/transcripts, else
~/.cache/capcut-cli/transcripts (%LOCALAPPDATA% on Windows), written
atomically; an unreadable or mismatched entry is ignored and
rewritten. --no-cache bypasses it and --dry-run never writes it. The
test helper isolates the cache dir per spawn.
render --burn-captions passed drawtext no font file, so every burned
caption used ffmpeg's default face. Each caption now uses its text
material's font: the first style block's font.path in the content,
else the material's font_path, passed as drawtext fontfile= when the
file exists and is a .ttf, .otf or .ttc. The path is escaped for both
filtergraph parsing levels and quoted, so commas, brackets, colons and
backslashes stay inside the option; a path with a quote or a control
character is not used. Anything else keeps the default face. The
render result and --dry-run plans report caption_fonts {from_draft,
default}.
fixture --out now refuses an occupied destination, an output symlink and
any path inside the source project before writing anything. The bundle is
built and redaction-checked in a temporary sibling directory and moved into
place only when complete, so a failed run leaves a new destination absent
and an empty one empty.
…s about

fixture --anonymize gives media files neutral names (media-001.<ext>,
the same file the same name in every mirror), replaces folder names on
media and project paths with folder-001 (standard folders are kept), sets
the project name to "project" and replaces every text character by
character with placeholders of the same UTF-16 length and script, so style
ranges and line wrapping still behave as in the original. Only the
replaced strings change; every other byte of each file is kept. The report
records anonymized: true and counts what was replaced, and the redaction
check runs on the anonymized bundle.
…st write

diff <project> --since-write compares the draft on disk with what the CLI
itself last wrote to it, so changes the app made when it opened or saved a
tool-written draft show up in the usual diff result (a = as written,
b = now) plus a since_write block with the write time, both sha256 digests,
whether the file changed, the top-level fields that differ and whether the
tracks were reordered. A draft with no recorded write exits 1.

Every write now keeps a copy of its result as <file>.last-write in the
draft's history directory, one per draft file, replaced atomically on each
write and on restore. It is not a snapshot: restore, restore --list and the
history cap ignore it. diff <a> <b> is unchanged.
@renezander030
renezander030 merged commit 54991a5 into master Nov 6, 2026
14 of 15 checks passed
@renezander030
renezander030 deleted the release/v0.33.0 branch November 6, 2026 11:21
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