ffmpeg-skill 2.21.0 → 2.22.0
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 +22 -1
- package/docs/contract.md +25 -3
- package/package.json +1 -1
- package/references/scripts.md +29 -2
- package/scripts/_common/emit.py +183 -6
- package/scripts/_common/probe.py +11 -0
- package/scripts/_common/runner.py +10 -3
- package/scripts/_contract.py +5 -2
- package/scripts/cut.py +16 -5
- package/scripts/export.py +4 -1
- package/scripts/join.py +2 -2
- package/scripts/silence.py +169 -3
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.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`2.22.0`) | 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.
|
|
134
|
+
"skill": {"id": "ffmpeg-skill", "version": "2.22.0", "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"},
|
|
@@ -679,7 +679,7 @@ Per-tool keys added after 2.2.2, all additive:
|
|
|
679
679
|
| `encoder`, `hw`, `notes` | every re-encoding tool, `export.py`, `render.py`, `batch.py` (and its rows), `waveform.py` | `encoder`: the video encoder the last command ran (after any GPU→CPU fallback; under `--dry-run`, the planned one), `copy` for a stream copy; absent when no command encoded video. A tool that runs other tools reports its stages': `render.py` its stages and an executed plan's tool, `waveform.py` the title/caption stage that writes its file (its `commands` include that stage's). `hw` is present when VideoToolbox was asked for (`--hw`, `FFMPEG_SKILL_HW=1`, or a stage or `batch.py` item that reported it), on `export.py` when `FFMPEG_SKILL_HW=1` is set but its delivery preset does not take it, and when an encode ran on VideoToolbox because the build has no `libx264`/`libx265`: `{requested, source: flag\|env\|auto, used, fallback, notes}`. `requested` says whether this run's encodes were asked onto VideoToolbox (`false` for that export, and for `source: "auto"`, where nobody asked: `notes` names the missing CPU encoder and the one that ran, and `--no-hw` is not offered as a remedy since there is no CPU encoder to go back to; a job VideoToolbox refuses there is a plain `kind: ffmpeg` failure, not retried), `used` whether the encoder that ran last is VideoToolbox (`null` under `--dry-run`, which ran nothing, and when the tool encoded nothing itself: `batch.py`, whose top-level `hw` gathers its items' `requested`/`source`/`fallback`/`notes` while each `results` row carries the item's own `encoder`, and `hw`/`notes` when VideoToolbox was asked for), `fallback` whether a job VideoToolbox refused was re-encoded on the CPU, and `notes` why. Only a VideoToolbox failure (the encoder could not open, or its compression session failed) is retried on the CPU; any other failure is reported as it happened, once. Every VideoToolbox encode adds a top-level `notes` line: its quality is not CRF-equivalent (`--quality` / a preset's CRF maps to `-q:v` approximately), and `verified` stays what it is for every tool, the measured properties of the output. A `kind: ffmpeg` failure under `--hw` / `FFMPEG_SKILL_HW` carries `encoder` and `hw` for the command that ran last (a stage's, when `render.py`/`waveform.py` re-raise it); when the command itself failed inside the run (not a tool's own check afterwards, such as `cut.py`'s), its message also names that encoder |
|
|
680
680
|
| `transcription` | `caption.py --transcribe` (every `--mode`); inside `filler` for `silence.py --filler --transcribe` | `{routing, detected_language?, engine, model, language}`: the engine and model that made the transcript; `language` is the language the engine was told -- the one named (`--language`, `--filler-lang`), else the one auto's detector named -- and, when nothing was named, the language the engine decoded where it reports one (whisper.cpp's word-timing JSON, faster-whisper, openai-whisper's word-timing JSON), else `null`; `detected_language` is present when auto's whisper.cpp detector named one; `routing` says why `--engine auto` chose the engine (`"requested"` for a named engine), ending `, no Parakeet engine transcribed it` when auto chose Parakeet and a Whisper engine made the transcript after every Parakeet engine failed |
|
|
681
681
|
| `notes` | `silence.py --filler --transcribe` (new for `silence.py`; `caption.py` already had it) | present when `--engine auto` ran the English-only Parakeet model on English *assumed* (no language given or detectable, and no Whisper engine installed or none that succeeded): the transcript is wrong if the speech is not English. `caption.py --transcribe` puts the same line in its `notes` |
|
|
682
|
-
| `reencode_reason`, `segment_precision`, `least_exact_precision`, `start_snapped` | `cut.py` (every run) | `reencode_reason`: why anything was re-encoded, distinct values in first-seen order, `[]` for a clean copy: `requested` (`--accurate`), `codec` (`--codec`), `vfr`, `vfr_inconclusive`, `pcm_container`, `copy_failed`, `tolerance`, `concat_fallback` (a `--segments` join that could not stay a stream copy, which now also sets `reencoded: true` and `mode: hybrid`; it reported `copy` before). `segment_precision`: each `--segments` segment's precision (`null` for one segment); `least_exact_precision`: the least exact of them, on every run. `precision` and `keyframe_snapped` keep their meaning (`keyframe_snapped` is `precision == "packet"`, a stream copy end to end); `least_exact_precision` differs from `precision` only for an audio-only join of copied and re-encoded parts. `start_snapped`: measured, `true` when a copied segment's picture starts more than one frame from its requested start (or its start could not be measured: an unknown start is never claimed exact), `false` when every start is where asked or everything re-encoded, `null` under `--dry-run`. `requested_segments`/`requested_duration` keep their meaning, the request clamped to the media's duration: a segment ended with the video (less than a frame of sound trimmed) shows in `duration_delta_seconds` and a `notes` line |
|
|
682
|
+
| `reencode_reason`, `segment_precision`, `least_exact_precision`, `start_snapped` | `cut.py` (every run) | `reencode_reason`: why anything was re-encoded, distinct values in first-seen order, `[]` for a clean copy: `requested` (`--accurate`), `codec` (`--codec`), `vfr`, `vfr_inconclusive`, `pcm_container`, `copy_failed`, `stream_lost` (the copy wrote no video or no audio: an MPEG-TS copy whose window holds no video keyframe, #367), `tolerance`, `concat_fallback` (a `--segments` join that could not stay a stream copy, which now also sets `reencoded: true` and `mode: hybrid`; it reported `copy` before). `segment_precision`: each `--segments` segment's precision (`null` for one segment); `least_exact_precision`: the least exact of them, on every run. `precision` and `keyframe_snapped` keep their meaning (`keyframe_snapped` is `precision == "packet"`, a stream copy end to end); `least_exact_precision` differs from `precision` only for an audio-only join of copied and re-encoded parts. `start_snapped`: measured, `true` when a copied segment's picture starts more than one frame from its requested start (or its start could not be measured: an unknown start is never claimed exact), `false` when every start is where asked or everything re-encoded, `null` under `--dry-run`. `requested_segments`/`requested_duration` keep their meaning, the request clamped to the media's duration: a segment ended with the video (less than a frame of sound trimmed) shows in `duration_delta_seconds` and a `notes` line |
|
|
683
683
|
| `edit_list`, `stored_preroll_seconds`, `av_start_skew_seconds`, `notes` | `cut.py` (every run) | `edit_list`: `true` only for a single-segment `.mp4`/`.m4v`/`.mov` stream copy run with `--edit-list`, which keeps the MP4 edit list the copy writes instead of `-avoid_negative_ts make_zero` (the default, unchanged): the keyframe's pre-roll is stored but hidden, so the picture starts at `--start`. `stored_preroll_seconds`: with `edit_list`, the seconds decoded from the keyframe the copy's seek lands on (measured with the seek ffmpeg makes) up to the first presented frame; `null` otherwise. Under `--edit-list` a copy is judged by its video's length (a `notes` line says so when the container is longer), and one that starts where asked but ends past `--tolerance` re-encodes (`tolerance`) without a `lossless_alternative`. `av_start_skew_seconds`: the output's audio start minus its video start (`null` when either is missing or under `--dry-run`); on a stream copy, when it is more than max(2 frames, 0.1 s) away from the source's own audio/video offset where the copy's picture starts (the keyframe it began from; `--start` under `--edit-list`), a `notes` line names `--accurate`, and `--edit-list` for a single `.mp4`/`.mov` copy whose start snapped. A re-encode carries no such note. The skew is reported, never repaired. `notes`: what the run did that a caller should know, in plain sentences (`[]` when nothing) |
|
|
684
684
|
| `video.start_time`, `audio.start_time` | `probe.py` (and every `probe` block a tool returns) | where each stream starts, in seconds on the file's own clock (ffprobe's stream `start_time`; `null` when unknown). A video stream that starts after its audio, or the reverse, is how a lossless cut's A/V skew shows; `cut.py` reads them for `av_start_skew_seconds` |
|
|
685
685
|
| `cut_reencode_reasons` | `batch.py` (and its `results` rows) | a row whose recipe ran `cut.py` lists each `reencode_reason` its `cut.py` calls named, once per call, beside `cut_reencoded`; the top level counts them across the folder (`{}` when no call re-encoded, `null` when no `cut.py` step ran) on a completed batch, as `cut_stream_copy` does: a failure document carries `results`, whose rows hold each row's reasons; a re-encoded call in a row served from a cache an earlier 2.x batch wrote, which recorded no reasons, counts as `unknown`. `cut_stream_copy` is unchanged |
|
|
@@ -709,6 +709,27 @@ silence a tool reported as fine while another measurement disagreed):
|
|
|
709
709
|
| `inputs_duration`, `transition_shortening`; step `duration`'s `inputs_total` | `join.py` | the clips' own total and how much the transitions took out of it. `expected_duration` is derived from the transition the join applied, so it cannot show that on its own |
|
|
710
710
|
| `silence_kinds`, `silence_breakdown` | `silence.py` | per detected silence, `digital` (at or below -90 dBFS) or `below_threshold` (quiet sound under `--threshold`), and the count and seconds of each with the level that defines it |
|
|
711
711
|
|
|
712
|
+
Keys added after 2.21, all additive (#367 follow-up, and a report of a filename with a space that
|
|
713
|
+
lost 40 s of sound with no error):
|
|
714
|
+
|
|
715
|
+
| key | tool | what it holds |
|
|
716
|
+
|---|---|---|
|
|
717
|
+
| step `streams`'s `expected_count`, `measured_count` | `cut.py`, `join.py`, `export.py` | how many video and audio streams the output should have and has. These writers map one of each, so a count below the expected one is `missing`; `expected`/`measured` keep their booleans |
|
|
718
|
+
| `verification[]` step `stream_lengths` (`property: stream_end`) | `cut.py`, `join.py`, `export.py`, when the output keeps both picture and sound | where each stream ends (`ends`: start plus its own length, the DURATION tag in Matroska/WebM) and how far before the later one (`shortfall`). The inputs' own gap is allowed (`input_shortfall`, summed over join's clips; for cut.py only the gap inside the requested ranges) plus `tolerance` (`_common.emit.STREAM_LENGTH_TOLERANCE`, 0.25 s); a stream that ends earlier is named in `short` and fails the step. Ends, not lengths: a stream that only starts late is cut's start-skew note. An output length that cannot be read fails with `reason: not_measured`, and so does an output gap when an input's own stream length could not be read (`input_shortfall: null`: the gap may be the input's) |
|
|
719
|
+
| `verification[]` step `decode` (`property: decodable`) | `cut.py`, `join.py`, `export.py` | `frames`: per kept stream, how many frames ffmpeg decoded from the first `window` seconds (`_common.emit.DECODE_WINDOW_SECONDS`, 1 s) and from its last `window` before the stream's own end (the read starts `DECODE_SEEK_MARGIN_SECONDS`, 10 s, earlier so a seek past the last keyframe still decodes, but only frames still on screen in the window count; a tail with no frame is read once more from up to `DECODE_LONG_GOP_SECONDS`, 60 s, earlier, for a GOP longer than the margin). A stream with no frame at either end is named in `undecodable` and fails the step; one with no end time of its own (the container's is the longest stream's) has its tail `not_measured`, which fails the step without calling it undecodable. `join.py` runs it on an audio-only join too |
|
|
720
|
+
| `probe.start_time` | `probe.py` and every output probe | where the file's timeline starts (the container's start time; MPEG-TS starts at about 1.4 s). A `-ss`, a cut's `--start` and the decode check's reads count from here |
|
|
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
|
+
| `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
|
+
|
|
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
|
+
|
|
712
733
|
Per-tool keys added after 2.10.11 ("nothing happens silently"), all additive:
|
|
713
734
|
|
|
714
735
|
| key | tool | what it holds |
|
|
@@ -785,6 +806,7 @@ here claims otherwise until real exit-code/stderr sniffing exists to back that u
|
|
|
785
806
|
A refusal also carries `error.reason`, a stable snake_case code for *why*
|
|
786
807
|
(`contract --json` → `json_output.error_reasons` lists them: `output_exists`, `output_is_input`,
|
|
787
808
|
`output_is_directory`, `output_dir_missing`, `output_dir_not_writable`, `output_locked`,
|
|
809
|
+
`silence.py --timemap`'s `timemap_path_taken`,
|
|
788
810
|
`render.py`'s `plan_*` reasons, and `--codec av1`/`prores`'s `encoder_missing` and
|
|
789
811
|
`encoder_list_unreadable`), and `error.details`, the facts the message is written from, so a
|
|
790
812
|
caller never parses the prose:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.22.0",
|
|
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",
|
package/references/scripts.md
CHANGED
|
@@ -29,10 +29,13 @@ Every row names the constant or flag it comes from, and a test holds the table t
|
|
|
29
29
|
| a levels pass keeps the sound's length to (or one AAC frame, 1024 samples, if longer) | 0.03 s | `loudness.AUDIO_LENGTH_TOLERANCE` | `loudness.py` |
|
|
30
30
|
| a joined picture is as long as the clips less the transitions, to (frames) | 1.5 | `join.DURATION_TOLERANCE_FRAMES` | `join.py` |
|
|
31
31
|
| ... and at least | 0.05 s | `join.DURATION_TOLERANCE_MIN` | `join.py` |
|
|
32
|
+
| the picture and the sound end together, beyond the gap the inputs had, to | 0.25 s | `_common.emit.STREAM_LENGTH_TOLERANCE` | `cut.py`, `join.py`, `export.py` (`stream_lengths`) |
|
|
33
|
+
| every kept stream decodes at least one frame in its first and its last | 1.0 s | `_common.emit.DECODE_WINDOW_SECONDS` | `cut.py`, `join.py`, `export.py` (`decode`) |
|
|
32
34
|
| a lossless cut may land this far from the asked time before it re-encodes | 0.5 s | `cut.py --tolerance` | `cut.py` |
|
|
33
35
|
| silence is sound at or below | -35 dBFS | `silence.py --threshold` | `silence.py` |
|
|
34
36
|
| ... lasting at least | 0.6 s | `silence.py --min-silence` | `silence.py` |
|
|
35
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`) |
|
|
36
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` |
|
|
37
40
|
| a filler word longer than this is a held vowel, kept | 1.2 s | `_common.decision.FILLER_MAX_WORD` | `silence.py --filler` |
|
|
38
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` |
|
|
@@ -165,7 +168,7 @@ in the length. `keyframe_snapped` is true for every stream copy (`precision`
|
|
|
165
168
|
than a frame from `--start`, or when its start could not be measured (an unknown start is
|
|
166
169
|
never claimed exact); `null` under `--dry-run`.
|
|
167
170
|
`reencode_reason` lists every cause of a re-encode (`requested`, `codec`, `vfr`,
|
|
168
|
-
`vfr_inconclusive`, `pcm_container`, `copy_failed`, `tolerance`, `concat_fallback`);
|
|
171
|
+
`vfr_inconclusive`, `pcm_container`, `copy_failed`, `stream_lost`, `tolerance`, `concat_fallback`);
|
|
169
172
|
`--segments` adds `segment_precision`, and `least_exact_precision` is the least exact one.
|
|
170
173
|
A `--segments` video copy join adds `join_check` (`packets`, `expected_packets`,
|
|
171
174
|
`max_step_seconds`, `audio_offset_ms` per part, `audio_parts_checked`, `ok`: the
|
|
@@ -637,7 +640,7 @@ each shorter clip's last frame (with silence) out to the longest.
|
|
|
637
640
|
|
|
638
641
|
### silence.py — remove dead air / jump cuts
|
|
639
642
|
```
|
|
640
|
-
silence.py INPUT [--threshold -35] [--min-silence 0.6] [--margin 0.
|
|
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]
|
|
641
644
|
silence.py INPUT --filler --words transcript.json [--filler-lang auto|en|ja|ko|zh|es|de|fr|pt|it|nl|pl|ru|tr]
|
|
642
645
|
[--filler-words FILE] [--filler-extra W,W] [--filler-keep W,W] [--filler-pad 0.02]
|
|
643
646
|
[--transcribe] [--engine auto|parakeet-mlx|parakeet.cpp|whisper.cpp|faster-whisper|openai-whisper]
|
|
@@ -651,6 +654,30 @@ without rendering; `--edl` saves the kept ranges in `cut.py --segments` format
|
|
|
651
654
|
so the user can edit the list by hand. Quiet rooms need `--threshold -40`
|
|
652
655
|
to `-45`; noisy ones `-30`. Always tell the user how many seconds were removed.
|
|
653
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
|
+
|
|
654
681
|
**`--filler` (1.17)** removes filler words as well, through the same
|
|
655
682
|
`keep_ranges()` graph with more, shorter ranges. It is **never**
|
|
656
683
|
applied without measured word timings: pass `--words` (a whisper JSON carrying
|
package/scripts/_common/emit.py
CHANGED
|
@@ -396,21 +396,190 @@ UNVERIFIED: Dict[str, List[str]] = {
|
|
|
396
396
|
}
|
|
397
397
|
|
|
398
398
|
|
|
399
|
-
|
|
399
|
+
# a picture and its sound may differ in length by this much more than they did in the input
|
|
400
|
+
STREAM_LENGTH_TOLERANCE = 0.25
|
|
401
|
+
# how many seconds at each end of every kept stream the decode check decodes
|
|
402
|
+
DECODE_WINDOW_SECONDS = 1.0
|
|
403
|
+
# how much earlier than its window the tail decode starts reading (a seek past the last keyframe)
|
|
404
|
+
DECODE_SEEK_MARGIN_SECONDS = 10.0
|
|
405
|
+
# how far back the one retry of an empty tail reads (a GOP longer than the margin)
|
|
406
|
+
DECODE_LONG_GOP_SECONDS = 60.0
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _expected_count(want: Any) -> int:
|
|
410
|
+
"""`expect_streams` values: a count, or True/False for "at least one" / "none"."""
|
|
411
|
+
return int(want) if not isinstance(want, bool) else (1 if want else 0)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def stream_inventory_step(meta: Dict[str, Any], expect: Dict[str, Any]) -> Dict[str, Any]:
|
|
400
415
|
"""The output's stream composition checked against what the operation should have produced
|
|
401
416
|
(#367: a cut of an MPEG-TS source lost its whole video stream and still said verified: true,
|
|
402
417
|
because the only properties measured were duration and start skew). `expect` comes from the
|
|
403
418
|
input's own probe and the tool's arguments (provenance "input_premeasurement"): a stream the
|
|
404
|
-
input had and the operation was not told to drop must still be there.
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
419
|
+
input had and the operation was not told to drop must still be there. A value is a count
|
|
420
|
+
(the cut, join and export writers map one stream of each kind) or a bool for "at least one";
|
|
421
|
+
fewer streams than that is `missing`."""
|
|
422
|
+
counts = meta.get("stream_counts") or {k: int(bool(meta.get(k))) for k in expect}
|
|
423
|
+
want = {k: _expected_count(v) for k, v in expect.items()}
|
|
424
|
+
got = {k: int(counts.get(k) or 0) if meta.get(k) else 0 for k in expect}
|
|
425
|
+
missing = [k for k in expect if got[k] < want[k]]
|
|
426
|
+
step: Dict[str, Any] = {"step": "streams", "property": "stream_inventory",
|
|
427
|
+
"expected": {k: want[k] > 0 for k in expect}, "measured": {k: got[k] > 0 for k in expect},
|
|
428
|
+
"expected_count": want, "measured_count": got,
|
|
429
|
+
"provenance": "input_premeasurement", "ok": not missing}
|
|
409
430
|
if missing:
|
|
410
431
|
step["missing"] = missing
|
|
411
432
|
return step
|
|
412
433
|
|
|
413
434
|
|
|
435
|
+
def stream_length(meta: Dict[str, Any], kind: str) -> Optional[float]:
|
|
436
|
+
"""One stream's own length: its duration, else the DURATION tag Matroska/WebM keep. A value
|
|
437
|
+
that is not a finite number is no length (a NaN compares false and would pass every check)."""
|
|
438
|
+
s = meta.get(kind) or {}
|
|
439
|
+
for v in (s.get("duration"), s.get("tagged_duration")):
|
|
440
|
+
if isinstance(v, (int, float)) and math.isfinite(v) and v > 0:
|
|
441
|
+
return float(v)
|
|
442
|
+
return None
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def stream_end(meta: Dict[str, Any], kind: str) -> Optional[float]:
|
|
446
|
+
"""Where one stream ends on the file's timeline: its start plus its own length."""
|
|
447
|
+
length = stream_length(meta, kind)
|
|
448
|
+
return None if length is None else ((meta.get(kind) or {}).get("start_time") or 0.0) + length
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def file_origin(meta: Dict[str, Any]) -> float:
|
|
452
|
+
"""Where the file's timeline starts: the container's start time, else its earliest stream's.
|
|
453
|
+
A -ss, a cut's --start and a window are counted from here, while stream starts and ends are
|
|
454
|
+
on the file's own timestamps."""
|
|
455
|
+
if isinstance(meta.get("start_time"), (int, float)):
|
|
456
|
+
return float(meta["start_time"])
|
|
457
|
+
starts = [(meta.get(k) or {}).get("start_time") for k in ("video", "audio")]
|
|
458
|
+
return min([s for s in starts if isinstance(s, (int, float))] or [0.0])
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def stream_lengths_step(meta: Dict[str, Any], references: Sequence[Dict[str, Any]],
|
|
462
|
+
tolerance: float = STREAM_LENGTH_TOLERANCE,
|
|
463
|
+
windows: Optional[Sequence[Tuple[float, float]]] = None) -> Dict[str, Any]:
|
|
464
|
+
"""Where the picture and the sound of the output end, against each other, allowing the gap
|
|
465
|
+
the inputs already had. A filename with a space was reported to have lost 40 s of sound with
|
|
466
|
+
no error: the container's length is the longest stream's, so a duration check passes while
|
|
467
|
+
one stream stops early. `short` names a stream that ends more than `tolerance` (plus the
|
|
468
|
+
inputs' own shortfall, summed over the inputs) before the other one. Ends, not lengths: a
|
|
469
|
+
stream that only starts late is the start-skew check's (cut.py reports it in a note). A length
|
|
470
|
+
that cannot be read is not a pass (`not_measured`). `windows` (cut.py's requested ranges, on
|
|
471
|
+
the input's own timeline) count only the gap the input has inside them: a source whose video
|
|
472
|
+
runs 5 s past its sound allows nothing to a cut from its middle."""
|
|
473
|
+
kinds = ("video", "audio")
|
|
474
|
+
lengths = {k: stream_length(meta, k) for k in kinds}
|
|
475
|
+
step: Dict[str, Any] = {"step": "stream_lengths", "property": "stream_end", "measured": lengths,
|
|
476
|
+
"tolerance": tolerance, "provenance": "input_premeasurement"}
|
|
477
|
+
unknown = [k for k in kinds if lengths[k] is None]
|
|
478
|
+
if unknown:
|
|
479
|
+
return dict(step, ok=False, reason="not_measured: no length for the %s stream" % " and ".join(unknown))
|
|
480
|
+
allowed: Dict[str, float] = {k: 0.0 for k in kinds}
|
|
481
|
+
unread_input = False
|
|
482
|
+
for ref in references:
|
|
483
|
+
if not all(ref.get(k) for k in kinds):
|
|
484
|
+
continue # an input with one stream (join's silent clip) brings no gap of its own
|
|
485
|
+
ref_ends = {k: stream_end(ref, k) for k in kinds}
|
|
486
|
+
if None in ref_ends.values():
|
|
487
|
+
unread_input = True # its gap is unknown, not zero
|
|
488
|
+
continue
|
|
489
|
+
rel = {k: ref_ends[k] - file_origin(ref) for k in kinds} # the clock cut's ranges are on
|
|
490
|
+
for s, e in (windows or [(0.0, max(rel.values()))]):
|
|
491
|
+
inside = {k: max(s, min(e, rel[k])) for k in kinds}
|
|
492
|
+
top = max(inside.values())
|
|
493
|
+
for k in kinds:
|
|
494
|
+
allowed[k] += top - inside[k]
|
|
495
|
+
ends = {k: stream_end(meta, k) for k in kinds}
|
|
496
|
+
last = max(ends.values())
|
|
497
|
+
raw = {k: last - ends[k] for k in kinds}
|
|
498
|
+
short = [k for k in kinds if raw[k] > allowed[k] + tolerance]
|
|
499
|
+
step.update(ends={k: round(v, 3) for k, v in ends.items()}, shortfall={k: round(v, 3) for k, v in raw.items()},
|
|
500
|
+
input_shortfall={k: round(v, 3) for k, v in allowed.items()}, ok=not short)
|
|
501
|
+
if short and unread_input:
|
|
502
|
+
# a gap the output has may be one an input already had: neither a pass nor a "short"
|
|
503
|
+
step["input_shortfall"] = None
|
|
504
|
+
step["reason"] = ("not_measured: an input's stream length cannot be read, so whether the %s gap "
|
|
505
|
+
"was already in the input is unknown" % " and ".join(short))
|
|
506
|
+
elif short:
|
|
507
|
+
step["short"] = short
|
|
508
|
+
return step
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def decode_step(path: str, meta: Dict[str, Any], kinds: Sequence[str], window: float = DECODE_WINDOW_SECONDS) -> Dict[str, Any]:
|
|
512
|
+
"""Decode the first `window` seconds of every kept stream and the last `window` before that
|
|
513
|
+
stream's own end, and count the frames. A stream ffprobe lists from the container header can
|
|
514
|
+
still hold nothing a decoder accepts; the inventory and length checks read the header, this
|
|
515
|
+
one reads the payload. The tail read starts DECODE_SEEK_MARGIN_SECONDS earlier, so a seek that
|
|
516
|
+
lands past the last keyframe (MPEG-TS seeks by timestamp, not to a keyframe) still decodes,
|
|
517
|
+
but only frames in the last `window` count, and the read stops a second past the end. A
|
|
518
|
+
stream whose own end cannot be read is not decoded at its end: its tail is `not_measured`,
|
|
519
|
+
not undecodable (the container's end is the longest stream's, not this one's)."""
|
|
520
|
+
from _common.runner import require_tool, run_analysis
|
|
521
|
+
ffmpeg = require_tool("ffmpeg")
|
|
522
|
+
frames: Dict[str, Dict[str, int]] = {}
|
|
523
|
+
unmeasured: List[str] = []
|
|
524
|
+
for kind in kinds:
|
|
525
|
+
# 0:V is the motion picture only: an attached cover picture would be 0:v:0
|
|
526
|
+
sel = ["-map", "0:%s:0" % ("V" if kind == "video" else "a"), "-f", "framecrc", "-"]
|
|
527
|
+
base = [ffmpeg, "-hide_banner", "-nostdin", "-v", "error"]
|
|
528
|
+
origin = file_origin(meta)
|
|
529
|
+
# the head is the stream's own first window: sound that starts 2 s in has none in the file's
|
|
530
|
+
head_from = max(0.0, ((meta.get(kind) or {}).get("start_time") or origin) - origin)
|
|
531
|
+
head = run_analysis(base + ["-ss", "%.3f" % head_from, "-i", path, "-t", "%g" % window] + sel, check=False)
|
|
532
|
+
frames[kind] = {"head": _framecrc_frames(head) if head.returncode == 0 else 0}
|
|
533
|
+
# the stream's own end only: the container's is the longest stream's, and a shorter stream
|
|
534
|
+
# read there would decode nothing and be called undecodable when it is only unmeasured
|
|
535
|
+
end = stream_end(meta, kind)
|
|
536
|
+
if end is None:
|
|
537
|
+
frames[kind]["tail"] = 0
|
|
538
|
+
unmeasured.append(kind)
|
|
539
|
+
continue
|
|
540
|
+
tail_from = max(0.0, end - origin - window - DECODE_SEEK_MARGIN_SECONDS) # -ss counts from the origin
|
|
541
|
+
# -copyts keeps the file's own timestamps, the timeline `end` is measured on
|
|
542
|
+
def tail_frames(start: float) -> int:
|
|
543
|
+
proc = run_analysis(base + ["-copyts", "-ss", "%.3f" % start, "-t", "%g" % (end - origin - start + 1.0), "-i", path]
|
|
544
|
+
+ sel, check=False)
|
|
545
|
+
return _framecrc_frames(proc, since=end - window) if proc.returncode == 0 else 0
|
|
546
|
+
frames[kind]["tail"] = tail_frames(tail_from)
|
|
547
|
+
if not frames[kind]["tail"] and tail_from > 0:
|
|
548
|
+
# a GOP longer than the margin (long-GOP MPEG-TS) has no keyframe after tail_from: read
|
|
549
|
+
# once more from up to DECODE_LONG_GOP_SECONDS earlier before calling it undecodable
|
|
550
|
+
# (bounded, so a failing hour-long stream is not decoded whole)
|
|
551
|
+
frames[kind]["tail"] = tail_frames(max(0.0, end - origin - window - DECODE_LONG_GOP_SECONDS))
|
|
552
|
+
bad = [k for k in kinds if not all(frames[k].values())]
|
|
553
|
+
step: Dict[str, Any] = {"step": "decode", "property": "decodable", "frames": frames, "window": window,
|
|
554
|
+
"provenance": "measurement", "ok": not bad}
|
|
555
|
+
if bad:
|
|
556
|
+
step["undecodable"] = [k for k in bad if k not in unmeasured]
|
|
557
|
+
if unmeasured:
|
|
558
|
+
step["reason"] = "not_measured: no end time for the %s stream" % " and ".join(unmeasured)
|
|
559
|
+
return step
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _framecrc_frames(proc: Any, since: Optional[float] = None) -> int:
|
|
563
|
+
"""Frames in ffmpeg's framecrc output; with `since`, only those still on screen after that time
|
|
564
|
+
(pts plus duration past it: a last frame held for 2 s starts before the window and still
|
|
565
|
+
counts, one that ends exactly at it does not). Rows
|
|
566
|
+
are `stream, dts, pts, duration, size, crc`, timestamps in the `#tb N: num/den` time base."""
|
|
567
|
+
tb: Dict[str, float] = {}
|
|
568
|
+
n = 0
|
|
569
|
+
for line in (proc.stdout or "").splitlines():
|
|
570
|
+
if line.startswith("#tb "):
|
|
571
|
+
idx, frac = line[4:].split(":", 1)
|
|
572
|
+
num, den = frac.strip().split("/")
|
|
573
|
+
tb[idx.strip()] = float(num) / float(den)
|
|
574
|
+
elif line and not line.startswith("#"):
|
|
575
|
+
cols = [c.strip() for c in line.split(",")]
|
|
576
|
+
if since is None:
|
|
577
|
+
n += 1
|
|
578
|
+
elif len(cols) > 3 and cols[0] in tb and (float(cols[2]) + float(cols[3])) * tb[cols[0]] > since + 1e-6:
|
|
579
|
+
n += 1
|
|
580
|
+
return n
|
|
581
|
+
|
|
582
|
+
|
|
414
583
|
def emit(output: Optional[str], *, ctx: "Optional[Context]" = None, **extra: Any) -> None:
|
|
415
584
|
"""Final stdout line: the output path, or a JSON document with --json.
|
|
416
585
|
|
|
@@ -432,9 +601,17 @@ def emit(output: Optional[str], *, ctx: "Optional[Context]" = None, **extra: Any
|
|
|
432
601
|
# verified nothing. Spec failures the tool cannot fix on its own (export's loudness gap)
|
|
433
602
|
# keep status completed and say verified: false, so a caller keys on one field.
|
|
434
603
|
expect_streams = extra.pop("expect_streams", None)
|
|
604
|
+
stream_reference = extra.pop("stream_reference", None)
|
|
605
|
+
stream_windows = extra.pop("stream_windows", None)
|
|
435
606
|
steps: List[Dict[str, Any]] = ([{"step": "probe", "ok": True}] if meta else []) + list(extra.pop("verification", None) or [])
|
|
436
607
|
if meta and expect_streams:
|
|
437
608
|
steps.append(stream_inventory_step(meta, expect_streams))
|
|
609
|
+
kept = [k for k in ("video", "audio") if _expected_count(expect_streams.get(k, 0))]
|
|
610
|
+
if stream_reference is not None and len(kept) == 2:
|
|
611
|
+
steps.append(stream_lengths_step(meta, stream_reference, windows=stream_windows))
|
|
612
|
+
present = [k for k in kept if meta.get(k)]
|
|
613
|
+
if present:
|
|
614
|
+
steps.append(decode_step(output, meta, present))
|
|
438
615
|
# a tool that ran others (render.py) verified what its stages verified too (absorb_stage())
|
|
439
616
|
steps += [s for s in ctx.stage_verification if s not in steps]
|
|
440
617
|
if output and not ctx.dry_run and os.path.splitext(output)[1].lower() not in MEDIA_EXT:
|
package/scripts/_common/probe.py
CHANGED
|
@@ -206,12 +206,21 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
|
|
|
206
206
|
"file": path,
|
|
207
207
|
"format": fmt.get("format_name"),
|
|
208
208
|
"duration": duration,
|
|
209
|
+
# where the file's timeline starts (MPEG-TS starts at 1.4 s, not 0): a -ss and a cut's
|
|
210
|
+
# --start count from here
|
|
211
|
+
"start_time": _to_float(fmt.get("start_time")),
|
|
209
212
|
"size_bytes": _to_int(fmt.get("size")),
|
|
210
213
|
"bitrate": _to_int(fmt.get("bit_rate")),
|
|
211
214
|
"video": None,
|
|
212
215
|
"audio": None,
|
|
213
216
|
"subtitle_streams": len(subs),
|
|
214
217
|
"data_streams": data_stream_count,
|
|
218
|
+
# how many picture and sound streams the file carries (a cover picture is not a video
|
|
219
|
+
# stream); the streams check compares them with what the operation should have written
|
|
220
|
+
"stream_counts": {
|
|
221
|
+
"video": sum(1 for s in streams if s.get("codec_type") == "video" and s.get("disposition", {}).get("attached_pic", 0) == 0),
|
|
222
|
+
"audio": sum(1 for s in streams if s.get("codec_type") == "audio"),
|
|
223
|
+
},
|
|
215
224
|
# container-level chapter markers and the common tags, so metadata.py's result is
|
|
216
225
|
# verifiable the same way every other tool's is (additive keys, 1.x-safe)
|
|
217
226
|
"chapters": [{
|
|
@@ -286,6 +295,8 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
|
|
|
286
295
|
"nb_frames": _to_int(video.get("nb_frames")),
|
|
287
296
|
# the stream's own length (a container's also counts audio priming and a longer track)
|
|
288
297
|
"duration": _to_float(video.get("duration")),
|
|
298
|
+
# Matroska/WebM keep the stream's length in a DURATION tag only, as for audio below
|
|
299
|
+
"tagged_duration": _tag_seconds((video.get("tags") or {}).get("DURATION")),
|
|
289
300
|
# where the stream starts; a video stream that starts after its audio (or the reverse)
|
|
290
301
|
# is how a lossless cut's A/V skew shows up
|
|
291
302
|
"start_time": _to_float(video.get("start_time")),
|
|
@@ -1377,9 +1377,16 @@ def _run_with_progress(cmd: List[str], check: bool) -> subprocess.CompletedProce
|
|
|
1377
1377
|
|
|
1378
1378
|
|
|
1379
1379
|
def shell_quote(s: str) -> str:
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1380
|
+
"""One argument as a POSIX shell word, so a printed command can be pasted and run again.
|
|
1381
|
+
Anything outside a known-safe set is single-quoted: the old deny-list left a backtick, `!`, a
|
|
1382
|
+
leading `#` or `~` and non-ASCII bare, and a shell ran `x`id`.mp4`'s backtick as a command
|
|
1383
|
+
and dropped `#tag.mp4` (and everything after it) as a comment."""
|
|
1384
|
+
if s and _SHELL_SAFE.fullmatch(s):
|
|
1385
|
+
return s
|
|
1386
|
+
return "'" + s.replace("'", "'\\''") + "'"
|
|
1387
|
+
|
|
1388
|
+
|
|
1389
|
+
_SHELL_SAFE = re.compile(r"[A-Za-z0-9_@%+=:,./-]+")
|
|
1383
1390
|
|
|
1384
1391
|
|
|
1385
1392
|
def ffmpeg_base(overwrite: bool = True) -> List[str]:
|
package/scripts/_contract.py
CHANGED
|
@@ -417,6 +417,7 @@ ERROR_REASONS = {
|
|
|
417
417
|
"output_is_directory": "the output path is a directory; details: {output}",
|
|
418
418
|
"output_dir_missing": "the output's directory does not exist; details: {output, directory}",
|
|
419
419
|
"output_dir_not_writable": "the output's directory is not writable; details: {output, directory}",
|
|
420
|
+
"timemap_path_taken": "silence.py --timemap names the same file as -o, --edl or --plan; details: {timemap, flag, path}",
|
|
420
421
|
"output_locked": "another run holds the output's lock; details: {lock, output, pid, pid_alive, deadline, why}",
|
|
421
422
|
"plan_inputs_changed": "render.py PLAN: a fingerprinted input is missing or changed; details: {plan, plan_sha256, checked_at, "
|
|
422
423
|
"inputs: [{path, status: unchanged|changed|missing, expected: {size, sha256_head_tail}, actual: {size, "
|
|
@@ -472,6 +473,8 @@ DIAGNOSTIC_CODES = {
|
|
|
472
473
|
"sync_low_confidence": ("warnings", "sync.py / multicam.py: a source's offset has a correlation confidence below 0.1; {path, confidence}"),
|
|
473
474
|
"cropdetect_windows_failed": ("warnings", "cropdetect.py: sampled windows ffmpeg could not decode (windows_failed); {failed, windows, starts}"),
|
|
474
475
|
"filler_words_untimed": ("warnings", "silence.py --filler: listed words in the transcript with no usable timing were not removed (filler.matched_untimed); {count}"),
|
|
476
|
+
"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)"),
|
|
477
|
+
"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
478
|
"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
479
|
# media and defaults
|
|
477
480
|
"silent_track_mixed": ("warnings", "audio.py --on-silent warn: a silent track was mixed anyway; {tracks}"),
|
|
@@ -630,8 +633,8 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
630
633
|
"keyframe_snapped": {"type": "boolean", "description": "precision is packet: the run is a stream copy end to end, which lands on keyframes and packets. Whether a copied start really moved is start_snapped"},
|
|
631
634
|
"start_snapped": {"type": ["boolean", "null"], "description": "measured: true when a copied segment's picture starts more than one frame from its requested start (the keyframe the copy's seek lands on, measured with the seek ffmpeg makes); false when every start is where asked (an MP4 edit list hides the pre-roll, the start is a keyframe, an audio-only copy, or everything re-encoded); null when a copied start was not measured (--dry-run)"},
|
|
632
635
|
"reencoded": {"type": "boolean"},
|
|
633
|
-
"reencode_reason": {"type": "array", "items": {"enum": ["requested", "codec", "vfr", "vfr_inconclusive", "pcm_container", "copy_failed", "tolerance", "concat_fallback"]},
|
|
634
|
-
"description": "why anything was re-encoded, distinct values in first-seen order ([] when nothing was): requested (--accurate), codec (--codec), vfr (a variable-frame-rate source forced --accurate), vfr_inconclusive (its frame timing could not be measured), pcm_container (compressed audio into a .wav), copy_failed (the stream copy errored), tolerance (a keyframe snap past --tolerance), concat_fallback (--segments parts could not be joined by stream copy)"},
|
|
636
|
+
"reencode_reason": {"type": "array", "items": {"enum": ["requested", "codec", "vfr", "vfr_inconclusive", "pcm_container", "copy_failed", "stream_lost", "tolerance", "concat_fallback"]},
|
|
637
|
+
"description": "why anything was re-encoded, distinct values in first-seen order ([] when nothing was): requested (--accurate), codec (--codec), vfr (a variable-frame-rate source forced --accurate), vfr_inconclusive (its frame timing could not be measured), pcm_container (compressed audio into a .wav), copy_failed (the stream copy errored), stream_lost (the copy wrote no video or no audio, as an MPEG-TS copy with no keyframe in its window does), tolerance (a keyframe snap past --tolerance), concat_fallback (--segments parts could not be joined by stream copy)"},
|
|
635
638
|
"segment_precision": {"type": ["array", "null"], "items": {"enum": ["packet", "sample", "codec_frame", "frame"]},
|
|
636
639
|
"description": "--segments only: each segment's precision in order; least_exact_precision is the least exact of them (null for a single segment)"},
|
|
637
640
|
"edit_list": {"type": "boolean", "description": "--edit-list on a single MP4/MOV stream copy: it kept the edit list it wrote instead of -avoid_negative_ts make_zero, so the keyframe's pre-roll is stored but hidden and the picture and the sound start at the requested time (false for every other path, the default included)"},
|
package/scripts/cut.py
CHANGED
|
@@ -865,8 +865,17 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
|
|
|
865
865
|
info("stream copy failed, falling back to re-encode")
|
|
866
866
|
return cut_one(src, start, end, dst, True, crf, preset, tolerance, meta, reasons + ["copy_failed"], edit_list_ok)
|
|
867
867
|
die(f"ffmpeg failed:\n{proc.stderr.strip()}", kind="ffmpeg")
|
|
868
|
-
if not reencode and
|
|
869
|
-
|
|
868
|
+
out_meta = probe(dst) if not reencode and not STATE.dry_run and copy_t is None else None
|
|
869
|
+
if out_meta is not None:
|
|
870
|
+
# #367: a copy from MPEG-TS whose window holds no video keyframe writes no video at all
|
|
871
|
+
# (the TS demuxer does not seek back to one, as the MP4 demuxer does), and ffmpeg exits 0
|
|
872
|
+
lost = [k for k in ("video", "audio") if meta.get(k) and not out_meta.get(k) and not (k == "video" and audio_only)]
|
|
873
|
+
if lost:
|
|
874
|
+
info(f"the stream copy wrote no {' or '.join(lost)} stream (no keyframe to start it from); "
|
|
875
|
+
"re-encoding this segment")
|
|
876
|
+
return _with_notes(cut_one(src, start, end, dst, True, crf, preset, tolerance, meta, reasons + ["stream_lost"],
|
|
877
|
+
edit_list_ok), notes)
|
|
878
|
+
if out_meta is not None and tolerance >= 0:
|
|
870
879
|
got = out_meta.get("duration") or 0.0
|
|
871
880
|
vdur, fps = (out_meta.get("video") or {}).get("duration"), (meta.get("video") or {}).get("fps")
|
|
872
881
|
if edit_list and not audio_only and vdur and fps and abs(got - vdur) > 1.0 / fps:
|
|
@@ -1678,10 +1687,12 @@ def main() -> int:
|
|
|
1678
1687
|
info(f"wrote {output} ({fmt_secs(got)}, expected ~{expected:.3f}s, "
|
|
1679
1688
|
+ ("re-encoded" if reencoded else "lossless stream copy") + f", {precision} precision)")
|
|
1680
1689
|
# the streams the source had and this cut was not told to drop (an audio extension drops the
|
|
1681
|
-
# picture): #367, a TS cut that lost its video must not say verified
|
|
1682
|
-
|
|
1690
|
+
# picture): #367, a TS cut that lost its video must not say verified. ffmpeg's default stream
|
|
1691
|
+
# selection writes one of each, so that is the count; the source's own picture/sound gap is
|
|
1692
|
+
# the reference for the lengths check
|
|
1693
|
+
expect_streams = {"video": int(bool(meta.get("video")) and not is_audio_output(output)), "audio": int(bool(meta.get("audio")))}
|
|
1683
1694
|
emit(output, expected_duration=round(expected, 6), duration_error_ms=error_ms, precision=precision, reencoded=reencoded,
|
|
1684
|
-
expect_streams=expect_streams,
|
|
1695
|
+
expect_streams=expect_streams, stream_reference=[meta], stream_windows=[(s, e) for s, e in requested],
|
|
1685
1696
|
dropped_non_av_streams=bool(DROPPED_STREAMS),
|
|
1686
1697
|
requested_start=round(requested[0][0], 6) if len(requested) == 1 else None,
|
|
1687
1698
|
requested_end=round(requested[0][1], 6) if len(requested) == 1 else None,
|
package/scripts/export.py
CHANGED
|
@@ -350,7 +350,10 @@ def main() -> int:
|
|
|
350
350
|
if notes:
|
|
351
351
|
extra["notes"] = notes
|
|
352
352
|
# the delivery keeps the source's picture and, except as a GIF, its sound
|
|
353
|
-
extra["expect_streams"] = {"video": bool(meta.get("video")), "audio": has_audio and args.preset != "gif"}
|
|
353
|
+
extra["expect_streams"] = {"video": int(bool(meta.get("video"))), "audio": int(bool(has_audio and args.preset != "gif"))}
|
|
354
|
+
extra["stream_reference"] = [meta]
|
|
355
|
+
if trimmed: # only the kept 0..max of the source can have handed its gap to the output
|
|
356
|
+
extra["stream_windows"] = [(0.0, float(p["max"]))]
|
|
354
357
|
emit(output, **extra)
|
|
355
358
|
return 0
|
|
356
359
|
|
package/scripts/join.py
CHANGED
|
@@ -195,7 +195,7 @@ def join_audio(args: argparse.Namespace, metas: List[dict]) -> int:
|
|
|
195
195
|
+ ("crossfade" if d else "butt join") + ")")
|
|
196
196
|
overlap = overlap_report(durs, expected)
|
|
197
197
|
emit(output, mode="audio", **overlap, **reported(), clips=n, transition=args.transition if d else "none", expected_duration=expected,
|
|
198
|
-
sample_rate=rate, channels=channels, video=False)
|
|
198
|
+
sample_rate=rate, channels=channels, video=False, expect_streams={"audio": 1})
|
|
199
199
|
return 0
|
|
200
200
|
|
|
201
201
|
|
|
@@ -598,7 +598,7 @@ def main() -> int:
|
|
|
598
598
|
info(f"wrote {output} ({fmt_secs(measured)}, {expected_text(expected)}, {w}x{h} @ {fps:g}fps, {n} clips, {args.transition})")
|
|
599
599
|
overlap = overlap_report(lens, expected)
|
|
600
600
|
emit(output, mode="video", **overlap, **reported(), clips=n, transition=args.transition, expected_duration=expected,
|
|
601
|
-
verification=verification, expect_streams={"video":
|
|
601
|
+
verification=verification, expect_streams={"video": 1, "audio": 1}, stream_reference=metas,
|
|
602
602
|
audio_substituted=substituted,
|
|
603
603
|
dropped_non_av_streams=any(m.get("subtitle_streams") or m.get("data_streams") for m in metas))
|
|
604
604
|
return 0
|
package/scripts/silence.py
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
|