ffmpeg-skill 3.8.13 → 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 +1 -0
- package/SKILL.md +1 -1
- package/docs/contract.md +8 -3
- package/mcp/server.py +0 -1
- package/package.json +5 -2
- package/references/flags.md +2 -2
- package/references/scripts.md +18 -7
- package/scripts/_common/asr.py +1 -1
- package/scripts/_common/color.py +7 -2
- package/scripts/_common/decision.py +13 -3
- package/scripts/_common/probe.py +4 -4
- package/scripts/_common/runner.py +26 -3
- package/scripts/_contract.py +22 -8
- package/scripts/_platforms.py +0 -5
- package/scripts/background.py +1 -1
- package/scripts/caption.py +2 -3
- package/scripts/check.py +1 -1
- package/scripts/color.py +7 -1
- package/scripts/crop.py +3 -2
- package/scripts/cropdetect.py +1 -1
- package/scripts/cut.py +1 -1
- package/scripts/export.py +36 -3
- 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 +1 -1
- package/scripts/loudness.py +0 -5
- package/scripts/multicam.py +1 -1
- 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/sphere.py +23 -3
- package/scripts/stabilize.py +5 -2
- package/scripts/sync.py +1 -3
- package/scripts/verify.py +82 -20
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:
|
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"},
|
|
@@ -951,7 +956,7 @@ A refusal also carries `error.reason`, a stable snake_case code for *why*
|
|
|
951
956
|
`output_is_directory`, `output_dir_missing`, `output_dir_not_writable`, `output_locked`,
|
|
952
957
|
`output_container_codecs` (a `.webm` output for an H.264/HEVC or AAC encode, which WebM cannot hold),
|
|
953
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),
|
|
954
|
-
`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`,
|
|
955
960
|
`render.py`'s `plan_*` reasons, `--codec av1`/`prores`'s `encoder_missing` and
|
|
956
961
|
`encoder_list_unreadable`, and `--transcribe`'s `asr_engine_missing`, `no_speech`, `engine_failed`
|
|
957
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
|
```
|
|
@@ -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.
|
|
@@ -1669,14 +1676,17 @@ Each step runs with `--json`, and a failed one keeps its tool's own `kind`,
|
|
|
1669
1676
|
completed one carries the tool's `verified`. Steps that do not apply are listed
|
|
1670
1677
|
under the file's `skipped` (`quick`, `no_video`, `no_audio`, `not_hdr`,
|
|
1671
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).
|
|
1672
1682
|
Run this first when a user hands over footage from a device you have not
|
|
1673
1683
|
seen before, and fix or report what fails.
|
|
1674
1684
|
|
|
1675
1685
|
### look.py — see the result
|
|
1676
1686
|
```
|
|
1677
|
-
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)
|
|
1678
1688
|
look.py INPUT --at 2.5 [--at 7] [-o basename] # single frames -> basename_2.500s.png
|
|
1679
|
-
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)
|
|
1680
1690
|
look.py INPUT --safe tiktok [--at 3] # shade what the app's UI covers
|
|
1681
1691
|
look.py INPUT --guides title,action,4:3 [--at 3] # broadcast safe-area outlines
|
|
1682
1692
|
```
|
|
@@ -2370,7 +2380,8 @@ neither.
|
|
|
2370
2380
|
proxy.py INPUT [--width W | --scale F] [--quality N] [--fps N] [--no-audio] [-o OUT]
|
|
2371
2381
|
```
|
|
2372
2382
|
Not a delivery preset: resizes to `--width` (default 640) or by `--scale`
|
|
2373
|
-
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
|
|
2374
2385
|
x264/x265 preset, keeps the source's own dynamic range (HDR stays HDR;
|
|
2375
2386
|
run `color.py --to-sdr` first if SDR is wanted) and keeps a subtitle/data
|
|
2376
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"]
|
package/scripts/_common/probe.py
CHANGED
|
@@ -10,6 +10,8 @@ import math
|
|
|
10
10
|
import os
|
|
11
11
|
import re
|
|
12
12
|
import statistics
|
|
13
|
+
import sys
|
|
14
|
+
from array import array
|
|
13
15
|
from fractions import Fraction
|
|
14
16
|
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
15
17
|
from _common.emit import die, warn
|
|
@@ -70,15 +72,13 @@ def decode_pcm_mono(path: str, sample_rate: int, seconds: Optional[float] = None
|
|
|
70
72
|
if check:
|
|
71
73
|
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")))
|
|
72
74
|
return []
|
|
73
|
-
import array
|
|
74
|
-
import sys
|
|
75
75
|
data, proc = proc.stdout, None
|
|
76
|
-
pcm = array
|
|
76
|
+
pcm = array("h")
|
|
77
77
|
pcm.frombytes(memoryview(data)[: len(data) // 2 * 2])
|
|
78
78
|
del data
|
|
79
79
|
if sys.byteorder == "big":
|
|
80
80
|
pcm.byteswap() # s16le on the pipe
|
|
81
|
-
return array
|
|
81
|
+
return array("f", (v / 32768.0 for v in pcm))
|
|
82
82
|
|
|
83
83
|
|
|
84
84
|
def rms_envelope(samples: Sequence[float], step: int, *, full_blocks_only: bool = False, remove_mean: bool = False) -> List[float]:
|
|
@@ -511,6 +511,11 @@ def apply_common(args: "argparse.Namespace") -> None:
|
|
|
511
511
|
# --json-brief is a shorter --json, not a second output mode: it implies it, so a caller that
|
|
512
512
|
# passes only --json-brief still gets a JSON document (and --json --json-brief is the brief one).
|
|
513
513
|
STATE.json = bool(getattr(args, "json", False)) or STATE.json_brief
|
|
514
|
+
if hasattr(args, "json"):
|
|
515
|
+
# and a tool that branches on args.json reads the same mode: through 3.8.13, probe --field,
|
|
516
|
+
# check, cropdetect, silence --list, report and verify printed text (or a bare document,
|
|
517
|
+
# or a document and then a path) under --json-brief alone (review of #521)
|
|
518
|
+
args.json = STATE.json
|
|
514
519
|
STATE.progress = bool(getattr(args, "progress", False))
|
|
515
520
|
STATE.fast = bool(getattr(args, "fast", False))
|
|
516
521
|
STATE.overwrite = bool(getattr(args, "overwrite", False))
|
|
@@ -991,7 +996,7 @@ def _refuse_output_is_input(output: str, inp: str, real: str, why: str) -> None:
|
|
|
991
996
|
**details)
|
|
992
997
|
|
|
993
998
|
|
|
994
|
-
def refuse_output_is_input(output: str, *inputs: str) -> None:
|
|
999
|
+
def refuse_output_is_input(output: str, *inputs: str, why: str = "the result would replace the source") -> None:
|
|
995
1000
|
"""Tool-level twin of the run() guard, for tools whose final ffmpeg command does not name
|
|
996
1001
|
the user's input at all. `cut.py --segments` cuts each part into a temp dir and then concats
|
|
997
1002
|
a list file: the last command's only `-i` is that list, so `-o` equal to the input sailed
|
|
@@ -1009,7 +1014,7 @@ def refuse_output_is_input(output: str, *inputs: str) -> None:
|
|
|
1009
1014
|
except OSError:
|
|
1010
1015
|
continue
|
|
1011
1016
|
if same:
|
|
1012
|
-
_refuse_output_is_input(output, inp, out_real,
|
|
1017
|
+
_refuse_output_is_input(output, inp, out_real, why)
|
|
1013
1018
|
|
|
1014
1019
|
|
|
1015
1020
|
def _check_output_path(cmd: Sequence[str]) -> None:
|
|
@@ -1040,6 +1045,24 @@ def _check_output_path(cmd: Sequence[str]) -> None:
|
|
|
1040
1045
|
refuse("input", "output_dir_not_writable", f"output directory {parent!r} is not writable", output=output, directory=parent)
|
|
1041
1046
|
|
|
1042
1047
|
|
|
1048
|
+
def _check_output_extension(cmd: Sequence[str]) -> None:
|
|
1049
|
+
"""An ffmpeg output with no file extension and no `-f` after its last input leaves the muxer
|
|
1050
|
+
nothing to choose a container by: refused as `kind: input` before anything runs, dry runs
|
|
1051
|
+
included. Through 3.8.8 every tool that writes media handed it on and failed as `kind: ffmpeg`
|
|
1052
|
+
("Unable to choose an output format", audit A159's sweep). Only the ffmpeg commands run()
|
|
1053
|
+
executes are checked: a sidecar (a report, an SRT) is a plain file and needs no extension,
|
|
1054
|
+
and look.py takes an -o without an image extension as a stem (docs/design-decisions.md)."""
|
|
1055
|
+
output = cmd[-1]
|
|
1056
|
+
if output == "-" or output.startswith("pipe:") or len(os.path.splitext(output)[1]) > 1:
|
|
1057
|
+
return
|
|
1058
|
+
last_input = max((i for i, tok in enumerate(cmd[:-1]) if tok == "-i"), default=-1)
|
|
1059
|
+
if "-f" in cmd[last_input + 2:-1]:
|
|
1060
|
+
return
|
|
1061
|
+
refuse("input", "output_extension_missing",
|
|
1062
|
+
f"output {output!r} has no file extension, so ffmpeg cannot tell which container to write; "
|
|
1063
|
+
f"name it with one (for example {output + '.mp4'!r})", output=output)
|
|
1064
|
+
|
|
1065
|
+
|
|
1043
1066
|
# What the WebM muxer takes (6.1: "Only VP8 or VP9 or AV1 video and Vorbis or Opus audio and
|
|
1044
1067
|
# WebVTT subtitles are supported for WebM"), by the encoder a command names (#488).
|
|
1045
1068
|
_WEBM_VIDEO_ENCODER_RE = re.compile(r"^(?:libvpx(?:-vp9)?|vp8|vp9|libaom-av1|libsvtav1|librav1e|av1|vp8_\w+|vp9_\w+|av1_\w+)$")
|
|
@@ -2017,6 +2040,7 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True, ctx: "Op
|
|
|
2017
2040
|
_refuse_url_media(cmd)
|
|
2018
2041
|
_check_no_overwrite_input(cmd)
|
|
2019
2042
|
_check_output_path(cmd)
|
|
2043
|
+
_check_output_extension(cmd)
|
|
2020
2044
|
_check_container_codecs(cmd)
|
|
2021
2045
|
_check_build_components(cmd)
|
|
2022
2046
|
_check_existing_output(cmd)
|
|
@@ -2615,7 +2639,6 @@ def place_output(src: str, dst: str, move: bool = False) -> None:
|
|
|
2615
2639
|
with os.replace() under the lock instead of copied -- a checked `cut.py --segments` join was
|
|
2616
2640
|
copied whole, about 2 s for 240 MB. A rename that fails (another filesystem) falls back to
|
|
2617
2641
|
the copy."""
|
|
2618
|
-
import shutil
|
|
2619
2642
|
cmd = ["ffmpeg", dst]
|
|
2620
2643
|
_check_output_path(cmd)
|
|
2621
2644
|
_check_existing_output(cmd)
|
package/scripts/_contract.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
python3 scripts/_contract.py --json # the contract, with detected capabilities
|
|
5
5
|
python3 scripts/_contract.py --json --static # same, without environment detection
|
|
6
|
+
python3 scripts/_contract.py --json --tool cut # only cut under `tools` (repeatable)
|
|
6
7
|
python3 scripts/_contract.py doctor [--json] # which required capabilities this machine has
|
|
7
8
|
ffmpeg-skill contract --json # the same through the npm entry point
|
|
8
9
|
|
|
@@ -21,7 +22,6 @@ import argparse
|
|
|
21
22
|
import functools
|
|
22
23
|
import importlib.util
|
|
23
24
|
import json
|
|
24
|
-
import os
|
|
25
25
|
import platform
|
|
26
26
|
import re
|
|
27
27
|
import shutil
|
|
@@ -442,6 +442,7 @@ ERROR_REASONS = {
|
|
|
442
442
|
"output_looks_like_option": "the output starts with '-' (other than '-' for stdout), which ffmpeg would read as an option; ./name writes a file of that name; details: {output}",
|
|
443
443
|
"media_path_is_url": "an input or output has a protocol prefix (tcp:, http://, file:, ...) that ffmpeg would open as a URL; the skill reads and writes local files only; details: {path, scheme}",
|
|
444
444
|
"output_dir_missing": "the output's directory does not exist; details: {output, directory}",
|
|
445
|
+
"output_extension_missing": "a media output with no file extension (and no -f naming its format), so ffmpeg could not choose a container; refused before ffmpeg runs, dry runs included; details: {output}",
|
|
445
446
|
"output_dir_not_writable": "the output's directory is not writable; details: {output, directory}",
|
|
446
447
|
"output_container_codecs": "a .webm output whose command encodes video or sound WebM cannot hold (H.264/HEVC, AAC; WebM takes VP8/VP9/AV1 and Opus/Vorbis), refused before ffmpeg runs; details: {output, container, encoders}",
|
|
447
448
|
"timemap_path_taken": "silence.py --timemap names the same file as -o, --edl or --plan; details: {timemap, flag, path}",
|
|
@@ -775,7 +776,8 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
775
776
|
elif name == "verify":
|
|
776
777
|
extra = {"report": {"type": ["string", "null"]},
|
|
777
778
|
"files": {"type": "array", "description": "one entry per file: {file, probe, steps, skipped}. A step is {step, ok, seconds, error (one line, for the table)}, plus the tool's verified when it completed, and on a failure its own kind, reason, message, details and exit_code (reason step_timeout or step_output_unparsed when the tool printed no failure document); skipped lists the steps that do not apply as {step, reason: quick|no_video|no_audio|not_hdr|not_multichannel|input_unreadable, message}"},
|
|
778
|
-
"failed": {"type": "integer"}, "total": {"type": "integer"}
|
|
779
|
+
"failed": {"type": "integer"}, "total": {"type": "integer"},
|
|
780
|
+
"out_dir": {"type": ["string", "null"], "description": "the folder the steps' outputs are in when they are kept: --out, or with --keep alone a new folder that outlives the run; null when they were written to a temporary folder and deleted"}}
|
|
779
781
|
elif name == "batch":
|
|
780
782
|
extra = {"results": {"type": "array", "description": "one row per input, every input (an interrupted batch included): ok (every step completed), verified (every step also verified what it wrote; false under --dry-run) and unverified ([{step, tool, failed}] naming the steps that did not); a failed row's error {step, tool, kind, message, code, reason, details, hint, exit_code} from the failing step's own document; diagnostics from its steps (tagged with step); cache_miss {reason: output_missing|output_changed|entry_unverifiable, ...} when a cache entry was not served; skipped \"timeout\" or \"interrupted\" for an item that never started and interrupted true for one stopped while running; under --watch, skipped \"settling\" (ok false, not cached) for a source that changed since the previous poll, or, first seen, was modified within one poll interval: it is processed on a later pass. A row whose recipe ran cut.py carries cut_reencoded (each cut.py call's reencoded) and cut_reencode_reasons (each reason those calls named, once per call)"},
|
|
781
783
|
"processed": {"type": "integer"}, "total": {"type": "integer"},
|
|
@@ -1039,11 +1041,6 @@ def _ff_listing(binary: str, flag: str) -> Dict[str, Any]:
|
|
|
1039
1041
|
return {"names": names, "status": "parsed", "detail": f"{len(names)} entries"}
|
|
1040
1042
|
|
|
1041
1043
|
|
|
1042
|
-
def _ff_list(binary: str, flag: str) -> List[str]:
|
|
1043
|
-
"""Names from `ffmpeg -encoders` / `-filters` / `-bsfs` (empty list when ffmpeg is missing or unparsed)."""
|
|
1044
|
-
return _ff_listing(binary, flag)["names"]
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
1044
|
# GPU-backed encoders are named `<codec>_<backend>` by every ffmpeg build (h264_nvenc,
|
|
1048
1045
|
# hevc_videotoolbox, av1_qsv, h264_vaapi, hevc_amf, ...); recognised by the backend suffix so a
|
|
1049
1046
|
# new codec in a future ffmpeg build needs no change here.
|
|
@@ -2179,7 +2176,19 @@ def main() -> int:
|
|
|
2179
2176
|
ap.add_argument("command", nargs="?", choices=["contract", "doctor"], default="contract")
|
|
2180
2177
|
ap.add_argument("--json", action="store_true", help="JSON on stdout (the contract is always JSON)")
|
|
2181
2178
|
ap.add_argument("--static", action="store_true", help="omit environment detection (available / missing capabilities)")
|
|
2179
|
+
ap.add_argument("--tool", action="append", metavar="NAME",
|
|
2180
|
+
help="only this tool's entry under `tools` (repeatable; `cut` or `cut.py`); every other key is "
|
|
2181
|
+
"unchanged. The whole contract is about 950 KB; with one tool, 55-85 KB")
|
|
2182
2182
|
args = ap.parse_args()
|
|
2183
|
+
wanted: List[str] = []
|
|
2184
|
+
for name in args.tool or []:
|
|
2185
|
+
name = name[:-3] if name.endswith(".py") else name
|
|
2186
|
+
if name not in public_tools():
|
|
2187
|
+
ap.error(f"--tool {name}: no such tool (one of: {', '.join(public_tools())})")
|
|
2188
|
+
if name not in wanted:
|
|
2189
|
+
wanted.append(name)
|
|
2190
|
+
if wanted and args.command == "doctor":
|
|
2191
|
+
ap.error("--tool selects from the contract; doctor --json lists every tool under `tools`")
|
|
2183
2192
|
if args.command == "doctor":
|
|
2184
2193
|
d = doctor()
|
|
2185
2194
|
# On stderr in both modes, so `doctor --json`'s stdout stays one JSON document: an
|
|
@@ -2225,7 +2234,12 @@ def main() -> int:
|
|
|
2225
2234
|
# 1: something required is missing; 2: nothing proven missing but a required capability is unknown
|
|
2226
2235
|
# an unsupported Python/FFmpeg is a known failure, like a missing capability (not "unknown")
|
|
2227
2236
|
return 1 if d["missing"] or d.get("unsupported") else 2
|
|
2228
|
-
|
|
2237
|
+
doc = build(detect=not args.static)
|
|
2238
|
+
if wanted:
|
|
2239
|
+
# in the order asked; `provides` and `capability_map` stay whole (a few KB): they map ids
|
|
2240
|
+
# to tools across the skill, which one tool's entry does not change (#467 A220)
|
|
2241
|
+
doc["tools"] = sorted((t for t in doc["tools"] if t["name"] in wanted), key=lambda t: wanted.index(t["name"]))
|
|
2242
|
+
print(json.dumps(doc, indent=2, sort_keys=True, ensure_ascii=False))
|
|
2229
2243
|
return 0
|
|
2230
2244
|
|
|
2231
2245
|
|
package/scripts/_platforms.py
CHANGED
|
@@ -230,11 +230,6 @@ ALIASES: Dict[str, str] = {"youtube-shorts": "shorts", "yt-shorts": "shorts", "y
|
|
|
230
230
|
PLATFORM_CHOICES: List[str] = sorted({n for n in PLATFORMS if PLATFORMS[n]["check"] == n} | set(ALIASES))
|
|
231
231
|
|
|
232
232
|
|
|
233
|
-
def has_frame(name: str) -> bool:
|
|
234
|
-
"""True when this destination has a frame, and therefore a safe zone to place text inside."""
|
|
235
|
-
return bool(PLATFORMS.get(resolve(name) or "", {}).get("frame"))
|
|
236
|
-
|
|
237
|
-
|
|
238
233
|
def spec_of(name: str) -> Dict[str, Any]:
|
|
239
234
|
"""check.py's compliance row values for a destination."""
|
|
240
235
|
return dict(PLATFORMS[name]["spec"])
|
package/scripts/background.py
CHANGED
|
@@ -19,7 +19,7 @@ import sys
|
|
|
19
19
|
|
|
20
20
|
from _common.alpha import add_alpha_args, check_alpha_args, finish_chain, report as alpha_report, video_line
|
|
21
21
|
from _common.emit import note
|
|
22
|
-
from _common import STATE, add_common, apply_common, die, draws_chroma, emit, ffmpeg_base, info,
|
|
22
|
+
from _common import STATE, add_common, apply_common, die, draws_chroma, emit, ffmpeg_base, info, probe, rgb_chain_to_bt709, run, validate_color, video_args, X264_PRESETS, time_arg, fmt_secs
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
def gradient_source(w: int, h: int, fps: float, c0: str, c1: str, angle: float) -> str:
|
package/scripts/caption.py
CHANGED
|
@@ -37,17 +37,16 @@ import json
|
|
|
37
37
|
import os
|
|
38
38
|
import re
|
|
39
39
|
import sys
|
|
40
|
-
import unicodedata
|
|
41
40
|
from pathlib import Path
|
|
42
41
|
from typing import Any, Dict, List, Optional, Tuple
|
|
43
42
|
|
|
44
43
|
from _platforms import (PLATFORMS, PLATFORM_CHOICES, ASS_SCRIPT_HEIGHT, ass_units,
|
|
45
44
|
resolve as resolve_platform)
|
|
46
|
-
from _ass_overlay import (EMOJI_SENTINEL, emoji_placeholder,
|
|
45
|
+
from _ass_overlay import (EMOJI_SENTINEL, emoji_placeholder, ass_escape_markup,
|
|
47
46
|
strip_srt_markup, reinsert_srt_markup, carry_srt_markup, srt_alignment)
|
|
48
47
|
from _common.emit import refuse # noqa: E402
|
|
49
48
|
from _common.emoji import emoji_missing_asset_note, emoji_mono_on_colour_note
|
|
50
|
-
from _common import require_filter, emoji_filter_chain, EMOJI_ASSET_HINT, emoji_asset_for, emoji_codepoint_name, emoji_support, resolve_emoji_assets,
|
|
49
|
+
from _common import require_filter, emoji_filter_chain, EMOJI_ASSET_HINT, emoji_asset_for, emoji_codepoint_name, emoji_support, resolve_emoji_assets, NO_SPACE_SCRIPTS, char_script, text_width_em, emoji_clusters, has_emoji, detect_script, BIDI_SCRIPTS, STATE, brand_states_font, script_font_for_text, signed_time_arg, brand_caption_style, color_hex, drawn_chain, layer_base_chain, load_brand, overlay_format, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, X264_PRESETS, read_text_or_die, fmt_secs, refuse_existing_outputs, guard_sidecar
|
|
51
50
|
# The line breaker, lifted into _common/text.py in 1.16.0 so graphics.py can use the same rules.
|
|
52
51
|
# The ASR bridge and the SRT reader/writer live in _common.asr since 1.17 (silence.py --filler
|
|
53
52
|
# shares them). They stay caption.py's public names -- every caller and test that reached for
|
package/scripts/check.py
CHANGED
|
@@ -53,7 +53,7 @@ from _common.probe import (CHANGE_FLOOR, CHANGE_GRID, change_mask_tiles, change_
|
|
|
53
53
|
from _common.emit import file_identity, own_length
|
|
54
54
|
from _common.probe import PROBED_AUDIO
|
|
55
55
|
from _common.probe import display_size as shown_size, shorter_than_a_loudness_block
|
|
56
|
-
from _common import STATE, add_common, apply_common, die, emit,
|
|
56
|
+
from _common import STATE, add_common, apply_common, die, emit, probe, require_tool, run_analysis, dry_run_input_pending
|
|
57
57
|
|
|
58
58
|
# The one delivery table (scripts/_platforms.py): check.py's rows, export.py's presets and the
|
|
59
59
|
# render.py templates all read it, so a platform's loudness spec is stated once. Only the
|
package/scripts/color.py
CHANGED
|
@@ -581,7 +581,13 @@ def main() -> int:
|
|
|
581
581
|
else:
|
|
582
582
|
vf = hdr_to_sdr_chain(meta, args.tonemap, args.peak, args.desat)
|
|
583
583
|
sdr_path = "tonemap"
|
|
584
|
-
|
|
584
|
+
# the note names what hdr_to_sdr_chain read the source as: its own tags, PQ / BT.2020
|
|
585
|
+
# only where a tag is missing. Through 3.8.8 any non-HDR file was called "untagged
|
|
586
|
+
# input as PQ", a BT.709-tagged one included, which the chain linearised as BT.709 (A161)
|
|
587
|
+
read_as = v.get("hdr_format") or (
|
|
588
|
+
f"a non-HDR input read as transfer {v.get('color_transfer') or 'smpte2084 (untagged, taken as PQ)'}, "
|
|
589
|
+
f"primaries {v.get('color_primaries') or 'bt2020 (untagged)'} (--force)")
|
|
590
|
+
sdr_note = f"tone-mapped {read_as} to SDR BT.709 with {args.tonemap}"
|
|
585
591
|
info(f"note: {sdr_note}")
|
|
586
592
|
sdr_extra = {"sdr_path": sdr_path, "notes": [sdr_note]}
|
|
587
593
|
output = args.output or default_output(args.input, "sdr")
|
package/scripts/crop.py
CHANGED
|
@@ -12,7 +12,8 @@ The rectangle must lie entirely inside the source frame after accounting for
|
|
|
12
12
|
any display rotation, and --width/--height must be even (required for 4:2:0
|
|
13
13
|
chroma subsampling, the pixel format every encoder here uses) -- given values
|
|
14
14
|
are validated and refused, never silently rounded, since a caller-specified
|
|
15
|
-
rectangle should do exactly what was asked or fail loudly.
|
|
15
|
+
rectangle should do exactly what was asked or fail loudly. An odd --x/--y is
|
|
16
|
+
taken as given (the crop starts on that pixel), not moved to the chroma grid.
|
|
16
17
|
|
|
17
18
|
Examples:
|
|
18
19
|
python3 crop.py input.mp4 --x 100 --y 0 --width 1080 --height 1920
|
|
@@ -63,7 +64,7 @@ def main() -> int:
|
|
|
63
64
|
|
|
64
65
|
output = args.output or default_output(args.input, "crop")
|
|
65
66
|
# exact=1: without it crop rounds an odd --x/--y down to the chroma grid of a 4:2:0 frame,
|
|
66
|
-
# and the rectangle written was one pixel left of / above the one asked for (A170's sibling)
|
|
67
|
+
# and the rectangle written was one pixel left of / above the one asked for (A170's sibling, A157)
|
|
67
68
|
vf = [f"crop={args.width}:{args.height}:{args.x}:{args.y}:exact=1", "setsar=1"]
|
|
68
69
|
if square_pixels(meta["video"]):
|
|
69
70
|
vf.insert(0, square_pixels(meta["video"]))
|
package/scripts/cropdetect.py
CHANGED
|
@@ -33,7 +33,7 @@ import sys
|
|
|
33
33
|
from collections import Counter
|
|
34
34
|
from typing import Dict, List, Optional, Tuple
|
|
35
35
|
|
|
36
|
-
from _common import add_common, apply_common, die, emit, info, print_json, probe, require_tool, run_analysis, decode_gray_frames
|
|
36
|
+
from _common import add_common, apply_common, die, emit, info, print_json, probe, require_tool, run_analysis, decode_gray_frames
|
|
37
37
|
from _common.emit import note, warn
|
|
38
38
|
from _common.probe import PROBED_VIDEO, display_size, square_pixels, square_pixels_note
|
|
39
39
|
|
package/scripts/cut.py
CHANGED
|
@@ -65,7 +65,7 @@ from _common.probe import PROBED_AUDIO, PROBED_VIDEO, TS_EXTS, probed_maps, seve
|
|
|
65
65
|
from _common.runner import subtitle_codec_for, subtitle_container_holds
|
|
66
66
|
from _common.decision import SEEK_MARGIN, delivered_rate, frame_rate, grid_at_or_after, stream_starts
|
|
67
67
|
from _common.timeline import output_timecode_args
|
|
68
|
-
from _common import warn, record_fallback, refuse_existing_outputs, sibling_temp, source_codec_video_args, STATE, add_common, apply_common, audio_codec_for, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, is_audio_output, time_arg, probe, run, X264_PRESETS, keyframes_near,
|
|
68
|
+
from _common import warn, record_fallback, refuse_existing_outputs, sibling_temp, source_codec_video_args, STATE, add_common, apply_common, audio_codec_for, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, is_audio_output, time_arg, probe, run, X264_PRESETS, keyframes_near, concat_list_line, place_output, refuse_output_is_input, fmt_secs, measure_frame_timing, dry_run_input_pending
|
|
69
69
|
|
|
70
70
|
# outputs whose re-encode dropped a subtitle/data stream (reported as dropped_non_av_streams)
|
|
71
71
|
DROPPED_STREAMS: List[str] = []
|
package/scripts/export.py
CHANGED
|
@@ -43,6 +43,7 @@ import re
|
|
|
43
43
|
import os
|
|
44
44
|
import sys
|
|
45
45
|
import tempfile
|
|
46
|
+
from fractions import Fraction
|
|
46
47
|
from pathlib import Path
|
|
47
48
|
from typing import Dict, List, Optional
|
|
48
49
|
|
|
@@ -51,7 +52,7 @@ from _common import STATE, run_analysis, add_common, apply_common, bt709_label_f
|
|
|
51
52
|
from _common.decision import hw_preset_video, restate_last_swap
|
|
52
53
|
from _common.emit import note, record_fallback, record_skip, warn
|
|
53
54
|
from _common.probe import PROBED_AUDIO, PROBED_VIDEO, probed_maps, square_pixels, square_pixels_note
|
|
54
|
-
from _common.runner import ffmpeg_encoders, guard_sidecar, remember_written, replace_checked, replace_checked_together, reserve_sibling,
|
|
55
|
+
from _common.runner import ffmpeg_encoders, guard_sidecar, remember_written, replace_checked, replace_checked_together, reserve_sibling, subtitle_codec_for
|
|
55
56
|
from _common.emit import file_identity, input_length, own_length
|
|
56
57
|
from check import SPECS as PLATFORMS, measure_loudness
|
|
57
58
|
from _common.probe import SILENT_PEAK_DBFS, shorter_than_a_loudness_block
|
|
@@ -442,6 +443,32 @@ SIZE_MIN_VIDEO_KBPS = 100 # below this the picture is not worth delivering; re
|
|
|
442
443
|
SIZE_MAX_VIDEO_KBPS = 400_000 # 400 Mb/s: above any delivery line, and libx264 refuses far larger values
|
|
443
444
|
|
|
444
445
|
|
|
446
|
+
def _platform_rate_args(preset: str, meta: Dict) -> List[str]:
|
|
447
|
+
"""A platform preset that keeps the source's frame rate (the youtube family) conforms a source
|
|
448
|
+
faster than the platform's fps_max to the source rate divided by the smallest whole number that
|
|
449
|
+
brings it within the limit (120 -> 60, 119.88 -> 59.94, 100 -> 50), so every frame of the
|
|
450
|
+
output is one of the source's own. Through 3.8.8 a 120 fps source kept 120 fps and the
|
|
451
|
+
platform's own check FAILed the export (audit A115). [] when the preset has no limit or the
|
|
452
|
+
source is within it (cfr_args() then decides, as before)."""
|
|
453
|
+
platform = PLATFORM_OF.get(preset)
|
|
454
|
+
fps_max = (PLATFORMS.get(platform) or {}).get("fps_max") if platform else None
|
|
455
|
+
v = meta.get("video") or {}
|
|
456
|
+
src = v.get("fps")
|
|
457
|
+
if not fps_max or not src or src <= fps_max + 0.01:
|
|
458
|
+
return []
|
|
459
|
+
try:
|
|
460
|
+
exact = Fraction(v.get("avg_frame_rate") or "0")
|
|
461
|
+
except (ValueError, ZeroDivisionError):
|
|
462
|
+
exact = Fraction(0)
|
|
463
|
+
if not exact or abs(float(exact) - src) > 0.01:
|
|
464
|
+
exact = Fraction(src).limit_denominator(1001)
|
|
465
|
+
n = math.ceil(float(exact) / (fps_max + 0.01))
|
|
466
|
+
rate = exact / n
|
|
467
|
+
note(f"source is {src:g} fps, above {platform}'s limit of {fps_max:g}: exported at {float(rate):.6g} fps, "
|
|
468
|
+
f"one source frame in {n}")
|
|
469
|
+
return ["-fps_mode", "cfr", "-r", str(rate.numerator) if rate.denominator == 1 else f"{rate.numerator}/{rate.denominator}"]
|
|
470
|
+
|
|
471
|
+
|
|
445
472
|
def size_video_args(args, video: List[str], p: Dict, meta: Dict, has_audio: bool) -> List[str]:
|
|
446
473
|
"""--max-mb: the preset's x264 line with its CRF replaced by the bitrate that fits the size,
|
|
447
474
|
for a two-pass encode. Refused where that cannot be done honestly: another codec, --crf, --hw,
|
|
@@ -669,7 +696,7 @@ def main() -> int:
|
|
|
669
696
|
# a stream copy can't be frame-rate-conformed or retagged without decoding it — that would
|
|
670
697
|
# no longer be a copy, and would silently mislabel colour the agent never actually looked at
|
|
671
698
|
if "-r" not in video:
|
|
672
|
-
cmd += cfr_args(meta)
|
|
699
|
+
cmd += _platform_rate_args(args.preset, meta) or cfr_args(meta)
|
|
673
700
|
if tag_bt709 and not video[video.index("-c:v") + 1].endswith("_videotoolbox"):
|
|
674
701
|
cmd += bt709_tag_args(video[video.index("-c:v") + 1])
|
|
675
702
|
if not p.get("intermediate"):
|
|
@@ -767,7 +794,13 @@ def main() -> int:
|
|
|
767
794
|
# a private name: <stem>_loudnorm.<ext> is loudness.py's own default output, so a
|
|
768
795
|
# real file of that name next to the export was overwritten and renamed away (review 7)
|
|
769
796
|
tmp = str(Path(output).with_name(f".{Path(output).stem}.normalize-{os.getpid()}{Path(output).suffix}"))
|
|
770
|
-
|
|
797
|
+
# under --max-mb the sound is re-encoded at the bitrate the size budget was built on
|
|
798
|
+
# (the preset's); loudness.py's own 192k, raised to 256k/320k on an overshoot, put
|
|
799
|
+
# the file over the limit it had been encoded to meet (through 3.8.8, audit A112)
|
|
800
|
+
pinned = (["--audio-bitrate", p["audio"][p["audio"].index("-b:a") + 1]]
|
|
801
|
+
if args.max_mb is not None and "-b:a" in p["audio"] else [])
|
|
802
|
+
proc = run_tool([str(HERE / "loudness.py"), output, "-I", f"{spec['lufs']:g}", "--tp", f"{spec['tp']:g}", "-o", tmp, "--json"]
|
|
803
|
+
+ pinned + child_args())
|
|
771
804
|
try:
|
|
772
805
|
child = json.loads(proc.stdout)
|
|
773
806
|
except ValueError:
|
package/scripts/fit.py
CHANGED
|
@@ -34,14 +34,13 @@ Examples:
|
|
|
34
34
|
python3 fit.py input.mp4 --flip h # mirror horizontally
|
|
35
35
|
"""
|
|
36
36
|
import argparse
|
|
37
|
-
import math
|
|
38
37
|
import sys
|
|
39
38
|
from fractions import Fraction
|
|
40
39
|
from typing import List
|
|
41
40
|
|
|
42
41
|
from _common.decision import SEEK_MARGIN, delivered_rate, frame_rate, grid_at_or_after, length_output_t, stream_starts
|
|
43
42
|
from _common.timeline import exact_rate, output_timecode_args
|
|
44
|
-
from _common import video_args, STATE, add_common, apply_common, emit, aac_args, aspect_ratio, cfr_args, default_output, die, ffmpeg_base, info,
|
|
43
|
+
from _common import video_args, STATE, add_common, apply_common, emit, aac_args, aspect_ratio, cfr_args, default_output, die, ffmpeg_base, info, probe, run, run_keeping_subtitles, validate_color, pad_filters, add_pad_fill_args, X264_PRESETS, time_arg, fmt_secs, frame_size, display_size, square_pixels, square_pixels_note, note
|
|
45
44
|
from _common.emit import input_length
|
|
46
45
|
BLUR_DARKEN = 0.15 # how much --fit blur dims the blurred background copy (eq brightness)
|
|
47
46
|
|
|
@@ -227,7 +226,9 @@ def main() -> int:
|
|
|
227
226
|
# after it (a B-frame HEVC source) and lose the frames before it, so the first
|
|
228
227
|
# picture would not be the one the start timecode below names
|
|
229
228
|
m = min(SEEK_MARGIN, round(start, 3))
|
|
230
|
-
|
|
229
|
+
# a trim from the start seeks nowhere: no -ss, so the timeline stays where it is and
|
|
230
|
+
# a subtitle/data track is kept below (`-ss 0.000` dropped it, A158)
|
|
231
|
+
pre_input += ["-ss", f"{start - m:.3f}"] if round(start, 3) > 0 else []
|
|
231
232
|
# without sound the -t only counts frames (picture_output_t). Not from the centre:
|
|
232
233
|
# there the output's clock starts at the seek, between two frames, and the encoder
|
|
233
234
|
# rounds each frame to its own tick
|
package/scripts/freeze.py
CHANGED
|
@@ -20,7 +20,7 @@ import argparse
|
|
|
20
20
|
import sys
|
|
21
21
|
|
|
22
22
|
from _common.decision import av_trim, frame_durations, frame_rate, grid_at_or_after, silence_filter, stream_starts, tpad_length
|
|
23
|
-
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS,
|
|
23
|
+
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS, time_arg, fmt_secs, run
|
|
24
24
|
from _common.emit import input_length
|
|
25
25
|
|
|
26
26
|
|
package/scripts/graphics.py
CHANGED
|
@@ -38,11 +38,11 @@ from _common.emoji import emoji_missing_asset_note, emoji_mono_on_colour_note
|
|
|
38
38
|
from _common.drawtext import shaping_refusal
|
|
39
39
|
from _common import (guard_sidecar, require_filter, aac_args, add_common, brand_caption_style, script_font_for_text, apply_common, cfr_args,
|
|
40
40
|
color_hex, default_output, die, draws_chroma, drawn_chain, emit, escape_drawtext, escape_filter_path,
|
|
41
|
-
ffmpeg_base, info, layer_base_chain, load_brand, overlay_format,
|
|
41
|
+
ffmpeg_base, info, layer_base_chain, load_brand, overlay_format, probe, rgb_chain_to_bt709, run_keeping_subtitles, video_args,
|
|
42
42
|
drawtext_boxborderw, drawtext_center_align, X264_PRESETS, time_arg, fmt_secs, STATE, drawtext_text_opts,
|
|
43
43
|
LANGUAGE_NAMES, needs_shaping, detect_script, BIDI_SCRIPTS, font_family_of_file, font_family_for_script, has_emoji,
|
|
44
44
|
emoji_clusters, emoji_codepoint_name, char_script, emoji_filter_chain, emoji_asset_for, emoji_support, resolve_emoji_assets,
|
|
45
|
-
EMOJI_ASSET_HINT, text_width_em,
|
|
45
|
+
EMOJI_ASSET_HINT, text_width_em, wrap_text, WRAP_MODES,
|
|
46
46
|
SAFE_WIDTH_FRACTION, drawtext_draws_newline_glyph, is_multiline, _DRAWTEXT_PENDING)
|
|
47
47
|
from _common.emit import note, warn
|
|
48
48
|
from _common.alpha import (add_alpha_args, audio_line, check_alpha_args, decoder_args_for, default_ext, finish_chain,
|
|
@@ -429,7 +429,6 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
429
429
|
el["text"] = text
|
|
430
430
|
elements.append(el)
|
|
431
431
|
|
|
432
|
-
extra_inputs: List[str] = []
|
|
433
432
|
fc: List[str] = [] # filter_complex chains (used by templates that need animated boxes)
|
|
434
433
|
if 0 < min(W, H) < 64: # 0x0 is a dry-run probe of an intermediate that does not exist yet
|
|
435
434
|
die(f"the frame is {W}x{H}; the templates are sized from it and need at least 64 px on the short side")
|
package/scripts/insert.py
CHANGED
|
@@ -28,7 +28,7 @@ import math
|
|
|
28
28
|
import sys
|
|
29
29
|
|
|
30
30
|
from _common import still_input_args # noqa: E402
|
|
31
|
-
from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info,
|
|
31
|
+
from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, time_arg, fmt_secs
|
|
32
32
|
from _common.emit import note
|
|
33
33
|
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
34
34
|
|
package/scripts/look.py
CHANGED
|
@@ -19,7 +19,7 @@ from typing import List, Optional
|
|
|
19
19
|
|
|
20
20
|
from _platforms import PLATFORMS, PLATFORM_CHOICES, resolve as resolve_platform
|
|
21
21
|
from _common import still_input_args # noqa: E402
|
|
22
|
-
from _common import STATE, require_tool, add_common, apply_common, default_font_file, die, emit, escape_drawtext, ffmpeg_base, info,
|
|
22
|
+
from _common import STATE, require_tool, add_common, apply_common, default_font_file, die, emit, escape_drawtext, ffmpeg_base, info, probe, run, run_analysis, time_arg
|
|
23
23
|
from _common import drawtext_fontfile # noqa: E402
|
|
24
24
|
from _common import ffmpeg_filters, filter_missing_hint # noqa: E402
|
|
25
25
|
from _common.emit import note, record_fallback # noqa: E402
|
|
@@ -30,6 +30,8 @@ from _common.emit import input_length # noqa: E402
|
|
|
30
30
|
# --guides: broadcast safe areas as outlines, in fractions of the frame (a tile as much as a frame).
|
|
31
31
|
# Action and title safe are SMPTE ST 2046-1's 93 % and 90 % of a 16:9 picture; the 4:3 centre cut
|
|
32
32
|
# is the 4:3 window of a 16:9 frame with SD's own 90 % action / 80 % title safe inside it.
|
|
33
|
+
# the extensions an -o is taken verbatim with (one picture); any other -o is a stem
|
|
34
|
+
IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".webp")
|
|
33
35
|
GUIDES = {"action": 0.93, "title": 0.90}
|
|
34
36
|
GUIDE_43 = {"action": 0.90, "title": 0.80}
|
|
35
37
|
GUIDE_COLOURS = {"action": "yellow", "title": "cyan", "4:3": "white"}
|
|
@@ -319,7 +321,7 @@ def main() -> int:
|
|
|
319
321
|
csq = (square_pixels(cmeta.get("video")) + ",") if square_pixels(cmeta.get("video")) else ""
|
|
320
322
|
for idx, t in enumerate(args.at):
|
|
321
323
|
sec = time_arg(t, "--at", meta["video"].get("fps") if meta.get("video") else None)
|
|
322
|
-
if args.output and len(args.at) == 1:
|
|
324
|
+
if args.output and len(args.at) == 1 and Path(args.output).suffix.lower() in IMAGE_EXTS:
|
|
323
325
|
out = args.output # one frame, one named image file: the caller's -o is the contract
|
|
324
326
|
else:
|
|
325
327
|
# several frames, or -o given as a stem/prefix: an occurrence index keeps every
|
|
@@ -352,7 +354,7 @@ def main() -> int:
|
|
|
352
354
|
sec = time_arg(t, "--at", meta["video"].get("fps") if meta.get("video") else None)
|
|
353
355
|
if dur and sec > dur:
|
|
354
356
|
die(f"--at {t} is beyond the duration ({dur:.2f}s)")
|
|
355
|
-
if args.output and len(args.at) == 1 and Path(args.output).suffix.lower() in
|
|
357
|
+
if args.output and len(args.at) == 1 and Path(args.output).suffix.lower() in IMAGE_EXTS:
|
|
356
358
|
out = args.output # one frame, one named image file: the caller's -o is the contract
|
|
357
359
|
else:
|
|
358
360
|
# several frames, or -o given as a stem/prefix without an image extension
|
|
@@ -382,7 +384,10 @@ def main() -> int:
|
|
|
382
384
|
info(f"only {frames_avail} frame(s) available; sheet reduced to {cols}x{rows}")
|
|
383
385
|
step = dur / n
|
|
384
386
|
tile_w = max(2, (args.width // cols) // 2 * 2)
|
|
385
|
-
|
|
387
|
+
# -o without an image extension is a stem here too (<stem>_sheet.png), as for --at and
|
|
388
|
+
# --compare: given verbatim, ffmpeg could not choose a muxer for it (kind ffmpeg, A159)
|
|
389
|
+
out = (args.output if args.output and Path(args.output).suffix.lower() in IMAGE_EXTS
|
|
390
|
+
else os.path.join(outdir, f"{(args.output and Path(args.output).stem) or stem}_sheet.png"))
|
|
386
391
|
# sample at the middle of each slice so the first/last tiles are not black lead-in/out frames
|
|
387
392
|
# with only a handful of frames a mid-slice seek skips past them all: start at 0 instead
|
|
388
393
|
seek = 0.0 if frames_avail < 4 else step / 2
|
package/scripts/loop.py
CHANGED
|
@@ -32,7 +32,7 @@ import os
|
|
|
32
32
|
import sys
|
|
33
33
|
import tempfile
|
|
34
34
|
|
|
35
|
-
from _common import STATE, concat_list_line, add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info,
|
|
35
|
+
from _common import STATE, concat_list_line, add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, refuse_output_is_input, require_filter, require_tool, run, video_args, X264_PRESETS, time_arg, fmt_secs
|
|
36
36
|
from _common.emit import warn
|
|
37
37
|
from _common.decision import HELD_FRAMES_WARN_BYTES, av_trim, held_frames_bytes, length_output_t, stream_starts
|
|
38
38
|
from _common.probe import decode_gray_frames, decode_pcm_mono
|
package/scripts/loudness.py
CHANGED
|
@@ -114,11 +114,6 @@ def loudnorm_mode(stats: dict, lufs: float, tp: float, lra: float) -> str:
|
|
|
114
114
|
|
|
115
115
|
def _normalize(args, meta: dict, stats: dict, lufs: float, tp_max: float, output: str) -> dict:
|
|
116
116
|
"""One normalised file at `lufs` / `tp_max`, measured after: the keys emit() takes for it."""
|
|
117
|
-
af = (
|
|
118
|
-
f"loudnorm=I={lufs}:TP={tp_max}:LRA={args.lra}"
|
|
119
|
-
f":measured_I={stats['input_i']}:measured_TP={stats['input_tp']}:measured_LRA={stats['input_lra']}"
|
|
120
|
-
f":measured_thresh={stats['input_thresh']}:offset={stats['target_offset']}:linear=true:print_format=summary"
|
|
121
|
-
)
|
|
122
117
|
sr = args.sample_rate or meta["audio"].get("sample_rate") or 48000
|
|
123
118
|
ext = os.path.splitext(output)[1].lower()
|
|
124
119
|
bitrate_pinned = args.audio_bitrate is not None
|
package/scripts/multicam.py
CHANGED
|
@@ -32,7 +32,7 @@ import os
|
|
|
32
32
|
import sys
|
|
33
33
|
from typing import Dict, List, Optional, Tuple
|
|
34
34
|
|
|
35
|
-
from _common import guard_sidecar, label_chain, matrix_chain, range_chain, video_args, aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, time_arg, probe, run,
|
|
35
|
+
from _common import guard_sidecar, label_chain, matrix_chain, range_chain, video_args, aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, time_arg, probe, run, X264_PRESETS, fmt_secs, decode_pcm_mono, rms_envelope, STATE
|
|
36
36
|
from _common.emit import note, record_fallback, record_skip, warn
|
|
37
37
|
from _common.decision import av_trim, conform_output_t, conform_to_length, rebase_project_paths
|
|
38
38
|
from _common.probe import display_size, square_pixels, square_pixels_note
|
package/scripts/overlay.py
CHANGED
|
@@ -35,7 +35,7 @@ from typing import Any, Dict, List, Optional
|
|
|
35
35
|
|
|
36
36
|
from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
|
|
37
37
|
from _common import still_input_args # noqa: E402
|
|
38
|
-
from _common import STATE, script_font_for_text, drawtext_clean, drawtext_line_metrics, drawtext_text_opts, drawtext_draws_newline_glyph, is_multiline, needs_shaping, LANGUAGE_NAMES, has_emoji, detect_script, emoji_clusters, load_brand, drawn_chain, label_chain, layer_base_chain, matrix_chain, overlay_format, range_chain, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_drawtext, ffmpeg_base, info,
|
|
38
|
+
from _common import STATE, script_font_for_text, drawtext_clean, drawtext_line_metrics, drawtext_text_opts, drawtext_draws_newline_glyph, is_multiline, needs_shaping, LANGUAGE_NAMES, has_emoji, detect_script, emoji_clusters, load_brand, drawn_chain, label_chain, layer_base_chain, matrix_chain, overlay_format, range_chain, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_drawtext, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, X264_PRESETS, time_arg, fmt_secs
|
|
39
39
|
from _common.emit import note, warn
|
|
40
40
|
from _common.color import is_rgb
|
|
41
41
|
from _common.decision import length_output_t, window_expr, windowed_overlay_x
|
package/scripts/probe.py
CHANGED
|
@@ -15,10 +15,11 @@ Examples:
|
|
|
15
15
|
points a stream copy can start at, and where cut.py --smart's re-encode around a cut ends.
|
|
16
16
|
"""
|
|
17
17
|
import argparse
|
|
18
|
+
import json
|
|
18
19
|
import math
|
|
19
20
|
import sys
|
|
20
21
|
|
|
21
|
-
from _common import add_common, analyze_levels, apply_common, die, keyframe_times, print_json, probe, time_arg
|
|
22
|
+
from _common import STATE, add_common, analyze_levels, apply_common, die, keyframe_times, print_json, probe, time_arg
|
|
22
23
|
|
|
23
24
|
|
|
24
25
|
def keyframe_candidates(result: dict, times: list, window: float) -> list:
|
|
@@ -87,7 +88,17 @@ def main() -> int:
|
|
|
87
88
|
cur = r
|
|
88
89
|
for key in args.field.split("."):
|
|
89
90
|
cur = cur.get(key) if isinstance(cur, dict) else None
|
|
90
|
-
|
|
91
|
+
# a string as itself; anything else as JSON (true, null, {"codec": ...}), not Python's
|
|
92
|
+
# repr (True, None, {'codec': ...}), which no caller can parse (through 3.8.8, A123).
|
|
93
|
+
# Under --json (or --json-brief, which implies it) a missing field is null and a string
|
|
94
|
+
# is quoted: every line is JSON. Without either a missing field is an empty line, as it
|
|
95
|
+
# always was, so `[ -z "$(probe.py f --field x)" ]` keeps working
|
|
96
|
+
if isinstance(cur, str) and not STATE.json:
|
|
97
|
+
print(cur)
|
|
98
|
+
elif cur is None and not STATE.json:
|
|
99
|
+
print("")
|
|
100
|
+
else:
|
|
101
|
+
print(json.dumps(cur, ensure_ascii=False))
|
|
91
102
|
return 0
|
|
92
103
|
|
|
93
104
|
if args.compact:
|
package/scripts/proxy.py
CHANGED
|
@@ -5,7 +5,8 @@ editing-decision workflows that only need to look at (or feed a model) a
|
|
|
5
5
|
much smaller stand-in for the original.
|
|
6
6
|
|
|
7
7
|
Resizes to --width (default 640px, height follows the source aspect) or by
|
|
8
|
-
--scale factor,
|
|
8
|
+
--scale factor, never wider than the source (a proxy is not upscaled) and
|
|
9
|
+
never under 2px, re-encodes at a proxy-grade --quality (default 30 - well above any
|
|
9
10
|
delivery preset's 18-24 in export.py, since a proxy trades visual quality for
|
|
10
11
|
size and speed), and always uses the fastest x264/x265 preset. Keeps the
|
|
11
12
|
source's own dynamic range (an HDR source proxies to HEVC10, same as every
|
|
@@ -65,7 +66,15 @@ def main() -> int:
|
|
|
65
66
|
note(square_pixels_note(meta["video"]))
|
|
66
67
|
has_audio = bool(meta.get("audio")) and not args.no_audio
|
|
67
68
|
|
|
68
|
-
|
|
69
|
+
# A proxy is never wider than its source, and never under 2 px: through 3.8.8 the default
|
|
70
|
+
# --width 640 upscaled a smaller source, and a --scale that rounded to 0 became scale=0, which
|
|
71
|
+
# ffmpeg reads as "the input's width" (a full-size proxy, audit A162)
|
|
72
|
+
asked = even(sw * args.scale) if args.scale is not None else even(args.width)
|
|
73
|
+
ceiling = max(2, sw - sw % 2) if sw else asked
|
|
74
|
+
out_w = max(2, min(asked, ceiling))
|
|
75
|
+
if out_w != asked:
|
|
76
|
+
note(f"proxy width {out_w}px, not {asked}px: "
|
|
77
|
+
+ (f"the source is {sw}px wide and a proxy is not upscaled" if asked > ceiling else "2px is the smallest width"))
|
|
69
78
|
output = args.output or default_output(args.input, "proxy")
|
|
70
79
|
|
|
71
80
|
cmd = ffmpeg_base() + ["-i", args.input, "-vf", (square + "," if square else "") + f"scale={out_w}:-2"]
|
package/scripts/redact.py
CHANGED
|
@@ -113,7 +113,11 @@ def main() -> int:
|
|
|
113
113
|
# boxblur refuses a radius above half the plane: the chroma planes of 4:2:0 are half-size,
|
|
114
114
|
# so they get their own (halved) radius; a 30 px region with the default 20 used to fail
|
|
115
115
|
radius = max(1, min(args.blur_strength, min(args.width, args.height) // 2 - 1))
|
|
116
|
-
|
|
116
|
+
# at least 1 wherever the chroma plane can hold it: radius // 2 alone was 0 at
|
|
117
|
+
# --blur-strength 1, which left colour-only detail readable (review of #521). (FFmpeg
|
|
118
|
+
# 7.1 refuses a chroma radius of 1 on a 2 px chroma plane, a 4 px wide region; 6.1
|
|
119
|
+
# took it.) 0 is only a plane too small to blur: the luma still is
|
|
120
|
+
chroma = max(0, min(max(1, radius // 2), (min(args.width, args.height) // 2 - 1) // 2))
|
|
117
121
|
region = f"{crop},boxblur={radius}:{radius}:{chroma}:{radius}"
|
|
118
122
|
else:
|
|
119
123
|
region = f"{crop},scale={max(1, args.width // args.block_size)}:{max(1, args.height // args.block_size)}:flags=neighbor,scale={args.width}:{args.height}:flags=neighbor"
|
package/scripts/sphere.py
CHANGED
|
@@ -6,7 +6,8 @@ turning an equirectangular (or other spherical-projection) source into an
|
|
|
6
6
|
ordinary flat video pointed at a chosen direction -- the same "look this way"
|
|
7
7
|
operation a VR headset or a 360 video player's viewport does, baked into a
|
|
8
8
|
real file. --yaw/--pitch/--roll aim the camera; --h-fov/--v-fov set how wide
|
|
9
|
-
the view is
|
|
9
|
+
the view is (without --v-fov, the vertical view is the one that keeps the
|
|
10
|
+
output's pixels square).
|
|
10
11
|
|
|
11
12
|
This tool does not decide WHERE to point the camera -- there is no subject
|
|
12
13
|
detection or tracking here, only the typed rotation/FOV you give it (see this
|
|
@@ -29,9 +30,11 @@ Examples:
|
|
|
29
30
|
python3 sphere.py gopro_max.mp4 --input-projection fisheye --yaw -45 --pitch 10 -o angle.mp4
|
|
30
31
|
"""
|
|
31
32
|
import argparse
|
|
33
|
+
import math
|
|
32
34
|
import sys
|
|
33
35
|
|
|
34
36
|
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS, fmt_secs
|
|
37
|
+
from _common.emit import note
|
|
35
38
|
|
|
36
39
|
# v360's own AVOption names for the input projections real 360 cameras/exports actually
|
|
37
40
|
# produce (ffmpeg -h filter=v360 documents 24 total; this is the subset a caller is likely
|
|
@@ -59,7 +62,9 @@ def main() -> int:
|
|
|
59
62
|
aim.add_argument("--roll", type=float, default=0.0, help="tilt/roll rotation in degrees, -180..180 (default 0)")
|
|
60
63
|
fov = ap.add_argument_group("field of view")
|
|
61
64
|
fov.add_argument("--h-fov", type=float, default=90.0, help="output horizontal field of view in degrees, 1..170 (default 90)")
|
|
62
|
-
fov.add_argument("--v-fov", type=float, default=
|
|
65
|
+
fov.add_argument("--v-fov", type=float, default=None,
|
|
66
|
+
help="output vertical field of view in degrees, 1..170 (default: the one that keeps pixels square at "
|
|
67
|
+
"--h-fov and --width/--height, 58.7 at the defaults)")
|
|
63
68
|
out = ap.add_argument_group("output frame")
|
|
64
69
|
out.add_argument("--width", type=int, default=1920, help="output width in px, must be even (default 1920)")
|
|
65
70
|
out.add_argument("--height", type=int, default=1080, help="output height in px, must be even (default 1080)")
|
|
@@ -81,12 +86,27 @@ def main() -> int:
|
|
|
81
86
|
die(f"--roll must be -180..180, got {args.roll}")
|
|
82
87
|
if not 1 <= args.h_fov <= 170:
|
|
83
88
|
die(f"--h-fov must be 1..170, got {args.h_fov}")
|
|
84
|
-
if not 1 <= args.v_fov <= 170:
|
|
89
|
+
if args.v_fov is not None and not 1 <= args.v_fov <= 170:
|
|
85
90
|
die(f"--v-fov must be 1..170, got {args.v_fov}")
|
|
86
91
|
if args.width <= 0 or args.height <= 0:
|
|
87
92
|
die(f"--width/--height must be > 0, got width={args.width} height={args.height}")
|
|
88
93
|
if args.width % 2 or args.height % 2:
|
|
89
94
|
die(f"--width/--height must be even (4:2:0 chroma), got width={args.width} height={args.height}")
|
|
95
|
+
# A rectilinear view has square pixels when tan(v/2) / tan(h/2) = H / W. Through 3.8.8 the
|
|
96
|
+
# default was a fixed 60 degrees, which at 90 x 1920x1080 squashed the view 2.6 % vertically
|
|
97
|
+
# (audit A163); the vertical FOV now follows from the other three unless it is given.
|
|
98
|
+
square_v = math.degrees(2 * math.atan(math.tan(math.radians(args.h_fov) / 2) * args.height / args.width))
|
|
99
|
+
if args.v_fov is None:
|
|
100
|
+
if not 1 <= square_v <= 170:
|
|
101
|
+
die(f"--h-fov {args.h_fov:g} at {args.width}x{args.height} needs a vertical field of view of {square_v:.1f} "
|
|
102
|
+
"degrees for square pixels, outside 1..170", hint="give --v-fov, or a lower --h-fov")
|
|
103
|
+
args.v_fov = round(square_v, 4)
|
|
104
|
+
else:
|
|
105
|
+
stretch = math.tan(math.radians(args.v_fov) / 2) / math.tan(math.radians(square_v) / 2)
|
|
106
|
+
if abs(stretch - 1) > 0.005:
|
|
107
|
+
note(f"--h-fov {args.h_fov:g} / --v-fov {args.v_fov:g} at {args.width}x{args.height} is not square-pixel: the view is "
|
|
108
|
+
f"{'squashed' if stretch > 1 else 'stretched'} {abs(stretch - 1) * 100:.1f} % vertically "
|
|
109
|
+
f"(--v-fov {square_v:.2f} keeps it square; omit --v-fov to get it)")
|
|
90
110
|
|
|
91
111
|
meta = probe(args.input)
|
|
92
112
|
if not meta.get("video"):
|
package/scripts/stabilize.py
CHANGED
|
@@ -29,7 +29,7 @@ from pathlib import Path
|
|
|
29
29
|
|
|
30
30
|
from _common.probe import PROBED_VIDEO
|
|
31
31
|
from _common.runner import ffmpeg_failure_extra, default_selection_maps, require_filter
|
|
32
|
-
from _common import
|
|
32
|
+
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, escape_filter_path, ffmpeg_base, info, probe, require_tool, run, video_args, X264_PRESETS, run_analysis, dry_run_input_pending, fmt_secs, record_fallback, EVEN_SCALE
|
|
33
33
|
|
|
34
34
|
|
|
35
35
|
def main() -> int:
|
|
@@ -96,7 +96,10 @@ def main() -> int:
|
|
|
96
96
|
die(f"stabilization analysis (pass 1) failed:\n{proc.stderr.strip()[-1500:]}", kind="ffmpeg", **ffmpeg_failure_extra(proc.stderr))
|
|
97
97
|
|
|
98
98
|
crop_mode = {"keep": 0, "black": 1}[args.crop]
|
|
99
|
-
|
|
99
|
+
# optzoom=0: the zoom is --zoom and nothing else. vidstab's default (optzoom=1) adds its own
|
|
100
|
+
# "optimal" zoom on top, so --zoom 0 did not keep the original framing and --crop black
|
|
101
|
+
# rarely had an edge to fill (through 3.8.8, audit A156)
|
|
102
|
+
transform_vf = f"{even}vidstabtransform=input={trf_arg}:smoothing={args.smoothing}:crop={crop_mode}:zoom={args.zoom:g}:optzoom=0"
|
|
100
103
|
if args.tripod:
|
|
101
104
|
# Equivalent to relative=0:smoothing=0 -- overrides --smoothing, since averaging a
|
|
102
105
|
# camera path makes no sense once every frame is locked to one fixed reference.
|
package/scripts/sync.py
CHANGED
|
@@ -27,15 +27,13 @@ Examples:
|
|
|
27
27
|
python3 sync.py cam.mp4 recorder.wav --fix-drift --replace-audio # long takes: fix clock drift too
|
|
28
28
|
"""
|
|
29
29
|
import argparse
|
|
30
|
-
import cmath
|
|
31
|
-
import json
|
|
32
30
|
import math
|
|
33
31
|
import os
|
|
34
32
|
import sys
|
|
35
33
|
from typing import Dict, List
|
|
36
34
|
|
|
37
35
|
from _common.runner import fail_if_classified
|
|
38
|
-
from _common import STATE, video_args, add_common, apply_common, emit, aac_args, audio_codec_for, default_output, die, ffmpeg_base, info, probe,
|
|
36
|
+
from _common import STATE, video_args, add_common, apply_common, emit, aac_args, audio_codec_for, default_output, die, ffmpeg_base, info, probe, run, decode_pcm_mono, rms_envelope
|
|
39
37
|
from _common.emit import record_skip, warn
|
|
40
38
|
from _common.decision import SEEK_MARGIN, frame_rate, grid_at_or_after, length_output_t, silence_filter, stream_starts
|
|
41
39
|
from _common.timeline import output_timecode_args
|
package/scripts/verify.py
CHANGED
|
@@ -18,7 +18,6 @@ import argparse
|
|
|
18
18
|
import contextlib
|
|
19
19
|
import io
|
|
20
20
|
import json
|
|
21
|
-
import os
|
|
22
21
|
import subprocess
|
|
23
22
|
import sys
|
|
24
23
|
import tempfile
|
|
@@ -26,10 +25,12 @@ import time
|
|
|
26
25
|
from pathlib import Path
|
|
27
26
|
from typing import Any, Dict, List, Optional, Tuple
|
|
28
27
|
|
|
29
|
-
from _common import STATE, add_common, apply_common, die, emit, guard_sidecar, info, probe
|
|
28
|
+
from _common import STATE, add_common, apply_common, die, emit, guard_sidecar, info, probe, refuse_output_is_input, MEDIA_EXT as _MEDIA_EXT
|
|
30
29
|
|
|
31
30
|
HERE = Path(__file__).resolve().parent
|
|
32
|
-
|
|
31
|
+
# _common's one list less the stills, as batch.py walks a folder: verify kept a list of its own,
|
|
32
|
+
# and a folder walk skipped .ts, .3gp, .wmv, .gif, .ogg, .opus, .aif/.aiff, .caf and .wma (#467 A218)
|
|
33
|
+
MEDIA_EXT = {e for e in _MEDIA_EXT if e not in (".png", ".jpg", ".jpeg", ".webp")}
|
|
33
34
|
|
|
34
35
|
|
|
35
36
|
def unique_stems(files: List[Path]) -> Dict[Path, str]:
|
|
@@ -39,18 +40,31 @@ def unique_stems(files: List[Path]) -> Dict[Path, str]:
|
|
|
39
40
|
resolve to the identical output prefix. Each file's own steps still ran correctly in isolation,
|
|
40
41
|
but with --keep the second file's outputs silently overwrote the first file's on disk, and the
|
|
41
42
|
report showed PASS for both without ever flagging the collision (same bug class already fixed
|
|
42
|
-
in batch.py). Disambiguate every colliding stem with a stable per-collision index instead.
|
|
43
|
+
in batch.py). Disambiguate every colliding stem with a stable per-collision index instead.
|
|
44
|
+
|
|
45
|
+
A stem is compared case-folded (clip.mp4 and Clip.MOV share one name on a case-insensitive
|
|
46
|
+
disk), and an indexed name skips any stem already taken, a file's own included: through 3.8.8
|
|
47
|
+
two clip.mp4 and a clip_1.mp4 gave "clip_1" twice (audit A121)."""
|
|
43
48
|
counts: Dict[str, int] = {}
|
|
44
49
|
for f in files:
|
|
45
|
-
counts[f.stem] = counts.get(f.stem, 0) + 1
|
|
50
|
+
counts[f.stem.casefold()] = counts.get(f.stem.casefold(), 0) + 1
|
|
51
|
+
taken = {f.stem.casefold() for f in files if counts[f.stem.casefold()] == 1}
|
|
46
52
|
seen: Dict[str, int] = {}
|
|
47
53
|
stems: Dict[Path, str] = {}
|
|
48
54
|
for f in files:
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
stems[f] = f"{f.stem}_{seen[f.stem]}"
|
|
52
|
-
else:
|
|
55
|
+
key = f.stem.casefold()
|
|
56
|
+
if counts[key] == 1:
|
|
53
57
|
stems[f] = f.stem
|
|
58
|
+
continue
|
|
59
|
+
n = seen.get(key, 0)
|
|
60
|
+
while True:
|
|
61
|
+
n += 1
|
|
62
|
+
name = f"{f.stem}_{n}"
|
|
63
|
+
if name.casefold() not in taken:
|
|
64
|
+
break
|
|
65
|
+
seen[key] = n
|
|
66
|
+
taken.add(name.casefold())
|
|
67
|
+
stems[f] = name
|
|
54
68
|
return stems
|
|
55
69
|
|
|
56
70
|
|
|
@@ -96,6 +110,20 @@ def _probe_capturing(path: str) -> "Tuple[Optional[Dict[str, Any]], Dict[str, An
|
|
|
96
110
|
STATE.json = was_json
|
|
97
111
|
|
|
98
112
|
|
|
113
|
+
# The shared flags given to verify.py that every step receives as well (set in main())
|
|
114
|
+
FORWARD: Dict[str, bool] = {"overwrite": False, "fast": False}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _forwarded(argv: List[str]) -> List[str]:
|
|
118
|
+
"""--overwrite and --fast as the caller gave them to verify.py: through 3.8.8 neither reached
|
|
119
|
+
a step, so a rerun into the same --out failed every step on its own earlier output (audit
|
|
120
|
+
A119). The steps' --timeout is verify's own per-step limit (step()'s `timeout`)."""
|
|
121
|
+
out = ["--overwrite"] if FORWARD["overwrite"] else []
|
|
122
|
+
if FORWARD["fast"] and "--fast" not in argv:
|
|
123
|
+
out.append("--fast")
|
|
124
|
+
return out
|
|
125
|
+
|
|
126
|
+
|
|
99
127
|
def step(name: str, argv: List[str], timeout: float) -> Dict:
|
|
100
128
|
"""Run one step and keep what its tool said: `ok` (it completed), and on a failure the tool's
|
|
101
129
|
own `kind`, `reason` and `message` (its --json failure document), `error` being that message
|
|
@@ -113,7 +141,7 @@ def step(name: str, argv: List[str], timeout: float) -> Dict:
|
|
|
113
141
|
r.update(kind="verification", reason="hdr_lost", message=r["error"])
|
|
114
142
|
return r
|
|
115
143
|
try:
|
|
116
|
-
proc = subprocess.run([sys.executable, str(HERE / argv[0])] + argv[1:] + ["--json"], stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
144
|
+
proc = subprocess.run([sys.executable, str(HERE / argv[0])] + argv[1:] + _forwarded(argv) + ["--json"], stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
117
145
|
text=True, encoding="utf-8", errors="replace", timeout=timeout or None)
|
|
118
146
|
except subprocess.TimeoutExpired:
|
|
119
147
|
msg = f"timeout after {timeout:.0f}s"
|
|
@@ -146,7 +174,7 @@ def main() -> int:
|
|
|
146
174
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
147
175
|
ap.add_argument("paths", nargs="+", help="media files and/or folders")
|
|
148
176
|
ap.add_argument("--out", help="directory for outputs (default: temp dir, deleted unless --keep)")
|
|
149
|
-
ap.add_argument("--keep", action="store_true", help="keep the outputs")
|
|
177
|
+
ap.add_argument("--keep", action="store_true", help="keep the outputs (without --out: in a new folder, named as out_dir and on stderr)")
|
|
150
178
|
ap.add_argument("--quick", action="store_true", help="probe, copy cut, fit, caption, export only")
|
|
151
179
|
ap.add_argument("--seconds", type=float, default=6.0, help="length of the test cut taken from each file (default 6)")
|
|
152
180
|
ap.add_argument("--timeout", type=float, default=600.0, help="per-step timeout in seconds (default 600; 0 = no limit, as everywhere else)")
|
|
@@ -168,6 +196,7 @@ def main() -> int:
|
|
|
168
196
|
# fault ("9 of 11 verification steps failed") rather than the flag's
|
|
169
197
|
die(f"--seconds must be more than 0 (the length of the test cut), got {args.seconds:g}")
|
|
170
198
|
files = collect(args.paths)
|
|
199
|
+
FORWARD.update(overwrite=STATE.overwrite, fast=STATE.fast)
|
|
171
200
|
tmp = None
|
|
172
201
|
if args.out:
|
|
173
202
|
# made first, so a --report inside a new --out folder passes the guard below (#413 review)
|
|
@@ -177,20 +206,27 @@ def main() -> int:
|
|
|
177
206
|
# a file this run writes itself (#408): not one of the media it verifies (a folder's
|
|
178
207
|
# files included), nor an existing file without --overwrite
|
|
179
208
|
guard_sidecar(args.report, *args.paths, *map(str, files))
|
|
180
|
-
if not args.out:
|
|
209
|
+
if not args.out and args.keep:
|
|
210
|
+
# --keep without --out: a folder of its own that outlives the run, named in the document
|
|
211
|
+
# (`out_dir`) and on stderr; through 3.8.8 it was the temporary directory, deleted at exit
|
|
212
|
+
# whatever --keep said, and never named (audit A120)
|
|
213
|
+
outdir = Path(tempfile.mkdtemp(prefix="ffskill_verify_"))
|
|
214
|
+
elif not args.out:
|
|
181
215
|
tmp = tempfile.TemporaryDirectory(prefix="ffskill_verify_")
|
|
182
216
|
outdir = Path(tmp.name)
|
|
217
|
+
kept_dir = None if tmp else str(outdir)
|
|
183
218
|
|
|
184
219
|
stem_for = unique_stems(files)
|
|
185
|
-
|
|
220
|
+
# every file's plan is made before any step runs, so the names all of them will write are
|
|
221
|
+
# checked against every input first (below)
|
|
222
|
+
jobs: List[Tuple[Path, Dict, List[Tuple[str, List[str]]], Optional[Path]]] = []
|
|
186
223
|
for f in files:
|
|
187
|
-
info(f"=== {f}")
|
|
188
224
|
entry: Dict = {"file": str(f), "steps": [], "skipped": []}
|
|
189
225
|
meta, failure = _probe_capturing(str(f))
|
|
190
226
|
if meta is None:
|
|
191
227
|
entry["steps"].append(dict({"step": "probe", "ok": False, "seconds": 0, "error": _cell(failure.get("message", ""))}, **failure))
|
|
192
228
|
entry["skipped"].append({"step": "*", "reason": "input_unreadable", "message": "no other step runs on a file probe cannot read"})
|
|
193
|
-
|
|
229
|
+
jobs.append((f, entry, [], None))
|
|
194
230
|
continue
|
|
195
231
|
entry["probe"] = {k: meta.get(k) for k in ("duration", "format")}
|
|
196
232
|
entry["probe"]["video"] = {k: (meta.get("video") or {}).get(k) for k in ("codec", "width", "height", "fps", "pix_fmt", "hdr_format", "rotation", "variable_frame_rate_suspected")}
|
|
@@ -202,7 +238,8 @@ def main() -> int:
|
|
|
202
238
|
cut = f"{stem}_cut.mp4"
|
|
203
239
|
seg_end = min(dur, args.seconds) if dur else args.seconds
|
|
204
240
|
fast = ["--fast"]
|
|
205
|
-
plan = []
|
|
241
|
+
plan: List[Tuple[str, List[str]]] = []
|
|
242
|
+
cues: Optional[Path] = None
|
|
206
243
|
skipped: List[Dict[str, str]] = entry["skipped"]
|
|
207
244
|
|
|
208
245
|
def skip(step_name: str, reason: str, message: str) -> None:
|
|
@@ -212,7 +249,6 @@ def main() -> int:
|
|
|
212
249
|
plan.append(("cut accurate", ["cut.py", str(f), "--start", "0", "--end", f"{seg_end:.2f}", "--accurate", "-o", f"{stem}_acc.mp4"] + fast))
|
|
213
250
|
plan.append(("fit 9:16", ["fit.py", cut, "--aspect", "9:16", "--width", "720", "-o", f"{stem}_fit.mp4"] + fast))
|
|
214
251
|
cues = outdir / f"{stem_for[f]}_cues.txt"
|
|
215
|
-
cues.write_text("0:00-0:02 Verification caption\n0:02-0:04 Second | line\n", encoding="utf-8")
|
|
216
252
|
plan.append(("caption", ["caption.py", cut, "--text", str(cues), "--animate", "pop", "--karaoke", "-o", f"{stem}_cap.mp4"] + fast))
|
|
217
253
|
if not args.quick:
|
|
218
254
|
plan.append(("overlay text", ["overlay.py", cut, "--text", "verify", "--position", "top-left", "-o", f"{stem}_ovl.mp4"] + fast))
|
|
@@ -245,6 +281,30 @@ def main() -> int:
|
|
|
245
281
|
else:
|
|
246
282
|
for n in ("loudness measure", "silence list", "audio downmix"):
|
|
247
283
|
skip(n, "quick", "--quick runs probe, copy cut, fit, caption and export only")
|
|
284
|
+
jobs.append((f, entry, plan, cues))
|
|
285
|
+
|
|
286
|
+
# A name verify generates can be another input's (clip.mp4's cut is clip_cut.mp4, which can be
|
|
287
|
+
# a file of the same folder): each step checks its output against its own input only, so with
|
|
288
|
+
# --overwrite that input was replaced and its own steps then read the replacement. Every name
|
|
289
|
+
# the run will write -- step outputs, caption's sidecars, the cue file -- against every input,
|
|
290
|
+
# through a link or another case of the name included, before the first is written.
|
|
291
|
+
every_input = [str(f) for f in files]
|
|
292
|
+
for f, _entry, plan, cues in jobs:
|
|
293
|
+
written = [("cue file", str(cues))] if cues else []
|
|
294
|
+
for name, argv in plan:
|
|
295
|
+
if "-o" in argv:
|
|
296
|
+
out = argv[argv.index("-o") + 1]
|
|
297
|
+
written.append((name, out))
|
|
298
|
+
if argv[0] == "caption.py":
|
|
299
|
+
written += [(name, str(Path(out).with_suffix(ext))) for ext in (".srt", ".ass")]
|
|
300
|
+
for name, out in written:
|
|
301
|
+
refuse_output_is_input(out, *every_input, why=f"it is where verify writes the {name!r} step's output for {str(f)!r}")
|
|
302
|
+
|
|
303
|
+
results = []
|
|
304
|
+
for f, entry, plan, cues in jobs:
|
|
305
|
+
info(f"=== {f}")
|
|
306
|
+
if cues:
|
|
307
|
+
cues.write_text("0:00-0:02 Verification caption\n0:02-0:04 Second | line\n", encoding="utf-8")
|
|
248
308
|
for name, argv in plan:
|
|
249
309
|
r = step(name, argv, args.timeout)
|
|
250
310
|
entry["steps"].append(r)
|
|
@@ -275,14 +335,16 @@ def main() -> int:
|
|
|
275
335
|
info(f"wrote {args.report}")
|
|
276
336
|
if not args.json:
|
|
277
337
|
print(report)
|
|
278
|
-
if tmp
|
|
338
|
+
if tmp:
|
|
279
339
|
tmp.cleanup()
|
|
340
|
+
if kept_dir:
|
|
341
|
+
info(f"step outputs kept in {kept_dir}")
|
|
280
342
|
notes = (["--dry-run is ignored by verify.py: its steps ran and wrote their outputs"] if ignored_dry_run else [])
|
|
281
343
|
if failed:
|
|
282
344
|
die(f"{failed} of {total} verification steps failed", kind="verification", output=None, dry_run=STATE.dry_run,
|
|
283
|
-
report=args.report, files=results, failed=failed, total=total, **({"notes": notes} if notes else {}))
|
|
345
|
+
report=args.report, files=results, failed=failed, total=total, out_dir=kept_dir, **({"notes": notes} if notes else {}))
|
|
284
346
|
if args.json:
|
|
285
|
-
emit(None, report=args.report, files=results, failed=failed, total=total, **({"notes": notes} if notes else {}))
|
|
347
|
+
emit(None, report=args.report, files=results, failed=failed, total=total, out_dir=kept_dir, **({"notes": notes} if notes else {}))
|
|
286
348
|
return 0
|
|
287
349
|
|
|
288
350
|
|