ffmpeg-skill 3.8.5 → 3.8.7

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.
@@ -239,6 +239,8 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
239
239
  # on PATH). "analysis_only": ffmpeg still decodes/measures the input under --dry-run, but nothing
240
240
  # is encoded and no file is written. Every other tool with the flag runs no ffmpeg at all.
241
241
  DRY_RUN_ANALYSIS = {
242
+ # #466 A193: probe was "none" with an ffprobe note while --analyze decoded with ffmpeg
243
+ "probe": "read-only tool; --dry-run changes nothing: ffprobe still runs, and only with --analyze ffmpeg decodes the first 20 s of each video input to sample picture levels",
242
244
  "sync": "audio is decoded to find the offset; the aligned output is not written",
243
245
  "multicam": "audio is decoded to align the cameras; the switched output is not written",
244
246
  "scenes": "scene and audio-peak measurement runs; --sheet and --edl are not written",
@@ -255,7 +257,6 @@ DRY_RUN_ANALYSIS = {
255
257
  "overlay": "only for a multi-line --text on FFmpeg 8.0, 8.0.1 or 8.1.0: one drawtext measures each line on a generated one-frame picture (the input is not read), so the planned per-line positions are the ones a run uses; otherwise no ffmpeg runs and the composited output is never written",
256
258
  }
257
259
  DRY_RUN_NOTES = {
258
- "probe": "read-only tool; --dry-run changes nothing (ffprobe still runs)",
259
260
  "verify": "not supported: the flag is accepted but the steps run and outputs are written",
260
261
  }
261
262
 
@@ -477,7 +478,7 @@ DIAGNOSTIC_CODES = {
477
478
  "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
479
  "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
480
  "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}"),
481
+ "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
482
  # encoder decisions
482
483
  "av1_encoder_libaom": ("fallbacks", "--codec av1 on a build without libsvtav1: libaom-av1 encoded instead; {wanted, used}"),
483
484
  "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}"),
@@ -581,7 +582,7 @@ def alpha_result_schema(name: str) -> Dict[str, Any]:
581
582
  out["alpha_codec"]["description"] += " (an RGB source that is neither HDR nor BT.2020, on a prores4444/vp9 line has no matrix to keep: it is converted to BT.709 and tagged so; an HDR or BT.2020 one is not: FFmpeg's own conversion makes its YUV, as before)"
582
583
  out["alpha_dropped"] = {"type": "boolean", "description": "true when the source's alpha is used (a frame below opaque, or a stored alpha plane that could not be read) and the output does not keep it (color.py --retag too, when its copy fell back to an H.264 re-encode): without --keep-alpha the `alpha` verification step fails (reason alpha_dropped, attribution artifact, with an alpha_dropped warning); with --drop-alpha there is no such step and a note says the alpha was dropped on purpose. Absent on a source without alpha"}
583
584
  if name == "color":
584
- out["alpha_codec"]["description"] += ". --correct / --lut only: the grade works on the colour (at 4:4:4) and the source's alpha is merged back unchanged; the `alpha` step compares the written alpha with the source's frame by frame, tolerance 2 of 255 (comparison {ok, paired, worst_delta, worst_frame}; frames {expected, measured}; written {codec, pix_fmt, alpha_plane})"
585
+ out["alpha_codec"]["description"] += ". --correct / --lut only: the grade works on the colour (at 4:4:4) and the source's alpha is merged back onto that 8-bit picture (an 8-bit alpha unchanged; a 10- or 12-bit ProRes 4444 alpha keeps 8 bits of precision); the `alpha` step compares the written alpha with the source's frame by frame, tolerance 2 of 255 (comparison {ok, paired, worst_delta, worst_frame}; frames {expected, measured}; written {codec, pix_fmt, alpha_plane})"
585
586
  else:
586
587
  out["alpha_codec"]["description"] += ". Drawn on the input's alpha at 4:4:4 (layers composited with their alpha, text drawn into it); the `alpha` step (property alpha_at_least_source) passes when the written stream stores an alpha plane, every frame's alpha is at or above the source's (tolerance 2 of 255) and the output did not turn opaque in every frame the source was transparent in (reason: opaque_everywhere, alpha_below_source or no_alpha_plane when it fails)"
587
588
  out["alpha_codec"]["description"] += ". A source that stores an alpha plane whose alpha could not be read is never taken as opaque: its step fails with reason source_alpha_unmeasured"
@@ -608,6 +609,60 @@ NON_AV_STREAM_TOOLS = frozenset({"audio", "broll", "color", "cut", "deinterlace"
608
609
  "sphere", "straighten"})
609
610
 
610
611
 
612
+ # Top-level keys a tool emits that its own branch in output_schema() does not list (#466 A194: the
613
+ # schemas were written by hand and left these out). tests/test_contract.py's
614
+ # test_every_emitted_top_level_key_is_in_the_output_schema runs each tool and fails on a key that
615
+ # is in neither.
616
+ EMITTED_KEYS: Dict[str, Dict[str, Any]] = {
617
+ "cropdetect": {
618
+ "file": {"type": "string", "description": "the input path, as given"},
619
+ "source_width": {"type": "integer", "description": "the displayed frame's width (a 90/270 degree rotation swaps the stored size; a non-square-pixel source is squared at its display aspect): the frame crop and motion_centre are in"},
620
+ "source_height": {"type": "integer", "description": "the displayed frame's height, as source_width"},
621
+ "crop": {"type": ["object", "null"], "description": "{width, height, x, y}: the rectangle cropdetect read most often over the sampled windows, in source pixels (the full frame when there are no bars); null when it produced no reading"},
622
+ "confidence": {"type": "number", "description": "present with a crop: the share of readings (0..1) that gave that rectangle"},
623
+ "motion_centre": {"type": "array", "description": "--motion-centre only: [{time, x, y, x_frac, y_frac, motion}] per second over the same windows; x/y null for a window with no measured motion. Report only"}},
624
+ "graphics": {
625
+ "template": {"type": "string", "description": "the --template drawn"},
626
+ "text_renderer": {"enum": ["ass", "drawtext"], "description": "what drew the text: libass (a generated .ass) or drawtext"},
627
+ "script": {"type": "string", "description": "the writing system detected in the text, which picks the font (latin, han, ...)"},
628
+ "ass": {"type": "string", "description": "text_renderer ass only: the generated .ass path (--write-ass, else <output>_gfx.ass); under --dry-run the path that would be written"},
629
+ "emoji": {"type": "object", "description": "text with emoji only: {mode, count, clusters, assets, missing, overlays}"},
630
+ "broken_inside_word": {"type": "integer", "description": "present when above 0: words hard-sliced because they did not fit a line alone"}},
631
+ "loudness": {
632
+ "loudnorm_mode": {"enum": ["linear", "dynamic"], "description": "--dry-run only (a real run reports it in result): the mode loudnorm's planned pass 2 runs in for the measured input"}},
633
+ "join": {
634
+ "reencoded": {"type": "boolean", "description": "--copy --transition none only: false, the clips were joined by stream copy"},
635
+ "audio_substituted": {"type": "array", "items": {"type": "string"}, "description": "a re-encoded video join: the clips with no audio stream, whose span of the join is generated silence (notes says the same); [] when every clip has sound"},
636
+ "inputs_duration": {"type": ["number", "null"], "description": "the clips' own total length, seconds"},
637
+ "transition_shortening": {"type": ["number", "null"], "description": "how much the transitions took out of inputs_duration, seconds"}},
638
+ "multicam": {
639
+ "inputs": {"type": "array", "items": {"type": "string"}, "description": "the input paths as given, reference first; a camera number in cuts indexes this list"}},
640
+ "broll": {
641
+ "cutaways": {"type": "array", "description": "[{insert, at, end, from}] per cutaway: the B clip, where it starts and ends on A's timeline (seconds), and where in B it starts"},
642
+ "audio": {"enum": ["a", "b", "mix"], "description": "--audio: what plays under a cutaway"}},
643
+ "grid": {
644
+ "cols": {"type": "integer"}, "rows": {"type": "integer"},
645
+ "clips": {"type": "integer", "description": "how many input clips the grid holds"}},
646
+ "scenes": {
647
+ "highlights": {"type": "array", "description": "--highlights only: [{start, end, duration}] the proposed ranges"},
648
+ "highlights_total": {"type": "number", "description": "--highlights only: the ranges' total seconds"},
649
+ "highlights_rank_by": {"enum": ["audio", "duration"], "description": "--highlights only: --rank-by"},
650
+ "sheet": {"type": "string", "description": "--sheet only: the contact sheet path (under --dry-run the path that would be written; nothing is drawn)"}},
651
+ "batch": {
652
+ "jobs": {"type": "integer", "description": "the parallelism actually applied"},
653
+ "jobs_requested": {"type": ["integer", "null"], "description": "the --jobs asked for (auto resolved to a number)"},
654
+ "wall_seconds": {"type": "number", "description": "the batch's wall clock"},
655
+ "item_seconds_total": {"type": "number", "description": "the sum of the per-item times"},
656
+ "timed_out": {"type": "boolean", "description": "whether the shared --timeout budget ran out (a timed-out item's row carries skipped: timeout)"}},
657
+ "render": {
658
+ "template": {"type": "string", "description": "--write-project only: the template the written project was filled from"},
659
+ "pack": {"type": "array", "description": "--template naming several destinations (a social pack) only: one row per destination {platform, file, path, size_bytes, duration, check, guidance, ok}; output is then the <stem>_pack.md table"},
660
+ "outputs": {"type": "array", "items": {"type": "string"}, "description": "a social pack only: each destination's output path"}},
661
+ "report": {
662
+ "pack": {"type": "array", "description": "--pack only: the pack table's rows as {column: cell} objects, as read from the file"}},
663
+ }
664
+
665
+
611
666
  def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str, Any]:
612
667
  """What the tool prints on stdout with --json (keys observed in the implementation). `hw`: the
613
668
  tool takes --hw, so its `notes` can also carry a VideoToolbox encode's quality line."""
@@ -618,7 +673,15 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
618
673
  "timecode": {"type": "object", "description": "3.0.0: the source's start timecode {value, drop_frame, source}: the first HH:MM:SS[:;.]FF timecode tag from the video stream, then a tmcd data stream, then the container; all three null when none carries one"},
619
674
  "chapters": {"type": "array", "description": "the container's chapter markers [{index, start, end, title}]"},
620
675
  "subtitle_stream_details": {"type": "array", "description": "every subtitle stream in file order [{index (the n of -map 0:s:n), codec, language, title, default (the stream's default disposition)}]"},
621
- "keyframes": {"type": "array", "description": "--keyframes-near only: one entry per time [{at, window, keyframes (the video keyframes within window seconds of it), before (the nearest of those at or before it, or null when none is within the window), after (at or after it, likewise)}], seconds from the file's start; a failed keyframe read fails the run (kind ffmpeg)"}},
676
+ "keyframes": {"type": "array", "description": "--keyframes-near only: one entry per time [{at, window, keyframes (the video keyframes within window seconds of it), before (the nearest of those at or before it, or null when none is within the window), after (at or after it, likewise)}], seconds from the file's start; a failed keyframe read fails the run (kind ffmpeg)"},
677
+ "start_time": {"type": ["number", "null"], "description": "where the container's timeline starts, seconds (an MPEG-TS file often starts above 0)"},
678
+ "bitrate": {"type": ["integer", "null"], "description": "the container's overall bit rate, bits per second, as ffprobe reads it; null when it states none"},
679
+ "subtitle_streams": {"type": "integer", "description": "how many subtitle streams the file carries"},
680
+ "data_streams": {"type": "integer", "description": "how many data streams (a tmcd timecode track, telemetry) the file carries"},
681
+ "stream_counts": {"type": "object", "description": "{video, audio}: picture streams (a cover picture is not one) and sound streams"},
682
+ "tags": {"type": "object", "description": "the container's title, artist, album, comment, date and genre tags that are set, keys lower-cased"},
683
+ "audio_streams": {"type": "array", "description": "every audio stream in file order [{index (the n of -map 0:a:n), codec, channels, channel_layout, sample_rate, language, title}]"},
684
+ "levels": {"type": "object", "description": "--analyze only, on an input with a video stream: picture levels sampled at 2 fps over the first 20 s, on an 8-bit scale inferred from the values measured (a 10-bit or deeper picture whose luma never passes code 255 is read as 8-bit and left unscaled) {scale, y_min, y_max, y_avg, y_low_p5, y_high_p95, saturation_avg, looks_like_log, note}; {error} when no frame was analysed. Measured under --dry-run too"}},
622
685
  "additionalProperties": True}
623
686
  base = {"status": {"enum": ["completed"]}, "output": {"type": ["string", "null"], "description": "path written, or null"},
624
687
  "dry_run": {"type": "boolean"}, "commands": {"type": "array", "items": {"type": "string"}, "description": "the ffmpeg command lines this run planned or ran to write its output (render.py and waveform.py: their stages' too); a measurement pass is listed only where the tool records it (e.g. loudness.py pass 1, check.py, stabilize.py pass 1), and batch.py lists none of its steps' (each results row's step documents carry their own)"},
@@ -781,7 +844,7 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
781
844
  "drift_ppm": {"type": "array", "items": {"type": ["number", "null"]}, "description": "--fix-drift: per input, the drift measured against the reference (the reference itself 0, the clock); null for a camera whose drift was not measured, with the reason in drift_skipped_reason (also a diagnostics.skipped entry, drift_correction_skipped)"},
782
845
  "drift_skipped_reason": {"type": "array", "items": {"enum": ["too_short", "low_confidence", None]}, "description": "--fix-drift: per input, why its drift was not measured (too_short: no tail window far enough from the head; low_confidence: the tail did not correlate), null when it was and for the reference"}}
783
846
  elif name == "join":
784
- extra = {"mode": {"enum": ["video", "audio"]}, "clips": {"type": "integer"}, "transition": {"type": "string"},
847
+ extra = {"mode": {"enum": ["video", "audio", "copy"], "description": "copy: --copy --transition none wrote a stream copy"}, "clips": {"type": "integer"}, "transition": {"type": "string"},
785
848
  "expected_duration": {"type": ["number", "null"], "description": "clip lengths minus the transitions; null under --dry-run while an input is pending (its length is unknown)"},
786
849
  "frame_rate": {"type": "string", "description": "video mode only: the join's frame rate as an exact fraction (\"30000/1001\"), the rate every clip length and xfade offset is counted in"},
787
850
  "clip_frames": {"type": "array", "items": {"type": "integer"}, "description": "video mode only: per clip, the whole frames of frame_rate it holds in the join (its picture's, or the frames that cover a sound running more than a frame past it); its sound is cut to the samples those frames last. A pending --dry-run input counts 0"},
@@ -802,6 +865,8 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
802
865
  "notes": {"type": "array", "items": {"type": "string"}, "description": "present with `silent`: which tracks were silent and how to refuse them"},
803
866
  "timing": {"type": "object", "description": "#486: {tempo, atempo, length_samples, sample_rate}: the --tempo factor (null without it), the atempo stages (each 0.5..2.0, [] at tempo 1) whose product it is, --length-samples (null without it) and the sample rate that count is in (null when not measured yet); with --length-samples a length_samples verification step counts the written track's decoded samples (exact for PCM/FLAC/ALAC, within tolerance_samples, one codec frame, for a lossy codec)"},
804
867
  "audio": {"type": "object", "description": "what the mix was built from: voice (null | light | medium | strong), stereo_widen, channel (--channel N, the 0-based channel kept, else null), sample_rate (--sample-rate, else null; both are read back from the output as the channels / sample_rate verification steps), effects/effects_volume, and with --music the music_volume plus duck (null when --duck was not given, else the threshold in dB and linear, ratio, attack_ms, release_ms, amount_db actually used)"}}
868
+ for key, prop in EMITTED_KEYS.get(name, {}).items():
869
+ extra.setdefault(key, prop)
805
870
  if name in ("overlay", "graphics", "caption", "grid"):
806
871
  extra["font_used"] = FONT_USED_SCHEMA
807
872
  if name in ALPHA_LINE_TOOLS:
@@ -1672,6 +1737,18 @@ def _merge_optional(base: List[Dict[str, Any]], extra: List[Dict[str, Any]]) ->
1672
1737
  return out
1673
1738
 
1674
1739
 
1740
+ _SENTENCE_END = re.compile(r"(?<!\be\.g)(?<!\bi\.e)(?<!\betc)[.!?](?=\s|$)")
1741
+
1742
+
1743
+ def summary_sentence(docstring: str) -> str:
1744
+ """The docstring's first sentence, its wrapped lines joined (#466 A195: the first physical line
1745
+ cut 16 descriptions mid-phrase). A first paragraph with no full stop is taken whole."""
1746
+ para = re.split(r"\n\s*\n", docstring.strip(), maxsplit=1)[0]
1747
+ joined = " ".join(line.strip() for line in para.splitlines())
1748
+ end = _SENTENCE_END.search(joined)
1749
+ return joined[:end.end()] if end else joined
1750
+
1751
+
1675
1752
  def tool_spec(name: str, version: str) -> Dict[str, Any]:
1676
1753
  if name not in TOOL_META:
1677
1754
  # a public script without metadata is drift: fail loudly instead of guessing its role or capabilities
@@ -1696,7 +1773,7 @@ def tool_spec(name: str, version: str) -> Dict[str, Any]:
1696
1773
  "id": f"{SKILL_ID}/{name}",
1697
1774
  "name": name,
1698
1775
  "version": version,
1699
- "description": (parser.description or "").strip().splitlines()[0] if parser.description else "",
1776
+ "description": summary_sentence(parser.description or ""),
1700
1777
  "executable": f"scripts/{name}.py",
1701
1778
  # 1.20.0: SKILL.md's own request-table rows for this tool, machine-readable
1702
1779
  "examples": skill_examples().get(name, []),
@@ -2079,7 +2156,7 @@ def build(detect: bool = True) -> Dict[str, Any]:
2079
2156
  "hidden .<output>.ffskill-audit.jsonl beside the output, and a cache event's .ffskill-audit.jsonl in the "
2080
2157
  "cache directory (render.py --cache DIR) or the batch's output_dir. Never written under --dry-run; a write that "
2081
2158
  "fails is a diagnostics.warnings entry (audit_log_unwritable), never a failure"},
2082
- "error_kinds": {"input": "missing or unsuitable input, bad arguments", "ffmpeg": "ffmpeg/ffprobe returned an error (message carries the last stderr lines)", "output": "ffmpeg exited 0 but the artifact is missing, empty or unreadable (an empty file is removed)", "missing_tool": "ffmpeg or ffprobe not on PATH, or older than the supported 6.1 (error.reason ffmpeg_unsupported)", "timeout": "one ffmpeg/ffprobe run exceeded --timeout (default 1800 s, FFMPEG_SKILL_TIMEOUT) and was killed; partial output removed; exit 124", "verification": "the tool ran but its result failed the requested check: check.py platform rows (checks attached), render.py's check stage (output written, check attached), batch.py items (results attached), verify.py steps (files attached), color.py --retag (output written, but ffprobe does not read the requested colour tags on it); exit 1", "interrupted": "the tool received SIGINT or SIGTERM: the running ffmpeg (or sibling script) was stopped and its partial output removed; exit 130 or 143"},
2159
+ "error_kinds": {"input": "missing or unsuitable input, bad arguments", "ffmpeg": "ffmpeg/ffprobe returned an error (message carries the last stderr lines)", "output": "ffmpeg exited 0 but the artifact is missing, empty or unreadable (an empty file is removed)", "missing_tool": "something the run needs is not installed: ffmpeg or ffprobe not on PATH, or older than the supported 6.1 (error.reason ffmpeg_unsupported) (both exit 127); an encoder this build lacks for --codec, an export.py preset or an alpha line (encoder_missing); the libvpx-vp9 decoder for a VP9 source with an alpha layer (decoder_missing); no local speech-to-text engine for --transcribe (asr_engine_missing), or a named --engine that is not installed (these exit 1)","timeout": "one ffmpeg/ffprobe run exceeded --timeout (default 1800 s, FFMPEG_SKILL_TIMEOUT; verify.py's --timeout is per step, default 600 s) and was killed; partial output removed; exit 124", "verification": "the tool ran but its result failed the requested check: check.py platform rows (checks attached), render.py's check stage (output written, check attached), a render.py --template social pack with a destination that failed (pack rows attached; a destination that timed out or was interrupted gives that kind instead), batch.py items (results attached), verify.py steps (files attached), color.py --retag (output written, but ffprobe does not read the requested colour tags on it), loudness.py (output written, its true peak still above --tp after the re-encodes; result attached), join.py --copy (the stream-copied join does not hold the clips it was given; it is not moved to the output path, verification attached); exit 1", "interrupted": "the tool received SIGINT or SIGTERM: the running ffmpeg (or sibling script) was stopped and its partial output removed; exit 130 or 143"},
2083
2160
  "success_criterion": "exit 0 AND the output exists AND is non-empty AND ffprobe reads a stream from it; only then is status completed printed and the output probe attached",
2084
2161
  },
2085
2162
  "capabilities": caps,
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] = []
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/export.py CHANGED
@@ -484,7 +484,7 @@ def main() -> int:
484
484
  ap.add_argument("--max-mb", type=float, metavar="MB",
485
485
  help="fit the file under MB megabytes (MiB): a two-pass x264 encode at the video bitrate the "
486
486
  "running time leaves after the audio, measured afterwards (H.264 presets; not with --crf or --hw)")
487
- ap.add_argument("--normalize", action="store_true", help="youtube/youtube4k/reels/x: when the written file misses the platform's loudness spec, run loudness.py on it (audio re-encoded, video copied) so one export delivers")
487
+ ap.add_argument("--normalize", action="store_true", help=f"the platform presets ({', '.join(sorted(PLATFORM_OF))}): when the written file misses the platform's loudness spec, run loudness.py on it (audio re-encoded, video copied) so one export delivers")
488
488
  ap.add_argument("--alpha-mode", choices=["straight", "premultiplied"],
489
489
  help="alpha presets (prores4444, prores4444xq, qtrle, vp9-alpha, hap-alpha): write the colour straight "
490
490
  "(default, as decoded) or premultiplied by the alpha; written to the result as alpha_mode")
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:
@@ -55,6 +55,9 @@ from _common.emit import input_length # noqa: E402
55
55
  TEXT = "@TEXT@"
56
56
  BOX = "@BOX@" # a box's place in the filter chain, followed by its index (graphics.main fill_box)
57
57
  TEMPLATES = ["lower-third", "title", "chapter", "progress", "countdown", "bug", "sticker", "hook", "meme"]
58
+ # --dry-run on an input that does not exist yet, with no --end: how long after --start the planned
59
+ # graphic ends (its length is unknown; the plan says so in a note)
60
+ PENDING_PLACEHOLDER_SECONDS = 10.0
58
61
 
59
62
 
60
63
  def ff_color(hex_rgb: str, alpha: float = 1.0) -> str:
@@ -137,6 +140,12 @@ def main(force_route: Optional[str] = None) -> int:
137
140
  add_common(ap)
138
141
  args = ap.parse_args()
139
142
  apply_common(args)
143
+ # A176: --from -1 divided by zero (a traceback, no JSON); --from -3 built an empty filter
144
+ # graph and --scale 0 or below a 0-px or negative box, both refused by ffmpeg (kind ffmpeg)
145
+ if args.count_from < 0:
146
+ die(f"--from must be 0 or more (the number the countdown starts at), got {args.count_from}")
147
+ if args.scale <= 0:
148
+ die(f"--scale must be more than 0 (a size multiplier; 1 is the template's own size), got {args.scale:g}")
140
149
  # 2.2.6: whitespace-only text is no text -- stripped before the "needs --title" checks, which
141
150
  # used to pass on " " and burn an empty bar
142
151
  for _attr in ("name", "title", "subtitle", "text", "top", "bottom"):
@@ -202,7 +211,17 @@ def main(force_route: Optional[str] = None) -> int:
202
211
  info(f"--platform {args.platform}: safe margins top {m_top} / bottom {m_bottom} / left {m_left} / right {m_right} px")
203
212
  fps = meta["video"].get("fps")
204
213
  s = time_arg(args.start, "--start", fps) if args.start else 0.0
205
- e = time_arg(args.end, "--end", fps) if args.end else input_length(meta) # no --end is the clip's end (#491)
214
+ if args.end:
215
+ e = time_arg(args.end, "--end", fps)
216
+ elif meta.get("dry_run"):
217
+ # #466 A202: an input an earlier --dry-run stage would write is probe()'s stub, whose
218
+ # length is unknown, not 0 s: planning to "the clip's end" refused with "--end must be
219
+ # after --start". The plan ends the graphic at a stated placeholder instead
220
+ e = s + PENDING_PLACEHOLDER_SECONDS
221
+ note(f"{args.input} does not exist yet, so its length is unknown: the planned graphic ends at a placeholder "
222
+ f"{e:g} s; a real run ends it at the clip's end (or pass --end)")
223
+ else:
224
+ e = input_length(meta) # no --end is the clip's end (#491)
206
225
  # the picture's own length first: sound that runs on keeps the container open past the last frame
207
226
  picture_len = meta["video"].get("duration") or dur
208
227
  if picture_len and s >= picture_len:
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)