ffmpeg-skill 2.21.1 → 2.22.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 CHANGED
@@ -224,7 +224,7 @@ These are the rules the skill file gives the agent and the code enforces.
224
224
  |---|---|
225
225
  | `cut.py` | In/out or multi-segment cuts, lossless `-c copy` first, re-encode fallback, `--accurate` for frame-exact video and sample-exact audio, `--keep-hevc` to re-encode an HEVC source as HEVC, `--edit-list` to keep an `.mp4` copy's start exact, `--vfr-guard` to choose the VFR check; reports `precision`; `--snap beats` moves the in/out points onto a measured beat, or refuses when there is no measurable pulse |
226
226
  | `join.py` | Concatenate clips with xfade transitions, normalising size, fps, sample rate and channel layout (the widest clip's, or `--channels`); audio-only inputs are joined as audio; `--list parts.txt` reads generated segments (TTS lines) and checks every one first — missing, empty or unreadable parts are all named in one refusal, or skipped and reported with `--on-missing skip` |
227
- | `silence.py` | Detect and remove dead air (jump cuts) with a margin around speech; list or export the cut list; `--filler` also removes filler words, but only where a speech engine timed them; `--speech-aware` keeps a breath inside a sentence and cuts only at sentence-boundary pauses, composing with `--filler` through the same `keep_ranges()` |
227
+ | `silence.py` | Detect and remove dead air (jump cuts) with a margin around speech (`--margin`, 0.2 s each side); list or export the cut list, or a source-to-output time map (`--timemap`, JSON or CSV); reports the level at every cut and warns where one may land in a breath or a word's tail; `--filler` also removes filler words, but only where a speech engine timed them; `--speech-aware` keeps a breath inside a sentence and cuts only at sentence-boundary pauses, composing with `--filler` through the same `keep_ranges()` |
228
228
  | `fit.py` | Fit to a duration (pitch-preserving speed change or trim, smooth slow-mo) and/or aspect ratio (pad, crop or `--fit blur`'s blurred, darkened fill, with `--crop-x`/`--crop-y` to keep an off-centre subject) and/or exact `--width`/`--height`; rotate 90/180/270, flip h/v; force constant fps |
229
229
  | `crop.py` | Crop to an exact pixel rectangle (`--x --y --width --height`) — distinct from `fit.py --fit crop`, which crops to an aspect ratio it computes itself |
230
230
  | `cropdetect.py` | Measure existing black letterbox/pillarbox bars and report the `crop.py`-ready rectangle that removes them — analysis only, writes no file; `--motion-centre` reports the per-second motion centroid instead, for a 9:16 reframe — report only, the calling agent picks the crop, no auto-reframing |
@@ -439,6 +439,27 @@ Without the other 30, an agent writes raw ffmpeg for those jobs, and a mistake t
439
439
 
440
440
  Every command a tool ran is in its `--json` `commands`, shell-quoted so it can be pasted and run again, and `--dry-run` prints the commands without running anything. In the 100-prompt agent eval (1.17.0, [Tested on real footage](#tested-on-real-footage)) the agent made no raw ffmpeg calls and reported no false successes.
441
441
 
442
+ ### What does `verified` check, and what can it not tell me?
443
+
444
+ `verified` covers what can be measured from the file. Each tool measures its output again: the probe (the file opens, its duration, codecs, size and frame rate) and the tool's own promise. For example, loudness and true peak after `loudness.py` or `export.py`, the length against the asked one, and for `cut.py`, `join.py` and `export.py` the stream count, where picture and sound end, and whether both ends of every stream decode. `check.py` grades a file against a platform's limits. A value that cannot be read fails its step as `not_measured`; it does not pass.
445
+
446
+ It cannot tell you whether the result looks or sounds right, and the result says so: every writing tool lists the properties it did not measure in `unverified`. Examples are `picture_content` (what is in the frame), `subject_framing` after a reframe, `rendered_placement` and `legibility` of captions and titles, `grade_look` after a colour conversion, `removed_span_content` after a silence cut (whether a removed span held speech), `mix_balance` and `lip_sync`. For those, look at frames with `look.py` (a contact sheet, or `--safe` for a platform's UI zones) and listen where the result points you. `silence.py` lists the level at every cut, and `cut_warnings` names the cuts that may have landed in a breath or a word's tail.
447
+
448
+ ### Are silence removal and captions stable?
449
+
450
+ They are the most used tools here, and they are tested on every push on Linux, macOS and Windows, with FFmpeg 5.1 to 9. On 20 test files with known gaps, `silence.py` missed none and left at most 1 ms of silence (see [Tested on real footage](#tested-on-real-footage)). A cut keeps `--margin` seconds of air on each side, 0.2 s by default; raise it to 0.3 s for a speaker who trails off. `--speech-aware` keeps the short breaths inside a sentence. Filler words are removed only from measured word timings (`--words` or a local `--transcribe`), never guessed. `--timemap` writes where each kept and removed range sits in the source and the output, for re-timing subtitles. If `--threshold` is set high enough to cut into a breath or a weak word ending, the result warns at that cut.
451
+
452
+ `caption.py` burns SRT/ASS or muxes them as soft tracks, fits the size to the platform's safe area, and picks a font that covers the script (Japanese, Chinese, Korean, Arabic, Thai, Hindi and others). It needs FFmpeg's `subtitles` filter (libass); `doctor` reports whether this build has it. Placement and legibility are listed as `unverified`, so look at a contact sheet before delivering. Speech-to-text runs on a local engine you install; its first run may download a model.
453
+
454
+ ### How well does HDR to SDR work, and what should I watch for?
455
+
456
+ `color.py --to-sdr` converts PQ (HDR10), HLG and iPhone Dolby Vision 8.4 to SDR BT.709 with real tone mapping (`zscale` and `tonemap`, `hable` by default; `mobius`, `reinhard`, `bt2390` and others with `--tonemap`). BT.2020 footage on an SDR transfer gets a gamut conversion only. Watch for four things:
457
+
458
+ - It needs `zscale` (libzimg). The plain Homebrew `ffmpeg` lacks it, so use `ffmpeg-full`; `doctor` reports whether it is there.
459
+ - `--peak` (default 1000 nits) should match how bright the source was mastered. A wrong value makes highlights dull or clipped.
460
+ - Tone mapping is a judgement, and the result says so (`grade_look` in `unverified`). Compare frames with `look.py`, and try another `--tonemap` if skin or skies look off.
461
+ - An HDR source that only needs to play everywhere may not need converting at all. `--strip-dovi` removes the Dolby Vision layer from an iPhone clip, keeping its HLG picture, with a stream copy.
462
+
442
463
  ### How does ffmpeg-skill compare with Remotion?
443
464
 
444
465
  ffmpeg-skill edits footage you already have, on this machine, with FFmpeg tools whose flags are a machine-readable contract (`contract --json`). Remotion renders a video from React components. They complement each other: this repository does not call Remotion, and a file Remotion writes is an ordinary input (`probe.py`, `cut.py`, `fit.py`, `export.py`, `check.py`). [`remotion-ffmpeg-skill`](https://github.com/kajisho5/remotion-ffmpeg-skill) joins the two. It measures the footage with this repo, draws the graphics in Remotion from those measurements, and checks and delivers the result with this repo again. Install both (`npx ffmpeg-skill`, then `npx remotion-ffmpeg-skill`). As with the other skills, the dependency runs one way: it calls these tools, and nothing here calls it. HyperFrames renders a video from HTML and is the same kind of neighbour: a file it writes is an ordinary input too.
package/docs/contract.md CHANGED
@@ -21,7 +21,7 @@ The contract is derived from the code that runs, not maintained beside it:
21
21
  | Field | Meaning | Changes when |
22
22
  |---|---|---|
23
23
  | `contract_version` | shape of this document (`1.0`) | a key is renamed, removed or changes meaning |
24
- | `skill.version` | the npm / package.json version (`2.21.1`) | any release |
24
+ | `skill.version` | the npm / package.json version (`2.22.1`) | any release |
25
25
 
26
26
  A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
27
27
  ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
@@ -131,7 +131,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
131
131
  "deprecated": [{"what": "running on Python 3.9 (the requirements.python floor)", "since": "2.10.9", "replacement": "Python 3.10 or later; ...", "removed_in": "3.0.0", "where": "behaviour"},
132
132
  {"what": "running on FFmpeg 5.x (the requirements.ffmpeg floor)", "since": "2.14.0", "replacement": "FFmpeg 6.1 or later; ...", "removed_in": "3.0.0", "where": "behaviour"}],
133
133
  "removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
134
- "skill": {"id": "ffmpeg-skill", "version": "2.21.1", "execution_mode": "local", "kind": "execution",
134
+ "skill": {"id": "ffmpeg-skill", "version": "2.22.1", "execution_mode": "local", "kind": "execution",
135
135
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
136
136
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
137
137
  "requirements": {"python": ">=3.9 (standard library only; 3.9 deprecated since 2.10.9, removed in 3.0.0; 3.10+ recommended)", "ffmpeg": ">=5.0 (5.x deprecated since 2.14.0, removed in 3.0.0; 6.1+ recommended)", "ffprobe": ">=5.0"},
@@ -721,6 +721,15 @@ lost 40 s of sound with no error):
721
721
  | `probe.stream_counts` | `probe.py` and every output probe | `{video, audio}`: how many picture streams (a cover picture is not one) and sound streams the file carries |
722
722
  | `probe.video.tagged_duration` | `probe.py` and every output probe | the video stream's DURATION tag in seconds (Matroska/WebM give no stream duration), as `audio.tagged_duration` since 2.20 |
723
723
 
724
+ Keys added after 2.21.1, all additive (requests on X: a map of what a silence cut removed, and a
725
+ pointer to cuts that may sit in a breath):
726
+
727
+ | key | tool | what it holds |
728
+ |---|---|---|
729
+ | `timemap` | `silence.py --timemap FILE` | the path the time map was written to (`.json`: `{kept: [{source_start, source_end, output_start, output_end}], removed: [{source_start, source_end, output_time}]}`; `.csv`: `type,source_start,source_end,output_start,output_end` rows, a removed row's output columns both its cut point). Output times are computed from the millisecond-rounded cut bounds, not measured on the written file: the render keeps whole video frames and audio blocks, so each kept range can differ by up to one frame (about 20-40 ms) and the difference can add up over many cuts. Nothing is written under `--dry-run` |
730
+ | `cut_points` | `silence.py` (every run that cuts) | per cut, `{source_time, output_time, edge, rms_dbfs}`: the RMS level 50 ms either side of the cut on the source, the loudest channel (`null` for digital silence), `edge` `out` where the source is left and `in` where it is picked up; `filler: true` on an edge that sits on a removed filler word. The whole key is `null` when the source could not be decoded for the measurement (`cut_levels_unmeasured` warning): unmeasured, not silent |
731
+ | `cut_warnings` | `silence.py` | the `cut_points` (not filler cuts) whose level is within `silence.CUT_SOUND_MARGIN_DB` (10 dB) of `--threshold`: a breath or a word's weak tail may be cut there. A `cut_near_sound` warning names them. `verified` is unchanged: this is where to listen, not a measured failure |
732
+
724
733
  Per-tool keys added after 2.10.11 ("nothing happens silently"), all additive:
725
734
 
726
735
  | key | tool | what it holds |
@@ -797,6 +806,7 @@ here claims otherwise until real exit-code/stderr sniffing exists to back that u
797
806
  A refusal also carries `error.reason`, a stable snake_case code for *why*
798
807
  (`contract --json` → `json_output.error_reasons` lists them: `output_exists`, `output_is_input`,
799
808
  `output_is_directory`, `output_dir_missing`, `output_dir_not_writable`, `output_locked`,
809
+ `silence.py --timemap`'s `timemap_path_taken`,
800
810
  `render.py`'s `plan_*` reasons, and `--codec av1`/`prores`'s `encoder_missing` and
801
811
  `encoder_list_unreadable`), and `error.details`, the facts the message is written from, so a
802
812
  caller never parses the prose:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "2.21.1",
3
+ "version": "2.22.1",
4
4
  "description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
5
5
  "keywords": [
6
6
  "ffmpeg",
@@ -35,6 +35,7 @@ Every row names the constant or flag it comes from, and a test holds the table t
35
35
  | silence is sound at or below | -35 dBFS | `silence.py --threshold` | `silence.py` |
36
36
  | ... lasting at least | 0.6 s | `silence.py --min-silence` | `silence.py` |
37
37
  | a removed silence is digital silence when 99% of it sits at or below | -90 dBFS | `silence.DIGITAL_SILENCE_DB` | `silence.py` (`silence_kinds`) |
38
+ | a cut is flagged when the level around it is within this much of `--threshold` | 10 dB | `silence.CUT_SOUND_MARGIN_DB` | `silence.py` (`cut_warnings`) |
38
39
  | a track is silent when its peak is at or below | -50 dBFS | `_common.probe.SILENT_PEAK_DBFS` | `check.py`; the `--silence-threshold` default of `join.py`, `audio.py`, `waveform.py` |
39
40
  | a filler word longer than this is a held vowel, kept | 1.2 s | `_common.decision.FILLER_MAX_WORD` | `silence.py --filler` |
40
41
  | a pause this long between two tokens of an unspaced script is a word boundary | 0.1 s | `_common.decision.FILLER_BOUNDARY_GAP` | `silence.py --filler` |
@@ -639,7 +640,7 @@ each shorter clip's last frame (with silence) out to the longest.
639
640
 
640
641
  ### silence.py — remove dead air / jump cuts
641
642
  ```
642
- silence.py INPUT [--threshold -35] [--min-silence 0.6] [--margin 0.15] [--min-keep 0.2] [--list] [--edl keep.txt] [--speech-aware] [-o OUT]
643
+ silence.py INPUT [--threshold -35] [--min-silence 0.6] [--margin 0.2] [--min-keep 0.2] [--list] [--edl keep.txt] [--timemap map.json|map.csv] [--speech-aware] [-o OUT]
643
644
  silence.py INPUT --filler --words transcript.json [--filler-lang auto|en|ja|ko|zh|es|de|fr|pt|it|nl|pl|ru|tr]
644
645
  [--filler-words FILE] [--filler-extra W,W] [--filler-keep W,W] [--filler-pad 0.02]
645
646
  [--transcribe] [--engine auto|parakeet-mlx|parakeet.cpp|whisper.cpp|faster-whisper|openai-whisper]
@@ -653,6 +654,30 @@ without rendering; `--edl` saves the kept ranges in `cut.py --segments` format
653
654
  so the user can edit the list by hand. Quiet rooms need `--threshold -40`
654
655
  to `-45`; noisy ones `-30`. Always tell the user how many seconds were removed.
655
656
 
657
+ **Breaths and weak word endings.** `--margin` is the padding kept on each side of every cut
658
+ (default 0.2 s since 2.22, 0.15 s before; 0.3 s for a speaker who trails off). `--speech-aware` keeps the short
659
+ breaths inside a sentence and cuts only at sentence-boundary pauses. A `--threshold` set too high
660
+ (-25, say) counts a weak word ending as silence: the result then warns.
661
+
662
+ **Cut levels (2.22).** Every run measures the RMS level 50 ms either side of each cut on the
663
+ source (`cut_points`: `source_time`, `output_time`, `edge` out/in, `rms_dbfs` of the loudest
664
+ channel, `null` for digital silence; the whole key is `null`, with a `cut_levels_unmeasured`
665
+ warning, when the source could not be decoded for it) and lists in `cut_warnings` the cuts whose level is within 10 dB of `--threshold`,
666
+ with a `cut_near_sound` warning. Room tone sits well below the threshold; a breath or a word's
667
+ weak tail sits just under it. This is a pointer for listening, not a verdict: `verified` is
668
+ unchanged, and filler-word cuts (which sit in sound by design) are measured but not flagged.
669
+
670
+ **`--timemap FILE` (2.22)** writes the source-to-output time map: every kept range with its
671
+ source and output times, and every removed range with the output time it was cut at. `.csv`
672
+ gives `type,source_start,source_end,output_start,output_end` rows (a removed row's two output
673
+ columns are its cut point); any other path must end in `.json`. Use it to re-time subtitles
674
+ made on the source, or to find a removed sentence again. Its times advance by the
675
+ millisecond-rounded ranges the render cuts on; they are computed, not measured on the written
676
+ file, so each kept range can be off by up to one video frame or audio block. An existing map is refused without `--overwrite`,
677
+ and a missing directory or a directory at the path as `kind: input`, before anything runs (a dry
678
+ run predicts both); the map is written only after the cut is, so a failed or refused encode
679
+ leaves none. A dry run writes nothing.
680
+
656
681
  **`--filler` (1.17)** removes filler words as well, through the same
657
682
  `keep_ranges()` graph with more, shorter ranges. It is **never**
658
683
  applied without measured word timings: pass `--words` (a whisper JSON carrying
@@ -553,6 +553,46 @@ def video_encoder_of(cmd: Sequence[str]) -> Optional[str]:
553
553
  return enc
554
554
 
555
555
 
556
+ # A protocol prefix (`tcp:`, `http://`, `file:`, `concat:`, ...): ffmpeg opens such a value as a URL,
557
+ # while every guard here reads it as a path. Two or more letters, so a Windows
558
+ # drive letter (`C:\x.mp4`) is a path; `pipe:` is the skill's own stdout/stdin and stays allowed.
559
+ _URL_SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]+:")
560
+
561
+
562
+ def looks_like_url(value: str) -> bool:
563
+ """True for a value ffmpeg would open through a protocol rather than as a local file."""
564
+ return bool(_URL_SCHEME.match(value)) and not value.startswith("pipe:")
565
+
566
+
567
+ def _refuse_url_media(cmd: Sequence[str]) -> None:
568
+ """Refuse an ffmpeg/ffprobe command that names a URL as an input or as its output, before it
569
+ runs or is recorded. This skill reads and writes local files only, and the path guards below
570
+ (output-is-input, existing output, output directory) only mean something for a local path. A
571
+ `-f lavfi` input is a filtergraph, not a location, and is left alone; a local name that
572
+ contains a colon still works written as `./name`."""
573
+ base = os.path.basename(cmd[0])
574
+ if not (base.startswith("ffmpeg") or base.startswith("ffprobe")):
575
+ return
576
+ fmt = None
577
+ values = []
578
+ for i, a in enumerate(cmd[1:-1], start=1):
579
+ if a == "-f":
580
+ fmt = cmd[i + 1]
581
+ elif a == "-i":
582
+ if fmt != "lavfi":
583
+ values.append(cmd[i + 1])
584
+ fmt = None
585
+ last = cmd[-1]
586
+ if not last.startswith("-"):
587
+ values.append(last)
588
+ for value in values:
589
+ if looks_like_url(value):
590
+ refuse("input", "media_path_is_url",
591
+ f"{value!r} would be opened by ffmpeg as a URL ({value.split(':', 1)[0]}:), not as a local file; "
592
+ "this skill reads and writes local files only. Pass a plain path (a local name containing "
593
+ "a colon works as ./name)", path=value, scheme=value.split(":", 1)[0].lower())
594
+
595
+
556
596
  def _check_no_overwrite_input(cmd: Sequence[str]) -> None:
557
597
  """Refuse an ffmpeg command whose output path resolves to the same file as one of its
558
598
  inputs. ffmpeg's own "Output same as Input" guard only catches byte-identical path
@@ -615,6 +655,10 @@ def _check_output_path(cmd: Sequence[str]) -> None:
615
655
  output = cmd[-1]
616
656
  if output == "-" or output.startswith("pipe:") or output.startswith("-"):
617
657
  return
658
+ if looks_like_url(output):
659
+ refuse("input", "media_path_is_url",
660
+ f"output {output!r} would be opened by ffmpeg as a URL, not as a local file; pass a plain path "
661
+ "(a local name containing a colon works as ./name)", path=output, scheme=output.split(":", 1)[0].lower())
618
662
  if os.path.isdir(output):
619
663
  refuse("input", "output_is_directory",
620
664
  f"output {output!r} is a directory; pass a file path (e.g. {os.path.join(output, 'result.mp4')!r})", output=output)
@@ -1042,6 +1086,7 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True, ctx: "Op
1042
1086
  # or runs the command (color.py imports this module, hence the late import)
1043
1087
  from _common.color import fold_bt709_matrix
1044
1088
  cmd = fold_bt709_matrix(cmd)
1089
+ _refuse_url_media(cmd)
1045
1090
  _check_no_overwrite_input(cmd)
1046
1091
  _check_output_path(cmd)
1047
1092
  _check_existing_output(cmd)
@@ -1144,6 +1189,7 @@ def run_analysis(cmd: Sequence[str], *, check: bool = True, text: bool = True, r
1144
1189
  wall-clock limit as any other ffmpeg invocation and, with check=True, the same `kind: ffmpeg`
1145
1190
  failure instead of an exit-0 "0 scenes found" over a file ffmpeg could not read. record=True
1146
1191
  lists the command in the --json `commands` like run() does."""
1192
+ _refuse_url_media(cmd)
1147
1193
  if record:
1148
1194
  STATE.commands.append(_cmdline(cmd))
1149
1195
  limit = _limit_for(cmd)
@@ -415,8 +415,10 @@ ERROR_REASONS = {
415
415
  "output_exists": "an output already exists and --overwrite was not given; details.outputs: [{path, resolved, size, mtime_ns}]",
416
416
  "output_is_input": "the output resolves to the same file as an input; details: {output, input, resolved}",
417
417
  "output_is_directory": "the output path is a directory; details: {output}",
418
+ "media_path_is_url": "an input or output has a protocol prefix (tcp:, http://, file:, ...) that ffmpeg would open as a URL; the skill reads and writes local files only; details: {path, scheme}",
418
419
  "output_dir_missing": "the output's directory does not exist; details: {output, directory}",
419
420
  "output_dir_not_writable": "the output's directory is not writable; details: {output, directory}",
421
+ "timemap_path_taken": "silence.py --timemap names the same file as -o, --edl or --plan; details: {timemap, flag, path}",
420
422
  "output_locked": "another run holds the output's lock; details: {lock, output, pid, pid_alive, deadline, why}",
421
423
  "plan_inputs_changed": "render.py PLAN: a fingerprinted input is missing or changed; details: {plan, plan_sha256, checked_at, "
422
424
  "inputs: [{path, status: unchanged|changed|missing, expected: {size, sha256_head_tail}, actual: {size, "
@@ -472,6 +474,8 @@ DIAGNOSTIC_CODES = {
472
474
  "sync_low_confidence": ("warnings", "sync.py / multicam.py: a source's offset has a correlation confidence below 0.1; {path, confidence}"),
473
475
  "cropdetect_windows_failed": ("warnings", "cropdetect.py: sampled windows ffmpeg could not decode (windows_failed); {failed, windows, starts}"),
474
476
  "filler_words_untimed": ("warnings", "silence.py --filler: listed words in the transcript with no usable timing were not removed (filler.matched_untimed); {count}"),
477
+ "cut_near_sound": ("warnings", "silence.py: a cut point's level is within CUT_SOUND_MARGIN_DB of --threshold, so it may land in a breath or a word's weak tail (cut_warnings lists them)"),
478
+ "cut_levels_unmeasured": ("warnings", "silence.py: the source could not be decoded to measure the level at each cut; cut_points and cut_warnings are null (unmeasured, not silent)"),
475
479
  "filler_removal_caution": ("warnings", "silence.py --filler: a removed word that is often an ordinary word, or a discourse marker added with --filler-extra (also in filler.warnings)"),
476
480
  # media and defaults
477
481
  "silent_track_mixed": ("warnings", "audio.py --on-silent warn: a silent track was mixed anyway; {tracks}"),
@@ -6,7 +6,7 @@ side so words are not clipped, drops gaps shorter than --min-silence, and
6
6
  writes a frame-accurate re-encode in one pass (select, and ametadata for the sound).
7
7
 
8
8
  Examples:
9
- python3 silence.py talk.mp4 # -35 dB, gaps >= 0.6 s, 0.15 s margin
9
+ python3 silence.py talk.mp4 # -35 dB, gaps >= 0.6 s, 0.2 s margin
10
10
  python3 silence.py talk.mp4 --threshold -40 --min-silence 1 --margin 0.25
11
11
  python3 silence.py talk.mp4 --list # print the silences and the resulting cut list, no output
12
12
  python3 silence.py talk.mp4 --edl keep.txt # also save the kept ranges (START-END per line, cut.py --segments format)
@@ -15,7 +15,7 @@ import argparse
15
15
  import json
16
16
  import os
17
17
  import sys
18
- from typing import Any, Dict, List, Tuple
18
+ from typing import Any, Dict, List, Optional, Tuple
19
19
 
20
20
  # `detect` moved into _common/probe.py in 1.16.0 so metadata.py --auto-chapters can measure the
21
21
  # same silences without importing this tool; the body is unchanged and the name still lives here.
@@ -23,6 +23,8 @@ from _common import (filler_spans, FILLER_WORDS, FILLER_AMBIGUOUS, FILLER_DISCOU
23
23
  FILLER_PAD, read_text_or_die)
24
24
  from _common import asr as _asr
25
25
  from _common.emit import warn
26
+ from _common import refuse_existing_outputs, refuse_output_is_input, _check_output_path
27
+ from _common.emit import refuse
26
28
  from _common import detect_silences as detect, STATE, video_args, add_common, apply_common, audio_codec_for, cfr_args, default_output, die, emit, ffmpeg_base, info, is_audio_output, print_json, probe, run, X264_PRESETS, measured_level_dbfs, fmt_secs
27
29
 
28
30
 
@@ -44,6 +46,13 @@ def merge_spans(spans: List[Tuple[float, float]]) -> List[Tuple[float, float]]:
44
46
  return out
45
47
 
46
48
 
49
+ # around each cut, how many seconds on either side the level is measured over, and how close to
50
+ # --threshold (in dB) that level may come before the cut is flagged: room tone sits far below the
51
+ # threshold, a breath or a word's weak tail just under it
52
+ CUT_LEVEL_WINDOW = 0.05
53
+ CUT_SOUND_MARGIN_DB = 10.0
54
+ CUT_LEVEL_RATE = 8000
55
+ CUT_LEVEL_CHANNELS = 2 # measured one by one; a surround source is folded to two first
47
56
  DIGITAL_SILENCE_DB = -90.0 # 2.20: at or below this a span is digital silence (16-bit dither sits near -96)
48
57
  BREATH_FLOOR = 0.12 # shortest gap silencedetect is asked for under --speech-aware
49
58
 
@@ -88,6 +97,114 @@ def keep_ranges(silences: List[Tuple[float, float]], duration: float, margin: fl
88
97
  return keeps
89
98
 
90
99
 
100
+ def time_map(keeps: List[Tuple[float, float]], duration: float) -> Dict[str, Any]:
101
+ """Each kept range with its time in the source and in the output, and each removed range with
102
+ the output time it was cut at: re-timing subtitles, or finding a removed sentence again."""
103
+ kept: List[Dict[str, float]] = []
104
+ removed: List[Dict[str, float]] = []
105
+ out, prev = 0.0, 0.0
106
+ for s, e in keeps:
107
+ # the render cuts on bounds rounded to the millisecond (between(t,s,e) below): advance by
108
+ # that rounded length, or every off-grid range adds its own error to every later time
109
+ s, e = round(s, 3), round(e, 3)
110
+ if s - prev > 0.0005:
111
+ removed.append({"source_start": round(prev, 3), "source_end": round(s, 3), "output_time": round(out, 3)})
112
+ kept.append({"source_start": round(s, 3), "source_end": round(e, 3),
113
+ "output_start": round(out, 3), "output_end": round(out + (e - s), 3)})
114
+ out += e - s
115
+ prev = e
116
+ if duration - prev > 0.0005:
117
+ removed.append({"source_start": round(prev, 3), "source_end": round(duration, 3), "output_time": round(out, 3)})
118
+ return {"kept": kept, "removed": removed}
119
+
120
+
121
+ def write_time_map(path: str, tm: Dict[str, Any]) -> None:
122
+ """--timemap: JSON, or CSV for a .csv path (one row per kept and removed range)."""
123
+ if os.path.splitext(path)[1].lower() == ".csv":
124
+ rows = ["type,source_start,source_end,output_start,output_end"]
125
+ rows += [f"kept,{k['source_start']:.3f},{k['source_end']:.3f},{k['output_start']:.3f},{k['output_end']:.3f}" for k in tm["kept"]]
126
+ rows += [f"removed,{r['source_start']:.3f},{r['source_end']:.3f},{r['output_time']:.3f},{r['output_time']:.3f}" for r in tm["removed"]]
127
+ text = "\n".join(rows) + "\n"
128
+ else:
129
+ text = json.dumps(tm, indent=2) + "\n"
130
+ # staged and swapped in, so a full disk or an interrupted write leaves the previous map whole;
131
+ # the staging name is short and unique, so it neither outgrows the name limit nor truncates a
132
+ # sibling that happens to exist
133
+ import tempfile
134
+ fd, tmp = tempfile.mkstemp(prefix=".tm", suffix=".tmp", dir=os.path.dirname(os.path.abspath(path)))
135
+ try:
136
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
137
+ fh.write(text)
138
+ mask = os.umask(0) # mkstemp makes the file 0600; give the map the mode open() would have
139
+ os.umask(mask)
140
+ os.chmod(tmp, 0o666 & ~mask)
141
+ os.replace(tmp, path)
142
+ except BaseException:
143
+ try:
144
+ os.remove(tmp)
145
+ except OSError:
146
+ pass
147
+ raise
148
+
149
+
150
+ def cut_levels(path: str, tm: Dict[str, Any], duration: float,
151
+ filler_spans_: List[Tuple[float, float]]) -> Optional[List[Dict[str, Any]]]:
152
+ """The RMS level just around every cut, in dBFS (None for digital silence), measured on the
153
+ source: one decode at CUT_LEVEL_RATE, CUT_LEVEL_WINDOW on each side of the cut point. A probe
154
+ of the output can say its length and streams are right, not whether a cut landed in a breath
155
+ or a word's weak tail; the level there is the measurable part of that question. `edge` is
156
+ `out` where the source is left, `in` where it is picked up again; `filler` marks an edge that
157
+ sits on a removed word (it is meant to sit in sound). Each channel is measured on its own and
158
+ the loudest is reported: a mono mix reads antiphase stereo as silence. The PCM goes to a temp
159
+ file and only the windows are read back, so an hour of audio is not held in memory. None when
160
+ the decode fails: an unmeasured cut is not a silent one."""
161
+ import array
162
+ import math
163
+ import tempfile
164
+ from _common import require_tool, run_analysis
165
+ points = []
166
+ for r in tm["removed"]:
167
+ for t, edge in ((r["source_start"], "out"), (r["source_end"], "in")):
168
+ if (edge == "out" and t <= 0.0005) or (edge == "in" and t >= duration - 0.0005):
169
+ continue
170
+ # this edge, not the whole removed range: a filler merged into a silence leaves the
171
+ # silence's own edge next to speech, and that one still gets checked
172
+ on_word = any(fs - CUT_LEVEL_WINDOW <= t <= fe + CUT_LEVEL_WINDOW for fs, fe in filler_spans_)
173
+ points.append((t, r["output_time"], edge, on_word))
174
+ if not points:
175
+ return []
176
+ channels = CUT_LEVEL_CHANNELS
177
+ half = int(CUT_LEVEL_WINDOW * CUT_LEVEL_RATE)
178
+ frame = 2 * channels
179
+ out = []
180
+ with tempfile.TemporaryDirectory(prefix="silence-levels-") as tmp:
181
+ raw = os.path.join(tmp, "pcm.s16le")
182
+ proc = run_analysis([require_tool("ffmpeg"), "-hide_banner", "-loglevel", "error", "-nostdin", "-i", path, "-vn",
183
+ "-ac", str(channels), "-ar", str(CUT_LEVEL_RATE), "-f", "s16le", "-y", raw],
184
+ check=False, text=False)
185
+ if proc.returncode != 0 or not os.path.isfile(raw) or not os.path.getsize(raw):
186
+ return None
187
+ with open(raw, "rb") as fh:
188
+ for t, ot, edge, on_word in points:
189
+ first = max(0, int(round(t * CUT_LEVEL_RATE)) - half)
190
+ fh.seek(first * frame)
191
+ data = fh.read(2 * half * frame)
192
+ block = array.array("h")
193
+ block.frombytes(data[: len(data) // frame * frame])
194
+ if sys.byteorder == "big":
195
+ block.byteswap()
196
+ level = None
197
+ if block:
198
+ n = len(block) // channels
199
+ peak = max(math.sqrt(sum(x * x for x in block[c::channels]) / n) for c in range(channels)) / 32768.0
200
+ level = round(20 * math.log10(peak), 1) if peak > 0 else None
201
+ entry = {"source_time": round(t, 3), "output_time": round(ot, 3), "edge": edge, "rms_dbfs": level}
202
+ if on_word:
203
+ entry["filler"] = True
204
+ out.append(entry)
205
+ return out
206
+
207
+
91
208
  def _word_list(text: "str") -> "list":
92
209
  return [w.strip() for w in str(text or "").replace(",", "\n").splitlines() if w.strip()]
93
210
 
@@ -289,7 +406,7 @@ def main() -> int:
289
406
  ap.add_argument("-o", "--output", help="output file (default: <name>_tight.<ext>)")
290
407
  ap.add_argument("--threshold", type=float, default=-35.0, help="silence level in dBFS (default -35; use -40..-45 for quiet rooms)")
291
408
  ap.add_argument("--min-silence", type=float, default=0.6, help="only remove gaps at least this long in seconds (default 0.6)")
292
- ap.add_argument("--margin", type=float, default=0.15, help="seconds of silence to keep on each side of speech (default 0.15)")
409
+ ap.add_argument("--margin", type=float, default=0.2, help="seconds of silence to keep on each side of speech (default 0.2; 0.15 before 2.22)")
293
410
  ap.add_argument("--min-keep", type=float, default=0.2, help="drop kept pieces shorter than this (default 0.2)")
294
411
  ap.add_argument("--speech-aware", action="store_true",
295
412
  help="keep breaths shorter than --min-silence when they sit inside a sentence "
@@ -298,6 +415,10 @@ def main() -> int:
298
415
  "with --filler through the same keep_ranges() removal list.")
299
416
  ap.add_argument("--list", action="store_true", help="only print silences and the kept ranges")
300
417
  ap.add_argument("--edl", help="write the kept ranges to this file, one START-END per line")
418
+ ap.add_argument("--timemap", metavar="FILE",
419
+ help="write the source-to-output time map: every kept range with its source and output "
420
+ "times and every removed range with the output time it was cut at; .csv for CSV, "
421
+ "otherwise JSON (re-time subtitles, find a removed sentence again)")
301
422
  fil = ap.add_argument_group("filler words (1.17)")
302
423
  fil.add_argument("--filler", action="store_true",
303
424
  help="also remove filler words. Needs measured word timings: pass --words or "
@@ -336,6 +457,20 @@ def main() -> int:
336
457
  add_common(ap)
337
458
  args = ap.parse_args()
338
459
  apply_common(args)
460
+ if args.timemap:
461
+ if os.path.splitext(args.timemap)[1].lower() not in (".json", ".csv"):
462
+ die(f"--timemap must end in .json or .csv: {args.timemap}", kind="input")
463
+ # before any decode or write, so a dry run predicts it too: a missing directory or a
464
+ # directory at the path is a `kind: input` refusal, not a traceback from open()
465
+ _check_output_path([args.timemap])
466
+ # --overwrite allows replacing an old map, never the source, the cut or the EDL
467
+ refuse_output_is_input(args.timemap, *[p for p in (args.input, args.words) if p])
468
+ for flag, other in (("-o", args.output), ("--edl", args.edl), ("--plan", getattr(args, "plan", None))):
469
+ if other and os.path.realpath(other) == os.path.realpath(args.timemap):
470
+ refuse("input", "timemap_path_taken",
471
+ f"--timemap {args.timemap} is the same file as {flag}; give the map its own path",
472
+ timemap=args.timemap, flag=flag, path=other)
473
+ refuse_existing_outputs([args.timemap])
339
474
 
340
475
  if args.filler_pad < 0:
341
476
  die("--filler-pad cannot be negative: a negative pad turns each word's span inside out "
@@ -431,6 +566,35 @@ def main() -> int:
431
566
  summary["hint"] = f"no passage sits below {args.threshold:g} dBFS for {args.min_silence:g}s; try a higher --threshold (e.g. -25) or a shorter --min-silence"
432
567
  info("hint: " + summary["hint"])
433
568
 
569
+ tm = time_map(keeps, duration)
570
+ points: Any = False # False: not measured (a dry run over a pending input); None: the decode failed
571
+ if not (STATE.dry_run and not os.path.exists(args.input)):
572
+ points = cut_levels(args.input, tm, duration, list(filler_ranges))
573
+ if points is None:
574
+ warn("could not decode the source to measure the level at each cut; cut_points and cut_warnings are null "
575
+ "(unmeasured, not silent): listen at the cuts", "cut_levels_unmeasured")
576
+ summary["cut_points"] = summary["cut_warnings"] = None
577
+ elif points is not False:
578
+ near = [{k: v for k, v in c.items() if k != "filler"} for c in points
579
+ if not c.get("filler") and c["rms_dbfs"] is not None and c["rms_dbfs"] > args.threshold - CUT_SOUND_MARGIN_DB]
580
+ summary["cut_points"] = points
581
+ summary["cut_warnings"] = near
582
+ if near:
583
+ where = ", ".join("%.2fs" % c["source_time"] for c in near[:5]) + (", ..." if len(near) > 5 else "")
584
+ warn(f"{len(near)} cut point(s) sit within {CUT_SOUND_MARGIN_DB:g} dB of --threshold ({where}): "
585
+ "a breath or a word's weak tail may be cut; listen there, or lower --threshold / raise --margin",
586
+ "cut_near_sound", cuts=len(near))
587
+
588
+ def finish_time_map() -> None:
589
+ """Written last, once the cut it describes exists (or under --list, where the map is the
590
+ result): a map left beside a failed or refused encode would describe media never made."""
591
+ if not args.timemap:
592
+ return
593
+ if not STATE.dry_run: # an artifact like the EDL: a plan writes nothing
594
+ write_time_map(args.timemap, tm)
595
+ info(f"wrote {args.timemap}")
596
+ summary["timemap"] = args.timemap
597
+
434
598
  if args.edl:
435
599
  if not STATE.dry_run: # the EDL is an artifact like the cut itself: a plan writes nothing
436
600
  with open(args.edl, "w", encoding="utf-8") as fh:
@@ -439,6 +603,7 @@ def main() -> int:
439
603
  info(f"wrote {args.edl}")
440
604
 
441
605
  if args.list or args.filler_list:
606
+ finish_time_map()
442
607
  if args.json:
443
608
  emit(None, **summary)
444
609
  else:
@@ -469,6 +634,7 @@ def main() -> int:
469
634
  run(cmd)
470
635
  r = probe(output, role="output")
471
636
  info(f"wrote {output} ({fmt_secs(r['duration'])}, expected ~{kept:.3f}s)")
637
+ finish_time_map()
472
638
  emit(output, **summary)
473
639
  return 0
474
640