ffmpeg-skill 3.6.6 → 3.7.0
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 +11 -9
- package/SKILL.md +4 -2
- package/bin/install.js +79 -5
- package/docs/contract.md +22 -6
- package/package.json +1 -1
- package/references/scripts.md +34 -5
- package/scripts/_common/alpha.py +7 -4
- package/scripts/_common/color.py +30 -0
- package/scripts/_common/probe.py +13 -0
- package/scripts/_common/runner.py +88 -3
- package/scripts/_contract.py +23 -12
- package/scripts/_smartcut.py +7 -5
- package/scripts/audio.py +186 -11
- package/scripts/caption.py +29 -3
- package/scripts/check.py +52 -7
- package/scripts/export.py +20 -7
- package/scripts/graphics.py +6 -0
- package/scripts/loudness.py +76 -14
- package/scripts/multicam.py +21 -4
- package/scripts/overlay.py +6 -0
- package/scripts/pad.py +6 -0
- package/scripts/render.py +30 -3
- package/scripts/verify.py +9 -2
|
@@ -606,12 +606,26 @@ def kept_deliverables() -> List[str]:
|
|
|
606
606
|
return [p for p in STATE.delivered if os.path.isfile(p)]
|
|
607
607
|
|
|
608
608
|
|
|
609
|
+
def written_on_disk() -> List[str]:
|
|
610
|
+
"""Files this process wrote itself (run()'s outputs, STATE.written) that are still on disk, the
|
|
611
|
+
deliverables mark_delivered() named excepted. A stopped command's partial output is removed
|
|
612
|
+
before a stop is reported, so it is not among them."""
|
|
613
|
+
delivered = {os.path.realpath(p) for p in STATE.delivered}
|
|
614
|
+
return sorted(p for p in STATE.written if p not in delivered and os.path.isfile(p))
|
|
615
|
+
|
|
616
|
+
|
|
609
617
|
def kept_clause() -> str:
|
|
610
|
-
"""How a stopped run's message ends: "nothing was written"
|
|
611
|
-
|
|
618
|
+
"""How a stopped run's message ends: "nothing was written" only when no file this run wrote is
|
|
619
|
+
on disk (A182: every tool but export and render said it after writing its output), else which
|
|
620
|
+
deliverable it had already written and keeps (not the finished result: the step that was
|
|
621
|
+
stopped did not run on it), or which files it had written before the stop."""
|
|
612
622
|
kept = kept_deliverables()
|
|
613
623
|
if not kept:
|
|
614
|
-
|
|
624
|
+
written = written_on_disk()
|
|
625
|
+
if not written:
|
|
626
|
+
return "nothing was written"
|
|
627
|
+
return (f"{', '.join(written)} had already been written by this run before the stop and "
|
|
628
|
+
f"{'is' if len(written) == 1 else 'are'} still on disk")
|
|
615
629
|
return (f"{', '.join(kept)} had already been written by this run and {'is' if len(kept) == 1 else 'are'} kept, "
|
|
616
630
|
"without the step that was stopped (pass --overwrite to make it again)")
|
|
617
631
|
|
|
@@ -990,6 +1004,76 @@ def _check_output_path(cmd: Sequence[str]) -> None:
|
|
|
990
1004
|
refuse("input", "output_dir_not_writable", f"output directory {parent!r} is not writable", output=output, directory=parent)
|
|
991
1005
|
|
|
992
1006
|
|
|
1007
|
+
# What the WebM muxer takes (6.1: "Only VP8 or VP9 or AV1 video and Vorbis or Opus audio and
|
|
1008
|
+
# WebVTT subtitles are supported for WebM"), by the encoder a command names (#488).
|
|
1009
|
+
_WEBM_VIDEO_ENCODER_RE = re.compile(r"^(?:libvpx(?:-vp9)?|vp8|vp9|libaom-av1|libsvtav1|librav1e|av1|vp8_\w+|vp9_\w+|av1_\w+)$")
|
|
1010
|
+
_WEBM_AUDIO_ENCODER_RE = re.compile(r"^(?:libopus|opus|libvorbis|vorbis)$")
|
|
1011
|
+
_CODEC_OPTION_RE = re.compile(r"^-(?:c|codec)(?::([va])(?::\d+)?)?$|^-(v|a)codec$")
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
def _an_input_has(cmd: Sequence[str], kind: str) -> bool:
|
|
1015
|
+
"""False only when every input of `cmd` is a file that was read and holds no `kind` ("v"/"a")
|
|
1016
|
+
stream, and no filter graph could make one. An encoder option for a stream the output will
|
|
1017
|
+
not have selects nothing (FFmpeg applies -c:a after stream selection), so it is not a reason
|
|
1018
|
+
to refuse: cut.py names -c:a aac for a silent source too. Anything not known counts as present."""
|
|
1019
|
+
if any(tok in ("-filter_complex", "-lavfi", "-filter_complex_script") for tok in cmd):
|
|
1020
|
+
return True
|
|
1021
|
+
paths = [cmd[i + 1] for i, tok in enumerate(cmd[:-1]) if tok == "-i"]
|
|
1022
|
+
if not paths:
|
|
1023
|
+
return True
|
|
1024
|
+
for i, tok in enumerate(cmd[:-1]):
|
|
1025
|
+
if tok == "-f" and cmd[i + 1] in ("lavfi", "concat"):
|
|
1026
|
+
return True
|
|
1027
|
+
for path in paths:
|
|
1028
|
+
if not os.path.isfile(path):
|
|
1029
|
+
return True
|
|
1030
|
+
probe_cmd = [shutil.which("ffprobe") or "ffprobe", "-v", "error", "-select_streams", kind,
|
|
1031
|
+
"-show_entries", "stream=index", "-of", "csv=p=0", path]
|
|
1032
|
+
limit = _probe_limit() # PROBE_TIMEOUT, or --timeout when that is lower; never unlimited (--timeout 0)
|
|
1033
|
+
try:
|
|
1034
|
+
proc = subprocess.run(probe_cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=limit)
|
|
1035
|
+
except subprocess.TimeoutExpired:
|
|
1036
|
+
# a stalled read is reported as what it is, not turned into "the stream is there"
|
|
1037
|
+
die(f"ffprobe did not finish reading {path} within {limit:g} s and was killed; nothing was written",
|
|
1038
|
+
code=124, kind="timeout", output=cmd[-1])
|
|
1039
|
+
except OSError:
|
|
1040
|
+
return True
|
|
1041
|
+
if proc.returncode != 0 or proc.stdout.strip():
|
|
1042
|
+
return True
|
|
1043
|
+
return False
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def _check_container_codecs(cmd: Sequence[str]) -> None:
|
|
1047
|
+
"""A `.webm` output whose command encodes a picture or a sound WebM cannot hold (the tools'
|
|
1048
|
+
H.264/HEVC and AAC lines) is refused before anything runs, dry runs included: the muxer
|
|
1049
|
+
refused it only after the encode had started, as `kind: ffmpeg` with "Nothing was written
|
|
1050
|
+
into output file", and a dry run said completed (#488). A stream copy is left to the muxer:
|
|
1051
|
+
what it copies is not named in the command."""
|
|
1052
|
+
output = cmd[-1]
|
|
1053
|
+
if os.path.splitext(output)[1].lower() != ".webm":
|
|
1054
|
+
return
|
|
1055
|
+
refused: List[Any] = []
|
|
1056
|
+
for i, tok in enumerate(cmd[:-2]):
|
|
1057
|
+
m = _CODEC_OPTION_RE.match(tok)
|
|
1058
|
+
if not m:
|
|
1059
|
+
continue
|
|
1060
|
+
stream, value = m.group(1) or m.group(2), cmd[i + 1]
|
|
1061
|
+
if value == "copy" or stream is None:
|
|
1062
|
+
continue
|
|
1063
|
+
allowed = _WEBM_VIDEO_ENCODER_RE if stream == "v" else _WEBM_AUDIO_ENCODER_RE
|
|
1064
|
+
if not allowed.match(value):
|
|
1065
|
+
refused.append((stream, value))
|
|
1066
|
+
# only for a stream the output will have: asked last, and only when something would be refused
|
|
1067
|
+
refused = [value for stream, value in dict.fromkeys(refused) if _an_input_has(cmd, stream)]
|
|
1068
|
+
if refused:
|
|
1069
|
+
refuse("input", "output_container_codecs",
|
|
1070
|
+
f"output {output!r} is WebM, which holds only VP8/VP9/AV1 video and Opus/Vorbis sound; this tool "
|
|
1071
|
+
f"encodes {' and '.join(refused)} for it",
|
|
1072
|
+
hint="name a .mp4, .mov or .mkv output (Matroska holds what WebM refuses); "
|
|
1073
|
+
"a transparent VP9 WebM is --keep-alpha --alpha-codec vp9 or export.py --preset vp9-alpha",
|
|
1074
|
+
output=output, container="webm", encoders=refused)
|
|
1075
|
+
|
|
1076
|
+
|
|
993
1077
|
EVEN_SCALE = "scale=trunc(iw/2)*2:trunc(ih/2)*2"
|
|
994
1078
|
|
|
995
1079
|
|
|
@@ -1783,6 +1867,7 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True, ctx: "Op
|
|
|
1783
1867
|
_refuse_url_media(cmd)
|
|
1784
1868
|
_check_no_overwrite_input(cmd)
|
|
1785
1869
|
_check_output_path(cmd)
|
|
1870
|
+
_check_container_codecs(cmd)
|
|
1786
1871
|
_check_existing_output(cmd)
|
|
1787
1872
|
ctx.commands.append(_cmdline(cmd))
|
|
1788
1873
|
if not quiet:
|
package/scripts/_contract.py
CHANGED
|
@@ -171,6 +171,7 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
171
171
|
{"capability": "filter:acompressor", "when": "--compress / --voice"}, {"capability": "filter:alimiter", "when": "--limit / --voice strong"}, {"capability": "filter:agate", "when": "--gate"},
|
|
172
172
|
{"capability": "filter:deesser", "when": "--voice medium (the default) / --voice strong"},
|
|
173
173
|
{"capability": "filter:extrastereo", "when": "--stereo-widen"},
|
|
174
|
+
{"capability": "filter:atempo", "when": "--tempo"},
|
|
174
175
|
{"capability": AAC, "when": "output extension isn't .mp3/.opus/.ogg/.flac (audio_codec_for()'s default)"}] + AUDIO_OUT,
|
|
175
176
|
video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
176
177
|
"loudness": dict(role="analysis_and_execution", inputs=["video or audio asset"], outputs=["loudness measurement JSON (--measure-only)", "normalised artifact (video stream-copied)"],
|
|
@@ -241,12 +242,16 @@ DRY_RUN_ANALYSIS = {
|
|
|
241
242
|
"sync": "audio is decoded to find the offset; the aligned output is not written",
|
|
242
243
|
"multicam": "audio is decoded to align the cameras; the switched output is not written",
|
|
243
244
|
"scenes": "scene and audio-peak measurement runs; --sheet and --edl are not written",
|
|
244
|
-
"report": "probe
|
|
245
|
+
"report": "probe and loudness measurements run (and check.py, with --platform); contact sheets are not drawn under --dry-run (a report_section_dry_run skip); the HTML is not written",
|
|
245
246
|
"cropdetect": "the cropdetect filter runs over the sampled windows to measure bars; this tool never writes a file regardless of --dry-run",
|
|
246
247
|
"silence": "silencedetect runs so the reported silences and keep ranges are real; the cut output is not written. --filler --transcribe also runs the speech engine (its word timings are what the filler spans are planned on), which may download a model on first use; so does --transcribe with --sentence-min-silence or --sentence-margin-tail",
|
|
247
248
|
"loudness": "the loudnorm measurement pass runs so input_i and the planned pass-2 command are real; the normalised output is not written",
|
|
248
249
|
"check": "read-only tool; the loudness measurement (or, when it is skipped, the audio row's volumedetect) and the --content decode run under --dry-run too, so every row is present",
|
|
249
250
|
"stabilize": "vidstabdetect (pass 1, into a temp file) runs; the stabilised output (pass 2) is not written",
|
|
251
|
+
"metadata": "only with --auto-chapters: its silence and scene measurement decodes the input so the proposed chapters are real; otherwise no ffmpeg runs, and the tagged output is never written",
|
|
252
|
+
"cut": "only with --snap beats (and no --snap-source): the audio is decoded to find the beat grid the in/out points snap to; otherwise no ffmpeg runs, and the cut is never written",
|
|
253
|
+
"render": "each stage runs as its own --dry-run, so a stage whose input exists measures it as that tool does under --dry-run (a clip's --snap beats cut, a loudness or silence pass on a source); a stage whose input an earlier stage would write is not measured (measurement_input_pending); nothing is written",
|
|
254
|
+
"batch": "each recipe step runs as its own --dry-run on the item's source, so a step that measures (loudness, silence, check, a --snap beats cut, a render project's stages) measures it; nothing is written",
|
|
250
255
|
"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",
|
|
251
256
|
}
|
|
252
257
|
DRY_RUN_NOTES = {
|
|
@@ -434,6 +439,7 @@ ERROR_REASONS = {
|
|
|
434
439
|
"media_path_is_url": "an input or output has a protocol prefix (tcp:, http://, file:, ...) that ffmpeg would open as a URL; the skill reads and writes local files only; details: {path, scheme}",
|
|
435
440
|
"output_dir_missing": "the output's directory does not exist; details: {output, directory}",
|
|
436
441
|
"output_dir_not_writable": "the output's directory is not writable; details: {output, directory}",
|
|
442
|
+
"output_container_codecs": "a .webm output whose command encodes video or sound WebM cannot hold (H.264/HEVC, AAC; WebM takes VP8/VP9/AV1 and Opus/Vorbis), refused before ffmpeg runs; details: {output, container, encoders}",
|
|
437
443
|
"timemap_path_taken": "silence.py --timemap names the same file as -o, --edl or --plan; details: {timemap, flag, path}",
|
|
438
444
|
"output_locked": "another run holds the output's lock; details: {lock, output, pid, pid_alive, deadline, why}",
|
|
439
445
|
"plan_inputs_changed": "render.py PLAN: a fingerprinted input is missing or changed; details: {plan, plan_sha256, checked_at, "
|
|
@@ -449,6 +455,7 @@ ERROR_REASONS = {
|
|
|
449
455
|
"encoder_missing": "--codec av1/prores: the build's encoder list was read and has no encoder for the codec (kind missing_tool; details: {codec, encoders}); or ffmpeg itself reported \"Unknown encoder\" for the encoder the command named (kind ffmpeg; details: {name}; the hint points at doctor)",
|
|
450
456
|
"asr_engine_missing": "kind missing_tool: --transcribe (caption.py; silence.py with --filler or a sentence flag) found no local speech-to-text engine; the message lists the install lines; details: {}",
|
|
451
457
|
"encoder_list_unreadable": "--codec av1/prores: the build's encoder list could not be read, so whether it has the encoder is unknown (kind ffmpeg); details: {codec, encoders, error}",
|
|
458
|
+
"audio_too_short": "kind input: loudness.py: the audio has a true peak but is shorter than one 400 ms gating block, so it has no integrated loudness to normalise from (not silence); details: {input_tp}",
|
|
452
459
|
"no_speech": "kind input: --transcribe (caption.py; silence.py with --filler or a sentence flag) ran a local speech engine that heard no speech in the input; details: {engine}; also top-level reason and engine",
|
|
453
460
|
"engine_failed": "kind input: --transcribe: an installed speech engine ran and failed (a named --engine, or every engine --engine auto found); details: {engine, detail | engines: [{engine, reason, detail}]}; also top-level reason, engine and detail/engines",
|
|
454
461
|
"english_only": "kind input: --transcribe under --engine auto found only Parakeet engines, which it runs for English speech only, and the language is not English; details: {engine, engines}; also top-level reason, engine and engines",
|
|
@@ -500,7 +507,8 @@ DIAGNOSTIC_CODES = {
|
|
|
500
507
|
"cues_outside_video": ("warnings", "caption.py: cues wholly outside the video are not drawn; {count, duration}"),
|
|
501
508
|
"asr_language_assumed": ("warnings", "--transcribe: the language was assumed (English) and the transcript is wrong if it is not"),
|
|
502
509
|
# analysis
|
|
503
|
-
"
|
|
510
|
+
"speed_length_placeholder": ("skipped", "render.py --dry-run: a clip's length could not be read before its cut exists, so its speed stage was planned on a stand-in 10 s; {stage, clip, path, placeholder_seconds}"),
|
|
511
|
+
"drift_correction_skipped": ("skipped", "sync.py / multicam.py --fix-drift not applied for a source (drift_skipped_reason); {path, reason: too_short | low_confidence, ...}"),
|
|
504
512
|
"sync_low_confidence": ("warnings", "sync.py / multicam.py: a source's offset has a correlation confidence below 0.1; {path, confidence}"),
|
|
505
513
|
"cropdetect_windows_failed": ("warnings", "cropdetect.py: sampled windows ffmpeg could not decode (windows_failed); {failed, windows, starts}"),
|
|
506
514
|
"filler_words_untimed": ("warnings", "silence.py --filler: listed words in the transcript with no usable timing were not removed (filler.matched_untimed); {count}"),
|
|
@@ -521,7 +529,7 @@ DIAGNOSTIC_CODES = {
|
|
|
521
529
|
"normalize_on_by_default": ("warnings", "render.py: a platform export preset with no loudness stage got export.py --normalize by default; {preset}"),
|
|
522
530
|
# degraded or partial results (part 2)
|
|
523
531
|
"colour_not_converted": ("warnings", "export.py: an HDR or wide-gamut source written by a preset that tags SDR BT.709 without converting it; {preset, hdr_format}"),
|
|
524
|
-
"output_audio_silent": ("warnings", "export.py: the written audio is silent (-inf LUFS), so no gain meets the platform's loudness; {platform}"),
|
|
532
|
+
"output_audio_silent": ("warnings", "export.py: the written audio is silent (-inf LUFS and no true peak above the silence floor), so no gain meets the platform's loudness; {platform}"),
|
|
525
533
|
"flash_pass_failed": ("warnings", "check.py --content: the flash pass failed, so the flashes row is not measured (WARN, could not measure); the other rows stand"),
|
|
526
534
|
"repair_incomplete": ("warnings", "export.py --repair: decode errors remain after the remux (a damaged frame needs a re-encode); {timestamp_warnings, decode_errors}"),
|
|
527
535
|
"legalize_residual": ("warnings", "color.py --legalize: samples of the encoded file are still outside the legal range after the clamp (the encoder's error around clamped areas); the shares are in legalize.output"),
|
|
@@ -529,7 +537,7 @@ DIAGNOSTIC_CODES = {
|
|
|
529
537
|
"loudness_outside_spec": ("warnings", "export.py: the written file's loudness is outside the platform's spec (normalized: after --normalize too); {platform, lufs, tp, normalized}"),
|
|
530
538
|
"smart_fallback": ("fallbacks", "cut.py --smart could not make or verify the smart cut, so the whole cut was re-encoded as --accurate would (smart.reason says why); {reason_code}"),
|
|
531
539
|
"camera_not_started": ("fallbacks", "multicam.py: a range asked for a camera that had not started; camera 0 played it ({start, end, requested_camera, camera, starts_at}); `cuts` names the camera rendered"),
|
|
532
|
-
"loudness_not_measured": ("warnings", "export.py: the written file's loudness could not be read, so the platform's loudness was not verified (loudness.ok null, a failing loudness verification step)"),
|
|
540
|
+
"loudness_not_measured": ("warnings", "export.py: the written file's loudness could not be read, or the audio is shorter than one 400 ms gating block (loudness.too_short), so the platform's loudness was not verified (loudness.ok null, a failing loudness verification step)"),
|
|
533
541
|
"loudness_no_audio": ("skipped", "export.py: a platform preset's loudness check, on a source with no audio"),
|
|
534
542
|
"report_section_missing": ("warnings", "report.py: a loudness, compliance or contact-sheet section could not be built (also a failing verification step naming the section)"),
|
|
535
543
|
"report_section_dry_run": ("skipped", "report.py --dry-run draws no contact sheet"),
|
|
@@ -608,11 +616,11 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
608
616
|
"video": {"type": ["object", "null"]}, "audio": {"type": ["object", "null"]},
|
|
609
617
|
"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"},
|
|
610
618
|
"chapters": {"type": "array", "description": "the container's chapter markers [{index, start, end, title}]"},
|
|
611
|
-
"subtitle_stream_details": {"type": "array", "description": "every subtitle stream in file order [{index (the n of -map 0:s:n), codec, language, title}]"},
|
|
619
|
+
"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)}]"},
|
|
612
620
|
"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)"}},
|
|
613
621
|
"additionalProperties": True}
|
|
614
622
|
base = {"status": {"enum": ["completed"]}, "output": {"type": ["string", "null"], "description": "path written, or null"},
|
|
615
|
-
"dry_run": {"type": "boolean"}, "commands": {"type": "array", "items": {"type": "string"}, "description": "
|
|
623
|
+
"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)"},
|
|
616
624
|
"probe": {"type": "object", "description": "probe of the output when a file was written"},
|
|
617
625
|
"output_identity": {"type": "object", "description": "2.24: {size, mtime_ns} of the file this run wrote, absent under --dry-run; pass it to check.py --expect-identity SIZE:MTIME_NS so the check grades this file and not whatever later sits at the path"},
|
|
618
626
|
"encoder": {"type": "string", "description": "the video encoder the last ffmpeg command used (after any GPU->CPU fallback; under --dry-run the planned one; render.py and waveform.py: their stages' commands included), `copy` for a stream copy; absent when no command encoded video"},
|
|
@@ -630,7 +638,7 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
630
638
|
"levels": {"type": "object", "description": "--levels only: {standard, reference, bit_depth, legal {y, c}: inclusive legal code ranges at the file's depth, window_seconds, frames, out_of_range_pct {y, u, v, chroma}: share of samples outside them (chroma: Cb and Cr samples together), brng_pct (signalstats BRNG: pixels with any component outside the fixed BT.601/709 nominal range), worst_frame {time, y_pct, chroma_pct}, min {y, u, v}, max {y, u, v}, windows: the same per --levels-window seconds ({start, end, ...})}"},
|
|
631
639
|
"change_scope": {"type": "object", "description": "--expect-changed only, when the two files could be compared: {declared [{start, end, rect}], observed [{start, end, frames, rect [x, y, w, h], peak}]: every span with a tile above the floor, undeclared: the same for the tiles no declaration covers, declared_unchanged: declared spans in which nothing changed, floor, tile [columns, rows], frames, quiet_peak: the largest tile at or under the floor}. Times are this file's presentation times in seconds, rectangles are displayed pixels"},
|
|
632
640
|
"notes": {"type": "array", "items": {"type": "string"}, "description": "present when no --platform was named (youtube was assumed and judgement rows are WARN), when --compare paired a different number of frames than the reference has, or when --expect-changed compared two files that are not encoded alike"},
|
|
633
|
-
"checks": {"type": "array", "items": {"type": "object", "properties": {"check": {"type": "string", "description": "row name; --content adds black, frozen and silence; --compare adds similarity; --expect-changed adds change_scope; --levels adds legal levels; --expect-identity adds identity; audio FAILs when the track is present but silent (peak <= -50 dBFS)"}, "status": {"enum": ["PASS", "WARN", "FAIL"]}, "value": {}, "expected": {}, "fix": {"type": "string"}, "kind": {"enum": ["format", "judgement"]}}}}}
|
|
641
|
+
"checks": {"type": "array", "items": {"type": "object", "properties": {"check": {"type": "string", "description": "row name; --content adds black, frozen and silence; --compare adds similarity; --expect-changed adds change_scope; --levels adds legal levels; --expect-identity adds identity; audio FAILs when the track is present but silent (peak <= -50 dBFS). A value that could not be read is \"could not measure\", never a PASS: duration FAILs (WARN without a length limit) when the file states no length, and black, frozen and silence are then WARN rows; loudness is WARN for audio shorter than one 400 ms block; --tp without --lufs (custom) measures the true peak row"}, "status": {"enum": ["PASS", "WARN", "FAIL"]}, "value": {}, "expected": {}, "fix": {"type": "string"}, "kind": {"enum": ["format", "judgement"]}}}}}
|
|
634
642
|
elif name == "caption":
|
|
635
643
|
extra = {"transcription": {"type": "object", "description": "with --transcribe: {engine, model, language, routing (why auto chose it), detected_language when auto detected it}; when auto ran the English-only Parakeet model on English assumed, notes says so"},
|
|
636
644
|
"caption": {"type": "object", "description": "cue layout: shifted / wrapped / rebalanced / split / extended / dropped counts, plus wrap ('phrase' or 'measured'), phrase_breaks (1.16), broken_inside_word -- atoms hard-sliced at the column edge because they did not fit alone even at the size floor (1.18.4) -- and overlong -- now residual: a single character alone wider than the column. Burn mode: cues_burned -- non-blank cues that overlap [0, duration] and are drawn -- and cues_outside -- non-blank cues wholly outside it (2.2.6); a burn with no visible cue is refused, kind input. --ass: ass_malformed -- Dialogue lines that could not be read and are not counted -- and with --offset ass_shifted / ass_unshifted -- lines moved, and lines whose timestamps could not be read, copied as they were (each count above 0 is also a diagnostics.warnings entry)"},
|
|
@@ -705,7 +713,7 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
705
713
|
extra = {"report": {"type": "string"}, "check": {"type": ["object", "null"], "description": "check.py's document for --platform; null when it was not asked for or could not run (a failing compliance verification step then says why)"}}
|
|
706
714
|
elif name == "export":
|
|
707
715
|
extra = {"repair": {"type": "object", "description": "--repair (2.24): {before, after}, each {timestamp_warnings, decode_errors} counted over one full decode at -v warning"},
|
|
708
|
-
"loudness": {"type": "object", "description": "platform presets with audio: the written file's lufs/tp against the platform's target_lufs/target_tp, ok true when inside the spec; normalized true when --normalize ran loudness.py on the file; silent true (lufs \"-inf\", ok false, no normalize) when the written audio is silent; measured false (lufs, tp and ok null, reason not_measured) when the measurement could not be read"},
|
|
716
|
+
"loudness": {"type": "object", "description": "platform presets with audio: the written file's lufs/tp against the platform's target_lufs/target_tp, ok true when inside the spec; normalized true when --normalize ran loudness.py on the file; silent true (lufs \"-inf\", ok false, no normalize) when the written audio is silent; measured false (lufs, tp and ok null, reason not_measured) when the measurement could not be read, and with too_short true (lufs and ok null, tp measured) when the audio is shorter than one 400 ms gating block, which is not silence"},
|
|
709
717
|
"alpha_mode": {"enum": ["straight", "premultiplied"], "description": "alpha presets only (prores4444, prores4444xq, qtrle, vp9-alpha, hap-alpha): how the colour was written, from --alpha-mode (default straight). The `alpha` verification step compares the written file's alpha with the source's frame by frame, tolerance 2 of 255 per frame (comparison: {ok, paired, worst_delta, worst_frame}; with a different frame count the mean and the most transparent frame, paired false; frames: {expected, measured}, each {mean, min, frames}); `codec` compares the written codec and profile with the preset's"},
|
|
710
718
|
"key": {"type": "object", "description": "--preset key-fill only: the key file {output (<out stem>_key.mov), identity, sha256, timecode (the source's start timecode written on both, or null)}. Verification adds key_fill_pair (width, height, frame count, rate and the video streams' lengths equal on both files) and key_level (the key's level against the source's alpha frame by frame, tolerance 2 of 255); the source's subtitle/data streams are not written (non_av_streams.dropped, reason key_fill)"},
|
|
711
719
|
"fill_sha256": {"type": "string", "description": "--preset key-fill only: the fill file's SHA-256 (the key's is key.sha256)"},
|
|
@@ -714,9 +722,9 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
714
722
|
"dropped_non_av_streams": {"type": "boolean", "description": "true when non_av_streams.dropped is not empty; a notes line says so too"},
|
|
715
723
|
"notes": {"type": "array", "items": {"type": "string"}}}
|
|
716
724
|
elif name == "loudness":
|
|
717
|
-
extra = {"measured": {"type": "object", "description": "the loudnorm measurement of the input (input_i, input_tp, input_lra, input_thresh, target_offset); with --measure-only it is the whole result"},
|
|
725
|
+
extra = {"measured": {"type": "object", "description": "the loudnorm measurement of the input (input_i, input_tp, input_lra, input_thresh, target_offset); with --measure-only it is the whole result. silent true (input_i \"-inf\") when the audio has no sound; too_short true (input_i null, input_tp measured) when it is shorter than one 400 ms gating block: not silence, and a normalisation is refused with error.reason audio_too_short"},
|
|
718
726
|
"targets": {"type": "object", "description": "the requested lufs / tp / lra"},
|
|
719
|
-
"result": {"type": "object", "description": "the written file measured again (input_i, input_tp, input_lra, ...), plus tp_ceiling_used, audio_bitrate_used and
|
|
727
|
+
"result": {"type": "object", "description": "the written file measured again (input_i, input_tp, input_lra, ...), plus tp_ceiling_used, audio_bitrate_used, encodes and loudnorm_mode (linear, or dynamic when loudnorm could not apply one linear gain: the input's true peak plus the gain passes the ceiling, or its range is 0 or above --lra); note says why the loudness missed the target, naming dynamic mode when that is the cause. A loudness step on an output with no integrated loudness is ok false with reason not_measured"},
|
|
720
728
|
"versions": {"type": "array", "description": "--versions only (output is then null): one row per target, {target, output, targets, result}; every row's loudness and duration steps are in verification with a `version` key, and verified is true only when every version met its own target"},
|
|
721
729
|
"outputs": {"type": "array", "items": {"type": "string"}, "description": "--versions only: the files written, in the order asked"}}
|
|
722
730
|
elif name == "color":
|
|
@@ -768,7 +776,9 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
768
776
|
"segment_end_snap_seconds": {"type": ["array", "null"], "items": {"type": ["number", "null"]}, "description": "--segments stream-copy join: per segment, the signed seconds its end moved to the keyframe the copied part stops at (the first keyframe at or after the requested end, never an earlier one; null for a part that runs to the end of the video, a part of a source with no B-frames, which is cut to its requested end as in every 2.x release, or a Matroska/MPEG-TS part, which is not snapped). null as a whole unless the output is those copied parts: a single segment, audio, --dry-run, or any re-encode or fallback"}}
|
|
769
777
|
elif name == "multicam":
|
|
770
778
|
extra = {"cuts": {"type": "array", "description": "[[start, end, camera], ...] on the reference timeline: the camera actually rendered. A range whose camera had not started yet plays camera 0, and diagnostics.fallbacks names the camera asked for (code camera_not_started)"},
|
|
771
|
-
"offsets_seconds": {"type": "array"}, "confidence": {"type": "array"}
|
|
779
|
+
"offsets_seconds": {"type": "array"}, "confidence": {"type": "array"},
|
|
780
|
+
"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)"},
|
|
781
|
+
"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"}}
|
|
772
782
|
elif name == "join":
|
|
773
783
|
extra = {"mode": {"enum": ["video", "audio"]}, "clips": {"type": "integer"}, "transition": {"type": "string"},
|
|
774
784
|
"expected_duration": {"type": ["number", "null"], "description": "clip lengths minus the transitions; null under --dry-run while an input is pending (its length is unknown)"},
|
|
@@ -789,7 +799,8 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
789
799
|
"dynamics": {"type": "array", "items": {"enum": ["agate", "acompressor", "alimiter"]}, "description": "typed dynamics filters applied, in graph order"},
|
|
790
800
|
"silent": {"type": "array", "description": "--on-silent warn (default), only when a track was silent: [{flag, path, peak_db}] for each --music/--replace/--effects file, or under --duck the voice (flag \"input\"), whose whole-file peak is at or below --silence-threshold (default -50 dBFS); it was mixed anyway. --on-silent fail refuses instead, reason \"silent (peak X dBFS)\" in problems"},
|
|
791
801
|
"notes": {"type": "array", "items": {"type": "string"}, "description": "present with `silent`: which tracks were silent and how to refuse them"},
|
|
792
|
-
"
|
|
802
|
+
"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)"},
|
|
803
|
+
"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)"}}
|
|
793
804
|
if name in ("overlay", "graphics", "caption", "grid"):
|
|
794
805
|
extra["font_used"] = FONT_USED_SCHEMA
|
|
795
806
|
if name in ALPHA_LINE_TOOLS:
|
package/scripts/_smartcut.py
CHANGED
|
@@ -34,6 +34,7 @@ from fractions import Fraction
|
|
|
34
34
|
from typing import Any, Dict, List, Optional, Tuple
|
|
35
35
|
|
|
36
36
|
from _common import STATE, aac_args, concat_list_line, ffmpeg_base, require_tool, run
|
|
37
|
+
from _common.color import carried_tag_args
|
|
37
38
|
from _common.runner import fail_if_classified
|
|
38
39
|
|
|
39
40
|
# the stream parameters a re-encoded part must share with the source (ffprobe -show_streams)
|
|
@@ -129,11 +130,12 @@ def x264_line(params: Dict[str, Any], crf: int, preset: str) -> List[str]:
|
|
|
129
130
|
"-x264-params", f"{b}:open-gop=0"]
|
|
130
131
|
if level:
|
|
131
132
|
args += ["-level:v", f"{level // 10}.{level % 10}"]
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
133
|
+
# spelled the way the output options take them (#454: ffprobe's bt470m/bt470bg transfer and
|
|
134
|
+
# gbr space are not their names)
|
|
135
|
+
args += carried_tag_args([("-colorspace", params.get("color_space")), ("-color_primaries", params.get("color_primaries")),
|
|
136
|
+
("-color_trc", params.get("color_transfer"))])
|
|
137
|
+
if params.get("color_range") and params.get("color_range") != "unknown":
|
|
138
|
+
args += ["-color_range", str(params["color_range"])]
|
|
137
139
|
return args
|
|
138
140
|
|
|
139
141
|
|
package/scripts/audio.py
CHANGED
|
@@ -18,6 +18,8 @@ Examples:
|
|
|
18
18
|
python3 audio.py interview.mp4 -o interview.wav # extract the audio (no video in the output)
|
|
19
19
|
python3 audio.py multi.mkv --audio-stream 1 --voice -o lav.m4a # pick the second audio track, clean it, write M4A
|
|
20
20
|
python3 audio.py talk.wav --compress --comp-threshold -20 --comp-ratio 4 --limit --limit-ceiling -1 -o talk_dyn.wav
|
|
21
|
+
python3 audio.py line.wav --tempo 1.25 --length-samples 96000 -o line_fit.wav # faster, same pitch, exactly 2 s at 48 kHz
|
|
22
|
+
python3 audio.py room.wav --channel 2 --sample-rate 48000 -o mic3.wav # the third microphone alone, resampled to 48 kHz
|
|
21
23
|
"""
|
|
22
24
|
import argparse
|
|
23
25
|
import os
|
|
@@ -42,7 +44,7 @@ VOICE_CHAIN = "highpass=f=80,deesser=i=0.4,afftdn=nf=-25:tn=1,acompressor=thresh
|
|
|
42
44
|
VOICE_LEVELS = {
|
|
43
45
|
"light": "highpass=f=80,acompressor=threshold=-18dB:ratio=2:attack=5:release=80:makeup=1",
|
|
44
46
|
"medium": VOICE_CHAIN,
|
|
45
|
-
"strong": VOICE_CHAIN + ",deesser=i=0.6,acompressor=threshold=-24dB:ratio=4:attack=5:release=120:makeup=3,alimiter=limit=0.891251:level=disabled",
|
|
47
|
+
"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",
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
# The sidechain threshold the music bed has used since 1.4 is the linear 0.05 that ffmpeg's
|
|
@@ -95,6 +97,9 @@ def dynamics_filter(name: str, args: argparse.Namespace) -> str:
|
|
|
95
97
|
opts.append(f"{opt}={value:g}")
|
|
96
98
|
if name == "alimiter":
|
|
97
99
|
opts.append("level=disabled") # keep the level: a limiter must not normalise the whole track upwards
|
|
100
|
+
# latency compensation: without it the output is the input delayed by the look-ahead
|
|
101
|
+
# (--limit-attack, 5 ms by default: 240 samples at 48 kHz) and drifts off the picture (#476)
|
|
102
|
+
opts.append("latency=1")
|
|
98
103
|
return name + ("=" + ":".join(opts) if opts else "")
|
|
99
104
|
|
|
100
105
|
|
|
@@ -173,6 +178,90 @@ def main_track_channels(args: argparse.Namespace, meta: Dict[str, Any]) -> int:
|
|
|
173
178
|
return (meta.get("audio") or {}).get("channels") or 0
|
|
174
179
|
|
|
175
180
|
|
|
181
|
+
def main_track_rate(args: argparse.Namespace, meta: Dict[str, Any]) -> int:
|
|
182
|
+
"""Sample rate of the track audio.py processes (as main_track_channels() picks it); 0 when it
|
|
183
|
+
is not known (a dry run's pending input)."""
|
|
184
|
+
if args.replace:
|
|
185
|
+
if dry_run_input_pending(args.replace) or not os.path.isfile(args.replace):
|
|
186
|
+
return 0
|
|
187
|
+
proc = run([require_tool("ffprobe"), "-v", "error", "-select_streams", "a:0", "-show_entries",
|
|
188
|
+
"stream=sample_rate", "-of", "csv=p=0", args.replace], quiet=True, check=False)
|
|
189
|
+
first = (proc.stdout or "").strip().splitlines()[:1]
|
|
190
|
+
return int(first[0]) if first and first[0].strip().isdigit() else 0
|
|
191
|
+
streams = meta.get("audio_streams") or []
|
|
192
|
+
track = streams[args.audio_stream] if 0 <= args.audio_stream < len(streams) else (meta.get("audio") or {})
|
|
193
|
+
try:
|
|
194
|
+
return int(track.get("sample_rate") or 0)
|
|
195
|
+
except (TypeError, ValueError):
|
|
196
|
+
return 0
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# --tempo: atempo keeps the pitch; one stage is kept within 0.5..2.0 (above 2 it drops whole
|
|
200
|
+
# blocks of samples), so a factor outside that range is a chain of stages whose product is it (#486)
|
|
201
|
+
TEMPO_RANGE = (0.1, 10.0)
|
|
202
|
+
LOSSLESS_AUDIO = ("pcm_", "flac", "alac", "wavpack", "tta")
|
|
203
|
+
# samples per frame of the lossy encoders audio_codec_for() picks: a decoded length can differ from
|
|
204
|
+
# the samples written by the encoder's own padding, by at most one frame
|
|
205
|
+
CODEC_FRAME_SAMPLES = {"aac": 1024, "mp3": 1152, "opus": 960, "vorbis": 2048, "ac3": 1536, "eac3": 1536}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def atempo_steps(factor: float) -> List[float]:
|
|
209
|
+
"""atempo stages, each in 0.5..2.0, whose product is `factor`."""
|
|
210
|
+
steps: List[float] = []
|
|
211
|
+
while factor > 2.0:
|
|
212
|
+
steps.append(2.0)
|
|
213
|
+
factor /= 2.0
|
|
214
|
+
while factor < 0.5:
|
|
215
|
+
steps.append(0.5)
|
|
216
|
+
factor /= 0.5
|
|
217
|
+
return steps + [factor]
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def decoded_samples(path: str) -> "int | None":
|
|
221
|
+
"""Samples per channel of the first audio track, counted by decoding it (astats); None when
|
|
222
|
+
the count could not be read."""
|
|
223
|
+
proc = run([require_tool("ffmpeg"), "-hide_banner", "-nostats", "-i", path, "-map", "0:a:0", "-af",
|
|
224
|
+
"astats=measure_perchannel=none:measure_overall=Number_of_samples", "-f", "null", "-"],
|
|
225
|
+
quiet=True, check=False)
|
|
226
|
+
found = [line.rsplit(":", 1)[-1].strip() for line in (proc.stderr or "").splitlines() if "Number of samples:" in line]
|
|
227
|
+
return int(found[-1]) if proc.returncode == 0 and found and found[-1].isdigit() else None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def length_step(path: str, codec: str, expected: int) -> Dict[str, Any]:
|
|
231
|
+
"""The `length_samples` verification step: the written track's decoded length against
|
|
232
|
+
--length-samples, exact for a lossless codec, within one codec frame for a lossy one."""
|
|
233
|
+
measured = decoded_samples(path)
|
|
234
|
+
lossless = codec.startswith(LOSSLESS_AUDIO)
|
|
235
|
+
tolerance = 0 if lossless else CODEC_FRAME_SAMPLES.get(codec, 2048)
|
|
236
|
+
step: Dict[str, Any] = {"step": "length_samples", "expected": expected, "measured": measured,
|
|
237
|
+
"tolerance_samples": tolerance, "codec": codec}
|
|
238
|
+
if measured is None:
|
|
239
|
+
return dict(step, ok=False, reason="not_measured")
|
|
240
|
+
return dict(step, ok=abs(measured - expected) <= tolerance)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
SAMPLE_RATE_RANGE = (1000, 768000)
|
|
244
|
+
OPUS_DECODED_RATE = 48000 # what an Opus stream decodes at, whatever rate was encoded (measured: 16 kHz in, 48 kHz out)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def property_step(name: str, expected: int, measured: Any) -> Dict[str, Any]:
|
|
248
|
+
"""A verification step comparing one probed property of the written track with the request
|
|
249
|
+
(#487: `sample_rate`, `channels`); `reason: not_measured` when probe could not read it."""
|
|
250
|
+
try:
|
|
251
|
+
value = int(measured)
|
|
252
|
+
except (TypeError, ValueError):
|
|
253
|
+
return {"step": name, "expected": expected, "measured": None, "ok": False, "reason": "not_measured"}
|
|
254
|
+
return {"step": name, "expected": expected, "measured": value, "ok": value == expected}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def conform_filter(samples: int, rate: int) -> str:
|
|
258
|
+
"""Pad with silence or trim to exactly `samples` at `rate` (#478): counted in samples, not in
|
|
259
|
+
seconds written to the millisecond, which left a result up to 24 samples (48 kHz) off. The
|
|
260
|
+
aformat pins the rate the count is in, so a resampler FFmpeg inserts sits ahead of the count."""
|
|
261
|
+
head = f"aformat=sample_rates={rate}," if rate else ""
|
|
262
|
+
return f"{head}apad=whole_len={samples},atrim=end_sample={samples}"
|
|
263
|
+
|
|
264
|
+
|
|
176
265
|
def main() -> int:
|
|
177
266
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
178
267
|
ap.add_argument("input")
|
|
@@ -209,8 +298,21 @@ def main() -> int:
|
|
|
209
298
|
help="widen the stereo image, 0..1 (0 = untouched, 1 = maximum); needs a real stereo source: a mono input is "
|
|
210
299
|
"refused (duplicating it leaves both channels identical, so there is nothing to widen) and more than two "
|
|
211
300
|
"channels are refused unless --downmix folds them to stereo first")
|
|
301
|
+
fades.add_argument("--channel", type=int, default=None, metavar="N",
|
|
302
|
+
help="keep only channel N of the track (0-based, in the track's own order), as a 1-channel output "
|
|
303
|
+
"(--stereo duplicates it to both sides); checked on the output by probe")
|
|
304
|
+
fades.add_argument("--sample-rate", type=int, default=None, metavar="HZ",
|
|
305
|
+
help="resample the output to HZ (aresample); checked on the output by probe. Default: the track's own rate")
|
|
212
306
|
fades.add_argument("--downmix", action="store_true", help="downmix 5.1/7.1 to stereo with the ITU-R BS.775 weights (centre and surrounds -3 dB, LFE dropped), scaled so the fold-down cannot clip")
|
|
213
307
|
fades.add_argument("--replace", help="replace the audio with this file (trimmed/padded to the video)")
|
|
308
|
+
timing = ap.add_argument_group("timing (an audio output, or --replace beside a kept picture)")
|
|
309
|
+
timing.add_argument("--tempo", type=float, default=None, metavar="FACTOR",
|
|
310
|
+
help="change the tempo of the main track keeping its pitch (atempo; 2 = twice as fast, half as long), "
|
|
311
|
+
f"{TEMPO_RANGE[0]:g}..{TEMPO_RANGE[1]:g}; outside 0.5..2.0 it is a chain of atempo stages. "
|
|
312
|
+
"Refused on the input's own sound while its picture is stream-copied (use fit.py --method speed)")
|
|
313
|
+
timing.add_argument("--length-samples", type=int, default=None, metavar="N",
|
|
314
|
+
help="pad with silence or trim the output to exactly N samples per channel at the output rate "
|
|
315
|
+
"(an audio output only: beside a picture the sound is conformed to the picture)")
|
|
214
316
|
dyn = ap.add_argument_group("dynamics (typed; each flag is one option of ffmpeg's acompressor / alimiter / agate)")
|
|
215
317
|
dyn.add_argument("--compress", action="store_true", help="compressor (acompressor); order: gate -> compressor -> limiter")
|
|
216
318
|
dyn.add_argument("--comp-threshold", type=float, help="dBFS above which gain is reduced, -60..0 (ffmpeg default -12.4)")
|
|
@@ -299,6 +401,31 @@ def main() -> int:
|
|
|
299
401
|
# or --replace's first track. meta["audio"] is the input's first track, so a mono a:0 left a
|
|
300
402
|
# stereo --audio-stream 1 unfolded by --mono, and a stereo a:0 halved a mono one (A128)
|
|
301
403
|
in_channels = main_track_channels(args, meta)
|
|
404
|
+
keep_video = has_video and not audio_out
|
|
405
|
+
if args.tempo is not None:
|
|
406
|
+
if not (TEMPO_RANGE[0] <= args.tempo <= TEMPO_RANGE[1]):
|
|
407
|
+
die(f"--tempo must be {TEMPO_RANGE[0]:g}..{TEMPO_RANGE[1]:g}, got {args.tempo:g}")
|
|
408
|
+
if keep_video and not args.replace:
|
|
409
|
+
die("--tempo changes the length of the input's own sound, and its picture is stream-copied, so the two "
|
|
410
|
+
"would drift apart; write an audio output (-o NAME.wav), or retime both with fit.py --method speed",
|
|
411
|
+
kind="input")
|
|
412
|
+
if args.length_samples is not None:
|
|
413
|
+
if args.length_samples < 1:
|
|
414
|
+
die(f"--length-samples must be a positive number of samples, got {args.length_samples}")
|
|
415
|
+
if keep_video:
|
|
416
|
+
die("--length-samples needs an audio output: beside a stream-copied picture the sound is conformed to "
|
|
417
|
+
"the picture's length; write -o NAME.wav (or .flac, .m4a, ...)", kind="input")
|
|
418
|
+
if args.sample_rate is not None and not (SAMPLE_RATE_RANGE[0] <= args.sample_rate <= SAMPLE_RATE_RANGE[1]):
|
|
419
|
+
die(f"--sample-rate must be {SAMPLE_RATE_RANGE[0]}..{SAMPLE_RATE_RANGE[1]} Hz, got {args.sample_rate}")
|
|
420
|
+
if args.channel is not None:
|
|
421
|
+
if args.channel < 0:
|
|
422
|
+
die(f"--channel is a 0-based channel index, got {args.channel}")
|
|
423
|
+
if args.downmix:
|
|
424
|
+
die("--channel and --downmix contradict each other: --channel keeps one channel, --downmix folds them all to stereo")
|
|
425
|
+
if in_channels and args.channel >= in_channels:
|
|
426
|
+
die(f"--channel {args.channel}: the track has {in_channels} channel(s), 0..{in_channels - 1}",
|
|
427
|
+
kind="input", channels=in_channels)
|
|
428
|
+
in_channels = 1 # what every later stage sees: the one channel kept
|
|
302
429
|
if args.stereo_widen is not None:
|
|
303
430
|
if args.mono:
|
|
304
431
|
die("--stereo-widen and --mono contradict each other: there is no stereo image in a 1-channel output")
|
|
@@ -321,6 +448,9 @@ def main() -> int:
|
|
|
321
448
|
idx += 1
|
|
322
449
|
|
|
323
450
|
fx: List[str] = []
|
|
451
|
+
if args.channel is not None:
|
|
452
|
+
# #487: one channel by index, first, so every later stage works on it alone
|
|
453
|
+
fx.append(f"pan=mono|c0=c{args.channel}")
|
|
324
454
|
if args.downmix:
|
|
325
455
|
# ITU-R BS.775: centre and surrounds at -3 dB (0.707), LFE dropped. pan's '<' scales each
|
|
326
456
|
# output so its gains sum to 1 over the channels the input has (1/2.414 on 5.1, 1/3.121 on
|
|
@@ -357,6 +487,22 @@ def main() -> int:
|
|
|
357
487
|
# as it goes before the centre collapses. It runs after the channel layout is settled, so
|
|
358
488
|
# a --downmix 5.1 source is widened on the stereo fold-down rather than on six channels.
|
|
359
489
|
fx.append(f"extrastereo=m={1 + 2 * args.stereo_widen:g}")
|
|
490
|
+
tempo_steps = atempo_steps(args.tempo) if args.tempo is not None and args.tempo != 1.0 else []
|
|
491
|
+
if tempo_steps:
|
|
492
|
+
fx.append(",".join(f"atempo={f:.10g}" for f in tempo_steps))
|
|
493
|
+
# the main track's length in the output: the source's, or the source's / --tempo on an audio
|
|
494
|
+
# output of the input's own sound (beside a picture, or for --replace, it is the picture's)
|
|
495
|
+
main_dur = dur / args.tempo if dur and tempo_steps and not (keep_video or args.replace) else dur
|
|
496
|
+
rate = args.sample_rate or main_track_rate(args, meta)
|
|
497
|
+
# where the output ends, which is where a fade-out has to end: --length-samples when it is
|
|
498
|
+
# given (a fade timed on the source's length sat past the trim and was never heard)
|
|
499
|
+
end_dur = args.length_samples / rate if args.length_samples is not None and rate else main_dur
|
|
500
|
+
if args.length_samples is not None and rate and rate != OPUS_DECODED_RATE and "libopus" in audio_codec_for(output, args.bitrate):
|
|
501
|
+
# An Opus stream decodes at 48 kHz whatever rate went in (a 16 kHz track too), so the
|
|
502
|
+
# decoded track has N * 48000 / rate samples and the count asked for is not the file's
|
|
503
|
+
die(f"--length-samples counts at {rate} Hz, and an Opus output decodes at {OPUS_DECODED_RATE} Hz: "
|
|
504
|
+
f"the file would not hold {args.length_samples} samples",
|
|
505
|
+
kind="input", hint="add --sample-rate 48000 and give the length in 48 kHz samples, or write .wav/.flac")
|
|
360
506
|
|
|
361
507
|
graph: List[str] = []
|
|
362
508
|
graph.append(f"[{main_src}]{','.join(fx) if fx else 'anull'}[main]")
|
|
@@ -369,9 +515,9 @@ def main() -> int:
|
|
|
369
515
|
inputs += ["-i", args.music]
|
|
370
516
|
m = f"{idx}:a:0"
|
|
371
517
|
idx += 1
|
|
372
|
-
mfx = [f"volume={args.music_volume:g}dB", f"atrim=0:{
|
|
373
|
-
if args.music_fade_out and
|
|
374
|
-
mfx.append(f"afade=t=out:st={max(0.0,
|
|
518
|
+
mfx = [f"volume={args.music_volume:g}dB", f"atrim=0:{main_dur:.6f}" if main_dur else "anull"]
|
|
519
|
+
if args.music_fade_out and end_dur:
|
|
520
|
+
mfx.append(f"afade=t=out:st={max(0.0, end_dur - args.music_fade_out):.3f}:d={args.music_fade_out:g}")
|
|
375
521
|
graph.append(f"[{m}]{','.join(mfx)}[music]")
|
|
376
522
|
if args.duck:
|
|
377
523
|
graph.append("[main]asplit=2[mainA][sc]")
|
|
@@ -390,7 +536,7 @@ def main() -> int:
|
|
|
390
536
|
inputs += ["-i", args.effects]
|
|
391
537
|
e = f"{idx}:a:0"
|
|
392
538
|
idx += 1
|
|
393
|
-
efx = [f"volume={args.effects_volume:g}dB", f"atrim=0:{
|
|
539
|
+
efx = [f"volume={args.effects_volume:g}dB", f"atrim=0:{main_dur:.6f}" if main_dur else "anull"]
|
|
394
540
|
graph.append(f"[{e}]{','.join(efx)}[effects]")
|
|
395
541
|
graph.append(f"[{last}][effects]amix=inputs=2:duration=first:dropout_transition=2:normalize=0[mixfx]")
|
|
396
542
|
last = "mixfx"
|
|
@@ -398,17 +544,35 @@ def main() -> int:
|
|
|
398
544
|
post: List[str] = []
|
|
399
545
|
if args.fade_in:
|
|
400
546
|
post.append(f"afade=t=in:st=0:d={args.fade_in:g}")
|
|
401
|
-
if args.fade_out and
|
|
402
|
-
post.append(f"afade=t=out:st={max(0.0,
|
|
547
|
+
if args.fade_out and end_dur:
|
|
548
|
+
post.append(f"afade=t=out:st={max(0.0, end_dur - args.fade_out):.3f}:d={args.fade_out:g}")
|
|
403
549
|
# The audio track is conformed to the source duration whenever it is known: padded with
|
|
404
550
|
# silence if the graph came out short, trimmed if long. A mixed track can come out a few
|
|
405
551
|
# hundredths short (amix's dropout_transition, a looped bed's atrim boundary), and with
|
|
406
552
|
# -shortest below that used to shorten the *video* to match: 12.00 s in, 11.925 s out, four
|
|
407
553
|
# frames of a stream-copied picture gone (#164). Padding the audio, not cutting the picture,
|
|
408
554
|
# is the only correct answer for a tool whose contract says the video is never touched.
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
post.append(f"
|
|
555
|
+
if args.sample_rate:
|
|
556
|
+
# #487: resampled after the mix and the fades, ahead of the sample count, which is in this rate
|
|
557
|
+
post.append(f"aresample={args.sample_rate}")
|
|
558
|
+
conform_samples = None
|
|
559
|
+
if args.length_samples is not None:
|
|
560
|
+
# #486: an exact length asked for, counted at the track's rate (unknown only for a dry
|
|
561
|
+
# run's pending input, whose real run pins it)
|
|
562
|
+
conform_samples = args.length_samples
|
|
563
|
+
post.append(conform_filter(conform_samples, rate))
|
|
564
|
+
elif main_dur and (args.replace or args.music or keep_video):
|
|
565
|
+
if rate:
|
|
566
|
+
conform_samples = round(main_dur * rate)
|
|
567
|
+
# The count is of samples, and sound that starts after the picture keeps its start
|
|
568
|
+
# (apad and atrim=end_sample do not move timestamps): N samples from 0.3 s end 0.3 s
|
|
569
|
+
# past the picture. The trim on the timestamp, which is what this was before #478,
|
|
570
|
+
# ends it with the picture; half a sample of slack keeps the Nth sample of a track
|
|
571
|
+
# that starts at 0.
|
|
572
|
+
post.append(conform_filter(conform_samples, rate) + f",atrim=end={main_dur + 0.5 / rate:.6f}")
|
|
573
|
+
else:
|
|
574
|
+
# a dry run's pending input: no rate to count in yet; the real run counts samples
|
|
575
|
+
post.append(f"apad,atrim=0:{main_dur:.6f}")
|
|
412
576
|
if post:
|
|
413
577
|
graph.append(f"[{last}]{','.join(post)}[out]")
|
|
414
578
|
last = "out"
|
|
@@ -444,7 +608,18 @@ def main() -> int:
|
|
|
444
608
|
"ratio": round(max(2.0, args.duck_amount / 3), 1),
|
|
445
609
|
"attack_ms": args.duck_attack, "release_ms": args.duck_release}
|
|
446
610
|
if args.duck else None)
|
|
447
|
-
|
|
611
|
+
verification: List[Dict[str, Any]] = []
|
|
612
|
+
if args.length_samples is not None and not STATE.dry_run:
|
|
613
|
+
verification.append(length_step(output, str(a.get("codec") or ""), args.length_samples))
|
|
614
|
+
if args.sample_rate is not None and not STATE.dry_run:
|
|
615
|
+
verification.append(property_step("sample_rate", args.sample_rate, a.get("sample_rate")))
|
|
616
|
+
if args.channel is not None and not STATE.dry_run:
|
|
617
|
+
verification.append(property_step("channels", 2 if args.stereo else 1, a.get("channels")))
|
|
618
|
+
audio_block["channel"] = args.channel
|
|
619
|
+
audio_block["sample_rate"] = args.sample_rate
|
|
620
|
+
timing_block = {"tempo": args.tempo, "atempo": tempo_steps, "length_samples": args.length_samples,
|
|
621
|
+
"sample_rate": rate or None}
|
|
622
|
+
emit(output, audio=audio_block, timing=timing_block, **({"verification": verification} if verification else {}), video=bool(has_video and not audio_out), audio_stream=args.audio_stream,
|
|
448
623
|
dynamics=[f for f in (args.gate and "agate", args.compress and "acompressor", args.limit and "alimiter") if f],
|
|
449
624
|
dropped_non_av_streams=dropped_streams,
|
|
450
625
|
**({"silent": silent, "notes": notes} if silent else {}))
|