ffmpeg-skill 1.16.1 → 1.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -9
- package/SKILL.md +27 -22
- package/docs/contract.md +35 -10
- package/package.json +1 -1
- package/references/gotchas.md +4 -0
- package/references/scripts.md +281 -1
- package/scripts/_common/__init__.py +25 -3
- package/scripts/_common/asr.py +369 -0
- package/scripts/_common/decision.py +346 -0
- package/scripts/_common/probe.py +14 -0
- package/scripts/_common/runner.py +10 -6
- package/scripts/_common/text.py +131 -7
- package/scripts/_contract.py +8 -6
- package/scripts/batch.py +287 -22
- package/scripts/caption.py +157 -198
- package/scripts/cut.py +136 -1
- package/scripts/render.py +326 -26
- package/scripts/scenes.py +81 -7
- package/scripts/silence.py +207 -5
- package/scripts/verify.py +1 -1
- package/scripts/waveform.py +1 -2
package/references/scripts.md
CHANGED
|
@@ -64,6 +64,7 @@ BT.2020 primaries on an SDR transfer, which `hdr_format` names "BT.2020 SDR" --
|
|
|
64
64
|
### cut.py — cut / join segments
|
|
65
65
|
```
|
|
66
66
|
cut.py INPUT [--start T] [--end T | --duration T] [--segments A-B,C-D,...] [--accurate] [-o OUT]
|
|
67
|
+
cut.py INPUT --start T --end T --snap beats [--snap-tolerance 0.12] [--snap-source FILE] [--min-confidence 0.5]
|
|
67
68
|
```
|
|
68
69
|
Times accept `12.5`, `1:30`, `00:01:30.250`. Default is `-c copy` (snaps to
|
|
69
70
|
keyframes, instant, lossless); if the snapped result deviates more than
|
|
@@ -74,6 +75,49 @@ the result was "lossless stream copy" or "re-encoded"; when the snap forced a
|
|
|
74
75
|
re-encode, the result's `lossless_alternative` names the nearest keyframe
|
|
75
76
|
`--start` that would stream-copy instead, so the trade can be offered.
|
|
76
77
|
|
|
78
|
+
**`--snap beats` (1.17)** moves each in/out point to the nearest *measured*
|
|
79
|
+
beat within `--snap-tolerance` seconds (default 0.12, about a quarter of a beat
|
|
80
|
+
at 120 BPM). The grid comes from the input's own audio, or from
|
|
81
|
+
`--snap-source` — either a `scenes.py --beats --json` document (no second
|
|
82
|
+
decode) or a separate music file. The snap happens *before* the
|
|
83
|
+
keyframe/tolerance decision, so lossless-vs-accurate is judged on where the cut
|
|
84
|
+
actually lands.
|
|
85
|
+
|
|
86
|
+
"Measured" is exact: the only points a cut may move onto are the
|
|
87
|
+
`supported_beats` — the grid points a measured onset actually marks — never the
|
|
88
|
+
full regular grid, which by construction runs on through a passage with no music
|
|
89
|
+
in it. A cut asked for inside that passage stays where it was asked for, and
|
|
90
|
+
`snap.grid` is `"supported"` with `snap.grid_points` saying how many there were.
|
|
91
|
+
`--min-confidence` must be greater than 0: at 0 every grid is "reliable",
|
|
92
|
+
including one measured from noise.
|
|
93
|
+
|
|
94
|
+
The number of in/out points never changes: a point with no beat inside the
|
|
95
|
+
tolerance is returned unchanged, and no point is ever invented. Three
|
|
96
|
+
`kind: input` refusals, nothing written:
|
|
97
|
+
|
|
98
|
+
- confidence below `--min-confidence` → *"no reliable beat grid in this audio
|
|
99
|
+
(confidence 0.21, needs 0.5): cutting to invented beats would move your in/out
|
|
100
|
+
points to times nothing in the audio supports. Re-run with `--snap none`, or
|
|
101
|
+
pass `--snap-source` from a music bed."*
|
|
102
|
+
- no audio stream → names `--snap none`.
|
|
103
|
+
- no in/out point at all (a whole-file copy) → there is nothing to snap, and
|
|
104
|
+
this tool never proposes cuts of its own.
|
|
105
|
+
|
|
106
|
+
Result: `snap.mode`, `snap.tolerance`, `snap.confidence`, `snap.tempo_bpm`,
|
|
107
|
+
`snap.moved` (one row per point, with `from`/`to`/`delta`/`snapped`),
|
|
108
|
+
`snap.snapped`, `snap.unchanged`, `snap.grid`, `snap.grid_points`, and
|
|
109
|
+
`snap.source` (`"measured"` or the path). In a `render.py` result, `snap.clips`
|
|
110
|
+
carries one entry per snapped clip with `clip` naming its index, and the first
|
|
111
|
+
entry's keys are repeated at the top level for a single-clip project. A clip
|
|
112
|
+
served from `--cache` was snapped when it was first rendered, and says so
|
|
113
|
+
(`source: "cache"`) rather than reporting `snap: null`. A `--snap-source`
|
|
114
|
+
document that does
|
|
115
|
+
not carry `beat_grid.supported_beats` (one written before 1.17) is refused
|
|
116
|
+
rather than treated as if every grid point were supported, as is one whose
|
|
117
|
+
`tempo_bpm` is null while it lists beats.
|
|
118
|
+
`render.py` forwards a project's `"snap": {"to": "beats", ...}` (project-wide
|
|
119
|
+
or per clip) to this flag and reports what came back.
|
|
120
|
+
|
|
77
121
|
### fit.py — target duration and/or aspect, rotate/flip
|
|
78
122
|
```
|
|
79
123
|
fit.py INPUT [--duration T --method speed|trim [--from-center] [--max-speed 4]]
|
|
@@ -422,6 +466,9 @@ each shorter clip's last frame (with silence) out to the longest.
|
|
|
422
466
|
### silence.py — remove dead air / jump cuts
|
|
423
467
|
```
|
|
424
468
|
silence.py INPUT [--threshold -35] [--min-silence 0.6] [--margin 0.15] [--min-keep 0.2] [--list] [--edl keep.txt] [-o OUT]
|
|
469
|
+
silence.py INPUT --filler --words transcript.json [--filler-lang auto|en|ja|es|de|fr|pt|it]
|
|
470
|
+
[--filler-words FILE] [--filler-extra W,W] [--filler-keep W,W] [--filler-pad 0.02]
|
|
471
|
+
[--transcribe] [--filler-list] [--max-cuts 400]
|
|
425
472
|
```
|
|
426
473
|
Runs `silencedetect`, keeps `--margin` seconds of air around speech, drops
|
|
427
474
|
gaps shorter than `--min-silence`, and re-encodes once with `select`/`aselect`
|
|
@@ -430,6 +477,60 @@ without rendering; `--edl` saves the kept ranges in `cut.py --segments` format
|
|
|
430
477
|
so the user can edit the list by hand. Quiet rooms need `--threshold -40`
|
|
431
478
|
to `-45`; noisy ones `-30`. Always tell the user how many seconds were removed.
|
|
432
479
|
|
|
480
|
+
**`--filler` (1.17)** removes filler words as well, through the same
|
|
481
|
+
`keep_ranges()`/`aselect` graph with more, shorter ranges. It is **never**
|
|
482
|
+
applied without measured word timings: pass `--words` (a whisper JSON carrying
|
|
483
|
+
per-word `start`/`end`) or `--transcribe` (the same local engine `caption.py`
|
|
484
|
+
uses, never required). There is no heuristic fallback — finding an "um" by
|
|
485
|
+
looking for short quiet blips would cut real speech, so the tool refuses
|
|
486
|
+
instead. `--filler-list` reports what would go and writes nothing.
|
|
487
|
+
|
|
488
|
+
Built-in lists, one per language:
|
|
489
|
+
|
|
490
|
+
| lang | words |
|
|
491
|
+
|---|---|
|
|
492
|
+
| en | um, uh, erm, hmm, mm, mhm, er, ah |
|
|
493
|
+
| ja | えー, えーと, えっと, あの, あのー, その, そのー, まあ, なんか |
|
|
494
|
+
| es | eh, este, esto, mmm |
|
|
495
|
+
| de | äh, ähm, hm |
|
|
496
|
+
| fr | euh, hein |
|
|
497
|
+
| pt | é, hum |
|
|
498
|
+
| it | ehm |
|
|
499
|
+
|
|
500
|
+
**Discourse markers are not disfluencies.** `like`, `tipo` and `cioè` are
|
|
501
|
+
deliberately *not* in the default lists: they are grammatical words in most
|
|
502
|
+
sentences, and removing them cuts meaning rather than noise — a judgement about
|
|
503
|
+
content, which this skill does not make. They are reachable with
|
|
504
|
+
`--filler-extra like`, which says so in its own `--help`. Japanese `なんか` is
|
|
505
|
+
in the `ja` list because leaving it out makes the flag useless for Japanese,
|
|
506
|
+
and it is orthographically identical to the pronoun use — so every run that
|
|
507
|
+
removes one warns, and `--filler-keep なんか` takes it back out. Matching is
|
|
508
|
+
case-folded, punctuation-stripped and whole-token only: `umbrella` survives.
|
|
509
|
+
|
|
510
|
+
Three refusals, all `kind: input`, all before any encode:
|
|
511
|
+
|
|
512
|
+
- `--filler` with neither `--words` nor `--transcribe` → names both flags.
|
|
513
|
+
- `--transcribe` with no engine on PATH → the same message `caption.py` gives,
|
|
514
|
+
with the three install lines.
|
|
515
|
+
- `--transcribe` where the engine runs but its build produces no word-level
|
|
516
|
+
timings → names that engine, says some builds do not support word timestamps,
|
|
517
|
+
and points at `--words`. (`--transcribe` drives whichever engine is installed
|
|
518
|
+
with *its* word-timestamp option — whisper.cpp `--output-json-full`,
|
|
519
|
+
faster-whisper `word_timestamps=True`, openai-whisper `--word_timestamps
|
|
520
|
+
True` — because an SRT cannot answer this: a cue has a start and an end, a
|
|
521
|
+
word does not. `--words` is the tested path and the one to prefer.)
|
|
522
|
+
- a transcript with segments but no word-level timings → says that cutting on
|
|
523
|
+
segment boundaries would remove whole sentences, and how to re-run whisper.
|
|
524
|
+
|
|
525
|
+
Results: `filler.removed_count`, `filler.removed_seconds`, `filler.removed`
|
|
526
|
+
(one entry per span), `filler.lang`, `filler.list`, `filler.word_timings` and
|
|
527
|
+
`filler.warnings`. The existing `removed_seconds` keeps exactly the meaning it
|
|
528
|
+
has always had — the seconds of **silence** this run removed, the figure the
|
|
529
|
+
same run would report without `--filler` — and `removed_seconds_total` is the
|
|
530
|
+
additive sibling covering everything that went. A filler word quiet enough to
|
|
531
|
+
sit inside a detected silence is merged into it rather than counted twice, so
|
|
532
|
+
the two figures can be equal.
|
|
533
|
+
|
|
433
534
|
### join.py — concatenate with transitions
|
|
434
535
|
```
|
|
435
536
|
join.py CLIP1 CLIP2 [...] [--transition fade|dissolve|wipeleft|slideleft|fadeblack|fadewhite|circleopen|none]
|
|
@@ -447,6 +548,7 @@ output; the result says so with `dropped_non_av_streams: true`.
|
|
|
447
548
|
```
|
|
448
549
|
render.py --init project.json # starter file
|
|
449
550
|
render.py project.json [--fast] [--dry-run] [--stop-after STAGE] [--work DIR --keep]
|
|
551
|
+
render.py project.json --cache DIR [--from STAGE] # reuse identical stages from a previous run
|
|
450
552
|
render.py plan.json # execute a plan written by <tool> --plan plan.json
|
|
451
553
|
```
|
|
452
554
|
A plan is a single tool's dry run as an artifact: `cut.py in.mp4 --start 2 --end 8
|
|
@@ -479,6 +581,44 @@ entries and the file path are validated before the first stage runs, and the
|
|
|
479
581
|
stage plans its `metadata.py` command under `--dry-run`/`--plan` like every
|
|
480
582
|
other stage, so the plan lists `chapters` and the run does the same work.
|
|
481
583
|
|
|
584
|
+
**`--cache DIR` (1.17)** reuses the artifact of a stage that already ran with
|
|
585
|
+
exactly the same arguments and the same inputs. Opt-in only: **there is no
|
|
586
|
+
default cache directory** — one appearing on someone's disk unasked would
|
|
587
|
+
contradict this tool's "a plan leaves nothing behind" posture. The key is a
|
|
588
|
+
sha1 over the stage name, the tool, its arguments (every existing path replaced
|
|
589
|
+
by its content hash), the input hashes, the **forwarded** flags (`--fast`,
|
|
590
|
+
`--overwrite`, `--timeout`, `--codec`), the output's extension, and **the ffmpeg
|
|
591
|
+
build banner, the skill version and the contract version**. `--fast` matters as
|
|
592
|
+
much as any of them: it rewrites every child's preset to `veryfast`, so without
|
|
593
|
+
it in the key a `--cache --fast` draft would be served back to a later run that
|
|
594
|
+
asked for the delivery. The banner rather than `major.minor` because two 7.1.x
|
|
595
|
+
builds with different libx264 produce different bytes from the same command. Those last three are in the key deliberately: a
|
|
596
|
+
different build simply *misses* rather than being asked to trust a file it did
|
|
597
|
+
not write, and a stage whose implementation changed cannot serve back an
|
|
598
|
+
artifact the old one produced. Each entry is `DIR/<key><ext>` plus a
|
|
599
|
+
`DIR/<key>.json` sidecar (stage, versions, creation time, size, seconds). A hit
|
|
600
|
+
hardlinks the artifact into the work directory, or copies it where the
|
|
601
|
+
filesystem will not link — never moves it, since the cache has to outlive the
|
|
602
|
+
run's own cleanup. Any mismatch is a silent miss.
|
|
603
|
+
|
|
604
|
+
A stage whose flags changed misses the cache, so editing the captions block (for
|
|
605
|
+
example to `"fit_size": "off"`) re-runs captions and everything after it and
|
|
606
|
+
serves the earlier stages from the cache.
|
|
607
|
+
|
|
608
|
+
`stages_done` is unchanged: a cached stage is still a stage that happened.
|
|
609
|
+
Nothing is written under `--dry-run`, which instead reports `cache.would_hit`.
|
|
610
|
+
Result: `cache.dir`, `cache.ffmpeg`, `cache.hits`, `cache.misses`,
|
|
611
|
+
`cache.saved_seconds`, `cache.entries`. `--from STAGE` starts at that stage and
|
|
612
|
+
takes every earlier one from the cache; without `--cache`, or when an earlier
|
|
613
|
+
stage is not there, it refuses (`kind: input`) rather than quietly re-encoding
|
|
614
|
+
what it promised to skip. An unwritable cache directory is `kind: output`.
|
|
615
|
+
|
|
616
|
+
`"snap": {"to": "beats", "tolerance": 0.12, "min_confidence": 0.5, "source":
|
|
617
|
+
"music.mp3"}` at the project root (or inside one clip) forwards `--snap beats`
|
|
618
|
+
to the clip cut for every clip that has `in`/`out`; the measured grid comes back
|
|
619
|
+
in the render result's `snap`. A project without `"snap"` builds the command
|
|
620
|
+
line 1.16 built.
|
|
621
|
+
|
|
482
622
|
Stages: clips (cut, optional speed) → join (transition) → silence → fit →
|
|
483
623
|
captions → graphics → overlays → audio → loudness → export → chapters → check. Keys mirror the
|
|
484
624
|
CLI flags of each script (see the docstring); a key `render.py` does not read -- at
|
|
@@ -509,6 +649,17 @@ Alias spellings are accepted everywhere one name is: `youtube-shorts`/`yt-shorts
|
|
|
509
649
|
(`check.py --platform`, `export.py --preset`, `caption.py`/`graphics.py`/`overlay.py
|
|
510
650
|
--platform`, `look.py --safe`, `render.py --template`).
|
|
511
651
|
|
|
652
|
+
**Caption size on the template path (1.17.1).** A template's caption `size` and
|
|
653
|
+
`margin` come from the delivery table, not from a literal in the JSON, so the
|
|
654
|
+
filled project also states `"fit_size": "on"`: a size nobody asked for must not
|
|
655
|
+
switch off the size fitter the way a stated `--size` does (before 1.17.1 a long
|
|
656
|
+
cue was split across two consecutive cues on every `--template` run). A template
|
|
657
|
+
file that states its own `fit_size`, and a `--brand` whose caption block states a
|
|
658
|
+
size, both win. A project may state the policy itself — `"captions": {"fit_size":
|
|
659
|
+
"auto"|"on"|"off", "min_size": N, "fit_size_scope": "file"|"cue"}` — and
|
|
660
|
+
`"fit_size": "off"` renders the captions 1.17.0 rendered, byte for byte. The
|
|
661
|
+
render result carries the caption stage's own block as `caption`.
|
|
662
|
+
|
|
512
663
|
Under `--dry-run` a pack prints every child's planned commands and its table reads `planned`
|
|
513
664
|
with no size or duration: nothing was encoded, so nothing is reported as verified. `--chapters`
|
|
514
665
|
reaches a pack's audio destination like it does the single-template form.
|
|
@@ -549,6 +700,7 @@ already has a picture.
|
|
|
549
700
|
### scenes.py — scene changes and highlight candidates
|
|
550
701
|
```
|
|
551
702
|
scenes.py INPUT [--threshold 10] [--min-scene 1] [--highlights N [--target SECONDS] [--max-scene 15]] [--edl picks.txt] [--sheet scenes.png] [--json]
|
|
703
|
+
scenes.py INPUT --beats [--beat-step 0.01] [--beat-range 60-200] [--min-confidence 0.5] [--json]
|
|
552
704
|
```
|
|
553
705
|
Lists scenes with audio energy, the loudest moments, and (with
|
|
554
706
|
`--highlights`) proposes N ranges that add up to `--target` seconds, biased to
|
|
@@ -560,6 +712,45 @@ Dissolves and very slow fades are not cuts and will be missed. Highlights are
|
|
|
560
712
|
a proposal engine, not a judgement of content: tell the user what it picked
|
|
561
713
|
and why (energy, scene length).
|
|
562
714
|
|
|
715
|
+
**`--beats` (1.17)** measures the music's beat grid and reports it:
|
|
716
|
+
|
|
717
|
+
```json
|
|
718
|
+
"beats": [0.0, 0.5, 1.0, ...], // 25 of them over a 12 s click track
|
|
719
|
+
"beat_grid": {"tempo_bpm": 120.0, "interval": 0.5, "confidence": 0.997,
|
|
720
|
+
"supported_beats": [0.0, 0.5, 1.0, ...], // the 24 an onset marks
|
|
721
|
+
"phase": 0.0, "onsets": 24, "supported": 24, "unsupported": 1,
|
|
722
|
+
"method": "rms-flux-autocorrelation", "step_s": 0.01,
|
|
723
|
+
"range_bpm": [60, 200], "usable": true}
|
|
724
|
+
```
|
|
725
|
+
|
|
726
|
+
Method: onset strength as the half-wave-rectified first difference of
|
|
727
|
+
`log(envelope)`, peaks above `median + 1.5·MAD` over a ±1 s window with a 60 ms
|
|
728
|
+
refractory gap, tempo from the autocorrelation of the onset signal inside
|
|
729
|
+
`--beat-range` with its half and double checked, phase chosen to catch the most
|
|
730
|
+
onset strength. `confidence` is half how far the winning lag stands above the
|
|
731
|
+
other lags (in standard deviations) and half the fraction of onsets that land
|
|
732
|
+
on the grid. With `--beats` the file is decoded once, at 22050 Hz, and the
|
|
733
|
+
`supported + unsupported == len(beats)` always, and `supported_beats` is that
|
|
734
|
+
supported subset — the list `cut.py --snap beats` moves onto.
|
|
735
|
+
|
|
736
|
+
With `--beats` the file is decoded once, at 22050 Hz, and the 0.5 s scene
|
|
737
|
+
envelope is derived from that same pass rather than from a second 8 kHz decode.
|
|
738
|
+
One consequence worth knowing: `scenes.py X --json` and `scenes.py X --beats
|
|
739
|
+
--json` report very slightly different `audio_rms`/`audio_peak` figures for the
|
|
740
|
+
same file, because the two envelopes are built from PCM at different rates. The
|
|
741
|
+
scene boundaries and their ranking are unaffected; only the fourth decimal of
|
|
742
|
+
the level moves. `--beats` also holds ~2.75x the samples in memory, which is
|
|
743
|
+
worth knowing on a feature-length input.
|
|
744
|
+
|
|
745
|
+
**A beat grid is a measurement of the music's periodicity, not of where a cut
|
|
746
|
+
belongs.** A low confidence means the audio has no steady pulse — speech,
|
|
747
|
+
ambience, rubato — and the skill will not snap to a grid it cannot measure.
|
|
748
|
+
`scenes.py` is the analysis tool, so it *reports* a weak measurement
|
|
749
|
+
(`usable: false`) rather than refusing it; refusing belongs to the tools that
|
|
750
|
+
would change a file on the strength of it (`cut.py --snap beats`). `--edl` with
|
|
751
|
+
`--beats` is unchanged: beats are never written as an EDL, because a beat is
|
|
752
|
+
not a cut. No audio stream is a `kind: input` refusal.
|
|
753
|
+
|
|
563
754
|
### check.py — pre-delivery compliance
|
|
564
755
|
```
|
|
565
756
|
check.py INPUT --platform youtube|shorts|reels|tiktok|x|linkedin|facebook|broadcast|podcast|custom [--no-loudness] [--json]
|
|
@@ -582,13 +773,42 @@ construction, not by two lists agreeing.
|
|
|
582
773
|
|
|
583
774
|
### batch.py — same recipe over a folder, cached
|
|
584
775
|
```
|
|
585
|
-
batch.py FOLDER --recipe batch.json [--force] [--watch SECONDS] [--json]
|
|
776
|
+
batch.py FOLDER --recipe batch.json [--force] [--watch SECONDS] [--jobs N|auto] [--json]
|
|
586
777
|
```
|
|
587
778
|
`batch.json` holds either `steps` (a list of script argv with `{in}`/`{out}`
|
|
588
779
|
placeholders, chained) or `project` (a render project applied per file).
|
|
589
780
|
Outputs land in `output_dir` with `suffix`; a content-hash cache skips files
|
|
590
781
|
already done with the same recipe. Use `--dry-run` to preview the plan.
|
|
591
782
|
|
|
783
|
+
**`--jobs N` (1.17)** processes N files at once (threads: the work is
|
|
784
|
+
subprocess waiting). Capped at `min(N, cpu_count, 8)` — every item is itself an
|
|
785
|
+
ffmpeg that already threads across cores, so beyond a few concurrent encodes
|
|
786
|
+
the jobs contend and wall-clock stops improving while memory does not. A number
|
|
787
|
+
above the cap is clamped with a note, not refused, and both `jobs` and
|
|
788
|
+
`jobs_requested` are in the result. `--jobs auto` is `min(cpu_count, 4)`.
|
|
789
|
+
|
|
790
|
+
- **One timeout budget for the whole batch**, not one per item: `--timeout` is
|
|
791
|
+
computed into a deadline once, no new item starts after it, and the run exits
|
|
792
|
+
124 `kind: timeout` with the items that never started marked
|
|
793
|
+
`"skipped": "timeout"`. That shared budget applies when a `--timeout` was
|
|
794
|
+
actually given, or when `--jobs > 1` asked for the batch to be treated as one
|
|
795
|
+
piece of work; the default sequential path with the default timeout is 1.16's
|
|
796
|
+
behaviour exactly, where a long folder was never cut off part-way.
|
|
797
|
+
- **Determinism**: every item is written into its own slot in one list indexed
|
|
798
|
+
by position in the sorted file list — cached hits included, which is what keeps
|
|
799
|
+
the table in file order when the cache is only partially warm — and each item's
|
|
800
|
+
log lines are buffered and flushed in file order, so the per-item table and the
|
|
801
|
+
log read exactly as a serial run's whatever order the encodes finish in.
|
|
802
|
+
- **A failed item is a row, not a dead run**: a worker that raises becomes
|
|
803
|
+
`ok: false` with its reason, and the summary and the table are still printed.
|
|
804
|
+
Ctrl-C cancels what has not started, keeps what finished, and exits 130
|
|
805
|
+
`kind: interrupted` with the partial table.
|
|
806
|
+
- With `--jobs > 1` each item gets its own work subdirectory
|
|
807
|
+
`<workdir>/<index>-<stem>/`, because step file names are stem-derived and two
|
|
808
|
+
sources sharing a stem would otherwise write over each other. `--jobs 1` (the
|
|
809
|
+
default) keeps the flat layout and is 1.16's path exactly.
|
|
810
|
+
- `--watch` composes: each pass is parallel, passes are sequential.
|
|
811
|
+
|
|
592
812
|
### caption.py --transcribe — optional local speech-to-text
|
|
593
813
|
If `whisper-cli` (whisper.cpp), `faster-whisper` or `whisper` is installed,
|
|
594
814
|
`caption.py input.mp4 --transcribe [--language ja] [--model base]` writes the
|
|
@@ -726,11 +946,71 @@ caption.py INPUT --srt FILE[:LANG] | --ass FILE | --text CUES.txt [--write-srt O
|
|
|
726
946
|
[--audio-stream N] [--fps N] [--lang XX] [--offset TIME]
|
|
727
947
|
[--max-lines N] [--min-duration S] [--wrap phrase|measured]
|
|
728
948
|
[--font NAME] [--fonts-dir DIR] [--size N] [--color RRGGBB] [--outline N] [--outline-color RRGGBB]
|
|
949
|
+
[--fit-size auto|on|off] [--min-size N] [--fit-size-scope file|cue]
|
|
729
950
|
[--bold] [--box] [--position bottom|top|center|top-left|...] [--margin N]
|
|
730
951
|
[--animate none|fade|pop|slide] [--karaoke [--highlight-color RRGGBB]] [--write-ass OUT.ass]
|
|
731
952
|
[--emoji auto|color|png|mono|none] [--emoji-assets DIR] [--emoji-scale 1.0] [--emoji-max 60] [-o OUT]
|
|
732
953
|
caption.py --text CUES.txt --write-srt OUT.srt # generate the SRT only
|
|
733
954
|
```
|
|
955
|
+
**`--fit-size` (1.17): the size is fitted before a cue is split.** At the TikTok
|
|
956
|
+
caption size (24 ASS units against the 288-line script grid) a line has about
|
|
957
|
+
six em, so an ordinary sentence needs four lines — and `--max-lines 2` then cut
|
|
958
|
+
it into consecutive cues, so half of it arrived late. That was the size, not the
|
|
959
|
+
breaker. `--fit-size` walks the size down until every cue wraps within
|
|
960
|
+
`--max-lines`, and only then lays the cues out.
|
|
961
|
+
|
|
962
|
+
- `auto` (default) shrinks only a size the *skill* chose. An explicit `--size`,
|
|
963
|
+
or a `brand.json` `styles.caption.size`, is a statement about the look and is
|
|
964
|
+
never overridden.
|
|
965
|
+
- `on` always fits, `--size` or not. **`off` is 1.16.1 exactly, byte for byte**
|
|
966
|
+
(a pinned ASS fixture asserts it).
|
|
967
|
+
- `--min-size` is the floor, default `ass_units(0.045) = 13` — **4.5 % of the
|
|
968
|
+
frame height**, one floor for every destination (87 px of type on a 1920-tall
|
|
969
|
+
frame, above the ~3.5 % where mobile legibility and the platforms' own caption
|
|
970
|
+
UIs bottom out). Nothing per-platform is measured, so nothing is claimed.
|
|
971
|
+
- `--fit-size-scope file` (default) uses one size for the whole file; `cue`
|
|
972
|
+
writes a per-cue `{\fsN}` override. A size that changes from cue to cue reads
|
|
973
|
+
as a mistake, so it is opt-in — it exists for the one outlier cue that would
|
|
974
|
+
otherwise shrink a ten-minute file. Each cue is laid out at the size it will
|
|
975
|
+
be **drawn** at: a cue drawn larger has a narrower line in em, so wrapping
|
|
976
|
+
everything to the file-wide budget and then drawing some cues large would put
|
|
977
|
+
lines off the side of the frame.
|
|
978
|
+
|
|
979
|
+
The text is **never** touched: this skill does not rewrite, shorten or
|
|
980
|
+
paraphrase a caption to make it fit. Below the floor the cue is split exactly as
|
|
981
|
+
before and `fit_exhausted: true` says so, with `--min-size` named as the flag
|
|
982
|
+
that would go smaller and `|` as the manual break. `--min-size` above `--size`
|
|
983
|
+
is a `kind: input` refusal. `--mode mux` is unaffected: soft subtitles carry no
|
|
984
|
+
size, so the SRT is the one 1.16 wrote.
|
|
985
|
+
|
|
986
|
+
Results, alongside the existing caption stats:
|
|
987
|
+
`fit_size`, `size_requested`, `size_used`, `size_floor`, `size_pct_height`,
|
|
988
|
+
`shrunk`, `fit_scope`, `fit_exhausted`, and `size_source` (`input` or
|
|
989
|
+
`platform-frame`).
|
|
990
|
+
|
|
991
|
+
**"caption text unchanged" (1.17.1).** When the drawn text equals the cues that
|
|
992
|
+
were handed in — nothing transcribed, no cue dropped, no cue *split* across two
|
|
993
|
+
consecutive cues and no glyph stripped by `--emoji none` — the summary says
|
|
994
|
+
`caption text unchanged: the cues were burned exactly as given (line breaks,
|
|
995
|
+
timing and type size only)` and the result carries `text_unchanged: true`. It is
|
|
996
|
+
the honest sentence for a report, made automatic: only the line breaks, the
|
|
997
|
+
timing and the type size ever move, and those do not count as a change. The key
|
|
998
|
+
is burn mode only; `--mode mux` never touches the text and omits it.
|
|
999
|
+
|
|
1000
|
+
Under `--dry-run`/`--plan` on an input that does not exist yet there is no
|
|
1001
|
+
geometry to measure. With `--platform` the destination's own frame is used —
|
|
1002
|
+
that frame *is* what the real run will have, so the planned `FontSize` is the
|
|
1003
|
+
one the run will burn. Without a platform nothing can stand in for the frame
|
|
1004
|
+
and `size_used` is `null`, rather than presenting the requested size as a
|
|
1005
|
+
fitted one.
|
|
1006
|
+
|
|
1007
|
+
| ASS size | px on 1920 | % frame h | em per line |
|
|
1008
|
+
|---|---|---|---|
|
|
1009
|
+
| 24 (default) | 160.0 | 8.3 % | 6.08 |
|
|
1010
|
+
| 19 | 126.7 | 6.6 % | 7.67 |
|
|
1011
|
+
| 16 | 106.7 | 5.6 % | 9.11 |
|
|
1012
|
+
| 13 (floor) | 86.7 | 4.5 % | 11.22 |
|
|
1013
|
+
|
|
734
1014
|
Text cue format, one per line: `0:00-0:03 Hello`, `00:00:03.500 --> 00:00:06 Two | lines`,
|
|
735
1015
|
or `00:00:03:15 --> 00:00:06:00 SMPTE non-drop-frame timecode` (`hh:mm:ss:ff`, frame count
|
|
736
1016
|
converted with `--fps`, or the input video's own fps when `--input` is given and `--fps` is
|
|
@@ -73,10 +73,21 @@ from _common.probe import (
|
|
|
73
73
|
from _common.decision import (
|
|
74
74
|
aac_args, add_pad_fill_args, audio_codec_for, AUDIO_CODECS, brand_caption_style, BRAND_DEFAULTS,
|
|
75
75
|
description_block, _evidence_rank, fmt_chapter_time, propose_chapters,
|
|
76
|
+
filler_spans, FILLER_WORDS, FILLER_AMBIGUOUS, FILLER_DISCOURSE_MARKERS, FILLER_MAX_WORD,
|
|
77
|
+
FILLER_MIN_GAP, FILLER_PAD, normalise_filler_token,
|
|
78
|
+
beat_grid, snap_points, BEAT_MIN_CONFIDENCE, BEAT_ONSET_K, BEAT_OCTAVE_MARGIN,
|
|
79
|
+
BEAT_REFRACTORY_S, BEAT_WINDOW_S, BEAT_SUPPORT_DIVISOR, BEAT_ALIGN_DIVISOR,
|
|
80
|
+
BEAT_Z_FLOOR, BEAT_Z_SPAN,
|
|
81
|
+
_onset_strength, _pick_onsets, _autocorrelation_peak, _grid_score,
|
|
76
82
|
brand_states_font, cfr_args, concat_list_line, db_to_linear, default_output, encoder_args, escape_filter_path,
|
|
77
83
|
fmt_secs, fmt_smpte_time, fmt_srt_time, is_audio_output, load_brand, MissingFpsError, pad_filters, parse_time,
|
|
78
84
|
signed_time_arg, SVT_PRESET, time_arg, video_args, x264_args, _x264_raw
|
|
79
85
|
)
|
|
86
|
+
from _common.asr import (
|
|
87
|
+
ASR_ENGINES, ASR_INSTALL_HINT, _asr_run, die_no_engine, parse_srt, transcribe, _transcribe_in,
|
|
88
|
+
transcribe_words, _words_from_openai_whisper_json, _words_from_whisper_cpp_json,
|
|
89
|
+
whisper_word_timings, write_srt
|
|
90
|
+
)
|
|
80
91
|
from _common.color import (
|
|
81
92
|
bt709_tag_args, color_hex, _COLOR_TOKEN_RE, _sdr_bt709, validate_color
|
|
82
93
|
)
|
|
@@ -96,11 +107,12 @@ from _common.text import (
|
|
|
96
107
|
PENALTY_NEUTRAL, PENALTY_OKURIGANA,
|
|
97
108
|
PENALTY_PARTICLE, PENALTY_SENTENCE_END, _rebalance, _rebalance_phrase, SAFE_WIDTH_FRACTION, _split_hyphens,
|
|
98
109
|
_particle_ends, _particle_starts, wrap_text, wrap_variants, WRAP_MODES,
|
|
110
|
+
fit_size, line_em_for_size, MIN_CAPTION_FRACTION, ass_units_local,
|
|
99
111
|
script_font_for_text, script_font_status, _script_font_uncached, _SCRIPT_RANGES, SCRIPTS, _SHAPING_BUILD_CACHE,
|
|
100
112
|
SHAPING_SCRIPTS, text_width_em, _VS15, _VS16, WINDOWS_FONTS, _ZWJ
|
|
101
113
|
)
|
|
102
114
|
|
|
103
|
-
from _common import color, decision, runner, text # noqa: F401,E402
|
|
115
|
+
from _common import asr, color, decision, runner, text # noqa: F401,E402
|
|
104
116
|
|
|
105
117
|
# `_common.emit` and `_common.probe` are the FUNCTIONS, as they have always been -- the
|
|
106
118
|
# from-imports above rebound the package attribute the submodule import had set. The two modules
|
|
@@ -111,7 +123,7 @@ from _common import color, decision, runner, text # noqa: F401,E402
|
|
|
111
123
|
_emit_module = sys.modules["_common.emit"]
|
|
112
124
|
_probe_module = sys.modules["_common.probe"]
|
|
113
125
|
|
|
114
|
-
_MODULES = (runner, _emit_module, _probe_module, decision, color, text)
|
|
126
|
+
_MODULES = (runner, _emit_module, _probe_module, decision, color, text, asr)
|
|
115
127
|
|
|
116
128
|
|
|
117
129
|
class _Facade(_types.ModuleType):
|
|
@@ -168,6 +180,12 @@ __all__ = [
|
|
|
168
180
|
"char_script", "_check_existing_output", "_check_no_overwrite_input", "_check_output_path", "child_args",
|
|
169
181
|
"child_limit", "_CHILDREN", "_cleanup_partial_output", "_cmdline", "CODECS", "color_hex", "_COLOR_TOKEN_RE",
|
|
170
182
|
"concat_list_line", "Context", "_CRF_DEFAULT", "_CURRENT_CTX", "db_to_linear", "decode_pcm_mono", "description_block", "_evidence_rank", "fmt_chapter_time", "propose_chapters",
|
|
183
|
+
"filler_spans", "FILLER_WORDS", "FILLER_AMBIGUOUS", "FILLER_DISCOURSE_MARKERS",
|
|
184
|
+
"FILLER_MAX_WORD", "FILLER_MIN_GAP", "FILLER_PAD", "normalise_filler_token",
|
|
185
|
+
"beat_grid", "snap_points", "BEAT_MIN_CONFIDENCE", "BEAT_ONSET_K", "BEAT_OCTAVE_MARGIN",
|
|
186
|
+
"BEAT_REFRACTORY_S", "BEAT_WINDOW_S", "BEAT_SUPPORT_DIVISOR", "BEAT_ALIGN_DIVISOR",
|
|
187
|
+
"BEAT_Z_FLOOR", "BEAT_Z_SPAN",
|
|
188
|
+
"_onset_strength", "_pick_onsets", "_autocorrelation_peak", "_grid_score",
|
|
171
189
|
"default_font_file", "default_output", "DEFAULT_TIMEOUT", "detect_script", "die", "drawtext_boxborderw",
|
|
172
190
|
"_DRAWTEXT_PENDING", "drawtext_shaping", "drawtext_text_opts", "_DRAWTEXT_TMPDIR", "_drawtext_tmpdir",
|
|
173
191
|
"dry_run_input_pending", "emit", "emoji_asset_for", "EMOJI_ASSET_HINT", "emoji_clusters",
|
|
@@ -193,6 +211,9 @@ __all__ = [
|
|
|
193
211
|
"time_arg", "_timed_out", "_to_float", "_to_int", "_unwatch", "_V2_HANDLED", "validate_color", "verify_output",
|
|
194
212
|
"video_args", "_VS15", "_VS16", "_watch", "WINDOWS_FONTS", "write_plan", "x264_args", "X264_PRESETS",
|
|
195
213
|
"_x264_raw", "_ZWJ",
|
|
214
|
+
"ASR_ENGINES", "ASR_INSTALL_HINT", "_asr_run", "die_no_engine", "parse_srt", "transcribe",
|
|
215
|
+
"_transcribe_in", "transcribe_words", "_words_from_openai_whisper_json",
|
|
216
|
+
"_words_from_whisper_cpp_json", "whisper_word_timings", "write_srt",
|
|
196
217
|
"_atoms", "best_break", "_bare_word", "break_penalty", "_break_spaced", "_cut_penalty", "_fix_orphans",
|
|
197
218
|
"_fix_weak_lines", "_function_words", "FUNCTION_WORDS", "_HYPHENS", "_is_hiragana", "_is_ideograph",
|
|
198
219
|
"_is_kana", "_is_weak_line", "JA_NO_LINE_END", "JA_NO_LINE_START", "JA_PARTICLE_WORDS", "JA_PARTICLES",
|
|
@@ -200,5 +221,6 @@ __all__ = [
|
|
|
200
221
|
"JA_SENTENCE_END", "_join", "ORPHAN_MIN_EM", "PENALTY_FORBIDDEN", "PENALTY_FUNCTION_WORD",
|
|
201
222
|
"PENALTY_IDEOGRAPHS", "PENALTY_NEUTRAL", "PENALTY_OKURIGANA", "PENALTY_PARTICLE", "PENALTY_SENTENCE_END",
|
|
202
223
|
"_rebalance", "_rebalance_phrase", "SAFE_WIDTH_FRACTION", "_split_hyphens", "_particle_ends",
|
|
203
|
-
"_particle_starts", "wrap_text", "wrap_variants", "WRAP_MODES"
|
|
224
|
+
"_particle_starts", "wrap_text", "wrap_variants", "WRAP_MODES",
|
|
225
|
+
"fit_size", "line_em_for_size", "MIN_CAPTION_FRACTION", "ass_units_local"
|
|
204
226
|
]
|