ffmpeg-skill 3.8.5 → 3.8.6
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/docs/contract.md +4 -3
- package/mcp/server.py +3 -2
- package/package.json +1 -1
- package/references/flags.md +1 -1
- package/references/scripts.md +25 -7
- package/scripts/_common/decision.py +6 -1
- package/scripts/_common/runner.py +26 -2
- package/scripts/_contract.py +1 -1
- package/scripts/audio.py +38 -5
- package/scripts/batch.py +10 -0
- package/scripts/caption.py +51 -1
- package/scripts/color.py +6 -0
- package/scripts/cropdetect.py +6 -1
- package/scripts/cut.py +5 -0
- package/scripts/fit.py +5 -0
- package/scripts/graphics.py +6 -0
- package/scripts/look.py +7 -0
- package/scripts/loudness.py +8 -0
- package/scripts/metadata.py +3 -0
- package/scripts/overlay.py +18 -1
- package/scripts/render.py +50 -5
- package/scripts/scenes.py +14 -0
- package/scripts/sequence.py +4 -0
- package/scripts/silence.py +3 -0
- package/scripts/sync.py +6 -0
- package/scripts/verify.py +4 -0
- package/scripts/waveform.py +13 -4
package/docs/contract.md
CHANGED
|
@@ -21,7 +21,7 @@ The contract is derived from the code that runs, not maintained beside it:
|
|
|
21
21
|
| Field | Meaning | Changes when |
|
|
22
22
|
|---|---|---|
|
|
23
23
|
| `contract_version` | shape of this document (`2.0`) | a key is renamed, removed or changes meaning |
|
|
24
|
-
| `skill.version` | the npm / package.json version (`3.8.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`3.8.6`) | any release |
|
|
25
25
|
|
|
26
26
|
A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
|
|
27
27
|
ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
|
|
@@ -149,7 +149,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
|
|
|
149
149
|
"deprecated": [],
|
|
150
150
|
"removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"},
|
|
151
151
|
{"what": "running on FFmpeg 5.x (the requirements.ffmpeg floor)", "since": "2.14.0", "replacement": "FFmpeg 6.1 or later; ...", "removed_in": "3.0.0", "where": "behaviour"}],
|
|
152
|
-
"skill": {"id": "ffmpeg-skill", "version": "3.8.
|
|
152
|
+
"skill": {"id": "ffmpeg-skill", "version": "3.8.6", "execution_mode": "local", "kind": "execution",
|
|
153
153
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
154
154
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
155
155
|
"requirements": {"python": ">=3.10 (standard library only)", "ffmpeg": ">=6.1", "ffprobe": ">=6.1"},
|
|
@@ -984,7 +984,8 @@ row or column), `non_av_streams_dropped` (with the
|
|
|
984
984
|
first attempt's error beside `dropped_non_av_streams`), `measurement_input_pending` (skipped: a
|
|
985
985
|
dry-run measurement of an earlier stage's output), `ffmpeg_list_unreadable` (`ffmpeg -encoders` or
|
|
986
986
|
`-bsfs` could not be read; what needed it names the read failure, not a missing encoder) and
|
|
987
|
-
`timeout_env_invalid` (an `FFMPEG_SKILL_TIMEOUT` that is not a number, or is
|
|
987
|
+
`timeout_env_invalid` (an `FFMPEG_SKILL_TIMEOUT` that is not a number, read as the 1800 s default, or is
|
|
988
|
+
negative or above 500000 s, read as 0, no limit; a negative or too-long `--timeout` is refused instead, `kind: input`);
|
|
988
989
|
`av1_encoder_libaom` and `crf_clamped`; `font_substituted` (with `font_used`); `drift_correction_skipped`
|
|
989
990
|
(with `drift_skipped_reason`); `cropdetect_windows_failed` (with `windows_failed`);
|
|
990
991
|
`ass_lines_unshifted` / `ass_lines_malformed`; `timeline_parse_failed`; and every `warning:` line a
|
package/mcp/server.py
CHANGED
|
@@ -136,8 +136,9 @@ def timeout_from_call(args: Dict[str, Any]) -> float:
|
|
|
136
136
|
|
|
137
137
|
Structured `timeout` is the canonical key. A raw `argv` list ignores every other key, so
|
|
138
138
|
`--timeout` has to be read from that list: `--timeout 0` otherwise left the ceiling at 4x
|
|
139
|
-
the 1800 s default while the child itself ran with no limit. A negative value is 0
|
|
140
|
-
|
|
139
|
+
the 1800 s default while the child itself ran with no limit. A negative value is 0 here (no
|
|
140
|
+
outer ceiling): the child refuses a negative --timeout itself, at once, as it refuses one too
|
|
141
|
+
long to wait for, which child_limit() gives no ceiling either."""
|
|
141
142
|
if isinstance(args.get("argv"), list):
|
|
142
143
|
parsed = _timeout_in_argv([str(a) for a in args["argv"]])
|
|
143
144
|
if parsed is None:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "3.8.
|
|
3
|
+
"version": "3.8.6",
|
|
4
4
|
"mcpName": "io.github.kajisho5/ffmpeg-skill",
|
|
5
5
|
"description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
|
|
6
6
|
"keywords": [
|
package/references/flags.md
CHANGED
|
@@ -9,7 +9,7 @@ Scripts live in `scripts/` next to SKILL.md; run them with `python3 <skill-dir>/
|
|
|
9
9
|
|
|
10
10
|
## Shared flags
|
|
11
11
|
|
|
12
|
-
Shared flags (every script): `--dry-run`; `--json` (output path, its probe, the commands run); `--json-brief` (status/output/verified + a `summary`); `--fast` (preview quality); `--progress` (%/ETA); `--timeout SECONDS` (`kind: timeout`, default 1800; `0` for hour-long encodes); `--overwrite` (step 7); `--plan FILE` (a dry run `render.py FILE` runs later; refuses a changed input). Re-encoding tools also take `--hw` (Apple Silicon GPU, big files; `FFMPEG_SKILL_HW=1` = default, not export), `--codec h264|hevc|av1|prores` and `--quality N`: unset, SDR is x264 (`cut.py`: HEVC source stays HEVC, `--no-keep-hevc` x264), HDR is x265 Main10; `prores` needs `-o NAME.mov`, `h264` refuses HDR (`color.py --to-sdr` first).
|
|
12
|
+
Shared flags (every script): `--dry-run`; `--json` (output path, its probe, the commands run); `--json-brief` (status/output/verified + a `summary`); `--fast` (preview quality); `--progress` (%/ETA); `--timeout SECONDS` (`kind: timeout`, default 1800; `0` for hour-long encodes; below 0 or above 500000 is refused); `--overwrite` (step 7); `--plan FILE` (a dry run `render.py FILE` runs later; refuses a changed input). Re-encoding tools also take `--hw` (Apple Silicon GPU, big files; `FFMPEG_SKILL_HW=1` = default, not export), `--codec h264|hevc|av1|prores` and `--quality N`: unset, SDR is x264 (`cut.py`: HEVC source stays HEVC, `--no-keep-hevc` x264), HDR is x265 Main10; `prores` needs `-o NAME.mov`, `h264` refuses HDR (`color.py --to-sdr` first).
|
|
13
13
|
|
|
14
14
|
## What `--dry-run` runs
|
|
15
15
|
|
package/references/scripts.md
CHANGED
|
@@ -359,7 +359,9 @@ scope-ratio film in a 16:9 frame) "detects" the same way as accidental
|
|
|
359
359
|
bars; look at the frame before cropping it away. `windows` / `windows_failed`
|
|
360
360
|
say how many windows were sampled and how many ffmpeg could not decode (those
|
|
361
361
|
are skipped, with a `cropdetect_windows_failed` warning; all of them failing is
|
|
362
|
-
a `kind: ffmpeg` failure).
|
|
362
|
+
a `kind: ffmpeg` failure). `--limit` is a fraction of the top pixel code, 0..1;
|
|
363
|
+
`--limit 1` is written to FFmpeg as that top code (255 for an 8-bit source), since
|
|
364
|
+
FFmpeg reads a bare `1` as code 1.
|
|
363
365
|
|
|
364
366
|
`--motion-centre` (1.18) reports the motion centroid once per second, sampled
|
|
365
367
|
across the same windows: `motion_centre: [{time, x, y, x_frac, y_frac, motion}]`,
|
|
@@ -1103,7 +1105,9 @@ render result carries the caption stage's own block as `caption`.
|
|
|
1103
1105
|
|
|
1104
1106
|
Under `--dry-run` a pack prints every child's planned commands and its table reads `planned`
|
|
1105
1107
|
with no size or duration: nothing was encoded, so nothing is reported as verified. `--chapters`
|
|
1106
|
-
reaches a pack's audio destination like it does the single-template form.
|
|
1108
|
+
reaches a pack's audio destination like it does the single-template form. `--stop-after`,
|
|
1109
|
+
`--cache`, `--from`, `--work`, `--keep` and `--export-timeline` apply to one render and are
|
|
1110
|
+
refused with a pack; `--audit-log` is handed to every destination.
|
|
1107
1111
|
|
|
1108
1112
|
Each template's frame, duration limit, loudness target and safe zones come from the one delivery
|
|
1109
1113
|
table (`scripts/_platforms.py`). Safe zones are the fraction of the frame the app's own UI covers;
|
|
@@ -1134,13 +1138,15 @@ whose destinations did not all pass exits non-zero with the per-destination rows
|
|
|
1134
1138
|
`audiogram` (1.16) is the one-call form of the podcast-to-video job: an `audiogram` stage runs
|
|
1135
1139
|
**first** (it makes the picture the rest of the chain works on, through `waveform.py --image`),
|
|
1136
1140
|
then captions, loudness, export and check. It needs `--image` (a local file) or a `background`
|
|
1137
|
-
colour in the project — this skill fetches nothing and invents no cover art
|
|
1141
|
+
colour in the project — this skill fetches nothing and invents no cover art; `--template
|
|
1142
|
+
audiogram` without `--image` is refused before anything runs, and a project with an `audiogram`
|
|
1143
|
+
and more than one clip is refused (it is drawn from one audio source) — and it is
|
|
1138
1144
|
deliberately **not** part of `--template all`, whose destinations all assume a source that
|
|
1139
1145
|
already has a picture.
|
|
1140
1146
|
|
|
1141
1147
|
### scenes.py — scene changes and highlight candidates
|
|
1142
1148
|
```
|
|
1143
|
-
scenes.py INPUT [--threshold 10] [--min-scene 1] [--highlights N [--target SECONDS] [--max-scene 15]
|
|
1149
|
+
scenes.py INPUT [--threshold 10] [--min-scene 1] [--highlights N [--target SECONDS] [--max-scene 15] [--edl picks.txt]] [--sheet scenes.png] [--json]
|
|
1144
1150
|
scenes.py INPUT --beats [--beat-step 0.01] [--beat-range 60-200] [--min-confidence 0.5] [--json] # INPUT may be audio-only
|
|
1145
1151
|
scenes.py INPUT [--shots] [--audio-peaks] [--speech] [--json]
|
|
1146
1152
|
```
|
|
@@ -1326,7 +1332,10 @@ batch.py FOLDER --recipe batch.json [--force] [--watch SECONDS] [--jobs N|auto]
|
|
|
1326
1332
|
`batch.json` holds either `steps` (a list of script argv with `{in}`/`{out}`
|
|
1327
1333
|
placeholders, chained) or `project` (a render project applied per file).
|
|
1328
1334
|
Outputs land in `output_dir` with `suffix`; a content-hash cache skips files
|
|
1329
|
-
already done with the same recipe. Use `--dry-run` to preview the plan.
|
|
1335
|
+
already done with the same recipe. Use `--dry-run` to preview the plan. The recipe's
|
|
1336
|
+
keys are `glob`, `steps`, `project`, `clip_key`, `output_dir`, `suffix` and `ext`; any
|
|
1337
|
+
other key is refused before an item runs, with the nearest valid name when one is close, else
|
|
1338
|
+
with the list of valid keys.
|
|
1330
1339
|
A recipe that runs `cut.py` rolls its calls up: each of those `results` rows carries
|
|
1331
1340
|
`cut_reencoded` (each call's `reencoded`) and `cut_reencode_reasons` (each
|
|
1332
1341
|
`reencode_reason` its calls named, once per call),
|
|
@@ -1807,7 +1816,9 @@ toggleable streams and copies the video and audio bit for bit. The suffix splits
|
|
|
1807
1816
|
on the last colon, and only when the tail is a BCP-47-shaped code *and* the whole
|
|
1808
1817
|
token is not itself a file on disk, so `C:\subs\en.srt` and a file named
|
|
1809
1818
|
`a:b.srt` are never mangled; a single `--srt` with no suffix still takes
|
|
1810
|
-
`--language`. `--
|
|
1819
|
+
`--language`. `--offset` shifts every added track (each later one as a
|
|
1820
|
+
`<output>_trackN.srt` copy); `--max-lines` and `--min-duration` lay out the first
|
|
1821
|
+
`--srt` only, and a note says so when they changed it. `--track-title` names a track (repeated in `--srt` order;
|
|
1811
1822
|
otherwise a frozen display-name table, and a code the table does not know gets
|
|
1812
1823
|
the code itself — never a guessed or translated name), `--default-track LANG`
|
|
1813
1824
|
marks one for auto-selection. Every other new track is explicitly marked *not*
|
|
@@ -2049,7 +2060,7 @@ completing with the old tags.
|
|
|
2049
2060
|
|
|
2050
2061
|
### audio.py — clean-up, music, ducking, layout
|
|
2051
2062
|
```
|
|
2052
|
-
audio.py INPUT [--voice [light|medium|strong]
|
|
2063
|
+
audio.py INPUT [--voice [light|medium|strong]] [--denoise [--denoise-strength 25]] [--gain dB]
|
|
2053
2064
|
[--music FILE [--music-volume -14] [--duck [--duck-amount 12] [--duck-threshold -26.02]
|
|
2054
2065
|
[--duck-attack 20] [--duck-release 400]] [--music-loop]]
|
|
2055
2066
|
[--effects FILE [--effects-volume -14]] [--stereo-widen 0..1]
|
|
@@ -2076,6 +2087,13 @@ alone), `medium` for a normal talking head, `strong` for phone/laptop audio.
|
|
|
2076
2087
|
stop at −1 dBFS: `medium` (the default, and the chain a bare `--voice` gets) can
|
|
2077
2088
|
clip a hot source, since its make-up gain has nothing above it — normalise
|
|
2078
2089
|
afterwards with `loudness.py`, or use `strong`, which measures quieter and safer.
|
|
2090
|
+
`--voice` with `--denoise` runs the level's chain with `afftdn=nf=-N:tn=1` at
|
|
2091
|
+
`--denoise-strength`: `light`, which has no denoise of its own, gets it after the
|
|
2092
|
+
highpass, and `medium`/`strong` run their own denoise stage at that strength
|
|
2093
|
+
(25 by default, the chain they have without `--denoise`). A parameter of a switch
|
|
2094
|
+
that is off is refused, as the `--duck-*` flags are: `--denoise-strength` without
|
|
2095
|
+
`--denoise`, `--music-volume`, `--music-fade-out` or `--music-loop` without `--music`,
|
|
2096
|
+
`--effects-volume` without `--effects` (a value equal to the default is not seen as given).
|
|
2079
2097
|
`--duck` uses a sidechain compressor keyed by the speech so music dips under
|
|
2080
2098
|
dialogue and swells in pauses:
|
|
2081
2099
|
`sidechaincompress=threshold=0.05:ratio=<amount/3, min 2>:attack=20:release=400:makeup=1`
|
|
@@ -70,9 +70,14 @@ def aspect_ratio(value: Any) -> Optional[Fraction]:
|
|
|
70
70
|
if len(parts) != 2:
|
|
71
71
|
return None
|
|
72
72
|
try:
|
|
73
|
-
|
|
73
|
+
w, h = int(parts[0]), int(parts[1])
|
|
74
|
+
ratio = Fraction(w, h)
|
|
74
75
|
except (ValueError, ZeroDivisionError):
|
|
75
76
|
return None
|
|
77
|
+
# a negative side (`-16:9`, `16:-9`) is no aspect anyone means: frame_size() made a negative
|
|
78
|
+
# frame of it, which fit.py's pad refused inside ffmpeg (kind ffmpeg) and the timeline wrote.
|
|
79
|
+
# Each side is looked at, not the ratio: `-16:-9` is a positive fraction
|
|
80
|
+
return None if w < 0 or h < 0 else ratio
|
|
76
81
|
|
|
77
82
|
|
|
78
83
|
def _even(n: float) -> int:
|
|
@@ -70,6 +70,14 @@ DEFAULT_TIMEOUT = 1800.0
|
|
|
70
70
|
|
|
71
71
|
PROBE_TIMEOUT = 120.0
|
|
72
72
|
|
|
73
|
+
# The largest per-call limit a run accepts (A8). subprocess's communicate() waits in poll(), whose
|
|
74
|
+
# timeout is a C int of milliseconds: past 2147483.647 s (24.8 days) it raised OverflowError after
|
|
75
|
+
# the child had started, so the tool died with a traceback while its ffmpeg ran on (measured on
|
|
76
|
+
# Linux with Python 3.11: `--timeout 1e8` and `1e300` both did). The outer ceiling a run_tool()
|
|
77
|
+
# child gets is 4x the per-call limit plus 60 s (child_limit()), so the per-call limit stops well
|
|
78
|
+
# below a quarter of that: 500000 s, about 5.8 days. 0 is "no limit" and is what a longer job passes.
|
|
79
|
+
MAX_TIMEOUT = 500000.0
|
|
80
|
+
|
|
73
81
|
|
|
74
82
|
def _env_timeout() -> float:
|
|
75
83
|
return _read_env_timeout()[0]
|
|
@@ -91,6 +99,11 @@ def _read_env_timeout() -> "Tuple[float, Optional[str]]":
|
|
|
91
99
|
return DEFAULT_TIMEOUT, f"FFMPEG_SKILL_TIMEOUT={raw!r} is not a number of seconds; using the default {DEFAULT_TIMEOUT:g} s"
|
|
92
100
|
if value < 0:
|
|
93
101
|
return 0.0, f"FFMPEG_SKILL_TIMEOUT={raw!r} is negative; read as 0, which means no time limit"
|
|
102
|
+
if value > MAX_TIMEOUT:
|
|
103
|
+
# inf, 1e300: longer than a wait can be on this platform, which was an OverflowError
|
|
104
|
+
# traceback once the first child had started
|
|
105
|
+
return 0.0, (f"FFMPEG_SKILL_TIMEOUT={raw!r} is longer than a run can wait for here ({MAX_TIMEOUT:.0f} s); "
|
|
106
|
+
"read as 0, which means no time limit")
|
|
94
107
|
return value, None
|
|
95
108
|
|
|
96
109
|
|
|
@@ -500,7 +513,13 @@ def apply_common(args: "argparse.Namespace") -> None:
|
|
|
500
513
|
out = getattr(args, "output", None)
|
|
501
514
|
guard_sidecar(STATE.plan, *inputs, siblings=(out,) if isinstance(out, str) else ())
|
|
502
515
|
if getattr(args, "timeout", None) is not None:
|
|
503
|
-
|
|
516
|
+
# a negative --timeout used to be read as 0 without a word, which removed every limit the
|
|
517
|
+
# caller thought it had tightened, and one past MAX_TIMEOUT was an OverflowError traceback
|
|
518
|
+
# after ffmpeg had started (the MCP call reported a failure while the tool wrote its output)
|
|
519
|
+
if not 0 <= float(args.timeout) <= MAX_TIMEOUT:
|
|
520
|
+
die(f"--timeout must be 0 (no limit) or a number of seconds up to {MAX_TIMEOUT:.0f}, got {float(args.timeout):g}",
|
|
521
|
+
hint="--timeout 0 runs without a time limit")
|
|
522
|
+
STATE.timeout = float(args.timeout)
|
|
504
523
|
else:
|
|
505
524
|
env_limit, env_problem = _read_env_timeout()
|
|
506
525
|
if env_problem:
|
|
@@ -2012,7 +2031,12 @@ def child_limit(per_call: Optional[float] = None) -> Optional[float]:
|
|
|
2012
2031
|
fires first on a healthy run, and it is the only thing that ends a child hung for a reason
|
|
2013
2032
|
that is not ffmpeg (a stuck import, a wedged pipe). None when the per-call limit is 0."""
|
|
2014
2033
|
limit = STATE.timeout if per_call is None else per_call
|
|
2015
|
-
|
|
2034
|
+
if not limit or limit != limit or limit > MAX_TIMEOUT:
|
|
2035
|
+
# 0 is "no limit". A per-call value no run can wait for (nan, inf, 1e300 from an MCP
|
|
2036
|
+
# `timeout`) has no ceiling either: the child refuses that --timeout itself, at once, and
|
|
2037
|
+
# communicate(timeout=4e300) would raise OverflowError with the child already running
|
|
2038
|
+
return None
|
|
2039
|
+
return limit * 4 + 60
|
|
2016
2040
|
|
|
2017
2041
|
|
|
2018
2042
|
def run_tool(argv: Sequence[str], *, per_call: Optional[float] = None,
|
package/scripts/_contract.py
CHANGED
|
@@ -477,7 +477,7 @@ DIAGNOSTIC_CODES = {
|
|
|
477
477
|
"measurement_input_pending": ("skipped", "--dry-run: a measurement skipped because its input is an earlier stage's output that does not exist yet; {path}"),
|
|
478
478
|
"non_av_streams_dropped": ("fallbacks", "the subtitle/data streams could not be copied, so the encode ran again without them (dropped_non_av_streams: true); {output, returncode, error: the first attempt's last stderr lines}; export.py adds dropped: the stream types this retry leaves out (data alone first, then subtitle too)"),
|
|
479
479
|
"ffmpeg_list_unreadable": ("warnings", "`ffmpeg -encoders` / `-bsfs` could not be read; what needs the list says so instead of 'the build lacks it'; {list, tool, error}"),
|
|
480
|
-
"timeout_env_invalid": ("warnings", "FFMPEG_SKILL_TIMEOUT was not a number (the default was used) or was negative (read as 0, no limit); {value, timeout}"),
|
|
480
|
+
"timeout_env_invalid": ("warnings", "FFMPEG_SKILL_TIMEOUT was not a number (the default was used), or was negative or above 500000 s (read as 0, no limit); {value, timeout}"),
|
|
481
481
|
# encoder decisions
|
|
482
482
|
"av1_encoder_libaom": ("fallbacks", "--codec av1 on a build without libsvtav1: libaom-av1 encoded instead; {wanted, used}"),
|
|
483
483
|
"crf_clamped": ("warnings", "the CRF handed to the encoder was above its maximum and was capped (the HEVC HDR line adds 2); {encoder, requested, asked, used}"),
|
package/scripts/audio.py
CHANGED
|
@@ -34,7 +34,8 @@ from _common.decision import conform_filter, conform_to_length
|
|
|
34
34
|
|
|
35
35
|
# --downmix: ITU-R BS.775 fold-down, renormalised by pan's '<' (see main()).
|
|
36
36
|
DOWNMIX_PAN = "pan=stereo|FL<FL+0.707*FC+0.707*BL+0.707*SL|FR<FR+0.707*FC+0.707*BR+0.707*SR"
|
|
37
|
-
|
|
37
|
+
VOICE_DENOISE = "afftdn=nf=-25:tn=1" # the denoise stage of the medium and strong chains
|
|
38
|
+
VOICE_CHAIN = f"highpass=f=80,deesser=i=0.4,{VOICE_DENOISE},acompressor=threshold=-18dB:ratio=3:attack=5:release=80:makeup=2"
|
|
38
39
|
|
|
39
40
|
# --voice [light|medium|strong] (1.13). "medium" is the chain --voice has always produced, so a
|
|
40
41
|
# bare --voice (and every existing call and MCP request) is byte-identical to before. "light"
|
|
@@ -48,6 +49,23 @@ VOICE_LEVELS = {
|
|
|
48
49
|
"strong": VOICE_CHAIN + ",deesser=i=0.6,acompressor=threshold=-24dB:ratio=4:attack=5:release=120:makeup=3,alimiter=limit=0.891251:level=disabled:latency=1",
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def voice_chain(level: str, denoise_strength: "float | None") -> str:
|
|
55
|
+
"""The --voice chain, with --denoise in it when asked (A136). `light` has no denoise of its
|
|
56
|
+
own, so --voice light --denoise used to drop the denoise without a word: it goes after the
|
|
57
|
+
highpass, where the other levels have theirs. `medium` and `strong` already denoise at
|
|
58
|
+
nf=-25; with --denoise that stage runs at --denoise-strength (25 by default, so the chain
|
|
59
|
+
--voice medium --denoise has always produced is unchanged)."""
|
|
60
|
+
chain = VOICE_LEVELS[level]
|
|
61
|
+
if denoise_strength is None:
|
|
62
|
+
return chain
|
|
63
|
+
stage = f"afftdn=nf=-{denoise_strength:g}:tn=1"
|
|
64
|
+
if VOICE_DENOISE in chain:
|
|
65
|
+
return chain.replace(VOICE_DENOISE, stage)
|
|
66
|
+
return chain.replace("highpass=f=80,", f"highpass=f=80,{stage},", 1)
|
|
67
|
+
|
|
68
|
+
|
|
51
69
|
# The sidechain threshold the music bed has used since 1.4 is the linear 0.05 that ffmpeg's
|
|
52
70
|
# sidechaincompress takes; -26.0206 dBFS is that same number in the unit the flag speaks, so the
|
|
53
71
|
# default command line is unchanged to the byte while the value is now sayable.
|
|
@@ -349,6 +367,24 @@ def main() -> int:
|
|
|
349
367
|
die(f"--duck is off but {duck_params[0]} was given; add --duck")
|
|
350
368
|
if args.duck and not args.music:
|
|
351
369
|
die("--duck ducks the music bed under the main track, but no --music was given; add --music FILE")
|
|
370
|
+
# A141: the same rule for the other parameters of a switch that is off. Each was accepted and
|
|
371
|
+
# did nothing: --music-fade-out 2 without --music faded nothing, --denoise-strength 40 without
|
|
372
|
+
# --denoise (also under --voice, whose own denoise stayed at 25) removed nothing more
|
|
373
|
+
bed_params = [flag for flag, name in (("--music-volume", "music_volume"), ("--music-fade-out", "music_fade_out"))
|
|
374
|
+
if getattr(args, name) != ap.get_default(name)] + (["--music-loop"] if args.music_loop else [])
|
|
375
|
+
if not args.music and bed_params:
|
|
376
|
+
die(f"--music is off but {bed_params[0]} was given; add --music FILE")
|
|
377
|
+
if not args.effects and args.effects_volume != ap.get_default("effects_volume"):
|
|
378
|
+
die("--effects is off but --effects-volume was given; add --effects FILE")
|
|
379
|
+
if not args.denoise and args.denoise_strength != ap.get_default("denoise_strength"):
|
|
380
|
+
die("--denoise is off but --denoise-strength was given; add --denoise"
|
|
381
|
+
+ (f" (--voice {args.voice} then denoises at that strength)" if args.voice else ""))
|
|
382
|
+
# afftdn's nf accepts -80..-20 only; 10..19.99 used to pass here and then fail inside ffmpeg.
|
|
383
|
+
if args.denoise and not 20 <= args.denoise_strength <= 60:
|
|
384
|
+
die(f"--denoise-strength must be 20..60 (afftdn noise floor in dB below full scale), got {args.denoise_strength:g}")
|
|
385
|
+
for flag, value in (("--fade-in", args.fade_in), ("--fade-out", args.fade_out), ("--music-fade-out", args.music_fade_out)):
|
|
386
|
+
if value < 0: # a negative length reached afade, which refused it (kind ffmpeg)
|
|
387
|
+
die(f"{flag} must be 0 or more seconds, got {value:g}")
|
|
352
388
|
|
|
353
389
|
for flag, value, lo, hi in (("--duck-amount", args.duck_amount, 0.0, 60.0),
|
|
354
390
|
("--duck-threshold", args.duck_threshold, -60.0, 0.0),
|
|
@@ -451,11 +487,8 @@ def main() -> int:
|
|
|
451
487
|
# 5.1 while the run said verified.
|
|
452
488
|
fx.append(DOWNMIX_PAN)
|
|
453
489
|
if args.voice:
|
|
454
|
-
fx.append(
|
|
490
|
+
fx.append(voice_chain(args.voice, args.denoise_strength if args.denoise else None))
|
|
455
491
|
elif args.denoise:
|
|
456
|
-
# afftdn's nf accepts -80..-20 only; 10..19.99 used to pass here and then fail inside ffmpeg.
|
|
457
|
-
if not 20 <= args.denoise_strength <= 60:
|
|
458
|
-
die(f"--denoise-strength must be 20..60 (afftdn noise floor in dB below full scale), got {args.denoise_strength:g}")
|
|
459
492
|
fx.append(f"afftdn=nf=-{args.denoise_strength:g}:tn=1")
|
|
460
493
|
if args.gain:
|
|
461
494
|
fx.append(f"volume={args.gain:g}dB")
|
package/scripts/batch.py
CHANGED
|
@@ -23,6 +23,7 @@ Examples:
|
|
|
23
23
|
python3 batch.py ~/Footage --recipe batch.json --watch 30 # poll the folder every 30 s
|
|
24
24
|
"""
|
|
25
25
|
import argparse
|
|
26
|
+
import difflib
|
|
26
27
|
import hashlib
|
|
27
28
|
import json
|
|
28
29
|
import os
|
|
@@ -237,12 +238,21 @@ def intermediate_ext(src: Path) -> str:
|
|
|
237
238
|
|
|
238
239
|
# the recipe keys the run reads as a string: a path, a glob, a file-name part
|
|
239
240
|
RECIPE_STRINGS = ("glob", "project", "output_dir", "suffix", "ext")
|
|
241
|
+
# every key a recipe may carry; anything else is refused, as render.py refuses a project's
|
|
242
|
+
RECIPE_KEYS = RECIPE_STRINGS + ("clip_key", "steps")
|
|
240
243
|
|
|
241
244
|
|
|
242
245
|
def check_recipe_types(recipe: Dict[str, Any]) -> None:
|
|
243
246
|
"""Refuse a recipe value of the wrong JSON type before anything runs (A18): a number where a
|
|
244
247
|
path or glob goes, an absolute glob (pathlib refuses it), a step that is not a list. Each was
|
|
245
248
|
a traceback, or a failure of every item naming the step's first character."""
|
|
249
|
+
for key in recipe:
|
|
250
|
+
# A104: a misspelled key ("ouput_dir", "sufix", "globs") was not read, so the batch ran
|
|
251
|
+
# with that key's default: other files, another folder, another name
|
|
252
|
+
if key not in RECIPE_KEYS:
|
|
253
|
+
near = difflib.get_close_matches(str(key), RECIPE_KEYS, n=1, cutoff=0.6)
|
|
254
|
+
die(f"recipe: unknown key {key!r}" + (f" (did you mean {near[0]!r}?)" if near
|
|
255
|
+
else f" (valid keys: {', '.join(sorted(RECIPE_KEYS))})"), kind="input")
|
|
246
256
|
for key in RECIPE_STRINGS:
|
|
247
257
|
value = recipe.get(key)
|
|
248
258
|
if value is not None and not isinstance(value, str):
|
package/scripts/caption.py
CHANGED
|
@@ -1151,6 +1151,14 @@ def main() -> int:
|
|
|
1151
1151
|
f"clear of the app's own UI)")
|
|
1152
1152
|
if args.margin is None:
|
|
1153
1153
|
args.margin = 30
|
|
1154
|
+
if args.ass and (args.animate not in (None, "none") or args.karaoke):
|
|
1155
|
+
# A74: both are drawn by the ASS this tool writes from cues; a caller's own --ass is burned
|
|
1156
|
+
# as it stands, so the flag was accepted and nothing moved (a brand's caption.animate or
|
|
1157
|
+
# caption.karaoke is a default for generated captions and is not a request here)
|
|
1158
|
+
asked = "--animate " + args.animate if args.animate not in (None, "none") else "--karaoke"
|
|
1159
|
+
die(f"{asked} is drawn by the ASS this tool generates from --srt, --text or --transcribe; --ass burns "
|
|
1160
|
+
"the file as it is, with the animation and karaoke its own tags give it",
|
|
1161
|
+
hint=f"drop {asked.split()[0]}, or pass the cues as --srt/--text and let this tool write the ASS")
|
|
1154
1162
|
# a brand's caption.animate is a burn-in default; over --mode mux (soft subtitles) it used
|
|
1155
1163
|
# to be applied anyway and then refused as "animation is burn only" -- ignore it there
|
|
1156
1164
|
args.animate = args.animate or (bcap.get("animate", "none") if args.brand and args.mode != "mux" else "none")
|
|
@@ -1183,6 +1191,10 @@ def main() -> int:
|
|
|
1183
1191
|
"above the size it is a floor for", kind="input")
|
|
1184
1192
|
if args.min_duration < 0:
|
|
1185
1193
|
die("--min-duration cannot be negative")
|
|
1194
|
+
if args.gap < 0:
|
|
1195
|
+
# A72: each auto-timed cue started before the one above it ended, and the first start
|
|
1196
|
+
# below 0 was clamped: the cues overlapped on screen
|
|
1197
|
+
die(f"--gap must be 0 or more seconds, got {args.gap:g}")
|
|
1186
1198
|
|
|
1187
1199
|
meta = None
|
|
1188
1200
|
if args.input:
|
|
@@ -1340,6 +1352,14 @@ def main() -> int:
|
|
|
1340
1352
|
if missing_srt and not STATE.dry_run:
|
|
1341
1353
|
die(f"{len(missing_srt)} SRT file(s) not found: " + ", ".join(m["path"] for m in missing_srt),
|
|
1342
1354
|
kind="input", problems=missing_srt)
|
|
1355
|
+
if args.offset and args.mode == "mux":
|
|
1356
|
+
# --offset shifts every --srt track (A61); one it would leave with no cue is refused here,
|
|
1357
|
+
# before the first file of this run is written (the first track's empty copy used to reach
|
|
1358
|
+
# ffmpeg, which failed on it as kind ffmpeg and left the copy behind)
|
|
1359
|
+
for p, _l in srt_tracks:
|
|
1360
|
+
if os.path.exists(p) and not any(e + args.offset > 0 for _s, e, _t in parse_srt(p)):
|
|
1361
|
+
die(f"--offset {args.offset:+g} moves every cue of {p} before 0: the track would be empty",
|
|
1362
|
+
kind="input")
|
|
1343
1363
|
srt_path = args.srt
|
|
1344
1364
|
if args.transcribe and not args.input:
|
|
1345
1365
|
die("--transcribe needs the input video")
|
|
@@ -1419,6 +1439,11 @@ def main() -> int:
|
|
|
1419
1439
|
if args.text:
|
|
1420
1440
|
cues = parse_text_cues(args.text, args.auto_seconds, args.gap, fps_for_tc)
|
|
1421
1441
|
cues, _ = lay_out(cues)
|
|
1442
|
+
if not cues:
|
|
1443
|
+
# --offset dropped every cue (one that ends at or before 0 goes): an empty SRT was
|
|
1444
|
+
# written, and with a frame rate known the report below indexed the empty list
|
|
1445
|
+
die(f"--offset {args.offset:+g} moves every cue of {args.text} before 0: no cue would be left",
|
|
1446
|
+
kind="input")
|
|
1422
1447
|
if args.write_srt:
|
|
1423
1448
|
srt_path = args.write_srt
|
|
1424
1449
|
elif args.input:
|
|
@@ -1540,7 +1565,27 @@ def main() -> int:
|
|
|
1540
1565
|
die(f"SRT file not found: {path}")
|
|
1541
1566
|
for n in range(1, len(added)):
|
|
1542
1567
|
path, lang = added[n]
|
|
1543
|
-
|
|
1568
|
+
pending = not os.path.exists(path) # a dry run's pending track: an earlier step writes it
|
|
1569
|
+
if pending and not args.offset:
|
|
1570
|
+
continue
|
|
1571
|
+
if args.offset:
|
|
1572
|
+
# A61: --offset moved the first --srt only, so the other languages stayed where
|
|
1573
|
+
# they were against the sound. Every added track is shifted by the same rule
|
|
1574
|
+
# (a cue that ends at or before 0 is dropped, a start below 0 is clamped). A
|
|
1575
|
+
# pending track's copy is planned under the same name, so the planned command
|
|
1576
|
+
# reads the file the real run reads; only its cues wait for the file
|
|
1577
|
+
copy = f"{os.path.splitext(output)[0]}_track{n}.srt"
|
|
1578
|
+
claim([(copy, f"shifted SRT {n}")])
|
|
1579
|
+
if not pending and not STATE.dry_run:
|
|
1580
|
+
moved = [(max(0.0, s + args.offset), e + args.offset, t) for s, e, t in parse_srt(path)
|
|
1581
|
+
if e + args.offset > 0] # never empty: refused before the first write, above
|
|
1582
|
+
write_srt(moved, copy)
|
|
1583
|
+
info(f"wrote {copy} ({len(moved)} cues of {os.path.basename(path)} shifted by {args.offset:+g} s)")
|
|
1584
|
+
side_notes.append(f"subtitle track {n} is {copy}, a copy of {os.path.basename(path)} shifted by "
|
|
1585
|
+
f"--offset {args.offset:+g} s like the first track"
|
|
1586
|
+
+ ("; re-run this command without --dry-run to produce it" if STATE.dry_run else ""))
|
|
1587
|
+
added[n] = (copy, lang)
|
|
1588
|
+
elif not srt_timings_shared_grammar(path):
|
|
1544
1589
|
# the same rule as the first track (#414): ffmpeg is given what parse_srt() reads
|
|
1545
1590
|
copy = f"{os.path.splitext(output)[0]}_track{n}.srt"
|
|
1546
1591
|
claim([(copy, f"normalised SRT {n}")])
|
|
@@ -1550,6 +1595,11 @@ def main() -> int:
|
|
|
1550
1595
|
side_notes.append(f"subtitle track {n} is {copy}, a copy of {os.path.basename(path)} with its timings "
|
|
1551
1596
|
"rewritten as hh:mm:ss,mmm, which ffmpeg reads as this tool does")
|
|
1552
1597
|
added[n] = (copy, lang)
|
|
1598
|
+
if len(added) > 1 and any(caption_stats.get(k) for k in ("wrapped", "split", "extended")):
|
|
1599
|
+
# --max-lines and --min-duration lay out the first --srt, the track the size fit is
|
|
1600
|
+
# made for; the others keep their own line breaks and durations, and the result says so
|
|
1601
|
+
side_notes.append(f"--max-lines/--min-duration laid out the first --srt only: the other {len(added) - 1} "
|
|
1602
|
+
"added track(s) keep the line breaks and cue lengths of their own files")
|
|
1553
1603
|
titles = list(args.track_title or [])
|
|
1554
1604
|
mp4_family = Path(output).suffix.lower() in (".mp4", ".m4v", ".mov")
|
|
1555
1605
|
dropped_titles: List[str] = []
|
package/scripts/color.py
CHANGED
|
@@ -464,6 +464,12 @@ def main() -> int:
|
|
|
464
464
|
add_common(ap)
|
|
465
465
|
args = ap.parse_args()
|
|
466
466
|
apply_common(args)
|
|
467
|
+
# A165: a peak of 0 or below and a negative desaturation reached zscale/tonemap, which refused
|
|
468
|
+
# them as an ffmpeg failure (kind ffmpeg, as if the build were at fault)
|
|
469
|
+
if args.peak <= 0:
|
|
470
|
+
die(f"--peak must be more than 0 nits, got {args.peak:g}")
|
|
471
|
+
if args.desat < 0:
|
|
472
|
+
die(f"--desat must be 0 or more, got {args.desat:g}")
|
|
467
473
|
|
|
468
474
|
meta = probe(args.input)
|
|
469
475
|
if not meta.get("video"):
|
package/scripts/cropdetect.py
CHANGED
|
@@ -156,7 +156,12 @@ def main() -> int:
|
|
|
156
156
|
duration = meta.get("duration") or 0.0
|
|
157
157
|
|
|
158
158
|
failed: List[Dict] = []
|
|
159
|
-
|
|
159
|
+
# A164: cropdetect reads a limit below 1 as a fraction of the top code and 1 or more as the
|
|
160
|
+
# code itself, so --limit 1, the top of the documented 0..1 range, was read as code 1 (nearly
|
|
161
|
+
# nothing is black) where 0.999 reads as nearly everything. The top of the range is written
|
|
162
|
+
# as the top code of the source's bit depth (255 for 8 bits, 1023 for 10).
|
|
163
|
+
limit = args.limit if args.limit < 1 else float((1 << int(meta["video"].get("bit_depth") or 8)) - 1)
|
|
164
|
+
rects = detect(args.input, args.seconds, args.samples, limit, args.round_to, duration, failed,
|
|
160
165
|
square_pixels(meta["video"]))
|
|
161
166
|
# windows_failed: a window ffmpeg could not decode was skipped without a word, so a
|
|
162
167
|
# crop measured on one window of five read as well-founded as one measured on all five
|
package/scripts/cut.py
CHANGED
|
@@ -1518,6 +1518,11 @@ def main() -> int:
|
|
|
1518
1518
|
die(f"--remove needs the input's duration, which {args.input} does not report"
|
|
1519
1519
|
+ (" yet (it is an earlier step's output)" if dry_run_input_pending(args.input) else ""))
|
|
1520
1520
|
args.segments = ",".join(f"{s:.6f}-{e:.6f}" for s, e in keep_ranges_after_removing(parse_segments(args.remove, fps, clamp=True), total, fps))
|
|
1521
|
+
elif args.segments and (args.end or args.duration or args.start is not None):
|
|
1522
|
+
# A50: the single-range flags were accepted beside --segments and not read, where --remove
|
|
1523
|
+
# refuses the same combination
|
|
1524
|
+
die("--segments names every range to keep: it does not combine with --start, --end or --duration "
|
|
1525
|
+
"(give the one range as --segments A-B, or drop --segments)")
|
|
1521
1526
|
if args.segments:
|
|
1522
1527
|
segments = parse_segments(args.segments, fps)
|
|
1523
1528
|
else:
|
package/scripts/fit.py
CHANGED
|
@@ -109,6 +109,11 @@ def main() -> int:
|
|
|
109
109
|
|
|
110
110
|
if args.fps is not None and args.fps <= 0:
|
|
111
111
|
die(f"--fps must be positive, got {args.fps:g}")
|
|
112
|
+
for flag, value in (("--width", args.width), ("--height", args.height)):
|
|
113
|
+
# A165: 0 read as "not given" (the side was sized from the other one, or the run was
|
|
114
|
+
# "nothing to do") and a negative size reached ffmpeg's pad as kind ffmpeg
|
|
115
|
+
if value is not None and value < 1:
|
|
116
|
+
die(f"{flag} must be a positive number of pixels, got {value}")
|
|
112
117
|
if not args.duration and not args.aspect and not args.width and not args.height and not args.fps and not args.rotate and not args.flip:
|
|
113
118
|
die("nothing to do: give --duration, --aspect, --width/--height, --rotate/--flip and/or --fps")
|
|
114
119
|
if not 0.0 <= args.crop_x <= 1.0:
|
package/scripts/graphics.py
CHANGED
|
@@ -137,6 +137,12 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
137
137
|
add_common(ap)
|
|
138
138
|
args = ap.parse_args()
|
|
139
139
|
apply_common(args)
|
|
140
|
+
# A176: --from -1 divided by zero (a traceback, no JSON); --from -3 built an empty filter
|
|
141
|
+
# graph and --scale 0 or below a 0-px or negative box, both refused by ffmpeg (kind ffmpeg)
|
|
142
|
+
if args.count_from < 0:
|
|
143
|
+
die(f"--from must be 0 or more (the number the countdown starts at), got {args.count_from}")
|
|
144
|
+
if args.scale <= 0:
|
|
145
|
+
die(f"--scale must be more than 0 (a size multiplier; 1 is the template's own size), got {args.scale:g}")
|
|
140
146
|
# 2.2.6: whitespace-only text is no text -- stripped before the "needs --title" checks, which
|
|
141
147
|
# used to pass on " " and burn an empty bar
|
|
142
148
|
for _attr in ("name", "title", "subtitle", "text", "top", "bottom"):
|
package/scripts/look.py
CHANGED
|
@@ -220,6 +220,9 @@ def main() -> int:
|
|
|
220
220
|
meta = probe(args.input)
|
|
221
221
|
if not meta.get("video"):
|
|
222
222
|
die("input has no video stream")
|
|
223
|
+
if args.width < 2:
|
|
224
|
+
# a width of 0 or below wrote a sheet two pixels a tile, or a still at the source's size
|
|
225
|
+
die(f"--width must be at least 2 px, got {args.width}")
|
|
223
226
|
dur = meta.get("duration") or 0.0
|
|
224
227
|
stem = Path(args.input).stem
|
|
225
228
|
outdir = str(Path(args.output).parent) if args.output else str(Path(args.input).parent)
|
|
@@ -363,6 +366,10 @@ def main() -> int:
|
|
|
363
366
|
cols, rows = (int(x) for x in args.tiles.lower().split("x"))
|
|
364
367
|
except ValueError:
|
|
365
368
|
die("--tiles must look like 4x3")
|
|
369
|
+
if cols < 1 or rows < 1:
|
|
370
|
+
# A21: 0 columns or rows divided the length by zero (a traceback, no JSON) and a
|
|
371
|
+
# negative count reached ffmpeg's tile filter (kind ffmpeg)
|
|
372
|
+
die(f"--tiles must be COLSxROWS with both at least 1 (like 4x3), got {args.tiles}")
|
|
366
373
|
n = cols * rows
|
|
367
374
|
dur = input_length(meta) # the sheet is spread over the length: none stated is refused with the way out (#491)
|
|
368
375
|
if not dur:
|
package/scripts/loudness.py
CHANGED
|
@@ -288,6 +288,14 @@ def main() -> int:
|
|
|
288
288
|
# parsed before anything is measured, and an explicitly empty --versions ('' or ',') is refused
|
|
289
289
|
# by parse_versions rather than read as "not given", which wrote one default file instead
|
|
290
290
|
plan = parse_versions(args.versions) if args.versions is not None else None
|
|
291
|
+
# A124/A185: loudnorm's own ranges, checked here as --versions checks its items. Outside them
|
|
292
|
+
# the measurement pass failed inside ffmpeg (kind ffmpeg, with a page of stderr)
|
|
293
|
+
for flag, value, lo, hi, unit in (("-I/--lufs", args.lufs, -70.0, -5.0, "LUFS"), ("--tp", args.tp, -9.0, 0.0, "dBTP"),
|
|
294
|
+
("--lra", args.lra, 1.0, 50.0, "LU")):
|
|
295
|
+
if not lo <= value <= hi:
|
|
296
|
+
die(f"{flag} must be {lo:g}..{hi:g} {unit} (loudnorm's range), got {value:g}")
|
|
297
|
+
if args.sample_rate is not None and args.sample_rate <= 0:
|
|
298
|
+
die(f"--sample-rate must be a positive number of Hz, got {args.sample_rate}")
|
|
291
299
|
if plan is not None and args.output:
|
|
292
300
|
die("--versions writes one file per target, named <name>_loudnorm_<target>.<ext>; it does not take -o")
|
|
293
301
|
|
package/scripts/metadata.py
CHANGED
|
@@ -215,6 +215,9 @@ def main() -> int:
|
|
|
215
215
|
die(f"--min-chapter must be > 0, got {args.min_chapter:g}")
|
|
216
216
|
if args.max_chapters < 0:
|
|
217
217
|
die(f"--max-chapters must be >= 0 (0 = no cap), got {args.max_chapters}")
|
|
218
|
+
if args.silence_min < 0:
|
|
219
|
+
# A185: a negative length reached silencedetect, which refused it (kind ffmpeg)
|
|
220
|
+
die(f"--silence-min must be 0 or more seconds, got {args.silence_min:g}")
|
|
218
221
|
tags = {k: getattr(args, k) for k in TAG_KEYS if getattr(args, k) is not None}
|
|
219
222
|
if not args.chapters and not args.clear_chapters and not args.auto_chapters and not tags:
|
|
220
223
|
die("nothing to write: give --chapters FILE, --auto-chapters, --clear-chapters and/or --title/--artist/...")
|
package/scripts/overlay.py
CHANGED
|
@@ -298,7 +298,7 @@ def main() -> int:
|
|
|
298
298
|
args.image = brand["logo"]
|
|
299
299
|
if args.position == ap.get_default("position"):
|
|
300
300
|
args.position = brand.get("logo_position", "top-right")
|
|
301
|
-
if
|
|
301
|
+
if args.scale is None and args.scale_percent is None: # a given 0 is refused below, not replaced
|
|
302
302
|
args.scale = int(brand.get("logo_scale", 160))
|
|
303
303
|
if args.opacity == 1.0:
|
|
304
304
|
args.opacity = float(brand.get("logo_opacity", 1.0))
|
|
@@ -402,6 +402,18 @@ def main() -> int:
|
|
|
402
402
|
hint="give a --start inside the clip")
|
|
403
403
|
if not 0 <= args.opacity <= 1:
|
|
404
404
|
die("--opacity must be within 0..1")
|
|
405
|
+
# A190: a size of 0 or below drew nothing (--font-size -10 completed, verified: true, with no
|
|
406
|
+
# text in the picture; --scale -1 is ffmpeg's "keep the size") and a negative fade was no fade
|
|
407
|
+
if args.font_size < 1:
|
|
408
|
+
die(f"--font-size must be at least 1 px, got {args.font_size}")
|
|
409
|
+
if args.scale is not None and args.scale < 1:
|
|
410
|
+
die(f"--scale must be a positive width in px, got {args.scale}")
|
|
411
|
+
if args.scale_percent is not None and args.scale_percent <= 0:
|
|
412
|
+
die(f"--scale-percent must be more than 0, got {args.scale_percent:g}")
|
|
413
|
+
if args.fade < 0:
|
|
414
|
+
die(f"--fade must be 0 or more seconds, got {args.fade:g}")
|
|
415
|
+
if args.border < 0:
|
|
416
|
+
die(f"--border must be 0 or more px, got {args.border}")
|
|
405
417
|
if args.chromakey and not args.video:
|
|
406
418
|
die("--chromakey needs --video")
|
|
407
419
|
if args.chromakey:
|
|
@@ -417,6 +429,11 @@ def main() -> int:
|
|
|
417
429
|
die("--place needs --video")
|
|
418
430
|
if args.place and (args.start or args.end or args.fade):
|
|
419
431
|
die("--place sets when each piece shows: drop --start / --end / --fade")
|
|
432
|
+
if args.video and args.fade:
|
|
433
|
+
# A171: the fade is built for an image, logo or text layer; a video layer was composited
|
|
434
|
+
# with a hard edge and the result said nothing
|
|
435
|
+
die(f"--fade {args.fade:g} fades an image, logo or text layer; a video layer (--video) is shown "
|
|
436
|
+
"from --start to --end without a fade", hint="drop --fade for the video layer")
|
|
420
437
|
|
|
421
438
|
output = args.output or default_output(args.input, "overlay", default_ext(alpha_codec) if alpha_codec else None)
|
|
422
439
|
enable = enable_expr(start, end)
|
package/scripts/render.py
CHANGED
|
@@ -275,6 +275,15 @@ def template_project(name: str, args) -> Dict[str, Any]:
|
|
|
275
275
|
("--brand", args.brand), ("--chapters", args.chapters), ("--image", args.image)):
|
|
276
276
|
if path and not os.path.isfile(path):
|
|
277
277
|
die(f"{flag}: file not found: {path}")
|
|
278
|
+
audiogram = tpl.get("audiogram")
|
|
279
|
+
if isinstance(audiogram, dict) and "$IMAGE" in audiogram.values() and not args.image and not args.write_project:
|
|
280
|
+
# A89: without --image the whole audiogram block was dropped as an unfilled placeholder,
|
|
281
|
+
# so the dry run planned a render with no picture and the real one failed at the export
|
|
282
|
+
# ("input has no video stream"). --write-project still writes the project to edit.
|
|
283
|
+
die(f"--template {name} draws the sound over a still picture and needs --image FILE "
|
|
284
|
+
"(a local file: nothing is fetched and no cover art is invented)",
|
|
285
|
+
hint='for a plain colour behind the waveform, write the project with --write-project and give it '
|
|
286
|
+
'"audiogram": {"background": COLOUR}')
|
|
278
287
|
proj, _ = fill_template(tpl, values)
|
|
279
288
|
if args.fit:
|
|
280
289
|
proj.setdefault("frame", {})["fit"] = args.fit
|
|
@@ -364,6 +373,8 @@ def render_pack(names: List[str], args) -> int:
|
|
|
364
373
|
("--intermediate-preset", args.intermediate_preset)):
|
|
365
374
|
if value:
|
|
366
375
|
argv += option_args(flag, value)
|
|
376
|
+
if STATE.audit_log:
|
|
377
|
+
argv += option_args("--audit-log", STATE.audit_log) # every destination's records, in the one file asked for
|
|
367
378
|
info(f"→ pack: {name}")
|
|
368
379
|
proc = run_tool(argv + child_args() + ["--json"])
|
|
369
380
|
try:
|
|
@@ -445,7 +456,7 @@ def check_keys(obj: Any, schema: str, label: str) -> None:
|
|
|
445
456
|
else f" (valid keys: {', '.join(sorted(valid))})"))
|
|
446
457
|
|
|
447
458
|
|
|
448
|
-
def check_overlay(ov: Any, label: str) -> None:
|
|
459
|
+
def check_overlay(ov: Any, label: str, rendered: bool = True) -> None:
|
|
449
460
|
"""#326: a video layer is one kind of overlay, as overlay.py's --image/--text/--logo/--video
|
|
450
461
|
are one mutually exclusive choice, and the chromakey* keys only act on it. A logo, image and
|
|
451
462
|
text in one entry still draw the first of them, as before; only a combination with the new
|
|
@@ -461,6 +472,12 @@ def check_overlay(ov: Any, label: str) -> None:
|
|
|
461
472
|
keyed = [k for k in ("chromakey", "chromakey_similarity", "chromakey_blend") if ov.get(k) is not None]
|
|
462
473
|
if keyed:
|
|
463
474
|
die(f"{label}: {keyed[0]} keys a colour out of a video layer and needs \"video\" (overlay.py --chromakey needs --video)")
|
|
475
|
+
if rendered and ov.get("video") and ov.get("fade"):
|
|
476
|
+
# A171: overlay.py --video has no fade; the layer was composited with a hard edge. Only a
|
|
477
|
+
# run that composites the layer refuses it: an export or a preview that stops before the
|
|
478
|
+
# overlays never read the entry, and completed
|
|
479
|
+
die(f"{label}: fade fades an image, logo or text layer; a video layer is shown from start to end "
|
|
480
|
+
"without one (drop \"fade\" from this entry)", kind="input")
|
|
464
481
|
if ov.get("chromakey") is None:
|
|
465
482
|
tuned = [k for k in ("chromakey_similarity", "chromakey_blend") if ov.get(k) is not None]
|
|
466
483
|
if tuned:
|
|
@@ -598,7 +615,8 @@ def validate_project(proj: Dict[str, Any], timeline: bool = False, stop_after: O
|
|
|
598
615
|
for i, item in enumerate(items if isinstance(items, list) else []):
|
|
599
616
|
check_keys(item, f"{name}[]", f"{name}[{i}]")
|
|
600
617
|
if name == "overlays":
|
|
601
|
-
check_overlay(item, f"overlays[{i}]"
|
|
618
|
+
check_overlay(item, f"overlays[{i}]",
|
|
619
|
+
rendered=not timeline and _reaches(READ_AT["overlays"], stop_after))
|
|
602
620
|
if name != "clips":
|
|
603
621
|
continue
|
|
604
622
|
# every clip stage starts from rel(c["src"]): without one it was a KeyError traceback
|
|
@@ -616,7 +634,29 @@ def validate_project(proj: Dict[str, Any], timeline: bool = False, stop_after: O
|
|
|
616
634
|
# the render reads a clip's snap only on a clip it cuts (in/out), the export never
|
|
617
635
|
if not timeline and (item.get("in") is not None or item.get("out") is not None):
|
|
618
636
|
require_object(item["snap"], f"clips[{i}].snap")
|
|
637
|
+
# A93: only the project's own snap.to was checked, so a clip's "beat" (or an
|
|
638
|
+
# unsupported grid) was read as "not beats" and the cut rendered unsnapped
|
|
639
|
+
check_snap_to(item["snap"], f"clips[{i}].snap")
|
|
619
640
|
check_keys(item["snap"], "snap", f"clips[{i}].snap")
|
|
641
|
+
if not timeline and "audiogram" in read and proj.get("audiogram") and isinstance(clips, list) and len(clips) > 1:
|
|
642
|
+
# A92: the audiogram stage draws the first clip's sound and the render carries on from
|
|
643
|
+
# that one file: every other clip was cut and then left out of the output without a word
|
|
644
|
+
die(f"audiogram: an audiogram is drawn from one audio source, and this project has {len(clips)} clips "
|
|
645
|
+
"(only the first would be in the output); join them first (join.py) and name the joined file as the one clip")
|
|
646
|
+
chapters = proj.get("chapters")
|
|
647
|
+
if chapters and not isinstance(chapters, (str, list)):
|
|
648
|
+
# A94: a number or an object was handed to metadata.py as a file name by the last stage,
|
|
649
|
+
# after the whole render ("chapters file not found: .../5"). false and 0 are "no chapters",
|
|
650
|
+
# as the stage's own `if ch:` reads them, and stay that
|
|
651
|
+
die(f'chapters: must be a chapters file path or a list of {{"at": TIME, "title": STR}}, got '
|
|
652
|
+
f"{type(chapters).__name__} {chapters!r:.60}")
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def check_snap_to(snap: Any, label: str) -> None:
|
|
656
|
+
"""Refuse a snap grid other than beats, for the project's `snap` and for a clip's own."""
|
|
657
|
+
if isinstance(snap, dict) and str(snap.get("to") or "") not in ("", "none", "beats"):
|
|
658
|
+
die(f'{label}.to: only "beats" (or "none") is a beat grid this skill can measure, got '
|
|
659
|
+
f'{snap.get("to")!r}', kind="input")
|
|
620
660
|
|
|
621
661
|
|
|
622
662
|
_LAST_DOC: Dict[str, Any] = {} # the JSON document the most recent sh() child printed
|
|
@@ -1298,6 +1338,13 @@ def main() -> int:
|
|
|
1298
1338
|
if len(names) > 1:
|
|
1299
1339
|
if args.write_project:
|
|
1300
1340
|
die("--write-project writes one project; name a single template")
|
|
1341
|
+
# A85: a pack runs one full render per destination and handed none of these to them,
|
|
1342
|
+
# so --stop-after did not stop, --cache was not read and --work/--keep kept nothing
|
|
1343
|
+
for flag, value in (("--stop-after", args.stop_after), ("--cache", args.cache), ("--from", args.from_stage),
|
|
1344
|
+
("--work", args.work), ("--keep", args.keep), ("--export-timeline", args.export_timeline)):
|
|
1345
|
+
if value:
|
|
1346
|
+
die(f"{flag} applies to one render, and --template {args.template} is a pack of {len(names)} "
|
|
1347
|
+
f"({', '.join(names)}), each a full render of its own: name a single template to use {flag}")
|
|
1301
1348
|
return render_pack(names, args)
|
|
1302
1349
|
proj: Dict[str, Any] = template_project(names[0], args)
|
|
1303
1350
|
base = Path.cwd()
|
|
@@ -1444,9 +1491,7 @@ def main() -> int:
|
|
|
1444
1491
|
# so a project that does not name "snap" builds the command line 1.16 built.
|
|
1445
1492
|
snap_spec = proj.get("snap") or {}
|
|
1446
1493
|
snap_reports: List[Dict[str, Any]] = []
|
|
1447
|
-
|
|
1448
|
-
die(f'snap.to: only "beats" (or "none") is a beat grid this skill can measure, got '
|
|
1449
|
-
f'{snap_spec.get("to")!r}', kind="input")
|
|
1494
|
+
check_snap_to(snap_spec, "snap")
|
|
1450
1495
|
|
|
1451
1496
|
# ---- clips
|
|
1452
1497
|
# 2.2.1: every clip source is checked before the first one is cut, and every problem is named
|
package/scripts/scenes.py
CHANGED
|
@@ -146,6 +146,20 @@ def main() -> int:
|
|
|
146
146
|
add_common(ap)
|
|
147
147
|
args = ap.parse_args()
|
|
148
148
|
apply_common(args)
|
|
149
|
+
if args.highlights < 0:
|
|
150
|
+
die(f"--highlights must be 0 or more (the number of ranges to propose), got {args.highlights}")
|
|
151
|
+
if not args.highlights:
|
|
152
|
+
# A145: these shape or write the --highlights picks; without it they were accepted and
|
|
153
|
+
# nothing was written (--edl FILE completed with no file)
|
|
154
|
+
for flag, given in (("--edl", args.edl is not None), ("--target", args.target is not None),
|
|
155
|
+
("--max-scene", args.max_scene != ap.get_default("max_scene"))):
|
|
156
|
+
if given:
|
|
157
|
+
die(f"{flag} applies to the ranges --highlights proposes, and --highlights was not given; "
|
|
158
|
+
"add --highlights N")
|
|
159
|
+
if args.target is not None and args.target <= 0:
|
|
160
|
+
die(f"--target must be more than 0 seconds, got {args.target:g}")
|
|
161
|
+
if args.max_scene <= 0:
|
|
162
|
+
die(f"--max-scene must be more than 0 seconds, got {args.max_scene:g}")
|
|
149
163
|
if args.highlights: # the EDL is written only with --highlights
|
|
150
164
|
guard_sidecar(args.edl, args.input, siblings=(args.sheet,))
|
|
151
165
|
|
package/scripts/sequence.py
CHANGED
|
@@ -96,6 +96,10 @@ def main() -> int:
|
|
|
96
96
|
|
|
97
97
|
if args.fps <= 0:
|
|
98
98
|
die("--fps must be > 0")
|
|
99
|
+
for flag, value in (("--width", args.width), ("--height", args.height)):
|
|
100
|
+
# A109: 0 read as "not given" and a negative size reached ffmpeg (kind ffmpeg)
|
|
101
|
+
if value is not None and value < 1:
|
|
102
|
+
die(f"{flag} must be a positive number of pixels, got {value}")
|
|
99
103
|
try:
|
|
100
104
|
rate = exact_rate(args.fps) # 29.97 is 30000/1001, as every other tool reads it
|
|
101
105
|
except TimelineError as exc:
|
package/scripts/silence.py
CHANGED
|
@@ -627,6 +627,9 @@ def main() -> int:
|
|
|
627
627
|
if not math.isfinite(args.margin) or args.margin < 0:
|
|
628
628
|
# nan made inside_margin() drop every silence and re-encode the whole file unchanged
|
|
629
629
|
die(f"--margin must be a finite number of seconds, 0 or more (got {args.margin:g})", kind="input")
|
|
630
|
+
if args.min_silence < 0:
|
|
631
|
+
# A52: a negative length reached silencedetect, which refused it (kind ffmpeg)
|
|
632
|
+
die(f"--min-silence must be 0 or more seconds, got {args.min_silence:g}", kind="input")
|
|
630
633
|
for flag, value in (("--margin-lead", args.margin_lead), ("--margin-tail", args.margin_tail)):
|
|
631
634
|
if value is not None and (not math.isfinite(value) or value < 0):
|
|
632
635
|
die(f"{flag} must be a finite number of seconds, 0 or more (got {value:g})", kind="input")
|
package/scripts/sync.py
CHANGED
|
@@ -299,6 +299,12 @@ def main() -> int:
|
|
|
299
299
|
add_common(ap)
|
|
300
300
|
args = ap.parse_args()
|
|
301
301
|
apply_common(args)
|
|
302
|
+
# A190: with a search range of 0 or below no lag is tried, and the run reported an offset
|
|
303
|
+
# (0.012 s at confidence 0) for a correlation it never made
|
|
304
|
+
if args.max_offset <= 0:
|
|
305
|
+
die(f"--max-offset must be more than 0 seconds (the largest offset searched), got {args.max_offset:g}")
|
|
306
|
+
if args.analyze_seconds <= 0:
|
|
307
|
+
die(f"--analyze-seconds must be more than 0 seconds, got {args.analyze_seconds:g}")
|
|
302
308
|
if args.analyze_seconds > 900:
|
|
303
309
|
die(f"--analyze-seconds {args.analyze_seconds:g}: the window is decoded into memory; 900 s is the ceiling")
|
|
304
310
|
args.sources = [args.second] + list(args.more_sources)
|
package/scripts/verify.py
CHANGED
|
@@ -163,6 +163,10 @@ def main() -> int:
|
|
|
163
163
|
STATE.dry_run = False
|
|
164
164
|
info("--dry-run is ignored by verify.py: its steps run and write their outputs")
|
|
165
165
|
|
|
166
|
+
if args.seconds <= 0:
|
|
167
|
+
# A190: every step was handed a cut of no length and failed, which read as the tools'
|
|
168
|
+
# fault ("9 of 11 verification steps failed") rather than the flag's
|
|
169
|
+
die(f"--seconds must be more than 0 (the length of the test cut), got {args.seconds:g}")
|
|
166
170
|
files = collect(args.paths)
|
|
167
171
|
tmp = None
|
|
168
172
|
if args.out:
|
package/scripts/waveform.py
CHANGED
|
@@ -10,9 +10,10 @@ generated fresh, there is no source picture involved.
|
|
|
10
10
|
--style waveform draws the amplitude over time; --style spectrum draws a
|
|
11
11
|
frequency-over-time heatmap instead, which reads more information out of
|
|
12
12
|
dense mixes at the cost of being less immediately readable to a general
|
|
13
|
-
audience. Both accept --width/--height and --
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
audience. Both accept --width/--height and --split-channels (stereo drawn as
|
|
14
|
+
two separate lanes instead of summed to one); waveform additionally takes
|
|
15
|
+
--color and --waveform-mode (how each sample is drawn). The spectrum is drawn
|
|
16
|
+
in showspectrum's own colour map, so --color with --style spectrum is refused.
|
|
16
17
|
|
|
17
18
|
Examples:
|
|
18
19
|
python3 waveform.py podcast.wav -o waveform.mp4
|
|
@@ -54,7 +55,7 @@ def main() -> int:
|
|
|
54
55
|
ap.add_argument("--width", type=int, default=1920, help="output width in px, must be even (default 1920)")
|
|
55
56
|
ap.add_argument("--height", type=int, default=1080, help="output height in px, must be even (default 1080)")
|
|
56
57
|
ap.add_argument("--fps", type=float, default=25.0, help="output frame rate (default 25)")
|
|
57
|
-
ap.add_argument("--color", default="lime", help="channel colour(s), pipe-separated per channel, e.g. 'lime' or 'cyan|magenta' (default lime)")
|
|
58
|
+
ap.add_argument("--color", default="lime", help="--style waveform only: channel colour(s), pipe-separated per channel, e.g. 'lime' or 'cyan|magenta' (default lime)")
|
|
58
59
|
ap.add_argument("--background", default="black", help="background colour (default black)")
|
|
59
60
|
ap.add_argument("--waveform-mode", choices=WAVEFORM_MODES, default="line", help="--style waveform only: how each sample is drawn (default line)")
|
|
60
61
|
ap.add_argument("--split-channels", action="store_true", help="draw each channel in its own lane instead of summing to one")
|
|
@@ -97,6 +98,14 @@ def main() -> int:
|
|
|
97
98
|
args.width, args.height = frame["w"], frame["h"]
|
|
98
99
|
args.fps = float(PLATFORMS[args.platform].get("fps") or args.fps)
|
|
99
100
|
info(f"--platform {args.platform}: {args.width}x{args.height} @ {args.fps:g}fps")
|
|
101
|
+
if args.style == "spectrum" and args.color != ap.get_default("color"):
|
|
102
|
+
# A144: showspectrum takes no colour, so the flag was accepted and the heatmap drawn in
|
|
103
|
+
# its own colour map (a brand's primary colour is a default and is not refused)
|
|
104
|
+
die(f"--color {args.color} colours the waveform's channels; --style spectrum is drawn in showspectrum's own "
|
|
105
|
+
"colour map and takes no colour", hint="drop --color, or use --style waveform")
|
|
106
|
+
if args.style == "spectrum" and args.waveform_mode != ap.get_default("waveform_mode"):
|
|
107
|
+
die(f"--waveform-mode {args.waveform_mode} is how --style waveform draws each sample; --style spectrum has no "
|
|
108
|
+
"such mode", hint="drop --waveform-mode, or use --style waveform")
|
|
100
109
|
brand = load_brand(args.brand) if args.brand else None
|
|
101
110
|
if brand:
|
|
102
111
|
bc = brand.get("colors") or {}
|