ffmpeg-skill 3.8.12 → 3.8.14
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 +2 -1
- package/SKILL.md +1 -1
- package/docs/contract.md +11 -5
- package/mcp/server.py +0 -1
- package/package.json +5 -2
- package/references/flags.md +2 -2
- package/references/scripts.md +72 -27
- package/scripts/_common/asr.py +1 -1
- package/scripts/_common/color.py +7 -2
- package/scripts/_common/decision.py +40 -8
- package/scripts/_common/emit.py +38 -10
- package/scripts/_common/probe.py +34 -6
- package/scripts/_common/runner.py +76 -7
- package/scripts/_contract.py +24 -10
- package/scripts/_platforms.py +0 -5
- package/scripts/_smartcut.py +23 -5
- package/scripts/audio.py +86 -28
- package/scripts/background.py +1 -1
- package/scripts/caption.py +2 -3
- package/scripts/check.py +3 -3
- package/scripts/color.py +7 -1
- package/scripts/crop.py +3 -2
- package/scripts/cropdetect.py +1 -1
- package/scripts/cut.py +50 -17
- package/scripts/export.py +39 -6
- package/scripts/fit.py +4 -3
- package/scripts/freeze.py +1 -1
- package/scripts/graphics.py +2 -3
- package/scripts/insert.py +1 -1
- package/scripts/look.py +9 -4
- package/scripts/loop.py +3 -3
- package/scripts/loudness.py +20 -12
- package/scripts/multicam.py +56 -15
- package/scripts/overlay.py +1 -1
- package/scripts/probe.py +13 -2
- package/scripts/proxy.py +11 -2
- package/scripts/redact.py +5 -1
- package/scripts/scenes.py +82 -21
- package/scripts/silence.py +62 -16
- package/scripts/sphere.py +23 -3
- package/scripts/stabilize.py +5 -2
- package/scripts/sync.py +11 -6
- package/scripts/verify.py +82 -20
- package/scripts/waveform.py +12 -2
package/README.md
CHANGED
|
@@ -331,6 +331,7 @@ rather than drifting silently. There is no separate `input_schema` file to forge
|
|
|
331
331
|
```bash
|
|
332
332
|
npx ffmpeg-skill contract --json # or: python3 scripts/_contract.py --json
|
|
333
333
|
npx ffmpeg-skill contract --json --static # without environment detection
|
|
334
|
+
npx ffmpeg-skill contract --json --tool cut # one tool's entry under `tools` (repeatable): ~60 KB, not ~950 KB
|
|
334
335
|
```
|
|
335
336
|
|
|
336
337
|
The contract is generated from the code that runs, not maintained beside it. For each of the 42 tools (`ffmpeg-skill/<name>`) it states:
|
|
@@ -514,7 +515,7 @@ type on every OS.
|
|
|
514
515
|
| Result | Measurement |
|
|
515
516
|
|---|---|
|
|
516
517
|
| **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 |
|
|
518
|
+
| **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
519
|
| **0 missed gaps** | `silence.py`, 20 cases with known gaps, ≤ 1 ms leftover silence |
|
|
519
520
|
| **F1 0.97** | `scenes.py`, 53 hard cuts between single takes, precision 0.95, recall 1.00 at the default threshold |
|
|
520
521
|
| **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/SKILL.md
CHANGED
|
@@ -16,7 +16,7 @@ Shared flags (the rest, codec defaults and `--hw`: `references/flags.md`):
|
|
|
16
16
|
|
|
17
17
|
Writing tools run nothing under `--dry-run`; the measuring tools (`probe`, `check`, `sync`, `multicam`,
|
|
18
18
|
`scenes`, `cropdetect`, `report`, `silence`, `loudness`, `stabilize`, `overlay`, `metadata`, `cut`, `render`,
|
|
19
|
-
`batch`) may still run ffmpeg/ffprobe without writing artifacts; `verify` ignores the flag. Per tool: `contract --json` `dry_run`.
|
|
19
|
+
`batch`) may still run ffmpeg/ffprobe without writing artifacts; `verify` ignores the flag. Per tool: `contract --json --tool NAME` `dry_run`.
|
|
20
20
|
|
|
21
21
|
Audio-only files work in the audio tools; picture tools refuse them (`references/workflow.md#audio-only-files`).
|
|
22
22
|
|
package/docs/contract.md
CHANGED
|
@@ -7,6 +7,11 @@ dry-run, input preservation and repeatability. It is the interface a planning ag
|
|
|
7
7
|
consumes instead of reading `SKILL.md`, which stays written for a coding agent that
|
|
8
8
|
follows the workflow by hand.
|
|
9
9
|
|
|
10
|
+
The whole document is about 950 KB, almost all of it the 42 `tools` entries. `--tool NAME`
|
|
11
|
+
(repeatable; `cut` or `cut.py`) keeps only the named tools under `tools`, in the order given,
|
|
12
|
+
and every other key as it is: about 55 to 85 KB for one tool. An unknown name is refused (exit
|
|
13
|
+
2) with the list of tools, and `doctor` does not take the flag.
|
|
14
|
+
|
|
10
15
|
The contract is derived from the code that runs, not maintained beside it:
|
|
11
16
|
|
|
12
17
|
- the tool list is every script in `scripts/` that does not start with `_`;
|
|
@@ -21,7 +26,7 @@ The contract is derived from the code that runs, not maintained beside it:
|
|
|
21
26
|
| Field | Meaning | Changes when |
|
|
22
27
|
|---|---|---|
|
|
23
28
|
| `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.
|
|
29
|
+
| `skill.version` | the npm / package.json version (`3.8.14`) | any release |
|
|
25
30
|
|
|
26
31
|
A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
|
|
27
32
|
ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
|
|
@@ -149,7 +154,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
|
|
|
149
154
|
"deprecated": [],
|
|
150
155
|
"removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"},
|
|
151
156
|
{"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.
|
|
157
|
+
"skill": {"id": "ffmpeg-skill", "version": "3.8.14", "execution_mode": "local", "kind": "execution",
|
|
153
158
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
154
159
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
155
160
|
"requirements": {"python": ">=3.10 (standard library only)", "ffmpeg": ">=6.1", "ffprobe": ">=6.1"},
|
|
@@ -689,7 +694,8 @@ says "output audio is silent" instead of recommending `loudness.py`.
|
|
|
689
694
|
Per-tool keys added in 1.13: `audio` (`audio.py`) reports the mix it built — the
|
|
690
695
|
`--voice` level, `stereo_widen`, whether an `--effects` bed was mixed, and with
|
|
691
696
|
`--music` the `music_volume` plus a `duck` object naming the threshold (dB and
|
|
692
|
-
linear), ratio
|
|
697
|
+
linear), ratio (the sidechain gate's 9000 since A134), attack, release and `amount_db`
|
|
698
|
+
(the depth in dB the bed is lowered under speech) actually used, or `null` when `--duck` was not
|
|
693
699
|
given. Since #486 `audio.py` also reports `timing`, `{tempo, atempo, length_samples, sample_rate}`:
|
|
694
700
|
the `--tempo` factor and the atempo stages (each 0.5..2.0) it was built from, `--length-samples`,
|
|
695
701
|
and the rate that count is in (`null` before a pending input is measured); with
|
|
@@ -814,7 +820,7 @@ pointer to cuts that may sit in a breath):
|
|
|
814
820
|
|
|
815
821
|
| key | tool | what it holds |
|
|
816
822
|
|---|---|---|
|
|
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
|
|
823
|
+
| `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
824
|
| `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
825
|
| `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
826
|
|
|
@@ -950,7 +956,7 @@ A refusal also carries `error.reason`, a stable snake_case code for *why*
|
|
|
950
956
|
`output_is_directory`, `output_dir_missing`, `output_dir_not_writable`, `output_locked`,
|
|
951
957
|
`output_container_codecs` (a `.webm` output for an H.264/HEVC or AAC encode, which WebM cannot hold),
|
|
952
958
|
`media_path_is_url` (`subfile,...:` included), `media_path_is_playlist` (an HLS, DASH, IMF or ffconcat playlist given as media, DASH and IMF told by content whatever the file is called), `media_path_has_line_break` (a path with a line break, which a concat list cannot hold),
|
|
953
|
-
`output_looks_like_option` (an output starting with `-`, other than `-` for stdout), `output_path_taken` (two files one run writes share a path), `media_path_not_a_file` (a FIFO or device as input), `silence.py --timemap`'s `timemap_path_taken`, `join.py --copy`'s `join_copy_mismatch`,
|
|
959
|
+
`output_looks_like_option` (an output starting with `-`, other than `-` for stdout), `output_extension_missing` (a media output with no file extension, which leaves ffmpeg no container to choose), `output_path_taken` (two files one run writes share a path), `media_path_not_a_file` (a FIFO or device as input), `silence.py --timemap`'s `timemap_path_taken`, `join.py --copy`'s `join_copy_mismatch`,
|
|
954
960
|
`render.py`'s `plan_*` reasons, `--codec av1`/`prores`'s `encoder_missing` and
|
|
955
961
|
`encoder_list_unreadable`, and `--transcribe`'s `asr_engine_missing`, `no_speech`, `engine_failed`
|
|
956
962
|
and `english_only`), and `error.details`, the facts the message is written from, so a
|
package/mcp/server.py
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "3.8.
|
|
3
|
+
"version": "3.8.14",
|
|
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": [
|
|
@@ -53,7 +53,10 @@
|
|
|
53
53
|
"docs/contract.md",
|
|
54
54
|
"SKILL.md",
|
|
55
55
|
"README.md",
|
|
56
|
-
"LICENSE"
|
|
56
|
+
"LICENSE",
|
|
57
|
+
"!**/__pycache__/**",
|
|
58
|
+
"!**/*.pyc",
|
|
59
|
+
"!**/*.pyo"
|
|
57
60
|
],
|
|
58
61
|
"scripts": {
|
|
59
62
|
"test": "python3 tests/run_parallel.py && python3 tests/test_contract.py",
|
package/references/flags.md
CHANGED
|
@@ -5,7 +5,7 @@ The long form of SKILL.md's Quick start. `--help` on a script is still its full
|
|
|
5
5
|
|
|
6
6
|
## Running the scripts
|
|
7
7
|
|
|
8
|
-
Scripts live in `scripts/` next to SKILL.md; run them with `python3 <skill-dir>/scripts/<name>.py`, and delivery templates in `templates/`. SKILL.md is enough to do a job: SKILL.md's request table routes the request, and `--help` on the script about to run is the cheapest full flag list. A reference file costs as much as SKILL.md; open one only for a question you have: `references/scripts.md` (most flags; `--help` lists all), `references/devices.md` (iPhone HDR, GoPro, DJI, screen recordings, Zoom), `references/gotchas.md` (the long form of the one-line rules at the end of SKILL.md). MCP: `tools/list` shows only the core 12 by default; the other 30 (per SKILL.md's request table) stay callable by name via `tools/call`, described by `contract --json`; set `FFMPEG_SKILL_MCP_FULL=1` for all 42.
|
|
8
|
+
Scripts live in `scripts/` next to SKILL.md; run them with `python3 <skill-dir>/scripts/<name>.py`, and delivery templates in `templates/`. SKILL.md is enough to do a job: SKILL.md's request table routes the request, and `--help` on the script about to run is the cheapest full flag list. A reference file costs as much as SKILL.md; open one only for a question you have: `references/scripts.md` (most flags; `--help` lists all), `references/devices.md` (iPhone HDR, GoPro, DJI, screen recordings, Zoom), `references/gotchas.md` (the long form of the one-line rules at the end of SKILL.md). MCP: `tools/list` shows only the core 12 by default; the other 30 (per SKILL.md's request table) stay callable by name via `tools/call`, described by `contract --json --tool NAME`; set `FFMPEG_SKILL_MCP_FULL=1` for all 42.
|
|
9
9
|
|
|
10
10
|
## Shared flags
|
|
11
11
|
|
|
@@ -13,4 +13,4 @@ Shared flags (every script accepts them; the exceptions are named): `--dry-run`
|
|
|
13
13
|
|
|
14
14
|
## What `--dry-run` runs
|
|
15
15
|
|
|
16
|
-
Writing tools run no ffmpeg under `--dry-run` (ffprobe still reads an input that exists); the measuring tools (`probe` with `--analyze`, `check`, `sync`, `multicam`, `scenes`, `cropdetect`, `report`, `silence`, `loudness`, `stabilize`; `overlay` for a multi-line `--text` on FFmpeg 8.0-8.1.0; `metadata` with `--auto-chapters`; `cut` with `--snap beats`) still run their ffmpeg measurements, skipping artifacts/side files (`--edl`, `--sheet`, a generated `.ass`), and `render` and `batch` run each stage or step as its own dry run; `verify` ignores the flag. Per tool: `contract --json` `dry_run`.
|
|
16
|
+
Writing tools run no ffmpeg under `--dry-run` (ffprobe still reads an input that exists); the measuring tools (`probe` with `--analyze`, `check`, `sync`, `multicam`, `scenes`, `cropdetect`, `report`, `silence`, `loudness`, `stabilize`; `overlay` for a multi-line `--text` on FFmpeg 8.0-8.1.0; `metadata` with `--auto-chapters`; `cut` with `--snap beats`) still run their ffmpeg measurements, skipping artifacts/side files (`--edl`, `--sheet`, a generated `.ass`), and `render` and `batch` run each stage or step as its own dry run; `verify` ignores the flag. Per tool: `contract --json --tool NAME` `dry_run`.
|
package/references/scripts.md
CHANGED
|
@@ -131,7 +131,10 @@ JSON with `duration`, `video{codec,width,height,fps,pix_fmt,color_space,rotation
|
|
|
131
131
|
(`hdr`, and `hdr_signal` equal to it, is true only for a PQ / HLG transfer or Dolby Vision -- since 2.0;
|
|
132
132
|
`bt2020_or_hdr` also counts BT.2020 primaries on an SDR transfer, which `hdr_format` names "BT.2020 SDR"),
|
|
133
133
|
`audio{codec,channels,sample_rate,start_time}` (`start_time`: where the stream starts on the file's
|
|
134
|
-
own clock, `null` when unknown). `--compact` gives one line per file.
|
|
134
|
+
own clock, `null` when unknown). `--compact` gives one line per file. `--field` prints one line
|
|
135
|
+
per file: a string as itself and any other value as JSON (`true`, `{"codec": ...}`), an empty
|
|
136
|
+
line for a field that is not there; with `--json` every line is JSON (a string quoted, a
|
|
137
|
+
missing field `null`).
|
|
135
138
|
|
|
136
139
|
### cut.py — cut / join segments
|
|
137
140
|
```
|
|
@@ -147,7 +150,7 @@ by default since 3.0.0 (`--keep-hevc`; `--no-keep-hevc` uses x264 as 2.x did);
|
|
|
147
150
|
`--codec` overrides all of them). `--accurate` always re-encodes; `--tolerance -1` never does for a keyframe snap
|
|
148
151
|
(a `--segments` join of open-GOP video, one whose later B-frame `.mp4`/`.mov` segment starts
|
|
149
152
|
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
|
|
153
|
+
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
154
|
the result was "lossless stream copy" or "re-encoded"; when the snap forced a
|
|
152
155
|
re-encode, the result's `lossless_alternative` names the nearest keyframe
|
|
153
156
|
`--start` that would stream-copy instead, so the trade can be offered.
|
|
@@ -338,7 +341,7 @@ the rectangle for you. Use this when the rectangle is already known (a
|
|
|
338
341
|
face-detection box, a saved crop, a hand-picked region). The rectangle must
|
|
339
342
|
lie entirely inside the source frame (after accounting for display rotation);
|
|
340
343
|
`--width`/`--height` must be even (4:2:0 chroma) and are refused, never
|
|
341
|
-
rounded, if they aren't.
|
|
344
|
+
rounded, if they aren't; an odd `--x`/`--y` is taken as given.
|
|
342
345
|
|
|
343
346
|
### cropdetect.py — measure black bars, report the crop rectangle
|
|
344
347
|
```
|
|
@@ -429,7 +432,9 @@ Wraps FFmpeg's `v360` filter to bake out an ordinary flat clip pointed at a
|
|
|
429
432
|
fixed direction — the same "look this way" operation a VR headset or a 360
|
|
430
433
|
player's viewport does. `--yaw`/`--pitch`/`--roll` aim the camera (each
|
|
431
434
|
-180..180, default 0); `--h-fov`/`--v-fov` set how wide the view is (each
|
|
432
|
-
1..170
|
|
435
|
+
1..170; `--h-fov` defaults to 90, and without `--v-fov` the vertical view is the
|
|
436
|
+
one that keeps the output's pixels square, 58.7 at 90 and 1920x1080; a pair that
|
|
437
|
+
is not square-pixel is drawn as given and said in `notes`). `--input-projection` must match the source's own
|
|
433
438
|
projection (default `equirect`, the most common capture/export format) —
|
|
434
439
|
this tool cannot detect it from the pixels, so a wrong value produces a
|
|
435
440
|
distorted or garbled output, not an error. There is no subject detection or
|
|
@@ -502,7 +507,9 @@ a temporary transforms file (deleted after the run), pass 2 smooths and
|
|
|
502
507
|
re-renders. `--shakiness` (default 5) trades analysis time for how much
|
|
503
508
|
motion it looks for; `--smoothing` (default 15) is how many neighbouring
|
|
504
509
|
frames the camera path is averaged over; `--zoom` crops in slightly to hide
|
|
505
|
-
the black edges stabilizing can introduce
|
|
510
|
+
the black edges stabilizing can introduce, and is the only zoom applied (0, the
|
|
511
|
+
default, keeps the original framing and lets the edges show: vidstab's own
|
|
512
|
+
"optimal zoom" is off). Needs an ffmpeg built with
|
|
506
513
|
`--enable-libvidstab`; `doctor` reports this tool `usable: no` when that's
|
|
507
514
|
missing (a plain Homebrew ffmpeg build, for example) rather than failing at
|
|
508
515
|
run time.
|
|
@@ -530,7 +537,9 @@ Renders the input's audio as a video: `--style waveform` (default, FFmpeg's
|
|
|
530
537
|
`showwaves`) draws amplitude over time; `--style spectrum` (`showspectrum`)
|
|
531
538
|
draws a frequency-over-time heatmap instead, reading more out of dense
|
|
532
539
|
mixes at the cost of being less immediately readable. The output always
|
|
533
|
-
carries the audio it visualizes.
|
|
540
|
+
carries the audio it visualizes. The default output of an audio input is
|
|
541
|
+
`<name>_waveform.mp4` (an audio extension cannot hold the picture), and `-o` with an audio
|
|
542
|
+
extension is refused. For an audio-only input (no video stream
|
|
534
543
|
needed) or any file with an audio track worth visualizing.
|
|
535
544
|
|
|
536
545
|
**Audiogram (1.16).** `--image PATH` puts a local still behind the
|
|
@@ -719,12 +728,14 @@ silence.py INPUT --filler --words transcript.json [--filler-lang auto|en|ja|ko|z
|
|
|
719
728
|
[--transcribe] [--engine auto|parakeet-mlx|parakeet.cpp|whisper.cpp|faster-whisper|openai-whisper]
|
|
720
729
|
[--model MODEL] [--filler-list] [--max-cuts 400]
|
|
721
730
|
```
|
|
722
|
-
Runs `silencedetect`, keeps `--margin` seconds of air around speech
|
|
731
|
+
Runs `silencedetect`, keeps `--margin` seconds of air around speech (none at the start or the
|
|
732
|
+
end of the sound, where there is no speech to keep it for), drops
|
|
723
733
|
gaps shorter than `--min-silence`, and re-encodes the kept ranges the way `cut.py --segments
|
|
724
734
|
--accurate` does: both streams cut on the same frame times, so the sound never drifts from the
|
|
725
735
|
picture at the cuts, and a sound that starts late keeps its offset. `--list` prints silences, kept ranges and seconds removed
|
|
726
736
|
without rendering; `--edl` saves the kept ranges in `cut.py --segments` format
|
|
727
|
-
so the user can edit the list by hand
|
|
737
|
+
so the user can edit the list by hand (written once the cut is, as `--timemap` is). `--max-cuts N`
|
|
738
|
+
(400) refuses to render more than N removal ranges; `--list` reports them whatever their number. Quiet rooms need `--threshold -40`
|
|
728
739
|
to `-45`; noisy ones `-30`. Always tell the user how many seconds were removed.
|
|
729
740
|
|
|
730
741
|
**Breaths and weak word endings.** `--margin` is the padding kept on each side of every cut
|
|
@@ -766,9 +777,10 @@ unchanged, and filler-word cuts (which sit in sound by design) are measured but
|
|
|
766
777
|
source and output times, and every removed range with the output time it was cut at. `.csv`
|
|
767
778
|
gives `type,source_start,source_end,output_start,output_end` rows (a removed row's two output
|
|
768
779
|
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
|
-
|
|
780
|
+
made on the source, or to find a removed sentence again. Its times advance by the exact
|
|
781
|
+
lengths of the ranges the render cuts on (with a picture, each bound on its nearest frame time;
|
|
782
|
+
with sound alone, as asked), rounded only when written, so nothing adds up over many cuts; they
|
|
783
|
+
are computed, not measured on the written file. An existing map is refused without `--overwrite`,
|
|
772
784
|
and a missing directory or a directory at the path as `kind: input`, before anything runs (a dry
|
|
773
785
|
run predicts both); the map is written only after the cut is, so a failed or refused encode
|
|
774
786
|
leaves none. A dry run writes nothing.
|
|
@@ -1201,7 +1213,8 @@ scenes.py INPUT [--shots] [--audio-peaks] [--speech] [--json]
|
|
|
1201
1213
|
```
|
|
1202
1214
|
Lists scenes with audio energy, the loudest moments, and (with
|
|
1203
1215
|
`--highlights`) proposes N ranges that add up to `--target` seconds, biased to
|
|
1204
|
-
the loudest window of each scene
|
|
1216
|
+
the loudest window of each scene; each range lies inside its scene (it starts at the cut or on
|
|
1217
|
+
the 0.5 s grid after it, to the hundredth rounded inward). Review the sheet + JSON, adjust the EDL, then
|
|
1205
1218
|
`cut.py --segments`. Cut detection is a one-frame spike test (benchmark on
|
|
1206
1219
|
hard cuts between real single takes: precision 0.95, recall 1.00 at the default
|
|
1207
1220
|
threshold; raise `--threshold` to 12 for 0.98 precision at 0.94 recall).
|
|
@@ -1619,8 +1632,14 @@ multicam.py REF CAM2 [CAM3 ...] [--switch "START-END:CAM,..."|energy [--min-shot
|
|
|
1619
1632
|
```
|
|
1620
1633
|
All inputs are aligned to the first one by audio (same engine as `sync.py`,
|
|
1621
1634
|
`--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
|
-
|
|
1635
|
+
each range of the reference timeline (gaps fall back to camera 0; ranges that overlap are
|
|
1636
|
+
refused, `kind: input`, since each moment has one camera), `--auto N`
|
|
1637
|
+
simply alternates every N seconds. Under `--fix-drift` a drifting camera's picture is retimed
|
|
1638
|
+
with its sound (`setpts=(PTS-STARTPTS)/ratio`), so a range lasts its reference length on both;
|
|
1639
|
+
`--write-project`'s clips name that camera's own in/out, with the ratio as the clip's `speed`
|
|
1640
|
+
(render.py plays it at that rate, so it lasts its reference length there too), and the project states
|
|
1641
|
+
`"transition": {"type": "none"}`: a switch is a cut, and render.py's default 0.5 s crossfade at every join
|
|
1642
|
+
would run the timeline 0.5 s a switch short and move each later switch. Audio comes from the reference unless
|
|
1624
1643
|
`--audio` picks another input, e.g. an external recorder that has no video.
|
|
1625
1644
|
`--offsets-only` reports offsets and confidence without rendering. `cuts` names
|
|
1626
1645
|
the camera actually rendered: a range whose camera has not started yet plays
|
|
@@ -1657,14 +1676,17 @@ Each step runs with `--json`, and a failed one keeps its tool's own `kind`,
|
|
|
1657
1676
|
completed one carries the tool's `verified`. Steps that do not apply are listed
|
|
1658
1677
|
under the file's `skipped` (`quick`, `no_video`, `no_audio`, `not_hdr`,
|
|
1659
1678
|
`not_multichannel`, `input_unreadable`) and as SKIP rows in the report.
|
|
1679
|
+
`--overwrite` and `--fast` given to verify.py reach every step, so a rerun into the same
|
|
1680
|
+
`--out` replaces the earlier outputs. The outputs are kept in `--out`, or with `--keep` alone
|
|
1681
|
+
in a new folder; either is the result's `out_dir` (`null` when they were deleted).
|
|
1660
1682
|
Run this first when a user hands over footage from a device you have not
|
|
1661
1683
|
seen before, and fix or report what fails.
|
|
1662
1684
|
|
|
1663
1685
|
### look.py — see the result
|
|
1664
1686
|
```
|
|
1665
|
-
look.py INPUT [--tiles 4x3] [--width 1280] [-o sheet.png] # contact sheet with timecodes
|
|
1687
|
+
look.py INPUT [--tiles 4x3] [--width 1280] [-o sheet.png] # contact sheet with timecodes (-o basename -> basename_sheet.png)
|
|
1666
1688
|
look.py INPUT --at 2.5 [--at 7] [-o basename] # single frames -> basename_2.500s.png
|
|
1667
|
-
look.py BEFORE --compare AFTER --at 4 [-o cmp.png] # side-by-side frame
|
|
1689
|
+
look.py BEFORE --compare AFTER --at 4 [-o cmp.png] # side-by-side frame (-o basename -> basename_vs_AFTER_0_4.000s.png)
|
|
1668
1690
|
look.py INPUT --safe tiktok [--at 3] # shade what the app's UI covers
|
|
1669
1691
|
look.py INPUT --guides title,action,4:3 [--at 3] # broadcast safe-area outlines
|
|
1670
1692
|
```
|
|
@@ -2015,7 +2037,8 @@ sync.py REFERENCE SOURCE [SOURCE ...] [--json] [--max-offset 30] [--analyze-seco
|
|
|
2015
2037
|
```
|
|
2016
2038
|
Cross-correlates loudness envelopes: coarse FFT search (20 ms), then a direct
|
|
2017
2039
|
1 ms refinement (pure Python, a 2-minute window takes ~1-3 s). Positive offset
|
|
2018
|
-
= the source recording started later
|
|
2040
|
+
= the source recording started later, so `--trim-second`/`--replace-audio` delay it by that
|
|
2041
|
+
much (a negative one drops its first -offset seconds). `--replace-audio` writes the reference
|
|
2019
2042
|
video with the source file's audio aligned (video stream copied); the output
|
|
2020
2043
|
keeps the reference's full length -- a shorter or head-trimmed source file is
|
|
2021
2044
|
padded with silence, never allowed to cut the picture.
|
|
@@ -2031,8 +2054,11 @@ could not be measured is skipped and says why: `drift_skipped_reason` is
|
|
|
2031
2054
|
`too_short` (no tail window far enough from the head) or `low_confidence`
|
|
2032
2055
|
(top level for one source, and in each `sources` entry; null when applied).
|
|
2033
2056
|
Check `confidence`
|
|
2034
|
-
(0–1, normalised correlation with a runner-up penalty
|
|
2035
|
-
|
|
2057
|
+
(0–1, normalised correlation with a runner-up penalty: the best other peak more than five
|
|
2058
|
+
steps from the winner); below 0.3 the match is doubtful. Since A126 the runner-up is never the
|
|
2059
|
+
winner's own neighbour, which held an exact match at 0.59, so a clean match can read near 1.0 and a
|
|
2060
|
+
tie between two offsets reads at most 0.5. The benchmark below was measured with the earlier runner-up and has
|
|
2061
|
+
not been re-run since; on real dialogue/music (±30 s offsets, gain, noise, EQ):
|
|
2036
2062
|
with the default 120 s window 40/40 within 10 ms (max 1.1 ms); with a 60 s
|
|
2037
2063
|
window 95 %, misses flagged below 0.3. Keep `--analyze-seconds` at least 4×
|
|
2038
2064
|
`--max-offset` (default 120 s vs 30 s): lags with under 35 % overlap are
|
|
@@ -2169,10 +2195,13 @@ highpass, and `medium`/`strong` run their own denoise stage at that strength
|
|
|
2169
2195
|
that is off is refused, as the `--duck-*` flags are: `--denoise-strength` without
|
|
2170
2196
|
`--denoise`, `--music-volume`, `--music-fade-out` or `--music-loop` without `--music`,
|
|
2171
2197
|
`--effects-volume` without `--effects` (a value equal to the default is not seen as given).
|
|
2172
|
-
`--duck`
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2198
|
+
`--duck` lowers the music by `--duck-amount` dB (0..60, default 12) while the speech is above
|
|
2199
|
+
`--duck-threshold`, and leaves it at its level in the pauses: a sidechain gate keyed by the
|
|
2200
|
+
speech passes the bed under speech, and that part, scaled by `-(1 - 10^(-amount/20))`, is added
|
|
2201
|
+
back to the bed (`sidechaingate=threshold=0.05:ratio=9000:range=0:knee=1:attack=20:release=400`, then
|
|
2202
|
+
`volume=-0.748811` for 12 dB, by default). Through 3.8.8 it was a sidechain compressor whose
|
|
2203
|
+
ratio was `amount/3` (at least 2), so the depth followed the speech's level, not the flag
|
|
2204
|
+
(audit A134). `--duck-threshold DB` (default −26.02 dBFS, i.e. the 0.05 linear),
|
|
2176
2205
|
`--duck-attack MS` (20) and `--duck-release MS` (400) move each one; a lower
|
|
2177
2206
|
threshold ducks on quieter speech, a shorter release brings the bed back faster.
|
|
2178
2207
|
`--json`'s `audio` block reports the settings the run actually used.
|
|
@@ -2200,7 +2229,19 @@ swresample. Video is always stream-copied, and so is a subtitle/data track
|
|
|
2200
2229
|
when the container can hold it (`dropped_non_av_streams` says when it could not).
|
|
2201
2230
|
`--limit` (and `--voice strong`'s limiter) is `alimiter` with `level=disabled:latency=1`: the
|
|
2202
2231
|
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).
|
|
2232
|
+
`--limit-attack` (5 ms, 240 samples at 48 kHz) late against the picture (#476). With `--music`
|
|
2233
|
+
or `--effects` both limiters run on the mix, after the beds are added: the combined mix is
|
|
2234
|
+
limited before it is encoded (A129). A lossy output (`.m4a`, `.mp3`, `.opus`, AAC beside a
|
|
2235
|
+
picture) can then peak over the ceiling, since the encoder works after the limiter; use
|
|
2236
|
+
`loudness.py` for final levels, which measures the written file. Without a bed they run on the
|
|
2237
|
+
main track, where they always ran.
|
|
2238
|
+
The typed dynamics flags speak dB where the filter takes a linear value: a knee
|
|
2239
|
+
(`--comp-knee`, `--gate-knee`) is 0..18.06 dB, the filters' linear 1..8, default 9.03 dB
|
|
2240
|
+
(2.82843); the defaults `--help` gives are ffmpeg's own, in the flag's unit (A135).
|
|
2241
|
+
`--bitrate` is the AAC rate beside a picture (192k) and the rate of a lossy audio output's
|
|
2242
|
+
codec (`.m4a`/`.aac` 256k, `.mp3` `-q:a 0`, `.ogg` `-q:a 6`, `.opus` 128k when it is not given);
|
|
2243
|
+
`.wav` and `.flac` refuse it (A143). A `--music-fade-out` on a bed shorter than the output (and
|
|
2244
|
+
not `--music-loop`ed) ends where the bed ends (A142).
|
|
2204
2245
|
With `--replace`, `--music` or a kept picture the sound is padded with silence or trimmed to the
|
|
2205
2246
|
source's length counted in samples at the track's rate (`apad=whole_len=N,atrim=end_sample=N`),
|
|
2206
2247
|
so it is exact to the sample, not to the millisecond (#478). Sound that starts after the picture
|
|
@@ -2249,8 +2290,11 @@ video containers or the codec matching the extension (.wav → PCM, .flac, .mp3)
|
|
|
2249
2290
|
The written file is measured again: a lossy encoder can push peaks past the
|
|
2250
2291
|
ceiling loudnorm held (ffmpeg's AAC at 192k turned one transient from -2.4 to
|
|
2251
2292
|
+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
|
|
2293
|
+
did not pass `--audio-bitrate` (an `.m4a`/`.aac` output starts at its 256k, so only 320k is
|
|
2294
|
+
left), then with the loudnorm ceiling lowered by the overshoot -- until the file itself meets
|
|
2295
|
+
`--tp`. A pinned `--audio-bitrate` reaches an audio output's codec as well as a video
|
|
2296
|
+
container's AAC (through 3.8.8 an `.m4a` was 256k whatever was asked, and its retries too);
|
|
2297
|
+
`.wav`/`.flac` refuse it. `result` reports
|
|
2254
2298
|
`tp_ceiling_used`, `audio_bitrate_used`, `encodes`, and a `note` when the
|
|
2255
2299
|
integrated loudness ended more than 1 LU from the target because of it.
|
|
2256
2300
|
|
|
@@ -2336,7 +2380,8 @@ neither.
|
|
|
2336
2380
|
proxy.py INPUT [--width W | --scale F] [--quality N] [--fps N] [--no-audio] [-o OUT]
|
|
2337
2381
|
```
|
|
2338
2382
|
Not a delivery preset: resizes to `--width` (default 640) or by `--scale`
|
|
2339
|
-
factor,
|
|
2383
|
+
factor, never wider than the source and never under 2 px (a `notes` line says
|
|
2384
|
+
when either caps the width asked), re-encodes at a proxy-grade `--quality` (default 30) with the fastest
|
|
2340
2385
|
x264/x265 preset, keeps the source's own dynamic range (HDR stays HDR;
|
|
2341
2386
|
run `color.py --to-sdr` first if SDR is wanted) and keeps a subtitle/data
|
|
2342
2387
|
track when the container can hold it (`dropped_non_av_streams`). Only executes the spec
|
package/scripts/_common/asr.py
CHANGED
|
@@ -1098,7 +1098,7 @@ def transcribe_words_result(video: str, language: "Optional[str]" = None, model:
|
|
|
1098
1098
|
_shutil = SafeShutil(_shutil) # look engines up and launch them by full path, never from the working directory
|
|
1099
1099
|
import subprocess as _subprocess
|
|
1100
1100
|
import tempfile as _tempfile
|
|
1101
|
-
from _common import require_tool,
|
|
1101
|
+
from _common import require_tool, STATE
|
|
1102
1102
|
ffmpeg = require_tool("ffmpeg")
|
|
1103
1103
|
wanted = requested_engine(engine)
|
|
1104
1104
|
language = engine_language(language)
|
package/scripts/_common/color.py
CHANGED
|
@@ -395,8 +395,13 @@ def hdr_to_sdr_chain(meta: dict, tonemap: str, peak: float = 1000.0, desat: floa
|
|
|
395
395
|
prim = v.get("color_primaries") or "bt2020"
|
|
396
396
|
space = v.get("color_space") or "bt2020nc"
|
|
397
397
|
source_peak = HLG_PEAK_NITS if trc == "arib-std-b67" else peak
|
|
398
|
-
# zscale needs explicit input tags when the file lacks them
|
|
399
|
-
|
|
398
|
+
# zscale needs explicit input tags when the file lacks them, and on 6.1 its `pin` does not
|
|
399
|
+
# reach a frame whose own primaries are unspecified ("no path between colorspaces", kind
|
|
400
|
+
# ffmpeg: --to-sdr --force on an untagged file failed through 3.8.8), so a frame missing a tag
|
|
401
|
+
# is labelled with what the chain reads it as first
|
|
402
|
+
missing = not (v.get("color_transfer") and v.get("color_primaries") and v.get("color_space"))
|
|
403
|
+
label = [f"setparams=color_primaries={prim}:color_trc={trc}:colorspace={space}"] if missing else []
|
|
404
|
+
chain: List[str] = label + [
|
|
400
405
|
f"zscale=tin={trc}:pin={prim}:min={space}:rin={v.get('color_range') or 'tv'}:t=linear"
|
|
401
406
|
f":npl={TONEMAP_REFERENCE_NITS:g}",
|
|
402
407
|
"format=gbrpf32le",
|
|
@@ -695,6 +695,16 @@ def _vt_bt709(codec: str) -> List[str]:
|
|
|
695
695
|
return ["-bsf:v", f"{codec}_metadata=colour_primaries=1:transfer_characteristics=1:matrix_coefficients=1"]
|
|
696
696
|
|
|
697
697
|
|
|
698
|
+
def source_transfer(v: Dict[str, Any]) -> str:
|
|
699
|
+
"""The transfer a 10-bit re-encode of a BT.2020 / HDR source is tagged with: the source's own
|
|
700
|
+
tag, or, when it has none, what probe() read it as. BT.2020 primaries with no transfer tag are
|
|
701
|
+
`hdr_format` "BT.2020 SDR" (not an HDR signal), so they are tagged bt2020-10, the SDR curve
|
|
702
|
+
BT.2020 defines; through 3.8.8 every such re-encode came out tagged HLG (audit A153). An
|
|
703
|
+
HDR signal with no transfer tag (Dolby Vision whose base layer is untagged) keeps the HLG
|
|
704
|
+
default it always had."""
|
|
705
|
+
return v.get("color_transfer") or ("arib-std-b67" if v.get("hdr_signal") else "bt2020-10")
|
|
706
|
+
|
|
707
|
+
|
|
698
708
|
def _vt_args(codec: str, crf: int, meta: Optional[Dict[str, Any]], keep_bt709: bool) -> Optional[List[str]]:
|
|
699
709
|
"""VideoToolbox encoder options for `codec`, or None (with a note in STATE.hw_notes) when the
|
|
700
710
|
request stays on the CPU: no VideoToolbox encoder for the codec, this machine cannot, or the
|
|
@@ -716,7 +726,7 @@ def _vt_args(codec: str, crf: int, meta: Optional[Dict[str, Any]], keep_bt709: b
|
|
|
716
726
|
v = (meta or {}).get("video") or {}
|
|
717
727
|
hdr = bool(v.get("bt2020_or_hdr"))
|
|
718
728
|
hdr_tags = ["-colorspace", v.get("color_space") or "bt2020nc", "-color_primaries", v.get("color_primaries") or "bt2020",
|
|
719
|
-
"-color_trc", v
|
|
729
|
+
"-color_trc", source_transfer(v)]
|
|
720
730
|
if codec == "prores":
|
|
721
731
|
return ["-c:v", name, "-profile:v", "hq", "-pix_fmt", "p210le"] + (hdr_tags if hdr else [])
|
|
722
732
|
# The HDR curve was fitted on the Main10 HEVC line; an 8-bit H.264 line (an export preset on
|
|
@@ -847,7 +857,7 @@ def _cpu_encoder_args(codec: str, crf: int, preset: str, meta: Optional[Dict[str
|
|
|
847
857
|
hdr = bool(v.get("bt2020_or_hdr"))
|
|
848
858
|
cs = v.get("color_space") or "bt2020nc"
|
|
849
859
|
prim = v.get("color_primaries") or "bt2020"
|
|
850
|
-
trc = v
|
|
860
|
+
trc = source_transfer(v)
|
|
851
861
|
hdr_tags = ["-colorspace", cs, "-color_primaries", prim, "-color_trc", trc]
|
|
852
862
|
if codec == "h264":
|
|
853
863
|
if hdr:
|
|
@@ -1152,7 +1162,7 @@ def source_codec_video_args(meta: Optional[Dict[str, Any]], crf: int = 18, prese
|
|
|
1152
1162
|
def _x265_hdr_default(v: Dict[str, Any], crf: int, preset: str) -> List[str]:
|
|
1153
1163
|
cs = v.get("color_space") or "bt2020nc"
|
|
1154
1164
|
prim = v.get("color_primaries") or "bt2020"
|
|
1155
|
-
trc = v
|
|
1165
|
+
trc = source_transfer(v)
|
|
1156
1166
|
x265 = f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}:range=limited:hdr10-opt=1{hdr10_x265_params(v)}" if trc == "smpte2084" else f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}"
|
|
1157
1167
|
return ["-c:v", "libx265", "-preset", preset, "-crf", str(min(51, crf + 2)), "-pix_fmt", "yuv420p10le", "-tag:v", "hvc1",
|
|
1158
1168
|
"-x265-params", x265, "-colorspace", cs, "-color_primaries", prim, "-color_trc", trc, "-movflags", "+faststart"]
|
|
@@ -1173,10 +1183,27 @@ AUDIO_CODECS = {
|
|
|
1173
1183
|
}
|
|
1174
1184
|
|
|
1175
1185
|
|
|
1176
|
-
|
|
1177
|
-
|
|
1186
|
+
LOSSLESS_AUDIO_EXT = {".wav", ".flac"} # the AUDIO_CODECS whose codec takes no bitrate
|
|
1187
|
+
|
|
1188
|
+
|
|
1189
|
+
def audio_codec_for(output_path: str, bitrate: Optional[str] = None) -> List[str]:
|
|
1190
|
+
"""Pick an audio codec that the output container can actually hold.
|
|
1191
|
+
|
|
1192
|
+
`bitrate` is one the caller asked for: it replaces the rate or quality an audio extension
|
|
1193
|
+
names (`-q:a 0` for .mp3, 256k for .m4a), which through 3.8.8 it never reached (A143), and is
|
|
1194
|
+
the AAC rate of any other container (192k when it is None). A lossless extension takes none;
|
|
1195
|
+
the caller refuses a bitrate for it."""
|
|
1178
1196
|
ext = os.path.splitext(output_path)[1].lower()
|
|
1179
|
-
|
|
1197
|
+
if ext not in AUDIO_CODECS:
|
|
1198
|
+
return ["-c:a", "aac", "-b:a", bitrate or "192k"]
|
|
1199
|
+
args = list(AUDIO_CODECS[ext])
|
|
1200
|
+
if bitrate is None or ext in LOSSLESS_AUDIO_EXT:
|
|
1201
|
+
return args
|
|
1202
|
+
for flag in ("-b:a", "-q:a"):
|
|
1203
|
+
if flag in args:
|
|
1204
|
+
i = args.index(flag)
|
|
1205
|
+
del args[i:i + 2]
|
|
1206
|
+
return args + ["-b:a", bitrate]
|
|
1180
1207
|
|
|
1181
1208
|
|
|
1182
1209
|
def is_audio_output(output_path: str) -> bool:
|
|
@@ -1590,7 +1617,7 @@ def _refine_grid(onset_times: "Sequence[float]", strengths: "Sequence[float]", i
|
|
|
1590
1617
|
it), and onset_time = phase + k * interval is solved for both unknowns with each pair weighted
|
|
1591
1618
|
by its onset strength squared; the pairing is then redone against the refitted grid, until it
|
|
1592
1619
|
stops moving (three passes at most). This only sharpens the grid the autocorrelation chose --
|
|
1593
|
-
the tempo cannot leave `bpm_range
|
|
1620
|
+
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
|
|
1594
1621
|
more than interval/BEAT_ALIGN_DIVISOR is discarded), and fewer than four paired grid points
|
|
1595
1622
|
keep the original: the fit needs evidence, and an onset-free stretch gives it none.
|
|
1596
1623
|
Returns phase in [0, interval).
|
|
@@ -1613,8 +1640,13 @@ def _refine_grid(onset_times: "Sequence[float]", strengths: "Sequence[float]", i
|
|
|
1613
1640
|
if sxx <= 0:
|
|
1614
1641
|
break
|
|
1615
1642
|
new_iv = sum(w * (k - mk) * (t - mt) for k, t, w in pairs) / sxx
|
|
1616
|
-
if
|
|
1643
|
+
if abs(new_iv - interval) > interval / BEAT_ALIGN_DIVISOR:
|
|
1617
1644
|
break
|
|
1645
|
+
# A146: a fit just outside `bpm_range` is held on its bound, not discarded. A tempo that
|
|
1646
|
+
# sits on the bound (90 BPM under --beat-range 60-90) fits a hair beyond it as often as
|
|
1647
|
+
# inside, and discarding the fit kept the step-rounded interval (89.76 BPM), whose grid
|
|
1648
|
+
# was 80 ms off a 30 s click track by its end
|
|
1649
|
+
new_iv = min(max(new_iv, 60.0 / hi_bpm), 60.0 / lo_bpm)
|
|
1618
1650
|
new_ph = mt - new_iv * mk
|
|
1619
1651
|
moved = abs(new_iv - iv) < 1e-9 and abs(new_ph - ph) < 1e-7
|
|
1620
1652
|
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:
|