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.
- package/README.md +3 -3
- package/SKILL.md +3 -3
- package/docs/contract.md +29 -20
- package/mcp/server.py +3 -2
- package/package.json +1 -1
- package/references/ci-platform-pitfalls.md +9 -6
- package/references/flags.md +2 -2
- package/references/gotchas.md +10 -4
- package/references/scripts.md +26 -8
- package/references/workflow.md +2 -2
- package/scripts/_common/decision.py +6 -1
- package/scripts/_common/runner.py +46 -5
- package/scripts/_contract.py +84 -7
- package/scripts/audio.py +38 -5
- package/scripts/batch.py +10 -0
- package/scripts/caption.py +51 -1
- package/scripts/color.py +6 -0
- package/scripts/cropdetect.py +6 -1
- package/scripts/cut.py +5 -0
- package/scripts/export.py +1 -1
- package/scripts/fit.py +5 -0
- package/scripts/graphics.py +20 -1
- package/scripts/look.py +7 -0
- package/scripts/loudness.py +8 -0
- package/scripts/metadata.py +3 -0
- package/scripts/overlay.py +18 -1
- package/scripts/render.py +50 -5
- package/scripts/scenes.py +14 -0
- package/scripts/sequence.py +4 -0
- package/scripts/silence.py +3 -0
- package/scripts/sync.py +6 -0
- package/scripts/verify.py +4 -0
- package/scripts/waveform.py +13 -4
package/scripts/_contract.py
CHANGED
|
@@ -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 "")
|
|
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)
|
|
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
|
-
|
|
37
|
+
VOICE_DENOISE = "afftdn=nf=-25:tn=1" # the denoise stage of the medium and strong chains
|
|
38
|
+
VOICE_CHAIN = f"highpass=f=80,deesser=i=0.4,{VOICE_DENOISE},acompressor=threshold=-18dB:ratio=3:attack=5:release=80:makeup=2"
|
|
38
39
|
|
|
39
40
|
# --voice [light|medium|strong] (1.13). "medium" is the chain --voice has always produced, so a
|
|
40
41
|
# bare --voice (and every existing call and MCP request) is byte-identical to before. "light"
|
|
@@ -48,6 +49,23 @@ VOICE_LEVELS = {
|
|
|
48
49
|
"strong": VOICE_CHAIN + ",deesser=i=0.6,acompressor=threshold=-24dB:ratio=4:attack=5:release=120:makeup=3,alimiter=limit=0.891251:level=disabled:latency=1",
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def voice_chain(level: str, denoise_strength: "float | None") -> str:
|
|
55
|
+
"""The --voice chain, with --denoise in it when asked (A136). `light` has no denoise of its
|
|
56
|
+
own, so --voice light --denoise used to drop the denoise without a word: it goes after the
|
|
57
|
+
highpass, where the other levels have theirs. `medium` and `strong` already denoise at
|
|
58
|
+
nf=-25; with --denoise that stage runs at --denoise-strength (25 by default, so the chain
|
|
59
|
+
--voice medium --denoise has always produced is unchanged)."""
|
|
60
|
+
chain = VOICE_LEVELS[level]
|
|
61
|
+
if denoise_strength is None:
|
|
62
|
+
return chain
|
|
63
|
+
stage = f"afftdn=nf=-{denoise_strength:g}:tn=1"
|
|
64
|
+
if VOICE_DENOISE in chain:
|
|
65
|
+
return chain.replace(VOICE_DENOISE, stage)
|
|
66
|
+
return chain.replace("highpass=f=80,", f"highpass=f=80,{stage},", 1)
|
|
67
|
+
|
|
68
|
+
|
|
51
69
|
# The sidechain threshold the music bed has used since 1.4 is the linear 0.05 that ffmpeg's
|
|
52
70
|
# sidechaincompress takes; -26.0206 dBFS is that same number in the unit the flag speaks, so the
|
|
53
71
|
# default command line is unchanged to the byte while the value is now sayable.
|
|
@@ -349,6 +367,24 @@ def main() -> int:
|
|
|
349
367
|
die(f"--duck is off but {duck_params[0]} was given; add --duck")
|
|
350
368
|
if args.duck and not args.music:
|
|
351
369
|
die("--duck ducks the music bed under the main track, but no --music was given; add --music FILE")
|
|
370
|
+
# A141: the same rule for the other parameters of a switch that is off. Each was accepted and
|
|
371
|
+
# did nothing: --music-fade-out 2 without --music faded nothing, --denoise-strength 40 without
|
|
372
|
+
# --denoise (also under --voice, whose own denoise stayed at 25) removed nothing more
|
|
373
|
+
bed_params = [flag for flag, name in (("--music-volume", "music_volume"), ("--music-fade-out", "music_fade_out"))
|
|
374
|
+
if getattr(args, name) != ap.get_default(name)] + (["--music-loop"] if args.music_loop else [])
|
|
375
|
+
if not args.music and bed_params:
|
|
376
|
+
die(f"--music is off but {bed_params[0]} was given; add --music FILE")
|
|
377
|
+
if not args.effects and args.effects_volume != ap.get_default("effects_volume"):
|
|
378
|
+
die("--effects is off but --effects-volume was given; add --effects FILE")
|
|
379
|
+
if not args.denoise and args.denoise_strength != ap.get_default("denoise_strength"):
|
|
380
|
+
die("--denoise is off but --denoise-strength was given; add --denoise"
|
|
381
|
+
+ (f" (--voice {args.voice} then denoises at that strength)" if args.voice else ""))
|
|
382
|
+
# afftdn's nf accepts -80..-20 only; 10..19.99 used to pass here and then fail inside ffmpeg.
|
|
383
|
+
if args.denoise and not 20 <= args.denoise_strength <= 60:
|
|
384
|
+
die(f"--denoise-strength must be 20..60 (afftdn noise floor in dB below full scale), got {args.denoise_strength:g}")
|
|
385
|
+
for flag, value in (("--fade-in", args.fade_in), ("--fade-out", args.fade_out), ("--music-fade-out", args.music_fade_out)):
|
|
386
|
+
if value < 0: # a negative length reached afade, which refused it (kind ffmpeg)
|
|
387
|
+
die(f"{flag} must be 0 or more seconds, got {value:g}")
|
|
352
388
|
|
|
353
389
|
for flag, value, lo, hi in (("--duck-amount", args.duck_amount, 0.0, 60.0),
|
|
354
390
|
("--duck-threshold", args.duck_threshold, -60.0, 0.0),
|
|
@@ -451,11 +487,8 @@ def main() -> int:
|
|
|
451
487
|
# 5.1 while the run said verified.
|
|
452
488
|
fx.append(DOWNMIX_PAN)
|
|
453
489
|
if args.voice:
|
|
454
|
-
fx.append(
|
|
490
|
+
fx.append(voice_chain(args.voice, args.denoise_strength if args.denoise else None))
|
|
455
491
|
elif args.denoise:
|
|
456
|
-
# afftdn's nf accepts -80..-20 only; 10..19.99 used to pass here and then fail inside ffmpeg.
|
|
457
|
-
if not 20 <= args.denoise_strength <= 60:
|
|
458
|
-
die(f"--denoise-strength must be 20..60 (afftdn noise floor in dB below full scale), got {args.denoise_strength:g}")
|
|
459
492
|
fx.append(f"afftdn=nf=-{args.denoise_strength:g}:tn=1")
|
|
460
493
|
if args.gain:
|
|
461
494
|
fx.append(f"volume={args.gain:g}dB")
|
package/scripts/batch.py
CHANGED
|
@@ -23,6 +23,7 @@ Examples:
|
|
|
23
23
|
python3 batch.py ~/Footage --recipe batch.json --watch 30 # poll the folder every 30 s
|
|
24
24
|
"""
|
|
25
25
|
import argparse
|
|
26
|
+
import difflib
|
|
26
27
|
import hashlib
|
|
27
28
|
import json
|
|
28
29
|
import os
|
|
@@ -237,12 +238,21 @@ def intermediate_ext(src: Path) -> str:
|
|
|
237
238
|
|
|
238
239
|
# the recipe keys the run reads as a string: a path, a glob, a file-name part
|
|
239
240
|
RECIPE_STRINGS = ("glob", "project", "output_dir", "suffix", "ext")
|
|
241
|
+
# every key a recipe may carry; anything else is refused, as render.py refuses a project's
|
|
242
|
+
RECIPE_KEYS = RECIPE_STRINGS + ("clip_key", "steps")
|
|
240
243
|
|
|
241
244
|
|
|
242
245
|
def check_recipe_types(recipe: Dict[str, Any]) -> None:
|
|
243
246
|
"""Refuse a recipe value of the wrong JSON type before anything runs (A18): a number where a
|
|
244
247
|
path or glob goes, an absolute glob (pathlib refuses it), a step that is not a list. Each was
|
|
245
248
|
a traceback, or a failure of every item naming the step's first character."""
|
|
249
|
+
for key in recipe:
|
|
250
|
+
# A104: a misspelled key ("ouput_dir", "sufix", "globs") was not read, so the batch ran
|
|
251
|
+
# with that key's default: other files, another folder, another name
|
|
252
|
+
if key not in RECIPE_KEYS:
|
|
253
|
+
near = difflib.get_close_matches(str(key), RECIPE_KEYS, n=1, cutoff=0.6)
|
|
254
|
+
die(f"recipe: unknown key {key!r}" + (f" (did you mean {near[0]!r}?)" if near
|
|
255
|
+
else f" (valid keys: {', '.join(sorted(RECIPE_KEYS))})"), kind="input")
|
|
246
256
|
for key in RECIPE_STRINGS:
|
|
247
257
|
value = recipe.get(key)
|
|
248
258
|
if value is not None and not isinstance(value, str):
|
package/scripts/caption.py
CHANGED
|
@@ -1151,6 +1151,14 @@ def main() -> int:
|
|
|
1151
1151
|
f"clear of the app's own UI)")
|
|
1152
1152
|
if args.margin is None:
|
|
1153
1153
|
args.margin = 30
|
|
1154
|
+
if args.ass and (args.animate not in (None, "none") or args.karaoke):
|
|
1155
|
+
# A74: both are drawn by the ASS this tool writes from cues; a caller's own --ass is burned
|
|
1156
|
+
# as it stands, so the flag was accepted and nothing moved (a brand's caption.animate or
|
|
1157
|
+
# caption.karaoke is a default for generated captions and is not a request here)
|
|
1158
|
+
asked = "--animate " + args.animate if args.animate not in (None, "none") else "--karaoke"
|
|
1159
|
+
die(f"{asked} is drawn by the ASS this tool generates from --srt, --text or --transcribe; --ass burns "
|
|
1160
|
+
"the file as it is, with the animation and karaoke its own tags give it",
|
|
1161
|
+
hint=f"drop {asked.split()[0]}, or pass the cues as --srt/--text and let this tool write the ASS")
|
|
1154
1162
|
# a brand's caption.animate is a burn-in default; over --mode mux (soft subtitles) it used
|
|
1155
1163
|
# to be applied anyway and then refused as "animation is burn only" -- ignore it there
|
|
1156
1164
|
args.animate = args.animate or (bcap.get("animate", "none") if args.brand and args.mode != "mux" else "none")
|
|
@@ -1183,6 +1191,10 @@ def main() -> int:
|
|
|
1183
1191
|
"above the size it is a floor for", kind="input")
|
|
1184
1192
|
if args.min_duration < 0:
|
|
1185
1193
|
die("--min-duration cannot be negative")
|
|
1194
|
+
if args.gap < 0:
|
|
1195
|
+
# A72: each auto-timed cue started before the one above it ended, and the first start
|
|
1196
|
+
# below 0 was clamped: the cues overlapped on screen
|
|
1197
|
+
die(f"--gap must be 0 or more seconds, got {args.gap:g}")
|
|
1186
1198
|
|
|
1187
1199
|
meta = None
|
|
1188
1200
|
if args.input:
|
|
@@ -1340,6 +1352,14 @@ def main() -> int:
|
|
|
1340
1352
|
if missing_srt and not STATE.dry_run:
|
|
1341
1353
|
die(f"{len(missing_srt)} SRT file(s) not found: " + ", ".join(m["path"] for m in missing_srt),
|
|
1342
1354
|
kind="input", problems=missing_srt)
|
|
1355
|
+
if args.offset and args.mode == "mux":
|
|
1356
|
+
# --offset shifts every --srt track (A61); one it would leave with no cue is refused here,
|
|
1357
|
+
# before the first file of this run is written (the first track's empty copy used to reach
|
|
1358
|
+
# ffmpeg, which failed on it as kind ffmpeg and left the copy behind)
|
|
1359
|
+
for p, _l in srt_tracks:
|
|
1360
|
+
if os.path.exists(p) and not any(e + args.offset > 0 for _s, e, _t in parse_srt(p)):
|
|
1361
|
+
die(f"--offset {args.offset:+g} moves every cue of {p} before 0: the track would be empty",
|
|
1362
|
+
kind="input")
|
|
1343
1363
|
srt_path = args.srt
|
|
1344
1364
|
if args.transcribe and not args.input:
|
|
1345
1365
|
die("--transcribe needs the input video")
|
|
@@ -1419,6 +1439,11 @@ def main() -> int:
|
|
|
1419
1439
|
if args.text:
|
|
1420
1440
|
cues = parse_text_cues(args.text, args.auto_seconds, args.gap, fps_for_tc)
|
|
1421
1441
|
cues, _ = lay_out(cues)
|
|
1442
|
+
if not cues:
|
|
1443
|
+
# --offset dropped every cue (one that ends at or before 0 goes): an empty SRT was
|
|
1444
|
+
# written, and with a frame rate known the report below indexed the empty list
|
|
1445
|
+
die(f"--offset {args.offset:+g} moves every cue of {args.text} before 0: no cue would be left",
|
|
1446
|
+
kind="input")
|
|
1422
1447
|
if args.write_srt:
|
|
1423
1448
|
srt_path = args.write_srt
|
|
1424
1449
|
elif args.input:
|
|
@@ -1540,7 +1565,27 @@ def main() -> int:
|
|
|
1540
1565
|
die(f"SRT file not found: {path}")
|
|
1541
1566
|
for n in range(1, len(added)):
|
|
1542
1567
|
path, lang = added[n]
|
|
1543
|
-
|
|
1568
|
+
pending = not os.path.exists(path) # a dry run's pending track: an earlier step writes it
|
|
1569
|
+
if pending and not args.offset:
|
|
1570
|
+
continue
|
|
1571
|
+
if args.offset:
|
|
1572
|
+
# A61: --offset moved the first --srt only, so the other languages stayed where
|
|
1573
|
+
# they were against the sound. Every added track is shifted by the same rule
|
|
1574
|
+
# (a cue that ends at or before 0 is dropped, a start below 0 is clamped). A
|
|
1575
|
+
# pending track's copy is planned under the same name, so the planned command
|
|
1576
|
+
# reads the file the real run reads; only its cues wait for the file
|
|
1577
|
+
copy = f"{os.path.splitext(output)[0]}_track{n}.srt"
|
|
1578
|
+
claim([(copy, f"shifted SRT {n}")])
|
|
1579
|
+
if not pending and not STATE.dry_run:
|
|
1580
|
+
moved = [(max(0.0, s + args.offset), e + args.offset, t) for s, e, t in parse_srt(path)
|
|
1581
|
+
if e + args.offset > 0] # never empty: refused before the first write, above
|
|
1582
|
+
write_srt(moved, copy)
|
|
1583
|
+
info(f"wrote {copy} ({len(moved)} cues of {os.path.basename(path)} shifted by {args.offset:+g} s)")
|
|
1584
|
+
side_notes.append(f"subtitle track {n} is {copy}, a copy of {os.path.basename(path)} shifted by "
|
|
1585
|
+
f"--offset {args.offset:+g} s like the first track"
|
|
1586
|
+
+ ("; re-run this command without --dry-run to produce it" if STATE.dry_run else ""))
|
|
1587
|
+
added[n] = (copy, lang)
|
|
1588
|
+
elif not srt_timings_shared_grammar(path):
|
|
1544
1589
|
# the same rule as the first track (#414): ffmpeg is given what parse_srt() reads
|
|
1545
1590
|
copy = f"{os.path.splitext(output)[0]}_track{n}.srt"
|
|
1546
1591
|
claim([(copy, f"normalised SRT {n}")])
|
|
@@ -1550,6 +1595,11 @@ def main() -> int:
|
|
|
1550
1595
|
side_notes.append(f"subtitle track {n} is {copy}, a copy of {os.path.basename(path)} with its timings "
|
|
1551
1596
|
"rewritten as hh:mm:ss,mmm, which ffmpeg reads as this tool does")
|
|
1552
1597
|
added[n] = (copy, lang)
|
|
1598
|
+
if len(added) > 1 and any(caption_stats.get(k) for k in ("wrapped", "split", "extended")):
|
|
1599
|
+
# --max-lines and --min-duration lay out the first --srt, the track the size fit is
|
|
1600
|
+
# made for; the others keep their own line breaks and durations, and the result says so
|
|
1601
|
+
side_notes.append(f"--max-lines/--min-duration laid out the first --srt only: the other {len(added) - 1} "
|
|
1602
|
+
"added track(s) keep the line breaks and cue lengths of their own files")
|
|
1553
1603
|
titles = list(args.track_title or [])
|
|
1554
1604
|
mp4_family = Path(output).suffix.lower() in (".mp4", ".m4v", ".mov")
|
|
1555
1605
|
dropped_titles: List[str] = []
|
package/scripts/color.py
CHANGED
|
@@ -464,6 +464,12 @@ def main() -> int:
|
|
|
464
464
|
add_common(ap)
|
|
465
465
|
args = ap.parse_args()
|
|
466
466
|
apply_common(args)
|
|
467
|
+
# A165: a peak of 0 or below and a negative desaturation reached zscale/tonemap, which refused
|
|
468
|
+
# them as an ffmpeg failure (kind ffmpeg, as if the build were at fault)
|
|
469
|
+
if args.peak <= 0:
|
|
470
|
+
die(f"--peak must be more than 0 nits, got {args.peak:g}")
|
|
471
|
+
if args.desat < 0:
|
|
472
|
+
die(f"--desat must be 0 or more, got {args.desat:g}")
|
|
467
473
|
|
|
468
474
|
meta = probe(args.input)
|
|
469
475
|
if not meta.get("video"):
|
package/scripts/cropdetect.py
CHANGED
|
@@ -156,7 +156,12 @@ def main() -> int:
|
|
|
156
156
|
duration = meta.get("duration") or 0.0
|
|
157
157
|
|
|
158
158
|
failed: List[Dict] = []
|
|
159
|
-
|
|
159
|
+
# A164: cropdetect reads a limit below 1 as a fraction of the top code and 1 or more as the
|
|
160
|
+
# code itself, so --limit 1, the top of the documented 0..1 range, was read as code 1 (nearly
|
|
161
|
+
# nothing is black) where 0.999 reads as nearly everything. The top of the range is written
|
|
162
|
+
# as the top code of the source's bit depth (255 for 8 bits, 1023 for 10).
|
|
163
|
+
limit = args.limit if args.limit < 1 else float((1 << int(meta["video"].get("bit_depth") or 8)) - 1)
|
|
164
|
+
rects = detect(args.input, args.seconds, args.samples, limit, args.round_to, duration, failed,
|
|
160
165
|
square_pixels(meta["video"]))
|
|
161
166
|
# windows_failed: a window ffmpeg could not decode was skipped without a word, so a
|
|
162
167
|
# crop measured on one window of five read as well-founded as one measured on all five
|
package/scripts/cut.py
CHANGED
|
@@ -1518,6 +1518,11 @@ def main() -> int:
|
|
|
1518
1518
|
die(f"--remove needs the input's duration, which {args.input} does not report"
|
|
1519
1519
|
+ (" yet (it is an earlier step's output)" if dry_run_input_pending(args.input) else ""))
|
|
1520
1520
|
args.segments = ",".join(f"{s:.6f}-{e:.6f}" for s, e in keep_ranges_after_removing(parse_segments(args.remove, fps, clamp=True), total, fps))
|
|
1521
|
+
elif args.segments and (args.end or args.duration or args.start is not None):
|
|
1522
|
+
# A50: the single-range flags were accepted beside --segments and not read, where --remove
|
|
1523
|
+
# refuses the same combination
|
|
1524
|
+
die("--segments names every range to keep: it does not combine with --start, --end or --duration "
|
|
1525
|
+
"(give the one range as --segments A-B, or drop --segments)")
|
|
1521
1526
|
if args.segments:
|
|
1522
1527
|
segments = parse_segments(args.segments, fps)
|
|
1523
1528
|
else:
|
package/scripts/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="
|
|
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:
|
package/scripts/graphics.py
CHANGED
|
@@ -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
|
-
|
|
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:
|
package/scripts/loudness.py
CHANGED
|
@@ -288,6 +288,14 @@ def main() -> int:
|
|
|
288
288
|
# parsed before anything is measured, and an explicitly empty --versions ('' or ',') is refused
|
|
289
289
|
# by parse_versions rather than read as "not given", which wrote one default file instead
|
|
290
290
|
plan = parse_versions(args.versions) if args.versions is not None else None
|
|
291
|
+
# A124/A185: loudnorm's own ranges, checked here as --versions checks its items. Outside them
|
|
292
|
+
# the measurement pass failed inside ffmpeg (kind ffmpeg, with a page of stderr)
|
|
293
|
+
for flag, value, lo, hi, unit in (("-I/--lufs", args.lufs, -70.0, -5.0, "LUFS"), ("--tp", args.tp, -9.0, 0.0, "dBTP"),
|
|
294
|
+
("--lra", args.lra, 1.0, 50.0, "LU")):
|
|
295
|
+
if not lo <= value <= hi:
|
|
296
|
+
die(f"{flag} must be {lo:g}..{hi:g} {unit} (loudnorm's range), got {value:g}")
|
|
297
|
+
if args.sample_rate is not None and args.sample_rate <= 0:
|
|
298
|
+
die(f"--sample-rate must be a positive number of Hz, got {args.sample_rate}")
|
|
291
299
|
if plan is not None and args.output:
|
|
292
300
|
die("--versions writes one file per target, named <name>_loudnorm_<target>.<ext>; it does not take -o")
|
|
293
301
|
|
package/scripts/metadata.py
CHANGED
|
@@ -215,6 +215,9 @@ def main() -> int:
|
|
|
215
215
|
die(f"--min-chapter must be > 0, got {args.min_chapter:g}")
|
|
216
216
|
if args.max_chapters < 0:
|
|
217
217
|
die(f"--max-chapters must be >= 0 (0 = no cap), got {args.max_chapters}")
|
|
218
|
+
if args.silence_min < 0:
|
|
219
|
+
# A185: a negative length reached silencedetect, which refused it (kind ffmpeg)
|
|
220
|
+
die(f"--silence-min must be 0 or more seconds, got {args.silence_min:g}")
|
|
218
221
|
tags = {k: getattr(args, k) for k in TAG_KEYS if getattr(args, k) is not None}
|
|
219
222
|
if not args.chapters and not args.clear_chapters and not args.auto_chapters and not tags:
|
|
220
223
|
die("nothing to write: give --chapters FILE, --auto-chapters, --clear-chapters and/or --title/--artist/...")
|
package/scripts/overlay.py
CHANGED
|
@@ -298,7 +298,7 @@ def main() -> int:
|
|
|
298
298
|
args.image = brand["logo"]
|
|
299
299
|
if args.position == ap.get_default("position"):
|
|
300
300
|
args.position = brand.get("logo_position", "top-right")
|
|
301
|
-
if
|
|
301
|
+
if args.scale is None and args.scale_percent is None: # a given 0 is refused below, not replaced
|
|
302
302
|
args.scale = int(brand.get("logo_scale", 160))
|
|
303
303
|
if args.opacity == 1.0:
|
|
304
304
|
args.opacity = float(brand.get("logo_opacity", 1.0))
|
|
@@ -402,6 +402,18 @@ def main() -> int:
|
|
|
402
402
|
hint="give a --start inside the clip")
|
|
403
403
|
if not 0 <= args.opacity <= 1:
|
|
404
404
|
die("--opacity must be within 0..1")
|
|
405
|
+
# A190: a size of 0 or below drew nothing (--font-size -10 completed, verified: true, with no
|
|
406
|
+
# text in the picture; --scale -1 is ffmpeg's "keep the size") and a negative fade was no fade
|
|
407
|
+
if args.font_size < 1:
|
|
408
|
+
die(f"--font-size must be at least 1 px, got {args.font_size}")
|
|
409
|
+
if args.scale is not None and args.scale < 1:
|
|
410
|
+
die(f"--scale must be a positive width in px, got {args.scale}")
|
|
411
|
+
if args.scale_percent is not None and args.scale_percent <= 0:
|
|
412
|
+
die(f"--scale-percent must be more than 0, got {args.scale_percent:g}")
|
|
413
|
+
if args.fade < 0:
|
|
414
|
+
die(f"--fade must be 0 or more seconds, got {args.fade:g}")
|
|
415
|
+
if args.border < 0:
|
|
416
|
+
die(f"--border must be 0 or more px, got {args.border}")
|
|
405
417
|
if args.chromakey and not args.video:
|
|
406
418
|
die("--chromakey needs --video")
|
|
407
419
|
if args.chromakey:
|
|
@@ -417,6 +429,11 @@ def main() -> int:
|
|
|
417
429
|
die("--place needs --video")
|
|
418
430
|
if args.place and (args.start or args.end or args.fade):
|
|
419
431
|
die("--place sets when each piece shows: drop --start / --end / --fade")
|
|
432
|
+
if args.video and args.fade:
|
|
433
|
+
# A171: the fade is built for an image, logo or text layer; a video layer was composited
|
|
434
|
+
# with a hard edge and the result said nothing
|
|
435
|
+
die(f"--fade {args.fade:g} fades an image, logo or text layer; a video layer (--video) is shown "
|
|
436
|
+
"from --start to --end without a fade", hint="drop --fade for the video layer")
|
|
420
437
|
|
|
421
438
|
output = args.output or default_output(args.input, "overlay", default_ext(alpha_codec) if alpha_codec else None)
|
|
422
439
|
enable = enable_expr(start, end)
|