ffmpeg-skill 3.8.4 → 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 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.4`) | any release |
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.4", "execution_mode": "local", "kind": "execution",
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 negative);
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, matching
140
- apply_common (0 means no limit)."""
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.4",
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": [
@@ -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
 
@@ -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 — and it is
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]] [--edl picks.txt] [--sheet scenes.png] [--json]
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`. `--track-title` names a track (repeated in `--srt` order;
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] | --denoise [--denoise-strength 25]] [--gain dB]
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
- return Fraction(int(parts[0]), int(parts[1]))
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
- STATE.timeout = max(0.0, float(args.timeout))
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
- return (limit * 4 + 60) if limit else None
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,
@@ -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
- VOICE_CHAIN = "highpass=f=80,deesser=i=0.4,afftdn=nf=-25:tn=1,acompressor=threshold=-18dB:ratio=3:attack=5:release=80:makeup=2"
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(VOICE_LEVELS[args.voice])
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):
@@ -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
- if os.path.exists(path) and not srt_timings_shared_grammar(path):
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] = []
@@ -1727,8 +1777,8 @@ def main() -> int:
1727
1777
  cues if (args.text or args.transcribe) else (
1728
1778
  planned_cues if planned_cues is not None else parse_srt(srt_path)))
1729
1779
  if args.karaoke and not getattr(args, "_word_timings", None):
1730
- # a copy that only rewrote the timings keeps the caller's word-timing JSON beside it
1731
- args._word_timings = whisper_word_timings(args.srt if normalised_only else srt_path)
1780
+ # Use the cue source's sidecar, even when layout rewrites the caller's SRT.
1781
+ args._word_timings = whisper_word_timings(args.srt if args.srt and not (args.text or args.transcribe) else srt_path)
1732
1782
  ass_path = args.write_ass or os.path.splitext(output)[0] + ".ass"
1733
1783
  claim([(ass_path, "ASS")])
1734
1784
  w, h = display_size(meta["video"])
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"):
@@ -156,7 +156,12 @@ def main() -> int:
156
156
  duration = meta.get("duration") or 0.0
157
157
 
158
158
  failed: List[Dict] = []
159
- rects = detect(args.input, args.seconds, args.samples, args.limit, args.round_to, duration, failed,
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:
@@ -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:
@@ -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
 
@@ -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/...")
@@ -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 not args.scale and not args.scale_percent:
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
- if snap_spec and str(snap_spec.get("to") or "") not in ("", "none", "beats"):
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
 
@@ -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:
@@ -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:
@@ -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 --color; waveform additionally
14
- takes --waveform-mode (how each sample is drawn) and --split-channels
15
- (stereo drawn as two separate lanes instead of summed to one).
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 {}