ffmpeg-skill 3.8.11 → 3.8.13
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 +1 -1
- package/docs/contract.md +5 -4
- package/package.json +1 -1
- package/references/scripts.md +54 -20
- package/scripts/_common/decision.py +116 -7
- package/scripts/_common/emit.py +38 -10
- package/scripts/_common/probe.py +34 -6
- package/scripts/_common/runner.py +50 -4
- package/scripts/_contract.py +2 -2
- package/scripts/_smartcut.py +23 -5
- package/scripts/audio.py +86 -28
- package/scripts/broll.py +74 -16
- package/scripts/check.py +2 -2
- package/scripts/cut.py +49 -16
- package/scripts/export.py +3 -3
- package/scripts/fit.py +35 -4
- package/scripts/freeze.py +4 -2
- package/scripts/graphics.py +17 -8
- package/scripts/loop.py +17 -3
- package/scripts/loudness.py +20 -7
- package/scripts/multicam.py +55 -14
- package/scripts/overlay.py +12 -13
- package/scripts/pad.py +12 -4
- package/scripts/scenes.py +82 -21
- package/scripts/silence.py +62 -16
- package/scripts/sync.py +16 -5
- package/scripts/waveform.py +12 -2
package/README.md
CHANGED
|
@@ -514,7 +514,7 @@ type on every OS.
|
|
|
514
514
|
| Result | Measurement |
|
|
515
515
|
|---|---|
|
|
516
516
|
| **92 / 92** | verification steps on a 10-file real-device corpus (GoPro, DJI, iPhone incl. Dolby Vision, Android screen recordings, HDR10, 24p, Tears of Steel), 0.8.0, local ffmpeg 6.1 |
|
|
517
|
-
| **40 / 40 within 10 ms** | `sync.py` offset detection, ±30 s offsets with gain, noise and EQ changes on real dialogue and music, 120 s windows (max error 1.1 ms); 60 s stress windows 95 % within 10 ms, 4 of 5 misses flagged by confidence |
|
|
517
|
+
| **40 / 40 within 10 ms** | `sync.py` offset detection, ±30 s offsets with gain, noise and EQ changes on real dialogue and music, 120 s windows (max error 1.1 ms); 60 s stress windows 95 % within 10 ms, 4 of 5 misses flagged by confidence (the confidence of 3.8.8 and earlier; not re-run since its runner-up changed) |
|
|
518
518
|
| **0 missed gaps** | `silence.py`, 20 cases with known gaps, ≤ 1 ms leftover silence |
|
|
519
519
|
| **F1 0.97** | `scenes.py`, 53 hard cuts between single takes, precision 0.95, recall 1.00 at the default threshold |
|
|
520
520
|
| **exact to the sample** | `cut.py --accurate` on WAV, FLAC (44.1 kHz) and AAC → WAV; WAV stream copy within 2 ms; AAC output +21 ms of encoder priming, reported as `codec_frame` (0.9.1) |
|
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 (`2.0`) | a key is renamed, removed or changes meaning |
|
|
24
|
-
| `skill.version` | the npm / package.json version (`3.8.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`3.8.13`) | 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`
|
|
@@ -149,7 +149,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
|
|
|
149
149
|
"deprecated": [],
|
|
150
150
|
"removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"},
|
|
151
151
|
{"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"}],
|
|
152
|
-
"skill": {"id": "ffmpeg-skill", "version": "3.8.
|
|
152
|
+
"skill": {"id": "ffmpeg-skill", "version": "3.8.13", "execution_mode": "local", "kind": "execution",
|
|
153
153
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
154
154
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
155
155
|
"requirements": {"python": ">=3.10 (standard library only)", "ffmpeg": ">=6.1", "ffprobe": ">=6.1"},
|
|
@@ -689,7 +689,8 @@ says "output audio is silent" instead of recommending `loudness.py`.
|
|
|
689
689
|
Per-tool keys added in 1.13: `audio` (`audio.py`) reports the mix it built — the
|
|
690
690
|
`--voice` level, `stereo_widen`, whether an `--effects` bed was mixed, and with
|
|
691
691
|
`--music` the `music_volume` plus a `duck` object naming the threshold (dB and
|
|
692
|
-
linear), ratio
|
|
692
|
+
linear), ratio (the sidechain gate's 9000 since A134), attack, release and `amount_db`
|
|
693
|
+
(the depth in dB the bed is lowered under speech) actually used, or `null` when `--duck` was not
|
|
693
694
|
given. Since #486 `audio.py` also reports `timing`, `{tempo, atempo, length_samples, sample_rate}`:
|
|
694
695
|
the `--tempo` factor and the atempo stages (each 0.5..2.0) it was built from, `--length-samples`,
|
|
695
696
|
and the rate that count is in (`null` before a pending input is measured); with
|
|
@@ -814,7 +815,7 @@ pointer to cuts that may sit in a breath):
|
|
|
814
815
|
|
|
815
816
|
| key | tool | what it holds |
|
|
816
817
|
|---|---|---|
|
|
817
|
-
| `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
|
|
818
|
+
| `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 the sums of the ranges the render cuts on (with a picture each bound moved to its nearest frame time, never before 0; with sound alone the range as asked), unrounded until they are written to the millisecond, so no error adds up over many cuts. They are computed, not measured on the written file. Nothing is written under `--dry-run` |
|
|
818
819
|
| `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 |
|
|
819
820
|
| `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 |
|
|
820
821
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "3.8.
|
|
3
|
+
"version": "3.8.13",
|
|
4
4
|
"mcpName": "io.github.kajisho5/ffmpeg-skill",
|
|
5
5
|
"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.",
|
|
6
6
|
"keywords": [
|
package/references/scripts.md
CHANGED
|
@@ -147,7 +147,7 @@ by default since 3.0.0 (`--keep-hevc`; `--no-keep-hevc` uses x264 as 2.x did);
|
|
|
147
147
|
`--codec` overrides all of them). `--accurate` always re-encodes; `--tolerance -1` never does for a keyframe snap
|
|
148
148
|
(a `--segments` join of open-GOP video, one whose later B-frame `.mp4`/`.mov` segment starts
|
|
149
149
|
between keyframes, or one that fails its check, still re-cuts).
|
|
150
|
-
Multiple segments are concatenated in the order given. `--remove A-B,...` (2.24) is the inverse: the ranges are dropped (overlaps merged, clamped to the file) and what is left is cut as `--segments` would cut it; a leftover shorter than one frame is not kept, and a removal that leaves nothing is refused. It does not combine with `--segments`, `--start` (even `--start 0`), `--end`, `--duration` or `--snap beats` (a snapped edge could move into a dropped range). Since 2.17.2 every join the re-encode makes fades the sound for `--declick-ms` (20 ms) on each side, so butted cuts do not click; `--no-declick` butts them as before, and a stream-copy join (sound copied) is not faded. The result reports `declick_ms` (null when there was no re-encoded join). stderr reports whether
|
|
150
|
+
Multiple segments are concatenated in the order given. `--remove A-B,...` (2.24) is the inverse: the ranges are dropped (overlaps merged, clamped to the file) and what is left is cut as `--segments` would cut it; a leftover shorter than one frame is not kept, nor (to a video output) one that holds less than one frame of picture, such as sound that runs past the last frame (a note names it), and a removal that leaves nothing is refused. It does not combine with `--segments`, `--start` (even `--start 0`), `--end`, `--duration` or `--snap beats` (a snapped edge could move into a dropped range). Since 2.17.2 every join the re-encode makes fades the sound for `--declick-ms` (20 ms) on each side, so butted cuts do not click; `--no-declick` butts them as before, and a stream-copy join (sound copied) is not faded. The result reports `declick_ms` (null when there was no re-encoded join). stderr reports whether
|
|
151
151
|
the result was "lossless stream copy" or "re-encoded"; when the snap forced a
|
|
152
152
|
re-encode, the result's `lossless_alternative` names the nearest keyframe
|
|
153
153
|
`--start` that would stream-copy instead, so the trade can be offered.
|
|
@@ -530,7 +530,9 @@ Renders the input's audio as a video: `--style waveform` (default, FFmpeg's
|
|
|
530
530
|
`showwaves`) draws amplitude over time; `--style spectrum` (`showspectrum`)
|
|
531
531
|
draws a frequency-over-time heatmap instead, reading more out of dense
|
|
532
532
|
mixes at the cost of being less immediately readable. The output always
|
|
533
|
-
carries the audio it visualizes.
|
|
533
|
+
carries the audio it visualizes. The default output of an audio input is
|
|
534
|
+
`<name>_waveform.mp4` (an audio extension cannot hold the picture), and `-o` with an audio
|
|
535
|
+
extension is refused. For an audio-only input (no video stream
|
|
534
536
|
needed) or any file with an audio track worth visualizing.
|
|
535
537
|
|
|
536
538
|
**Audiogram (1.16).** `--image PATH` puts a local still behind the
|
|
@@ -719,12 +721,14 @@ silence.py INPUT --filler --words transcript.json [--filler-lang auto|en|ja|ko|z
|
|
|
719
721
|
[--transcribe] [--engine auto|parakeet-mlx|parakeet.cpp|whisper.cpp|faster-whisper|openai-whisper]
|
|
720
722
|
[--model MODEL] [--filler-list] [--max-cuts 400]
|
|
721
723
|
```
|
|
722
|
-
Runs `silencedetect`, keeps `--margin` seconds of air around speech
|
|
724
|
+
Runs `silencedetect`, keeps `--margin` seconds of air around speech (none at the start or the
|
|
725
|
+
end of the sound, where there is no speech to keep it for), drops
|
|
723
726
|
gaps shorter than `--min-silence`, and re-encodes the kept ranges the way `cut.py --segments
|
|
724
727
|
--accurate` does: both streams cut on the same frame times, so the sound never drifts from the
|
|
725
728
|
picture at the cuts, and a sound that starts late keeps its offset. `--list` prints silences, kept ranges and seconds removed
|
|
726
729
|
without rendering; `--edl` saves the kept ranges in `cut.py --segments` format
|
|
727
|
-
so the user can edit the list by hand
|
|
730
|
+
so the user can edit the list by hand (written once the cut is, as `--timemap` is). `--max-cuts N`
|
|
731
|
+
(400) refuses to render more than N removal ranges; `--list` reports them whatever their number. Quiet rooms need `--threshold -40`
|
|
728
732
|
to `-45`; noisy ones `-30`. Always tell the user how many seconds were removed.
|
|
729
733
|
|
|
730
734
|
**Breaths and weak word endings.** `--margin` is the padding kept on each side of every cut
|
|
@@ -766,9 +770,10 @@ unchanged, and filler-word cuts (which sit in sound by design) are measured but
|
|
|
766
770
|
source and output times, and every removed range with the output time it was cut at. `.csv`
|
|
767
771
|
gives `type,source_start,source_end,output_start,output_end` rows (a removed row's two output
|
|
768
772
|
columns are its cut point); any other path must end in `.json`. Use it to re-time subtitles
|
|
769
|
-
made on the source, or to find a removed sentence again. Its times advance by the
|
|
770
|
-
|
|
771
|
-
|
|
773
|
+
made on the source, or to find a removed sentence again. Its times advance by the exact
|
|
774
|
+
lengths of the ranges the render cuts on (with a picture, each bound on its nearest frame time;
|
|
775
|
+
with sound alone, as asked), rounded only when written, so nothing adds up over many cuts; they
|
|
776
|
+
are computed, not measured on the written file. An existing map is refused without `--overwrite`,
|
|
772
777
|
and a missing directory or a directory at the path as `kind: input`, before anything runs (a dry
|
|
773
778
|
run predicts both); the map is written only after the cut is, so a failed or refused encode
|
|
774
779
|
leaves none. A dry run writes nothing.
|
|
@@ -1201,7 +1206,8 @@ scenes.py INPUT [--shots] [--audio-peaks] [--speech] [--json]
|
|
|
1201
1206
|
```
|
|
1202
1207
|
Lists scenes with audio energy, the loudest moments, and (with
|
|
1203
1208
|
`--highlights`) proposes N ranges that add up to `--target` seconds, biased to
|
|
1204
|
-
the loudest window of each scene
|
|
1209
|
+
the loudest window of each scene; each range lies inside its scene (it starts at the cut or on
|
|
1210
|
+
the 0.5 s grid after it, to the hundredth rounded inward). Review the sheet + JSON, adjust the EDL, then
|
|
1205
1211
|
`cut.py --segments`. Cut detection is a one-frame spike test (benchmark on
|
|
1206
1212
|
hard cuts between real single takes: precision 0.95, recall 1.00 at the default
|
|
1207
1213
|
threshold; raise `--threshold` to 12 for 0.98 precision at 0.94 recall).
|
|
@@ -1619,8 +1625,14 @@ multicam.py REF CAM2 [CAM3 ...] [--switch "START-END:CAM,..."|energy [--min-shot
|
|
|
1619
1625
|
```
|
|
1620
1626
|
All inputs are aligned to the first one by audio (same engine as `sync.py`,
|
|
1621
1627
|
`--fix-drift` for long takes). `--switch` names which camera is on screen for
|
|
1622
|
-
each range of the reference timeline (gaps fall back to camera 0
|
|
1623
|
-
|
|
1628
|
+
each range of the reference timeline (gaps fall back to camera 0; ranges that overlap are
|
|
1629
|
+
refused, `kind: input`, since each moment has one camera), `--auto N`
|
|
1630
|
+
simply alternates every N seconds. Under `--fix-drift` a drifting camera's picture is retimed
|
|
1631
|
+
with its sound (`setpts=(PTS-STARTPTS)/ratio`), so a range lasts its reference length on both;
|
|
1632
|
+
`--write-project`'s clips name that camera's own in/out, with the ratio as the clip's `speed`
|
|
1633
|
+
(render.py plays it at that rate, so it lasts its reference length there too), and the project states
|
|
1634
|
+
`"transition": {"type": "none"}`: a switch is a cut, and render.py's default 0.5 s crossfade at every join
|
|
1635
|
+
would run the timeline 0.5 s a switch short and move each later switch. Audio comes from the reference unless
|
|
1624
1636
|
`--audio` picks another input, e.g. an external recorder that has no video.
|
|
1625
1637
|
`--offsets-only` reports offsets and confidence without rendering. `cuts` names
|
|
1626
1638
|
the camera actually rendered: a range whose camera has not started yet plays
|
|
@@ -2015,7 +2027,8 @@ sync.py REFERENCE SOURCE [SOURCE ...] [--json] [--max-offset 30] [--analyze-seco
|
|
|
2015
2027
|
```
|
|
2016
2028
|
Cross-correlates loudness envelopes: coarse FFT search (20 ms), then a direct
|
|
2017
2029
|
1 ms refinement (pure Python, a 2-minute window takes ~1-3 s). Positive offset
|
|
2018
|
-
= the source recording started later
|
|
2030
|
+
= the source recording started later, so `--trim-second`/`--replace-audio` delay it by that
|
|
2031
|
+
much (a negative one drops its first -offset seconds). `--replace-audio` writes the reference
|
|
2019
2032
|
video with the source file's audio aligned (video stream copied); the output
|
|
2020
2033
|
keeps the reference's full length -- a shorter or head-trimmed source file is
|
|
2021
2034
|
padded with silence, never allowed to cut the picture.
|
|
@@ -2031,8 +2044,11 @@ could not be measured is skipped and says why: `drift_skipped_reason` is
|
|
|
2031
2044
|
`too_short` (no tail window far enough from the head) or `low_confidence`
|
|
2032
2045
|
(top level for one source, and in each `sources` entry; null when applied).
|
|
2033
2046
|
Check `confidence`
|
|
2034
|
-
(0–1, normalised correlation with a runner-up penalty
|
|
2035
|
-
|
|
2047
|
+
(0–1, normalised correlation with a runner-up penalty: the best other peak more than five
|
|
2048
|
+
steps from the winner); below 0.3 the match is doubtful. Since A126 the runner-up is never the
|
|
2049
|
+
winner's own neighbour, which held an exact match at 0.59, so a clean match can read near 1.0 and a
|
|
2050
|
+
tie between two offsets reads at most 0.5. The benchmark below was measured with the earlier runner-up and has
|
|
2051
|
+
not been re-run since; on real dialogue/music (±30 s offsets, gain, noise, EQ):
|
|
2036
2052
|
with the default 120 s window 40/40 within 10 ms (max 1.1 ms); with a 60 s
|
|
2037
2053
|
window 95 %, misses flagged below 0.3. Keep `--analyze-seconds` at least 4×
|
|
2038
2054
|
`--max-offset` (default 120 s vs 30 s): lags with under 35 % overlap are
|
|
@@ -2169,10 +2185,13 @@ highpass, and `medium`/`strong` run their own denoise stage at that strength
|
|
|
2169
2185
|
that is off is refused, as the `--duck-*` flags are: `--denoise-strength` without
|
|
2170
2186
|
`--denoise`, `--music-volume`, `--music-fade-out` or `--music-loop` without `--music`,
|
|
2171
2187
|
`--effects-volume` without `--effects` (a value equal to the default is not seen as given).
|
|
2172
|
-
`--duck`
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2188
|
+
`--duck` lowers the music by `--duck-amount` dB (0..60, default 12) while the speech is above
|
|
2189
|
+
`--duck-threshold`, and leaves it at its level in the pauses: a sidechain gate keyed by the
|
|
2190
|
+
speech passes the bed under speech, and that part, scaled by `-(1 - 10^(-amount/20))`, is added
|
|
2191
|
+
back to the bed (`sidechaingate=threshold=0.05:ratio=9000:range=0:knee=1:attack=20:release=400`, then
|
|
2192
|
+
`volume=-0.748811` for 12 dB, by default). Through 3.8.8 it was a sidechain compressor whose
|
|
2193
|
+
ratio was `amount/3` (at least 2), so the depth followed the speech's level, not the flag
|
|
2194
|
+
(audit A134). `--duck-threshold DB` (default −26.02 dBFS, i.e. the 0.05 linear),
|
|
2176
2195
|
`--duck-attack MS` (20) and `--duck-release MS` (400) move each one; a lower
|
|
2177
2196
|
threshold ducks on quieter speech, a shorter release brings the bed back faster.
|
|
2178
2197
|
`--json`'s `audio` block reports the settings the run actually used.
|
|
@@ -2200,7 +2219,19 @@ swresample. Video is always stream-copied, and so is a subtitle/data track
|
|
|
2200
2219
|
when the container can hold it (`dropped_non_av_streams` says when it could not).
|
|
2201
2220
|
`--limit` (and `--voice strong`'s limiter) is `alimiter` with `level=disabled:latency=1`: the
|
|
2202
2221
|
look-ahead is compensated, so the limited sound starts on the input's own sample instead of
|
|
2203
|
-
`--limit-attack` (5 ms, 240 samples at 48 kHz) late against the picture (#476).
|
|
2222
|
+
`--limit-attack` (5 ms, 240 samples at 48 kHz) late against the picture (#476). With `--music`
|
|
2223
|
+
or `--effects` both limiters run on the mix, after the beds are added: the combined mix is
|
|
2224
|
+
limited before it is encoded (A129). A lossy output (`.m4a`, `.mp3`, `.opus`, AAC beside a
|
|
2225
|
+
picture) can then peak over the ceiling, since the encoder works after the limiter; use
|
|
2226
|
+
`loudness.py` for final levels, which measures the written file. Without a bed they run on the
|
|
2227
|
+
main track, where they always ran.
|
|
2228
|
+
The typed dynamics flags speak dB where the filter takes a linear value: a knee
|
|
2229
|
+
(`--comp-knee`, `--gate-knee`) is 0..18.06 dB, the filters' linear 1..8, default 9.03 dB
|
|
2230
|
+
(2.82843); the defaults `--help` gives are ffmpeg's own, in the flag's unit (A135).
|
|
2231
|
+
`--bitrate` is the AAC rate beside a picture (192k) and the rate of a lossy audio output's
|
|
2232
|
+
codec (`.m4a`/`.aac` 256k, `.mp3` `-q:a 0`, `.ogg` `-q:a 6`, `.opus` 128k when it is not given);
|
|
2233
|
+
`.wav` and `.flac` refuse it (A143). A `--music-fade-out` on a bed shorter than the output (and
|
|
2234
|
+
not `--music-loop`ed) ends where the bed ends (A142).
|
|
2204
2235
|
With `--replace`, `--music` or a kept picture the sound is padded with silence or trimmed to the
|
|
2205
2236
|
source's length counted in samples at the track's rate (`apad=whole_len=N,atrim=end_sample=N`),
|
|
2206
2237
|
so it is exact to the sample, not to the millisecond (#478). Sound that starts after the picture
|
|
@@ -2249,8 +2280,11 @@ video containers or the codec matching the extension (.wav → PCM, .flac, .mp3)
|
|
|
2249
2280
|
The written file is measured again: a lossy encoder can push peaks past the
|
|
2250
2281
|
ceiling loudnorm held (ffmpeg's AAC at 192k turned one transient from -2.4 to
|
|
2251
2282
|
+3.7 dBFS). When it does, the tool re-encodes -- first at 256k then 320k if you
|
|
2252
|
-
did not pass `--audio-bitrate
|
|
2253
|
-
overshoot -- until the file itself meets
|
|
2283
|
+
did not pass `--audio-bitrate` (an `.m4a`/`.aac` output starts at its 256k, so only 320k is
|
|
2284
|
+
left), then with the loudnorm ceiling lowered by the overshoot -- until the file itself meets
|
|
2285
|
+
`--tp`. A pinned `--audio-bitrate` reaches an audio output's codec as well as a video
|
|
2286
|
+
container's AAC (through 3.8.8 an `.m4a` was 256k whatever was asked, and its retries too);
|
|
2287
|
+
`.wav`/`.flac` refuse it. `result` reports
|
|
2254
2288
|
`tp_ceiling_used`, `audio_bitrate_used`, `encodes`, and a `note` when the
|
|
2255
2289
|
integrated loudness ended more than 1 LU from the target because of it.
|
|
2256
2290
|
|
|
@@ -176,6 +176,16 @@ def fmt_secs(value: Optional[float]) -> str:
|
|
|
176
176
|
return "?s" if value is None else f"{value:.3f}s"
|
|
177
177
|
|
|
178
178
|
|
|
179
|
+
def ntsc_rate(fps: float) -> float:
|
|
180
|
+
"""`fps` as the rate it is a rounding of: 23.976, 29.97, 47.952, 59.94 and 119.88 (what
|
|
181
|
+
probe() reports, and what a caller types after `@`) are N * 1000 / 1001. Any other rate is
|
|
182
|
+
returned as it is. timeline.exact_rate() reads the same five rates the same way."""
|
|
183
|
+
whole = round(fps * 1.001)
|
|
184
|
+
if whole in (24, 30, 48, 60, 120) and abs(fps - whole / 1.001) < 0.01 and abs(fps - whole) > 0.01:
|
|
185
|
+
return whole * 1000 / 1001
|
|
186
|
+
return fps
|
|
187
|
+
|
|
188
|
+
|
|
179
189
|
def parse_time(value: str, fps: Optional[float] = None) -> float:
|
|
180
190
|
"""Accept seconds ('12.5'), mm:ss ('1:30'), hh:mm:ss(.ms) ('00:01:30.250'), SRT '00:01:30,250',
|
|
181
191
|
or -- when `fps` is given -- SMPTE non-drop-frame timecode 'hh:mm:ss:ff' ('00:01:30:15')."""
|
|
@@ -212,7 +222,10 @@ def parse_time(value: str, fps: Optional[float] = None) -> float:
|
|
|
212
222
|
# 3596.4 s of video). This is exactly what fmt_smpte_time() inverts; before, the two
|
|
213
223
|
# disagreed by ~0.1 % on the fractional NTSC rates and drifted apart over long files.
|
|
214
224
|
total_frames = (int(h) * 3600 + int(m) * 60 + int(s)) * whole_fps + frame
|
|
215
|
-
|
|
225
|
+
# at the picture's exact rate: probe()'s `fps` is rounded to three decimals, and frame 37
|
|
226
|
+
# counted at 29.97 is 1.2345679 s, 1.2 microseconds past the frame at 37 * 1001 / 30000,
|
|
227
|
+
# so a start read from 00:00:01:07 was the frame after it (ntsc_rate)
|
|
228
|
+
return total_frames / ntsc_rate(fps)
|
|
216
229
|
if len(parts) > 3:
|
|
217
230
|
raise ValueError(f"bad time: {value}")
|
|
218
231
|
total = 0.0
|
|
@@ -315,7 +328,9 @@ def fmt_smpte_time(seconds: float, fps: float) -> str:
|
|
|
315
328
|
if seconds < 0:
|
|
316
329
|
seconds = 0.0
|
|
317
330
|
whole_fps = int(round(fps))
|
|
318
|
-
|
|
331
|
+
# the frames are counted at the rate parse_time() reads the same text at (29.97 as
|
|
332
|
+
# 30000/1001): at 29.97 itself 06:00:00:00 came back as 05:59:59:29
|
|
333
|
+
total_frames = int(round(seconds * ntsc_rate(fps)))
|
|
319
334
|
frame = total_frames % whole_fps
|
|
320
335
|
secs_total = total_frames // whole_fps
|
|
321
336
|
h, rem = divmod(secs_total, 3600)
|
|
@@ -440,6 +455,56 @@ def grid_at_or_after(t: float, v0: float, rate: float) -> float:
|
|
|
440
455
|
return v0 + math.ceil((t - v0) * rate - 1e-6) / rate
|
|
441
456
|
|
|
442
457
|
|
|
458
|
+
def frame_window(start: Optional[float], end: Optional[float], meta: Optional[Dict[str, Any]],
|
|
459
|
+
half_open: bool = False) -> Optional[Tuple[Optional[float], Optional[float]]]:
|
|
460
|
+
"""The two times a filter compares a frame's `t` with so that a window from `start` to `end`
|
|
461
|
+
seconds of `meta`'s picture holds the frames asked: half a frame before the first frame at or
|
|
462
|
+
after `start`, and half a frame after the last frame at or before `end` (`half_open`: half a
|
|
463
|
+
frame before the first frame at or after `end`, which is then outside). A bound written as
|
|
464
|
+
the time itself is compared with `t` on the frame it names: `{:.3f}` put frame 37 of a
|
|
465
|
+
30000/1001 picture (1.234567 s) after a start of "1.235", and `t` is a double (pts x time
|
|
466
|
+
base) that is not always the number the text reads as (the frame at 1.4 s of a 25 fps MP4 is
|
|
467
|
+
not `<= 1.400`; FFmpeg 6.1.1). The grid is the one grid_at_or_after() counts on. None when
|
|
468
|
+
there is no grid to count on (no rate: a dry run's pending input; a picture probe() suspects
|
|
469
|
+
of a variable rate): the caller keeps the text it had. A bound of None stays None."""
|
|
470
|
+
v = (meta or {}).get("video") or {}
|
|
471
|
+
rate = frame_rate(meta)
|
|
472
|
+
if not rate or v.get("variable_frame_rate_suspected"):
|
|
473
|
+
return None
|
|
474
|
+
v0, half = stream_starts(meta)[0], 0.5 / rate
|
|
475
|
+
lo = None if start is None else grid_at_or_after(start, v0, rate) - half
|
|
476
|
+
if end is None:
|
|
477
|
+
hi = None
|
|
478
|
+
elif half_open:
|
|
479
|
+
hi = grid_at_or_after(end, v0, rate) - half
|
|
480
|
+
else:
|
|
481
|
+
hi = v0 + math.floor((end - v0) * rate + 1e-6) / rate + half
|
|
482
|
+
return lo, hi
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def window_expr(start: Optional[float], end: Optional[float], meta: Optional[Dict[str, Any]],
|
|
486
|
+
half_open: bool = False) -> str:
|
|
487
|
+
"""The expression that is true on the frames of frame_window(): `between(t,LO,HI)`, or
|
|
488
|
+
`gte(t,LO)` with no end. With no start the window opens at 0, as it always did. Without a
|
|
489
|
+
grid it is the millisecond text the tools wrote before."""
|
|
490
|
+
bounds = frame_window(start, end, meta, half_open)
|
|
491
|
+
if bounds is None:
|
|
492
|
+
lo = f"{start:.3f}" if start is not None else "0"
|
|
493
|
+
return f"gte(t,{lo})" if end is None else f"between(t,{lo},{end:.3f})"
|
|
494
|
+
lo = f"{bounds[0]:.6f}" if bounds[0] is not None else "0"
|
|
495
|
+
return f"gte(t,{lo})" if bounds[1] is None else f"between(t,{lo},{bounds[1]:.6f})"
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def windowed_overlay_x(x: Any, window: str) -> str:
|
|
499
|
+
"""An `overlay` x that puts the layer at `x` on the frames `window` is true on and off the
|
|
500
|
+
picture on every other frame. overlay's `enable` is not the main picture's alone: FFmpeg
|
|
501
|
+
evaluates it on each frame either input hands over, the layer's next one included, so a still
|
|
502
|
+
looped at 25 fps over a 30000/1001 picture was drawn from the frame before the window's
|
|
503
|
+
first to the frame before its last (36-66 for 37-67; FFmpeg 6.1.1). The position is
|
|
504
|
+
evaluated once per output frame with the main picture's `t`."""
|
|
505
|
+
return f"'if({window},{x},-w)'"
|
|
506
|
+
|
|
507
|
+
|
|
443
508
|
def av_trim(start: float, end: Optional[float], rate: float = 0.0, fill_gaps: bool = False) -> Tuple[str, str]:
|
|
444
509
|
"""The video and the audio filters that cut one segment [start, end) of the graph clock out of
|
|
445
510
|
both streams and shift both by the same constant, so an offset between them survives; `end`
|
|
@@ -536,6 +601,28 @@ def silence_filter(rate: Optional[int], lead: Optional[float] = None, tail: Opti
|
|
|
536
601
|
return ",".join(parts)
|
|
537
602
|
|
|
538
603
|
|
|
604
|
+
def tpad_length(seconds: float, meta: Optional[Dict[str, Any]]) -> float:
|
|
605
|
+
"""How long the picture `tpad=start_duration={seconds:.3f}` puts ahead of `meta`'s clip is:
|
|
606
|
+
tpad adds whole frames, the nearest count to the text it is given (0.23425 s at 25 fps is
|
|
607
|
+
written "0.234" and becomes 6 frames, 0.240 s). A sound delayed by `seconds` itself would
|
|
608
|
+
start up to half a frame before or after the picture it belongs to; delayed by this length it
|
|
609
|
+
moves with it (both streams of a clip shift by one constant: #409). `seconds` as it is when
|
|
610
|
+
there is no rate to count frames at (a dry run's pending input) or probe() suspects a
|
|
611
|
+
variable one."""
|
|
612
|
+
v = (meta or {}).get("video") or {}
|
|
613
|
+
rate = frame_rate(meta)
|
|
614
|
+
if not rate or v.get("variable_frame_rate_suspected"):
|
|
615
|
+
return seconds
|
|
616
|
+
try:
|
|
617
|
+
exact = Fraction(str(v.get("r_frame_rate") or ""))
|
|
618
|
+
except (ValueError, ZeroDivisionError):
|
|
619
|
+
exact = None
|
|
620
|
+
if exact is None or abs(float(exact) - rate) > 1e-6:
|
|
621
|
+
exact = Fraction(rate).limit_denominator(1001)
|
|
622
|
+
frames = math.floor(Fraction(f"{seconds:.3f}") * exact + Fraction(1, 2)) # a half rounds up, as av_rescale does
|
|
623
|
+
return float(frames / exact)
|
|
624
|
+
|
|
625
|
+
|
|
539
626
|
def av_clip_length(meta: Optional[Dict[str, Any]], fps: float) -> float:
|
|
540
627
|
"""The one length a clip holds once both streams start at its first frame (av_trim from the
|
|
541
628
|
video's start): the video's, or where the sound ends when it runs more than a frame past the
|
|
@@ -1086,10 +1173,27 @@ AUDIO_CODECS = {
|
|
|
1086
1173
|
}
|
|
1087
1174
|
|
|
1088
1175
|
|
|
1089
|
-
|
|
1090
|
-
|
|
1176
|
+
LOSSLESS_AUDIO_EXT = {".wav", ".flac"} # the AUDIO_CODECS whose codec takes no bitrate
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
def audio_codec_for(output_path: str, bitrate: Optional[str] = None) -> List[str]:
|
|
1180
|
+
"""Pick an audio codec that the output container can actually hold.
|
|
1181
|
+
|
|
1182
|
+
`bitrate` is one the caller asked for: it replaces the rate or quality an audio extension
|
|
1183
|
+
names (`-q:a 0` for .mp3, 256k for .m4a), which through 3.8.8 it never reached (A143), and is
|
|
1184
|
+
the AAC rate of any other container (192k when it is None). A lossless extension takes none;
|
|
1185
|
+
the caller refuses a bitrate for it."""
|
|
1091
1186
|
ext = os.path.splitext(output_path)[1].lower()
|
|
1092
|
-
|
|
1187
|
+
if ext not in AUDIO_CODECS:
|
|
1188
|
+
return ["-c:a", "aac", "-b:a", bitrate or "192k"]
|
|
1189
|
+
args = list(AUDIO_CODECS[ext])
|
|
1190
|
+
if bitrate is None or ext in LOSSLESS_AUDIO_EXT:
|
|
1191
|
+
return args
|
|
1192
|
+
for flag in ("-b:a", "-q:a"):
|
|
1193
|
+
if flag in args:
|
|
1194
|
+
i = args.index(flag)
|
|
1195
|
+
del args[i:i + 2]
|
|
1196
|
+
return args + ["-b:a", bitrate]
|
|
1093
1197
|
|
|
1094
1198
|
|
|
1095
1199
|
def is_audio_output(output_path: str) -> bool:
|
|
@@ -1503,7 +1607,7 @@ def _refine_grid(onset_times: "Sequence[float]", strengths: "Sequence[float]", i
|
|
|
1503
1607
|
it), and onset_time = phase + k * interval is solved for both unknowns with each pair weighted
|
|
1504
1608
|
by its onset strength squared; the pairing is then redone against the refitted grid, until it
|
|
1505
1609
|
stops moving (three passes at most). This only sharpens the grid the autocorrelation chose --
|
|
1506
|
-
the tempo cannot leave `bpm_range
|
|
1610
|
+
the tempo cannot leave `bpm_range` (a fit beyond a bound is held on it), an octave cannot flip (a refit that moves the interval by
|
|
1507
1611
|
more than interval/BEAT_ALIGN_DIVISOR is discarded), and fewer than four paired grid points
|
|
1508
1612
|
keep the original: the fit needs evidence, and an onset-free stretch gives it none.
|
|
1509
1613
|
Returns phase in [0, interval).
|
|
@@ -1526,8 +1630,13 @@ def _refine_grid(onset_times: "Sequence[float]", strengths: "Sequence[float]", i
|
|
|
1526
1630
|
if sxx <= 0:
|
|
1527
1631
|
break
|
|
1528
1632
|
new_iv = sum(w * (k - mk) * (t - mt) for k, t, w in pairs) / sxx
|
|
1529
|
-
if
|
|
1633
|
+
if abs(new_iv - interval) > interval / BEAT_ALIGN_DIVISOR:
|
|
1530
1634
|
break
|
|
1635
|
+
# A146: a fit just outside `bpm_range` is held on its bound, not discarded. A tempo that
|
|
1636
|
+
# sits on the bound (90 BPM under --beat-range 60-90) fits a hair beyond it as often as
|
|
1637
|
+
# inside, and discarding the fit kept the step-rounded interval (89.76 BPM), whose grid
|
|
1638
|
+
# was 80 ms off a 30 s click track by its end
|
|
1639
|
+
new_iv = min(max(new_iv, 60.0 / hi_bpm), 60.0 / lo_bpm)
|
|
1531
1640
|
new_ph = mt - new_iv * mk
|
|
1532
1641
|
moved = abs(new_iv - iv) < 1e-9 and abs(new_ph - ph) < 1e-7
|
|
1533
1642
|
iv, ph = new_iv, new_ph
|
package/scripts/_common/emit.py
CHANGED
|
@@ -640,14 +640,43 @@ def stream_inventory_step(meta: Dict[str, Any], expect: Dict[str, Any]) -> Dict[
|
|
|
640
640
|
return step
|
|
641
641
|
|
|
642
642
|
|
|
643
|
+
def _finite(v: Any) -> Optional[float]:
|
|
644
|
+
return float(v) if isinstance(v, (int, float)) and math.isfinite(v) else None
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def own_length(s: Optional[Dict[str, Any]]) -> Optional[float]:
|
|
648
|
+
"""The length of one probed stream (`meta["video"]`, `meta["audio"]`): its duration, else the
|
|
649
|
+
DURATION tag Matroska/WebM keep, which is where the stream ENDS on the file's clock (the
|
|
650
|
+
muxer's last timestamp plus its packet's duration), not how long it lasts: a sound from 1 s
|
|
651
|
+
to 6 s is tagged 6. The tag less the stream's start (from 0 when the start is a priming
|
|
652
|
+
frame before 0). A value that is not a finite number is no length (a NaN compares false and
|
|
653
|
+
would pass every check)."""
|
|
654
|
+
s = s or {}
|
|
655
|
+
duration = _finite(s.get("duration"))
|
|
656
|
+
if duration is not None and duration > 0:
|
|
657
|
+
return duration
|
|
658
|
+
tagged = _finite(s.get("tagged_duration"))
|
|
659
|
+
if tagged is None:
|
|
660
|
+
return None
|
|
661
|
+
length = tagged - max(_finite(s.get("start_time")) or 0.0, 0.0)
|
|
662
|
+
return length if length > 0 else None
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def own_end(s: Optional[Dict[str, Any]]) -> Optional[float]:
|
|
666
|
+
"""Where one probed stream ends on the file's own timestamps: its start plus its duration,
|
|
667
|
+
else the Matroska/WebM DURATION tag, which is already that end."""
|
|
668
|
+
s = s or {}
|
|
669
|
+
duration = _finite(s.get("duration"))
|
|
670
|
+
if duration is not None and duration > 0:
|
|
671
|
+
return (_finite(s.get("start_time")) or 0.0) + duration
|
|
672
|
+
if own_length(s) is None:
|
|
673
|
+
return None
|
|
674
|
+
return _finite(s.get("tagged_duration"))
|
|
675
|
+
|
|
676
|
+
|
|
643
677
|
def stream_length(meta: Dict[str, Any], kind: str) -> Optional[float]:
|
|
644
|
-
"""One stream's own length
|
|
645
|
-
|
|
646
|
-
s = meta.get(kind) or {}
|
|
647
|
-
for v in (s.get("duration"), s.get("tagged_duration")):
|
|
648
|
-
if isinstance(v, (int, float)) and math.isfinite(v) and v > 0:
|
|
649
|
-
return float(v)
|
|
650
|
-
return None
|
|
678
|
+
"""One stream's own length (own_length())."""
|
|
679
|
+
return own_length(meta.get(kind))
|
|
651
680
|
|
|
652
681
|
|
|
653
682
|
LENGTH_UNREAD_HINT = ("remux the file so its container states a length (export.py INPUT --preset copy -o remuxed.mkv), "
|
|
@@ -674,9 +703,8 @@ def input_length(meta: Optional[Dict[str, Any]], path: Optional[str] = None) ->
|
|
|
674
703
|
|
|
675
704
|
|
|
676
705
|
def stream_end(meta: Dict[str, Any], kind: str) -> Optional[float]:
|
|
677
|
-
"""Where one stream ends on the file's timeline
|
|
678
|
-
|
|
679
|
-
return None if length is None else ((meta.get(kind) or {}).get("start_time") or 0.0) + length
|
|
706
|
+
"""Where one stream ends on the file's timeline (own_end())."""
|
|
707
|
+
return own_end(meta.get(kind))
|
|
680
708
|
|
|
681
709
|
|
|
682
710
|
def file_origin(meta: Dict[str, Any]) -> float:
|
package/scripts/_common/probe.py
CHANGED
|
@@ -43,11 +43,16 @@ def fingerprint(path: str) -> Dict[str, Any]:
|
|
|
43
43
|
|
|
44
44
|
|
|
45
45
|
def decode_pcm_mono(path: str, sample_rate: int, seconds: Optional[float] = None, start: float = 0.0,
|
|
46
|
-
*, check: bool = True) ->
|
|
46
|
+
*, check: bool = True) -> "Sequence[float]":
|
|
47
47
|
"""Decode (part of) a file's audio to mono float samples in [-1, 1) at `sample_rate` via a
|
|
48
48
|
single ffmpeg pass under --timeout. Shared by scenes.py (audio envelope for cut scoring) and
|
|
49
49
|
sync.py (cross-correlation); an undecodable input is kind ffmpeg when check=True, else [].
|
|
50
|
-
The samples are on the file's clock: a sound that starts late is padded at its head.
|
|
50
|
+
The samples are on the file's clock: a sound that starts late is padded at its head.
|
|
51
|
+
|
|
52
|
+
The samples are an array('f'), 4 bytes each: a list of Python floats took about 32 (24 for the
|
|
53
|
+
float, 8 for the slot), so scenes.py held ~2.3 GB per hour of audio at 8 kHz + 22.05 kHz and
|
|
54
|
+
multicam --switch energy one such list per camera (A140). An s16 sample divided by 32768 is
|
|
55
|
+
exact in a float32, so every value read back is the double the list held."""
|
|
51
56
|
ffmpeg = require_tool("ffmpeg")
|
|
52
57
|
cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin"]
|
|
53
58
|
if start:
|
|
@@ -65,9 +70,15 @@ def decode_pcm_mono(path: str, sample_rate: int, seconds: Optional[float] = None
|
|
|
65
70
|
if check:
|
|
66
71
|
die(f"could not decode audio from {path}:\n{proc.stderr.decode(errors='replace').strip()}", kind="ffmpeg", **ffmpeg_failure_extra(proc.stderr.decode(errors="replace")))
|
|
67
72
|
return []
|
|
68
|
-
|
|
69
|
-
import
|
|
70
|
-
|
|
73
|
+
import array
|
|
74
|
+
import sys
|
|
75
|
+
data, proc = proc.stdout, None
|
|
76
|
+
pcm = array.array("h")
|
|
77
|
+
pcm.frombytes(memoryview(data)[: len(data) // 2 * 2])
|
|
78
|
+
del data
|
|
79
|
+
if sys.byteorder == "big":
|
|
80
|
+
pcm.byteswap() # s16le on the pipe
|
|
81
|
+
return array.array("f", (v / 32768.0 for v in pcm))
|
|
71
82
|
|
|
72
83
|
|
|
73
84
|
def rms_envelope(samples: Sequence[float], step: int, *, full_blocks_only: bool = False, remove_mean: bool = False) -> List[float]:
|
|
@@ -203,6 +214,23 @@ def shorter_than_a_loudness_block(length: "Optional[float]") -> bool:
|
|
|
203
214
|
return length is not None and 0 < length < LOUDNESS_BLOCK_SECONDS
|
|
204
215
|
|
|
205
216
|
|
|
217
|
+
def riff_wave_args(path: str) -> List[str]:
|
|
218
|
+
"""`-f wav` for a file that opens with a RIFF/WAVE header. FFmpeg 9.0.2 scores its MPEG-TS
|
|
219
|
+
probe above the WAV one when the PCM happens to hold 0x47 at a transport packet's spacing (a
|
|
220
|
+
steady tone can): ffprobe then reads a file this skill has just written as a broken transport
|
|
221
|
+
stream and fails with "End of file". The header says what the file is, so the probe is told.
|
|
222
|
+
Measured on 9.0.2 (CI): the same file fails unforced and reads with `-f wav`; 6.1 to 8.0 read
|
|
223
|
+
it either way."""
|
|
224
|
+
if not os.path.isfile(path): # a pipe or a device is never opened here: the open would block
|
|
225
|
+
return []
|
|
226
|
+
try:
|
|
227
|
+
with open(path, "rb") as fh:
|
|
228
|
+
head = fh.read(12)
|
|
229
|
+
except OSError:
|
|
230
|
+
return []
|
|
231
|
+
return ["-f", "wav"] if head[:4] == b"RIFF" and head[8:12] == b"WAVE" else []
|
|
232
|
+
|
|
233
|
+
|
|
206
234
|
def probe(path: str, role: str = "input") -> Dict[str, Any]:
|
|
207
235
|
"""Return a compact, script-friendly description of a media file.
|
|
208
236
|
|
|
@@ -229,7 +257,7 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
|
|
|
229
257
|
die(f"input not found: {path}")
|
|
230
258
|
ffprobe = require_tool("ffprobe")
|
|
231
259
|
proc = run(
|
|
232
|
-
[ffprobe, "-v", "error", *still_input_args(path),
|
|
260
|
+
[ffprobe, "-v", "error", *still_input_args(path), *riff_wave_args(path),
|
|
233
261
|
"-print_format", "json", "-show_format", "-show_streams", "-show_chapters", path],
|
|
234
262
|
quiet=True,
|
|
235
263
|
check=False,
|
|
@@ -2109,11 +2109,56 @@ def _set_refusal_note(ctx: "Context", reason: str, outcome: str) -> None:
|
|
|
2109
2109
|
ctx.hw_notes.append(reason + outcome)
|
|
2110
2110
|
|
|
2111
2111
|
|
|
2112
|
+
# A filter graph longer than this is handed to ffmpeg in a file (A138). Linux refuses any single
|
|
2113
|
+
# argument over 128 KiB (MAX_ARG_STRLEN) and Windows a whole command line over 32767 characters, and
|
|
2114
|
+
# a graph grows with the items it is built from: scenes.py --sheet's select expression is ~56
|
|
2115
|
+
# characters a scene, so ~2,340 scenes raised OSError E2BIG before ffmpeg ran, with a traceback and
|
|
2116
|
+
# no JSON. A graph under the limit is passed as it always was.
|
|
2117
|
+
LONG_FILTER_CHARS = 8000 if os.name == "nt" else 100_000
|
|
2118
|
+
_FILTER_OPTION_RE = re.compile(r"^-(?:filter_complex|lavfi|vf|af|filter(?::[^\s]+)?)$")
|
|
2119
|
+
|
|
2120
|
+
|
|
2121
|
+
def _filter_file_option(option: str) -> str:
|
|
2122
|
+
"""The option that reads `option`'s value from a file: `-/OPTION` from FFmpeg 7.0, which
|
|
2123
|
+
deprecated the `_script` spellings, and `-filter_complex_script` / `-filter_script[:spec]`
|
|
2124
|
+
before it (6.x, the oldest CI runs)."""
|
|
2125
|
+
complex_graph = option in ("-filter_complex", "-lavfi")
|
|
2126
|
+
spec = {"-vf": ":v", "-af": ":a"}.get(option, option[len("-filter"):] if option.startswith("-filter:") else "")
|
|
2127
|
+
if ffmpeg_version() >= (7, 0):
|
|
2128
|
+
return "-/filter_complex" if complex_graph else "-/filter" + spec
|
|
2129
|
+
return "-filter_complex_script" if complex_graph else "-filter_script" + spec
|
|
2130
|
+
|
|
2131
|
+
|
|
2132
|
+
@contextlib.contextmanager
|
|
2133
|
+
def long_filters_in_files(cmd: Sequence[str]) -> "Any":
|
|
2134
|
+
"""`cmd` with every filter graph longer than LONG_FILTER_CHARS moved into a temp file read by
|
|
2135
|
+
ffmpeg's file form of the same option; the files are removed when the block ends. The command a
|
|
2136
|
+
run records and prints is the caller's own, with the graph inline."""
|
|
2137
|
+
import tempfile
|
|
2138
|
+
out = list(cmd)
|
|
2139
|
+
made: List[str] = []
|
|
2140
|
+
try:
|
|
2141
|
+
if _is_ffmpeg(out):
|
|
2142
|
+
for i in range(1, len(out) - 1):
|
|
2143
|
+
if _FILTER_OPTION_RE.match(out[i]) and len(out[i + 1]) > LONG_FILTER_CHARS:
|
|
2144
|
+
fd, path = tempfile.mkstemp(prefix="ffmpeg-skill-graph-", suffix=".txt")
|
|
2145
|
+
made.append(path)
|
|
2146
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
2147
|
+
fh.write(out[i + 1])
|
|
2148
|
+
out[i], out[i + 1] = _filter_file_option(out[i]), path
|
|
2149
|
+
yield out
|
|
2150
|
+
finally:
|
|
2151
|
+
for path in made:
|
|
2152
|
+
with contextlib.suppress(OSError):
|
|
2153
|
+
os.remove(path)
|
|
2154
|
+
|
|
2155
|
+
|
|
2112
2156
|
def _execute(exec_cmd: List[str]) -> subprocess.CompletedProcess:
|
|
2113
2157
|
"""One attempt, never exiting on failure (run() decides after its retries)."""
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2158
|
+
with long_filters_in_files(exec_cmd) as cmd:
|
|
2159
|
+
if STATE.progress and _is_ffmpeg(cmd) and cmd[-1] != "-":
|
|
2160
|
+
return _run_with_progress(cmd, False)
|
|
2161
|
+
return _run_captured(cmd, False)
|
|
2117
2162
|
|
|
2118
2163
|
|
|
2119
2164
|
def run_analysis(cmd: Sequence[str], *, check: bool = True, text: bool = True, record: bool = False) -> subprocess.CompletedProcess:
|
|
@@ -2134,7 +2179,8 @@ def run_analysis(cmd: Sequence[str], *, check: bool = True, text: bool = True, r
|
|
|
2134
2179
|
# input filename on stderr, and loudness/check parse the loudnorm JSON out of it. The
|
|
2135
2180
|
# encoding kwargs are rejected with text=False, so they are only passed for text mode.
|
|
2136
2181
|
text_kw = {"encoding": "utf-8", "errors": "replace"} if text else {}
|
|
2137
|
-
|
|
2182
|
+
with long_filters_in_files(cmd) as exec_cmd:
|
|
2183
|
+
proc = subprocess.run(exec_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=text, **text_kw, timeout=limit)
|
|
2138
2184
|
except subprocess.TimeoutExpired:
|
|
2139
2185
|
_timed_out(cmd, limit or 0)
|
|
2140
2186
|
if check and proc.returncode != 0:
|