ffmpeg-skill 3.1.5 → 3.1.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/docs/contract.md +11 -7
- package/package.json +1 -1
- package/references/ci-platform-pitfalls.md +3 -2
- package/references/gotchas.md +5 -1
- package/references/scripts.md +12 -6
- package/scripts/_common/__init__.py +7 -5
- package/scripts/_common/asr.py +49 -10
- package/scripts/_common/color.py +40 -1
- package/scripts/_common/decision.py +177 -10
- package/scripts/_common/emit.py +2 -0
- package/scripts/_common/fonts.py +26 -0
- package/scripts/_common/probe.py +115 -5
- package/scripts/_common/runner.py +38 -1
- package/scripts/_common/timeline.py +123 -27
- package/scripts/_contract.py +34 -3
- package/scripts/broll.py +35 -14
- package/scripts/caption.py +105 -19
- package/scripts/color.py +37 -8
- package/scripts/crop.py +8 -3
- package/scripts/cropdetect.py +13 -9
- package/scripts/cut.py +4 -4
- package/scripts/export.py +16 -15
- package/scripts/fit.py +8 -4
- package/scripts/freeze.py +38 -10
- package/scripts/graphics.py +104 -67
- package/scripts/grid.py +49 -7
- package/scripts/insert.py +9 -1
- package/scripts/join.py +29 -19
- package/scripts/look.py +41 -12
- package/scripts/loop.py +45 -6
- package/scripts/multicam.py +16 -7
- package/scripts/overlay.py +68 -14
- package/scripts/proxy.py +8 -4
- package/scripts/redact.py +10 -5
- package/scripts/reverse.py +27 -2
- package/scripts/scenes.py +16 -5
- package/scripts/sequence.py +6 -2
- package/scripts/silence.py +91 -35
- package/scripts/speedramp.py +17 -5
- package/scripts/straighten.py +17 -5
- package/scripts/sync.py +8 -2
- package/scripts/waveform.py +8 -3
package/scripts/_contract.py
CHANGED
|
@@ -276,7 +276,7 @@ REENCODE_META: Dict[str, Dict[str, str]] = {
|
|
|
276
276
|
"speedramp": dict(video="always", audio="always", note="setpts/atempo per segment always forces a re-encode of both streams"),
|
|
277
277
|
"broll": dict(video="always", audio="conditional", note="the overlay graph always re-encodes the video stream; A's audio is stream-copied under --audio a and re-encoded to AAC under --audio b/mix"),
|
|
278
278
|
"metadata": dict(video="never", audio="never", note="-c copy on every stream; only the container's chapters and tags change -- --auto-chapters decodes to measure, but still writes with -c copy"),
|
|
279
|
-
"loop": dict(video="always", audio="conditional", note="
|
|
279
|
+
"loop": dict(video="always", audio="conditional", note="the repeats (the concat demuxer, the file listed once per repeat) always re-encode both streams; the audio codec is always AAC when present. --boomerang re-encodes the video through reverse/loop filters and drops the audio"),
|
|
280
280
|
"insert": dict(video="always", audio="never", note="always encodes a fresh silent clip from the still image; there is no audio stream to touch"),
|
|
281
281
|
"background": dict(video="always", audio="never", note="always encodes a fresh generated clip; there is no input to copy from"),
|
|
282
282
|
"reverse": dict(video="always", audio="conditional", note="video always re-encodes (reverse buffers and re-emits every frame); audio re-encodes to AAC when present and not dropped by --no-audio"),
|
|
@@ -440,6 +440,9 @@ ERROR_REASONS = {
|
|
|
440
440
|
"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)",
|
|
441
441
|
"asr_engine_missing": "kind missing_tool: --transcribe (caption.py, silence.py --filler) found no local speech-to-text engine; the message lists the install lines; details: {}",
|
|
442
442
|
"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}",
|
|
443
|
+
"no_speech": "kind input: --transcribe (caption.py, silence.py --filler) ran a local speech engine that heard no speech in the input; details: {engine}; also top-level reason and engine",
|
|
444
|
+
"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",
|
|
445
|
+
"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",
|
|
443
446
|
}
|
|
444
447
|
|
|
445
448
|
# The codes of the collector's records (docs/design-decisions.md, "Nothing happens silently"):
|
|
@@ -454,7 +457,7 @@ DIAGNOSTIC_CODES = {
|
|
|
454
457
|
"audit_log_unwritable": ("warnings", "an audit record could not be appended; never fails the run; {path, event, error}"),
|
|
455
458
|
"plan_unbound_inputs": ("warnings", "render.py PLAN: inputs that did not exist when the plan was made, so were not checked; {paths}"),
|
|
456
459
|
# the runner
|
|
457
|
-
"odd_dimensions_scaled_even": ("fallbacks", "an encode that failed on odd dimensions was run again scaled to even ones (the frame loses a row or column); {output, scale}"),
|
|
460
|
+
"odd_dimensions_scaled_even": ("fallbacks", "an encode that failed on odd dimensions was run again scaled to even ones, or a graph the retry cannot reach (broll.py's filter_complex) was scaled to even ones up front (the frame loses a row or column); {output, scale}"),
|
|
458
461
|
"measurement_input_pending": ("skipped", "--dry-run: a measurement skipped because its input is an earlier stage's output that does not exist yet; {path}"),
|
|
459
462
|
"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)"),
|
|
460
463
|
"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}"),
|
|
@@ -547,13 +550,23 @@ FONT_USED_SCHEMA = {
|
|
|
547
550
|
}
|
|
548
551
|
|
|
549
552
|
|
|
553
|
+
# the tools whose --json carries `dropped_non_av_streams` (a test reads the sources)
|
|
554
|
+
NON_AV_STREAM_TOOLS = frozenset({"audio", "broll", "color", "cut", "deinterlace", "denoise", "export", "fit", "freeze",
|
|
555
|
+
"graphics", "join", "loudness", "overlay", "pad", "proxy", "redact", "speedramp",
|
|
556
|
+
"sphere", "straighten"})
|
|
557
|
+
|
|
558
|
+
|
|
550
559
|
def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str, Any]:
|
|
551
560
|
"""What the tool prints on stdout with --json (keys observed in the implementation). `hw`: the
|
|
552
561
|
tool takes --hw, so its `notes` can also carry a VideoToolbox encode's quality line."""
|
|
553
562
|
if name == "probe":
|
|
554
563
|
return {"type": "object", "description": "one probe document, or an array of them for several inputs",
|
|
555
564
|
"properties": {"file": {"type": "string"}, "format": {"type": "string"}, "duration": {"type": "number"}, "size_bytes": {"type": "integer"},
|
|
556
|
-
"video": {"type": ["object", "null"]}, "audio": {"type": ["object", "null"]}
|
|
565
|
+
"video": {"type": ["object", "null"]}, "audio": {"type": ["object", "null"]},
|
|
566
|
+
"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"},
|
|
567
|
+
"chapters": {"type": "array", "description": "the container's chapter markers [{index, start, end, title}]"},
|
|
568
|
+
"subtitle_stream_details": {"type": "array", "description": "every subtitle stream in file order [{index (the n of -map 0:s:n), codec, language, title}]"}},
|
|
569
|
+
"additionalProperties": True}
|
|
557
570
|
base = {"status": {"enum": ["completed"]}, "output": {"type": ["string", "null"], "description": "path written, or null"},
|
|
558
571
|
"dry_run": {"type": "boolean"}, "commands": {"type": "array", "items": {"type": "string"}, "description": "every ffmpeg command line planned or run"},
|
|
559
572
|
"probe": {"type": "object", "description": "probe of the output when a file was written"},
|
|
@@ -594,6 +607,13 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
594
607
|
"speech": {"type": "array", "description": "--speech (1.18): [{time, speech_music_ratio}]"}}
|
|
595
608
|
elif name == "silence":
|
|
596
609
|
extra = {"silences": {"type": "array"}, "keep": {"type": "array"}, "input_duration": {"type": "number"}, "kept_duration": {"type": "number"}, "removed_seconds": {"type": "number"},
|
|
610
|
+
"removed_seconds_total": {"type": "number", "description": "--filler: seconds removed in all, silence plus filler words (removed_seconds stays the silence alone)"},
|
|
611
|
+
"cut_points": {"type": ["array", "null"], "description": "2.22: per cut {source_time, output_time, edge (out|in), rms_dbfs (the loudest channel 50 ms either side on the source; null for digital silence), filler (true on a removed filler word's edge)}; null when the source could not be decoded for it (cut_levels_unmeasured warning)"},
|
|
612
|
+
"cut_warnings": {"type": ["array", "null"], "description": "the cut_points (not filler cuts) within 10 dB of --threshold: where to listen (a cut_near_sound warning); verified is unchanged"},
|
|
613
|
+
"timemap": {"type": "string", "description": "--timemap FILE: the source-to-output time map written (JSON or CSV)"},
|
|
614
|
+
"silence_kinds": {"type": "array", "description": "2.20: per detected silence, digital (at or below -90 dBFS) or below_threshold"},
|
|
615
|
+
"silence_breakdown": {"type": "object", "description": "2.20: {digital, below_threshold}: the count and seconds of each, with the level that defines it"},
|
|
616
|
+
"hint": {"type": "string", "description": "when nothing sat below --threshold: the track's measured level and the --threshold that would find its pauses"},
|
|
597
617
|
"speech_aware": {"type": "object", "description": "--speech-aware (1.18): {min_silence, floor, breaths_kept, breaths_kept_seconds, breaths}"},
|
|
598
618
|
"filler": {"type": "object", "description": "--filler: {lang, source (whisper-json:FILE | whisper:ENGINE | parakeet:ENGINE), engine, transcription (with --transcribe: {engine, model, language, routing}), removed, removed_count, matched_untimed (listed words in the transcript with no usable timing, not removed), matched_too_long (listed words longer than a filler, kept), ...}"},
|
|
599
619
|
"notes": {"type": "array", "items": {"type": "string"}, "description": "--filler --transcribe under --engine auto: present when the English-only Parakeet model ran on English assumed (no --filler-lang, no language detected, no Whisper engine installed or none that succeeded); the transcript is wrong if the speech is not English"}}
|
|
@@ -651,6 +671,15 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
651
671
|
"notes": {"type": "array", "items": {"type": "string"}, "description": "--boomerang on a source with audio: the audio was dropped"}}
|
|
652
672
|
elif name == "cut":
|
|
653
673
|
extra = {"expected_duration": {"type": "number", "description": "seconds requested"},
|
|
674
|
+
"mode": {"enum": ["copy", "hybrid", "accurate"], "description": "copy: nothing re-encoded at any stage, the join included; accurate: --accurate was asked for or forced; hybrid: asked for lossless but a segment or the join re-encoded anyway (reencode_reason says why)"},
|
|
675
|
+
"requested_start": {"type": ["number", "null"], "description": "one segment: the requested start, clamped to the media's duration (null for --segments)"},
|
|
676
|
+
"requested_end": {"type": ["number", "null"], "description": "one segment: the requested end, clamped to the media's duration (null for --segments)"},
|
|
677
|
+
"requested_segments": {"type": ["array", "null"], "description": "--segments: [[start, end], ...] requested, clamped to the media's duration (null for one segment)"},
|
|
678
|
+
"requested_duration": {"type": "number", "description": "the requested length, as expected_duration"},
|
|
679
|
+
"output_duration": {"type": ["number", "null"], "description": "the written file's length, measured (null under --dry-run)"},
|
|
680
|
+
"duration_delta_seconds": {"type": ["number", "null"], "description": "output_duration minus requested_duration (null under --dry-run)"},
|
|
681
|
+
"nearest_keyframes": {"type": ["array", "null"], "description": "the keyframes within 5 s of a start whose stream copy snapped past --tolerance and was re-encoded (null otherwise)"},
|
|
682
|
+
"snap": {"type": ["object", "null"], "description": "--snap beats: {mode, tolerance, confidence, tempo_bpm, grid, grid_points, moved, snapped, unchanged, source}; null otherwise"},
|
|
654
683
|
"dropped_audio_streams": {"type": "integer", "description": "audio tracks of the source this cut did not write (cut writes one, on a stream copy as on a re-encode); 0 for a source with one or none. A notes line and an audio_tracks_dropped warning say so too"},
|
|
655
684
|
"duration_error_ms": {"type": ["number", "null"], "description": "written minus requested, measured by ffprobe (null under --dry-run)"},
|
|
656
685
|
"precision": {"enum": ["packet", "sample", "codec_frame", "frame"],
|
|
@@ -696,6 +725,8 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
696
725
|
"audio": {"type": "object", "description": "what the mix was built from: voice (null | light | medium | strong), stereo_widen, 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)"}}
|
|
697
726
|
if name in ("overlay", "graphics", "caption", "grid"):
|
|
698
727
|
extra["font_used"] = FONT_USED_SCHEMA
|
|
728
|
+
if name in NON_AV_STREAM_TOOLS and "dropped_non_av_streams" not in extra:
|
|
729
|
+
extra["dropped_non_av_streams"] = {"type": "boolean", "description": "true when the source's subtitle or data streams are not in the output: either the run leaves them out by design (a path whose output they would no longer match, such as speedramp.py's, join.py's or freeze.py's), or they could not be copied and the encode ran again without them (then a non_av_streams_dropped fallback says why); export.py's non_av_streams lists them"}
|
|
699
730
|
if name == "cropdetect":
|
|
700
731
|
extra.update({"windows": {"type": "integer", "description": "sampled windows (--samples)"},
|
|
701
732
|
"windows_failed": {"type": "integer", "description": "windows ffmpeg could not decode and that were skipped (a cropdetect_windows_failed warning when > 0); every window failing is a kind ffmpeg failure"}})
|
package/scripts/broll.py
CHANGED
|
@@ -21,7 +21,10 @@ import argparse
|
|
|
21
21
|
import sys
|
|
22
22
|
from typing import Any, Dict, List
|
|
23
23
|
|
|
24
|
-
from _common import
|
|
24
|
+
from _common.decision import av_trim, frame_rate, grid_at_or_after, stream_starts
|
|
25
|
+
from _common import STATE, add_common, aac_args, apply_common, cfr_args, as_limited, default_output, die, draws_chroma, drawn_chain, emit, ffmpeg_base, info, time_arg, label_chain, matrix_chain, range_chain, probe, run, validate_color, video_args, X264_PRESETS, fmt_secs
|
|
26
|
+
from _common.emit import note, record_fallback
|
|
27
|
+
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
25
28
|
|
|
26
29
|
|
|
27
30
|
def main() -> int:
|
|
@@ -55,10 +58,20 @@ def main() -> int:
|
|
|
55
58
|
if not meta_a.get("video"):
|
|
56
59
|
die("A-roll has no video stream")
|
|
57
60
|
dur_a = meta_a.get("duration") or 0.0
|
|
58
|
-
|
|
59
|
-
#
|
|
60
|
-
|
|
61
|
-
|
|
61
|
+
# the cutaway is scaled onto the frame ffmpeg has already rotated, not the stored one, and
|
|
62
|
+
# onto a non-square-pixel A-roll squared at its display aspect (#411)
|
|
63
|
+
w, h = display_size(meta_a["video"])
|
|
64
|
+
a_chain = square_pixels(meta_a["video"])
|
|
65
|
+
if a_chain:
|
|
66
|
+
note(square_pixels_note(meta_a["video"], "the A-roll"))
|
|
67
|
+
if (w % 2 or h % 2) and w and h:
|
|
68
|
+
# an odd-sized A-roll cannot be encoded 4:2:0 at its own size, and the run-time even-scale
|
|
69
|
+
# retry does not reach a filter_complex: through 3.1.3 this failed as kind "ffmpeg" (#411)
|
|
70
|
+
ew, eh = max(2, w - w % 2), max(2, h - h % 2) # never 0, which scale reads as "keep" (#416 review)
|
|
71
|
+
record_fallback(f"the A-roll is {w}x{h}, which 4:2:0 cannot hold: scaled to {ew}x{eh}",
|
|
72
|
+
"odd_dimensions_scaled_even", output=args.output or default_output(args.input, "broll"), scale=f"scale={ew}:{eh}")
|
|
73
|
+
a_chain = (a_chain + "," if a_chain else "") + f"scale={ew}:{eh},setsar=1"
|
|
74
|
+
w, h = ew, eh
|
|
62
75
|
fps = meta_a["video"].get("fps") or 30.0
|
|
63
76
|
has_audio_a = bool(meta_a.get("audio"))
|
|
64
77
|
|
|
@@ -72,13 +85,13 @@ def main() -> int:
|
|
|
72
85
|
meta_b = probe(path)
|
|
73
86
|
if not meta_b.get("video"):
|
|
74
87
|
die(f"{path} has no video stream")
|
|
75
|
-
at = time_arg(args.at[i], "--at", fps)
|
|
76
|
-
start_b = time_arg(per(args.from_, i, "0"), "--from", fps)
|
|
88
|
+
at = time_arg(args.at[i], "--at", fps, source=meta_a)
|
|
89
|
+
start_b = time_arg(per(args.from_, i, "0"), "--from", fps, source=meta_b)
|
|
77
90
|
if args.end:
|
|
78
|
-
end = time_arg(per(args.end, i, "0"), "--end", fps)
|
|
91
|
+
end = time_arg(per(args.end, i, "0"), "--end", fps, source=meta_a)
|
|
79
92
|
length = end - at
|
|
80
93
|
else:
|
|
81
|
-
length = time_arg(per(args.duration, i, "4"), "--duration", fps)
|
|
94
|
+
length = time_arg(per(args.duration, i, "4"), "--duration", fps, source=meta_a)
|
|
82
95
|
if length <= 0:
|
|
83
96
|
die(f"cutaway {i + 1}: length must be > 0 (at {at:g}s, got {length:g}s)")
|
|
84
97
|
if dur_a and at >= dur_a:
|
|
@@ -110,16 +123,19 @@ def main() -> int:
|
|
|
110
123
|
convert = [matrix_chain(m, meta_a) for m in [meta_a] + [c["meta"] for c in cutaways]]
|
|
111
124
|
if any(convert) or draws_chroma([args.pad_color], meta_a):
|
|
112
125
|
convert = [cv or label_chain(m, meta_a) for cv, m in zip(convert, [meta_a] + [c["meta"] for c in cutaways])]
|
|
113
|
-
|
|
114
|
-
|
|
126
|
+
# overlay mixes samples: a full-range clip over or under a limited one is taken to limited range (#410)
|
|
127
|
+
convert = [cv + range_chain(m, meta_a) for cv, m in zip(convert, [meta_a] + [c["meta"] for c in cutaways])]
|
|
128
|
+
if convert[0] or a_chain:
|
|
129
|
+
parts.append(f"[0:v]{convert[0]}{a_chain or 'null'}[a709]")
|
|
115
130
|
cur = "[a709]"
|
|
116
131
|
pad = f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2:color="
|
|
117
132
|
for i, c in enumerate(cutaways):
|
|
118
133
|
geo = (f"scale={w}:{h}:force_original_aspect_ratio=decrease,"
|
|
119
|
-
+ drawn_chain(pad + args.pad_color, c["meta"], [args.pad_color], pad + "black", line_meta=meta_a,
|
|
134
|
+
+ drawn_chain(pad + args.pad_color, as_limited(c["meta"]), [args.pad_color], pad + "black", line_meta=meta_a,
|
|
120
135
|
tag=f"pad{i}", labelled=True))
|
|
136
|
+
b_sq = (square_pixels(c["meta"]["video"]) + ",") if square_pixels(c["meta"]["video"]) else ""
|
|
121
137
|
parts.append(f"[{i + 1}:v]trim=start={c['from']:.3f}:duration={c['length']:.3f},setpts=PTS-STARTPTS+{c['at']:.3f}/TB,"
|
|
122
|
-
f"{convert[i + 1]}{geo},setsar=1,fps={fps:g},format={'yuv420p10le' if (meta_a.get('video') or {}).get('bt2020_or_hdr') else 'yuv420p'}[b{i}]")
|
|
138
|
+
f"{convert[i + 1]}{b_sq}{geo},setsar=1,fps={fps:g},format={'yuv420p10le' if (meta_a.get('video') or {}).get('bt2020_or_hdr') else 'yuv420p'}[b{i}]")
|
|
123
139
|
parts.append(f"{cur}[b{i}]overlay=0:0:eof_action=pass:enable='between(t,{c['at']:.3f},{c['at'] + c['length']:.3f})'[v{i}]")
|
|
124
140
|
cur = f"[v{i}]"
|
|
125
141
|
vout = cur
|
|
@@ -137,7 +153,12 @@ def main() -> int:
|
|
|
137
153
|
parts.append(f"[0:a:0]aformat=sample_rates=48000:channel_layouts=stereo{gate}[a0]")
|
|
138
154
|
layers.append("[a0]")
|
|
139
155
|
for i, c in enumerate(cutaways):
|
|
140
|
-
|
|
156
|
+
# B's sound is shifted by the same constant as its picture, whose first frame (the
|
|
157
|
+
# first at or after --from) lands on --at; rebasing the sound to its own first
|
|
158
|
+
# sample moved a track that starts 0.3 s late 0.279 s early (#409)
|
|
159
|
+
v0b = stream_starts(c["meta"])[0]
|
|
160
|
+
first = max(v0b, grid_at_or_after(c["from"], v0b, frame_rate(c["meta"])))
|
|
161
|
+
parts.append(f"[{i + 1}:a:0]{av_trim(first, first + c['length'])[1]},"
|
|
141
162
|
f"aformat=sample_rates=48000:channel_layouts=stereo,adelay={int(c['at'] * 1000)}|{int(c['at'] * 1000)}[ab{i}]")
|
|
142
163
|
layers.append(f"[ab{i}]")
|
|
143
164
|
parts.append(f"{''.join(layers)}amix=inputs={len(layers)}:normalize=0:dropout_transition=0,atrim=duration={dur_a:.3f}[aout]")
|
package/scripts/caption.py
CHANGED
|
@@ -45,7 +45,7 @@ from _platforms import (PLATFORMS, PLATFORM_CHOICES, ASS_SCRIPT_HEIGHT, ass_unit
|
|
|
45
45
|
resolve as resolve_platform)
|
|
46
46
|
from _ass_overlay import EMOJI_SENTINEL, emoji_placeholder, ass_escape
|
|
47
47
|
from _common.emit import refuse # noqa: E402
|
|
48
|
-
from _common import require_filter, emoji_filter_chain, EMOJI_ASSET_HINT, emoji_asset_for, emoji_codepoint_name, emoji_support, resolve_emoji_assets, ADVANCE_EM, LATIN_EM, NO_SPACE_SCRIPTS, _char_em, char_script, text_width_em, emoji_clusters, has_emoji, detect_script, BIDI_SCRIPTS, STATE, brand_states_font, script_font_for_text, signed_time_arg, brand_caption_style, color_hex, drawn_chain, label_chain, load_brand, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, x264_args, X264_PRESETS, read_text_or_die, fmt_secs, refuse_existing_outputs, guard_sidecar
|
|
48
|
+
from _common import require_filter, emoji_filter_chain, EMOJI_ASSET_HINT, emoji_asset_for, emoji_codepoint_name, emoji_support, resolve_emoji_assets, ADVANCE_EM, LATIN_EM, NO_SPACE_SCRIPTS, _char_em, char_script, text_width_em, emoji_clusters, has_emoji, detect_script, BIDI_SCRIPTS, STATE, brand_states_font, script_font_for_text, signed_time_arg, brand_caption_style, color_hex, drawn_chain, label_chain, layer_base_chain, load_brand, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, x264_args, X264_PRESETS, read_text_or_die, fmt_secs, refuse_existing_outputs, guard_sidecar
|
|
49
49
|
# The line breaker, lifted into _common/text.py in 1.16.0 so graphics.py can use the same rules.
|
|
50
50
|
# The ASR bridge and the SRT reader/writer live in _common.asr since 1.17 (silence.py --filler
|
|
51
51
|
# shares them). They stay caption.py's public names -- every caller and test that reached for
|
|
@@ -53,6 +53,8 @@ from _common import require_filter, emoji_filter_chain, EMOJI_ASSET_HINT, emoji_
|
|
|
53
53
|
from _common import asr as _asr
|
|
54
54
|
from _common.asr import ENGINE_CHOICES
|
|
55
55
|
from _common.emit import warn
|
|
56
|
+
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
57
|
+
from _common.runner import subtitle_codec_for, subtitle_container_holds
|
|
56
58
|
from _common.fonts import font_choice, record_font_choice
|
|
57
59
|
from _common import (ASR_INSTALL_HINT, die_no_engine, parse_srt, transcribe, whisper_word_timings,
|
|
58
60
|
write_srt)
|
|
@@ -144,7 +146,9 @@ def word_durations_from_audio(video: str, start: float, end: float, n_words: int
|
|
|
144
146
|
return [total_cs]
|
|
145
147
|
ffmpeg = require_tool("ffmpeg")
|
|
146
148
|
cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-ss", f"{start:.3f}", "-i", video,
|
|
147
|
-
"-map", f"0:a:{audio_stream}", "-t", f"{end - start:.3f}", "-vn",
|
|
149
|
+
"-map", f"0:a:{audio_stream}", "-t", f"{end - start:.3f}", "-vn",
|
|
150
|
+
# a sound that starts late is padded to its place on the file's clock (#414)
|
|
151
|
+
"-af", "aresample=async=1:first_pts=0", "-ac", "1", "-ar", "8000", "-f", "s16le", "-"]
|
|
148
152
|
proc = run_analysis(cmd, check=False, text=False) # under --timeout like every other measurement
|
|
149
153
|
n = len(proc.stdout) // 2
|
|
150
154
|
if proc.returncode != 0 or n < 800:
|
|
@@ -886,6 +890,23 @@ def track_title_for(code: Optional[str], given: Optional[str]) -> Optional[str]:
|
|
|
886
890
|
return LANG_TITLES.get(code.split("-")[0].lower(), code)
|
|
887
891
|
|
|
888
892
|
|
|
893
|
+
# a timing line in the grammar ffmpeg's SRT demuxer and parse_srt() read alike: hours, minutes,
|
|
894
|
+
# seconds and three-digit milliseconds on both sides (ffmpeg allows position fields after it)
|
|
895
|
+
_SHARED_SRT_TIMING = re.compile(r"^\s*\d{2,}:\d{2}:\d{2}[,.]\d{3}\s*-->\s*\d{2,}:\d{2}:\d{2}[,.]\d{3}(\s.*)?$")
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def srt_timings_shared_grammar(path: str) -> bool:
|
|
899
|
+
"""True when every timing line of the SRT at `path` reads the same to ffmpeg's SRT demuxer as
|
|
900
|
+
to parse_srt(). ffmpeg refuses `00:01,000` (no hours) and reads `00:00:01,5` as 1.005 s, where
|
|
901
|
+
parse_srt() reads 1.5 s (#414); such a file is handed to ffmpeg as a rewritten copy."""
|
|
902
|
+
text = read_text_or_die(path, "--srt").lstrip("\ufeff").replace("\r\n", "\n")
|
|
903
|
+
# a cue's timing line is its first line with "-->", as parse_srt() reads it (and ffmpeg too);
|
|
904
|
+
# a later line holding "-->" is dialogue, not a timing this check should judge
|
|
905
|
+
blocks = [[ln for ln in b.split("\n") if ln.strip()] for b in re.split(r"\n\s*\n", text)]
|
|
906
|
+
timings = [next((ln for ln in b if "-->" in ln), None) for b in blocks]
|
|
907
|
+
return all(_SHARED_SRT_TIMING.match(line) for line in timings if line is not None)
|
|
908
|
+
|
|
909
|
+
|
|
889
910
|
def mux_subtitle_codec(output: str) -> str:
|
|
890
911
|
ext = Path(output).suffix.lower()
|
|
891
912
|
if ext in (".mp4", ".m4v", ".mov"):
|
|
@@ -1129,6 +1150,11 @@ def main() -> int:
|
|
|
1129
1150
|
die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
|
|
1130
1151
|
if args.audio_stream and not audio_streams:
|
|
1131
1152
|
die("--audio-stream needs an input with audio streams")
|
|
1153
|
+
if args.transcribe and not audio_streams:
|
|
1154
|
+
# refused before any engine runs, as silence.py refuses it, not an unclassified
|
|
1155
|
+
# "Stream map '0:a:0' matches no streams" from the WAV extraction (#414)
|
|
1156
|
+
die(f"--transcribe needs speech to transcribe, and {args.input} has no audio stream",
|
|
1157
|
+
kind="input", hint="pick the file that carries the sound, or write the cues with --text")
|
|
1132
1158
|
if args.mode != "mux":
|
|
1133
1159
|
# before any transcription or sidecar: a build without libass fails these at parse time
|
|
1134
1160
|
# with a message that names no filter (#393), so ask the build first, dry run included
|
|
@@ -1139,9 +1165,9 @@ def main() -> int:
|
|
|
1139
1165
|
|
|
1140
1166
|
play_w = play_h = None
|
|
1141
1167
|
if meta and meta.get("video"):
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1168
|
+
# the displayed frame: rotation applied, non-square pixels squared (#411) -- the burn
|
|
1169
|
+
# below draws on the square-pixel picture, so a cue is not stretched by the player's SAR
|
|
1170
|
+
play_w, play_h = display_size(meta["video"])
|
|
1145
1171
|
# A --plan or --dry-run written before the input exists has no geometry to fit against, and a
|
|
1146
1172
|
# plan that describes a different FontSize from the run that executes it is not a plan. When
|
|
1147
1173
|
# --platform names a destination, that destination's frame IS the geometry the real run will
|
|
@@ -1379,9 +1405,15 @@ def main() -> int:
|
|
|
1379
1405
|
side_notes: List[str] = list(getattr(args, "_asr_notes", None) or [])
|
|
1380
1406
|
ass_counts: dict = {} # --ass: ass_shifted / ass_unshifted (with --offset) and ass_malformed, into `caption`
|
|
1381
1407
|
ass_sample_path = args.ass
|
|
1408
|
+
normalised_only = False # srt_path is a copy of the caller's SRT that only rewrites its timings
|
|
1382
1409
|
if args.srt and not (args.text or args.transcribe) and os.path.exists(srt_path or ""):
|
|
1383
1410
|
adjusted, changed = lay_out(parse_srt(srt_path))
|
|
1384
|
-
|
|
1411
|
+
# ffmpeg's own SRT reader takes a narrower grammar than parse_srt(): a time without hours
|
|
1412
|
+
# is refused and ",5" is read as 5 ms where this tool reads 500. A file outside the grammar
|
|
1413
|
+
# both share goes to ffmpeg as the copy this tool writes, so it gets the cues counted
|
|
1414
|
+
# here (#414)
|
|
1415
|
+
normalised_only = not changed and not srt_timings_shared_grammar(srt_path)
|
|
1416
|
+
if changed or normalised_only:
|
|
1385
1417
|
new_srt = os.path.splitext(output)[0] + "_adjusted.srt"
|
|
1386
1418
|
claim([(new_srt, "adjusted SRT")])
|
|
1387
1419
|
if STATE.dry_run:
|
|
@@ -1391,8 +1423,10 @@ def main() -> int:
|
|
|
1391
1423
|
info(f"wrote {new_srt} ({len(adjusted)} cues, adjusted from {os.path.basename(srt_path)})")
|
|
1392
1424
|
srt_path = new_srt
|
|
1393
1425
|
planned_cues = adjusted
|
|
1394
|
-
|
|
1395
|
-
|
|
1426
|
+
why = ("--offset/--max-lines/--min-duration" if changed else
|
|
1427
|
+
"its timings rewritten as hh:mm:ss,mmm, which ffmpeg reads as this tool does")
|
|
1428
|
+
side_notes.append(f"the {'muxed' if args.mode == 'mux' else 'burned'} subtitles are {new_srt}, the adjusted copy of "
|
|
1429
|
+
f"{os.path.basename(args.srt)} this run writes ({why})"
|
|
1396
1430
|
+ ("; re-run this command without --dry-run to produce it" if STATE.dry_run else ""))
|
|
1397
1431
|
if args.ass and args.offset and os.path.exists(args.ass):
|
|
1398
1432
|
shifted = os.path.splitext(output)[0] + "_offset.ass"
|
|
@@ -1423,10 +1457,34 @@ def main() -> int:
|
|
|
1423
1457
|
and not (STATE.dry_run and (args.text or args.transcribe))):
|
|
1424
1458
|
die(f"SRT file not found: {srt_path}")
|
|
1425
1459
|
codec = mux_subtitle_codec(output)
|
|
1460
|
+
out_ext = Path(output).suffix.lower().lstrip(".")
|
|
1461
|
+
# The picture and the sound are copied: a WebM holds only VP8/VP9/AV1 and Opus/Vorbis, so
|
|
1462
|
+
# anything else is refused here, not left to fail unclassified in the muxer (#414)
|
|
1463
|
+
if out_ext == "webm":
|
|
1464
|
+
vcodec = (meta.get("video") or {}).get("codec")
|
|
1465
|
+
# the stream -map 0:a:N copies (--audio-stream), not always the first one
|
|
1466
|
+
audio_streams = meta.get("audio_streams") or []
|
|
1467
|
+
acodec = audio_streams[args.audio_stream].get("codec") if args.audio_stream < len(audio_streams) else None
|
|
1468
|
+
bad = [c for c in (vcodec,) if c and c not in ("vp8", "vp9", "av1")] + \
|
|
1469
|
+
[c for c in (acodec,) if c and c not in ("opus", "vorbis")]
|
|
1470
|
+
if bad:
|
|
1471
|
+
die(f"--mode mux copies the picture and the sound, and a .webm holds only VP8/VP9/AV1 video and "
|
|
1472
|
+
f"Opus/Vorbis audio; {args.input} carries {' and '.join(bad)}", kind="input",
|
|
1473
|
+
hint="write .mkv (any codec) or .mp4 instead, or re-encode first with export.py")
|
|
1426
1474
|
# Keep any subtitle track(s) the input already has (e.g. chaining --mode mux once per
|
|
1427
|
-
# language to build a multi-language set)
|
|
1428
|
-
#
|
|
1475
|
+
# language to build a multi-language set): copied where the output container holds them as
|
|
1476
|
+
# they are, else converted to its own text format as export.py converts them (#380, #414);
|
|
1477
|
+
# a track the container cannot hold at all (a bitmap one in MPEG-4 or WebM) is refused.
|
|
1429
1478
|
existing_subs = meta.get("subtitle_streams") or 0
|
|
1479
|
+
existing_details = list(meta.get("subtitle_stream_details") or [])
|
|
1480
|
+
existing_codecs = [(existing_details[i] if i < len(existing_details) else {}).get("codec") or ""
|
|
1481
|
+
for i in range(existing_subs)]
|
|
1482
|
+
unheld = sorted({c or "unknown" for c in existing_codecs if not subtitle_container_holds(c, out_ext)})
|
|
1483
|
+
if unheld:
|
|
1484
|
+
die(f"{args.input} already carries a subtitle track ({', '.join(unheld)}) that a .{out_ext} file cannot "
|
|
1485
|
+
"hold, and --mode mux keeps every existing track", kind="input",
|
|
1486
|
+
hint="write .mkv, which holds any subtitle codec")
|
|
1487
|
+
existing_out = [subtitle_codec_for(c, out_ext) for c in existing_codecs]
|
|
1430
1488
|
# the first entry's path is srt_path, which --offset/--max-lines may have repointed at an
|
|
1431
1489
|
# adjusted copy; the rest are taken as written
|
|
1432
1490
|
added = [(srt_path, srt_tracks[0][1] if srt_tracks else args.language)] + \
|
|
@@ -1434,6 +1492,18 @@ def main() -> int:
|
|
|
1434
1492
|
for path, _lang in added[1:]:
|
|
1435
1493
|
if not os.path.exists(path) and not STATE.dry_run:
|
|
1436
1494
|
die(f"SRT file not found: {path}")
|
|
1495
|
+
for n in range(1, len(added)):
|
|
1496
|
+
path, lang = added[n]
|
|
1497
|
+
if os.path.exists(path) and not srt_timings_shared_grammar(path):
|
|
1498
|
+
# the same rule as the first track (#414): ffmpeg is given what parse_srt() reads
|
|
1499
|
+
copy = f"{os.path.splitext(output)[0]}_track{n}.srt"
|
|
1500
|
+
claim([(copy, f"normalised SRT {n}")])
|
|
1501
|
+
if not STATE.dry_run:
|
|
1502
|
+
write_srt(parse_srt(path), copy)
|
|
1503
|
+
info(f"wrote {copy} (timings of {os.path.basename(path)} rewritten as hh:mm:ss,mmm)")
|
|
1504
|
+
side_notes.append(f"subtitle track {n} is {copy}, a copy of {os.path.basename(path)} with its timings "
|
|
1505
|
+
"rewritten as hh:mm:ss,mmm, which ffmpeg reads as this tool does")
|
|
1506
|
+
added[n] = (copy, lang)
|
|
1437
1507
|
titles = list(args.track_title or [])
|
|
1438
1508
|
mp4_family = Path(output).suffix.lower() in (".mp4", ".m4v", ".mov")
|
|
1439
1509
|
dropped_titles: List[str] = []
|
|
@@ -1452,13 +1522,16 @@ def main() -> int:
|
|
|
1452
1522
|
if mp4_family:
|
|
1453
1523
|
cmd += ["-movflags", "+faststart"]
|
|
1454
1524
|
for i in range(existing_subs):
|
|
1455
|
-
cmd += [f"-c:s:{i}",
|
|
1525
|
+
cmd += [f"-c:s:{i}", existing_out[i]]
|
|
1456
1526
|
tracks: List[Dict[str, Any]] = []
|
|
1527
|
+
converted: List[str] = []
|
|
1457
1528
|
for i in range(existing_subs):
|
|
1458
|
-
|
|
1459
|
-
|
|
1529
|
+
detail = existing_details[i] if i < len(existing_details) else {}
|
|
1530
|
+
written = detail.get("codec") if existing_out[i] == "copy" else existing_out[i]
|
|
1531
|
+
if existing_out[i] != "copy":
|
|
1532
|
+
converted.append(f"{detail.get('codec')} -> {existing_out[i]}")
|
|
1460
1533
|
tracks.append({"index": i, "file": None, "language": detail.get("language"),
|
|
1461
|
-
"title": detail.get("title"), "codec":
|
|
1534
|
+
"title": detail.get("title"), "codec": written,
|
|
1462
1535
|
"default": False, "cues": None, "kept_from_input": True})
|
|
1463
1536
|
for n, (path, lang) in enumerate(added):
|
|
1464
1537
|
idx = existing_subs + n
|
|
@@ -1509,6 +1582,9 @@ def main() -> int:
|
|
|
1509
1582
|
notes.append(f"{total} subtitle tracks in an MPEG-4 container: the tracks are all there, "
|
|
1510
1583
|
"but many players only ever show the first -- write to .mkv for a "
|
|
1511
1584
|
"deliverable a viewer can actually switch")
|
|
1585
|
+
if converted:
|
|
1586
|
+
notes.append(f"the input's own subtitle track(s) were converted to the .{out_ext} container's text format "
|
|
1587
|
+
f"({', '.join(converted)}): their text and timing are kept, styling a format cannot carry is not")
|
|
1512
1588
|
if dropped_titles:
|
|
1513
1589
|
notes.append("an MPEG-4 container has no per-track title this tool can write back "
|
|
1514
1590
|
f"({', '.join(dropped_titles)} would be dropped), so the tracks are "
|
|
@@ -1520,6 +1596,14 @@ def main() -> int:
|
|
|
1520
1596
|
"as given and may not survive")
|
|
1521
1597
|
run(cmd)
|
|
1522
1598
|
result = probe(output, role="output")
|
|
1599
|
+
# the language and title each track carries in the written file: a converted track keeps
|
|
1600
|
+
# only what the container can store (MPEG-4 drops a two-letter code and every title)
|
|
1601
|
+
written = result.get("subtitle_stream_details") or []
|
|
1602
|
+
if len(written) == len(tracks):
|
|
1603
|
+
for track, detail in zip(tracks, written):
|
|
1604
|
+
track["language"] = detail.get("language")
|
|
1605
|
+
if track["kept_from_input"]:
|
|
1606
|
+
track["title"] = detail.get("title")
|
|
1523
1607
|
info(f"wrote {output} ({fmt_secs(result.get('duration'))}, mux, {len(added)} "
|
|
1524
1608
|
f"subtitle track(s) added, codec {codec})")
|
|
1525
1609
|
emit(output, tracks=tracks, subtitle_tracks=total, **({"notes": notes} if notes else {}),
|
|
@@ -1586,12 +1670,11 @@ def main() -> int:
|
|
|
1586
1670
|
cues if (args.text or args.transcribe) else (
|
|
1587
1671
|
planned_cues if planned_cues is not None else parse_srt(srt_path)))
|
|
1588
1672
|
if args.karaoke and not getattr(args, "_word_timings", None):
|
|
1589
|
-
|
|
1673
|
+
# a copy that only rewrote the timings keeps the caller's word-timing JSON beside it
|
|
1674
|
+
args._word_timings = whisper_word_timings(args.srt if normalised_only else srt_path)
|
|
1590
1675
|
ass_path = args.write_ass or os.path.splitext(output)[0] + ".ass"
|
|
1591
1676
|
claim([(ass_path, "ASS")])
|
|
1592
|
-
w, h = meta["video"]
|
|
1593
|
-
if meta["video"].get("rotation") in (90, -90, 270, -270):
|
|
1594
|
-
w, h = h, w
|
|
1677
|
+
w, h = display_size(meta["video"])
|
|
1595
1678
|
if not STATE.dry_run: # the generated ASS is an artifact of this run: a plan writes nothing
|
|
1596
1679
|
write_ass(cues_for_ass, ass_path, args, w, h, video=args.input if meta.get("audio") else None)
|
|
1597
1680
|
info(f"wrote {ass_path} ({len(cues_for_ass)} cues, animate={args.animate}, karaoke={args.karaoke})")
|
|
@@ -1681,9 +1764,12 @@ def main() -> int:
|
|
|
1681
1764
|
vf = drawn_chain(vf, meta, [args.color, args.outline_color]
|
|
1682
1765
|
+ ([args.highlight_color, args.upcoming_color] if args.karaoke else []))
|
|
1683
1766
|
|
|
1767
|
+
if square_pixels((meta or {}).get("video")):
|
|
1768
|
+
vf = square_pixels(meta["video"]) + "," + vf
|
|
1769
|
+
side_notes.append(square_pixels_note(meta["video"]))
|
|
1684
1770
|
cmd = ffmpeg_base() + ["-i", args.input]
|
|
1685
1771
|
chains, emoji_inputs = (emoji_filter_chain(emoji_plan or {}, "vsub", "vout", to_yuv=rgb_chain_to_bt709(meta),
|
|
1686
|
-
base_chain=
|
|
1772
|
+
base_chain=layer_base_chain(meta))
|
|
1687
1773
|
if emoji_plan else ([], []))
|
|
1688
1774
|
if chains:
|
|
1689
1775
|
for spec in emoji_inputs:
|
package/scripts/color.py
CHANGED
|
@@ -43,7 +43,7 @@ CORRECTION = {
|
|
|
43
43
|
"exposure": (0.0, -3.0, 3.0, "stops"), # exposure filter's own full range (linear-domain stops)
|
|
44
44
|
"contrast": (1.0, 0.0, 2.0, "x"), # eq filter; 0=flat grey, 1=unchanged, 2=double contrast
|
|
45
45
|
"saturation": (1.0, 0.0, 2.0, "x"), # eq filter; 0=grayscale, 1=unchanged, 2=double saturation
|
|
46
|
-
"temperature": (6500.0, 2000.0, 12000.0, "K"), # colortemperature filter; 6500=unchanged (
|
|
46
|
+
"temperature": (6500.0, 2000.0, 12000.0, "K"), # colortemperature filter; 6500=unchanged (no term: the filter itself tints at 6500)
|
|
47
47
|
"tint": (0.0, -1.0, 1.0, "x"), # mapped to colorbalance midtones, see correction_chain()
|
|
48
48
|
"gamma": (1.0, 0.1, 10.0, "x"), # eq filter's own gamma option; 1=unchanged (its own default)
|
|
49
49
|
"lift": (0.0, -1.0, 1.0, "x"), # colorbalance shadows (rs=gs=bs); 0=unchanged
|
|
@@ -116,11 +116,13 @@ def correction_chain(args: argparse.Namespace) -> str:
|
|
|
116
116
|
die(f"--levels-out-black {out_black} must be less than --levels-out-white {out_white}")
|
|
117
117
|
|
|
118
118
|
gm, rm, bm = -tint, tint / 2.0, tint / 2.0
|
|
119
|
-
rgb = [
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
119
|
+
rgb = [f"exposure=exposure={exposure:g}"]
|
|
120
|
+
if temperature != CORRECTION["temperature"][0]:
|
|
121
|
+
# colortemperature at 6500 K is not the identity it is documented as here: it multiplies by
|
|
122
|
+
# its own approximation of that white point, which tinted grey 808080 to 7E7E7B (#410), so
|
|
123
|
+
# "unchanged" adds no term at all
|
|
124
|
+
rgb.append(f"colortemperature=temperature={temperature:g}")
|
|
125
|
+
rgb.append(f"colorbalance=rs={lift:g}:gs={lift:g}:bs={lift:g}:rm={rm:g}:gm={gm:g}:bm={bm:g}:rh={gain:g}:gh={gain:g}:bh={gain:g}")
|
|
124
126
|
terms = [_rgb_stage(rgb), f"eq=contrast={contrast:g}:saturation={saturation:g}:gamma={gamma:g}"]
|
|
125
127
|
rgb2 = []
|
|
126
128
|
if (in_black, in_white, out_black, out_white) != (0, 255, 0, 255):
|
|
@@ -156,6 +158,31 @@ def _rgb_stage(filters: list) -> str:
|
|
|
156
158
|
+ ",scale=out_color_matrix=bt601,format=yuv420p")
|
|
157
159
|
|
|
158
160
|
|
|
161
|
+
# swscale's names for the non-BT.709 matrices a --codec prores re-encode keeps (it does not convert)
|
|
162
|
+
_SWS_MATRIX = {"smpte170m": "bt601", "bt470bg": "bt601", "smpte240m": "smpte240m", "fcc": "fcc"}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def lut_legs(meta=None) -> "tuple":
|
|
166
|
+
"""(to RGB, back to YUV) around --lut's lut3d: explicit BT.709 conversions through float RGB,
|
|
167
|
+
the matrix the picture holds when it reaches them (an untagged or BT.709 source as it is, a
|
|
168
|
+
BT.601 or RGB one after x264_args(source=...)'s conversion), limited range on the way out.
|
|
169
|
+
|
|
170
|
+
lut3d takes RGB, and left to libavfilter the way in honoured the frame's BT.709 tag while the
|
|
171
|
+
way back used swscale's BT.601 default, so even an identity LUT shifted every colour (2FAE4E to
|
|
172
|
+
249A4B, #410); --correct had its legs pinned for the same reason in #159. A LUT maps the RGB the
|
|
173
|
+
picture really shows, so unlike --correct's working-space pair (bt601 both ways, see
|
|
174
|
+
_rgb_stage) these legs use the BT.709 the picture is: an identity LUT then returns every sample
|
|
175
|
+
as --lut-strength 0, which applies none, does (6.1, BT.709/BT.601/untagged, limited and full
|
|
176
|
+
range, 8- and 10-bit). The input range is read from the frames, as every scale here reads it."""
|
|
177
|
+
# --codec prores keeps a BT.601 (or other non-BT.709) source's matrix and tags instead of
|
|
178
|
+
# converting it (decision.video_args), so the legs use that matrix (#416 review)
|
|
179
|
+
matrix = "bt709"
|
|
180
|
+
if STATE.codec == "prores":
|
|
181
|
+
matrix = _SWS_MATRIX.get(((meta or {}).get("video") or {}).get("color_space"), "bt709")
|
|
182
|
+
return (f"scale=in_color_matrix={matrix},format=gbrpf32le",
|
|
183
|
+
f"scale=out_color_matrix={matrix}:out_range=tv,format=yuv420p")
|
|
184
|
+
|
|
185
|
+
|
|
159
186
|
def hdr_to_sdr_chain(meta: dict, tonemap: str, peak: float, desat: float) -> str:
|
|
160
187
|
v = meta["video"]
|
|
161
188
|
trc = v.get("color_transfer") or "smpte2084"
|
|
@@ -442,6 +469,7 @@ def main() -> int:
|
|
|
442
469
|
if not (0.0 <= args.lut_strength <= 1.0):
|
|
443
470
|
die(f"--lut-strength {args.lut_strength:g} is outside 0..1")
|
|
444
471
|
lut = f"lut3d=file={escape_filter_path(args.lut)}:interp=tetrahedral"
|
|
472
|
+
to_rgb, to_yuv = lut_legs(meta)
|
|
445
473
|
if args.lut_strength == 0.0:
|
|
446
474
|
# 0 means "no LUT at all" -- without this branch, 0 (falling outside the open interval
|
|
447
475
|
# below) landed in the same "apply the LUT at full strength" fallback as an out-of-range
|
|
@@ -450,9 +478,10 @@ def main() -> int:
|
|
|
450
478
|
vf = "format=yuv420p"
|
|
451
479
|
elif args.lut_strength < 1.0:
|
|
452
480
|
# blend graded and original
|
|
453
|
-
|
|
481
|
+
# blended in RGB, both pictures through the same two legs
|
|
482
|
+
vf = f"{to_rgb},split[o][g];[g]{lut}[g2];[o][g2]blend=all_mode=normal:all_opacity={args.lut_strength:g},{to_yuv}"
|
|
454
483
|
else:
|
|
455
|
-
vf = f"{lut},
|
|
484
|
+
vf = f"{to_rgb},{lut},{to_yuv}"
|
|
456
485
|
output = args.output or default_output(args.input, "lut")
|
|
457
486
|
tag = "lut"
|
|
458
487
|
|
package/scripts/crop.py
CHANGED
|
@@ -22,6 +22,8 @@ import argparse
|
|
|
22
22
|
import sys
|
|
23
23
|
|
|
24
24
|
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, fmt_secs
|
|
25
|
+
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
26
|
+
from _common.emit import note
|
|
25
27
|
|
|
26
28
|
|
|
27
29
|
def main() -> int:
|
|
@@ -51,15 +53,18 @@ def main() -> int:
|
|
|
51
53
|
meta = probe(args.input)
|
|
52
54
|
if not meta.get("video"):
|
|
53
55
|
die("input has no video stream")
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
56
|
+
# the rectangle is in the displayed frame cropdetect.py reports: rotation applied, and a
|
|
57
|
+
# non-square-pixel source squared at its display aspect first (#411)
|
|
58
|
+
sw, sh = display_size(meta["video"])
|
|
57
59
|
if args.x + args.width > sw or args.y + args.height > sh:
|
|
58
60
|
die(f"crop rectangle ({args.x},{args.y},{args.width}x{args.height}) exceeds the source frame ({sw}x{sh})")
|
|
59
61
|
has_audio = bool(meta.get("audio"))
|
|
60
62
|
|
|
61
63
|
output = args.output or default_output(args.input, "crop")
|
|
62
64
|
vf = [f"crop={args.width}:{args.height}:{args.x}:{args.y}", "setsar=1"]
|
|
65
|
+
if square_pixels(meta["video"]):
|
|
66
|
+
vf.insert(0, square_pixels(meta["video"]))
|
|
67
|
+
note(square_pixels_note(meta["video"]))
|
|
63
68
|
cmd = ffmpeg_base() + ["-i", args.input, "-vf", ",".join(vf)]
|
|
64
69
|
cmd += video_args(meta, args.crf, args.preset)
|
|
65
70
|
cmd += cfr_args(meta, args.fps)
|
package/scripts/cropdetect.py
CHANGED
|
@@ -34,7 +34,8 @@ from collections import Counter
|
|
|
34
34
|
from typing import Dict, List, Optional, Tuple
|
|
35
35
|
|
|
36
36
|
from _common import add_common, apply_common, die, emit, info, print_json, probe, require_tool, run_analysis, decode_gray_frames, frame_flow
|
|
37
|
-
from _common.emit import warn
|
|
37
|
+
from _common.emit import note, warn
|
|
38
|
+
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
38
39
|
|
|
39
40
|
MOTION_CENTRE_FPS = 2.0
|
|
40
41
|
MOTION_CENTRE_W, MOTION_CENTRE_H = 64, 36
|
|
@@ -43,10 +44,11 @@ CROP_RE = re.compile(r"crop=(\d+):(\d+):(\d+):(\d+)")
|
|
|
43
44
|
|
|
44
45
|
|
|
45
46
|
def detect(path: str, seconds: float, samples: int, limit: float, round_to: int, duration: float,
|
|
46
|
-
failed: "Optional[List[Dict]]" = None) -> List[Tuple[int, int, int, int]]:
|
|
47
|
+
failed: "Optional[List[Dict]]" = None, square: str = "") -> List[Tuple[int, int, int, int]]:
|
|
47
48
|
"""Crop rectangles from `samples` windows spread over the file. A window ffmpeg cannot decode
|
|
48
49
|
is skipped, and appended to `failed` as {start, seconds, error} when the caller passes a list,
|
|
49
|
-
so the result can say how many of the readings it stands on.
|
|
50
|
+
so the result can say how many of the readings it stands on. `square` is the source's
|
|
51
|
+
square_pixels() scale, run first so the rectangle is in the frame crop.py crops (#411)."""
|
|
50
52
|
ffmpeg = require_tool("ffmpeg")
|
|
51
53
|
per_window = max(0.5, seconds / max(1, samples))
|
|
52
54
|
rects: List[Tuple[int, int, int, int]] = []
|
|
@@ -55,7 +57,7 @@ def detect(path: str, seconds: float, samples: int, limit: float, round_to: int,
|
|
|
55
57
|
start = 0.0 if duration <= 0 else (duration - per_window) * i / max(1, samples - 1) if samples > 1 else 0.0
|
|
56
58
|
start = max(0.0, start)
|
|
57
59
|
cmd = [ffmpeg, "-hide_banner", "-nostdin", "-ss", f"{start:.3f}", "-i", path, "-t", f"{per_window:.3f}",
|
|
58
|
-
"-vf", f"cropdetect=limit={limit:g}:round={round_to}:reset=1", "-f", "null", "-"]
|
|
60
|
+
"-vf", (square + "," if square else "") + f"cropdetect=limit={limit:g}:round={round_to}:reset=1", "-f", "null", "-"]
|
|
59
61
|
proc = run_analysis(cmd, check=False)
|
|
60
62
|
if proc.returncode != 0:
|
|
61
63
|
# One window ffmpeg cannot decode (a damaged stretch) is skipped; the other windows
|
|
@@ -144,16 +146,18 @@ def main() -> int:
|
|
|
144
146
|
meta = probe(args.input)
|
|
145
147
|
if not meta.get("video"):
|
|
146
148
|
die("input has no video stream")
|
|
147
|
-
sw, sh = meta["video"]["width"], meta["video"]["height"]
|
|
148
149
|
# cropdetect and the grey decode both see the frame after ffmpeg applies rotation, so the
|
|
149
150
|
# rectangle and the motion points are in that frame. Reporting the stored size made a
|
|
150
|
-
# 176x320 crop look taller than a 320x180 source.
|
|
151
|
-
|
|
152
|
-
|
|
151
|
+
# 176x320 crop look taller than a 320x180 source. A non-square-pixel source is measured
|
|
152
|
+
# squared at its display aspect, the frame crop.py then crops (#411).
|
|
153
|
+
sw, sh = display_size(meta["video"])
|
|
154
|
+
if square_pixels_note(meta["video"]):
|
|
155
|
+
note(square_pixels_note(meta["video"]))
|
|
153
156
|
duration = meta.get("duration") or 0.0
|
|
154
157
|
|
|
155
158
|
failed: List[Dict] = []
|
|
156
|
-
rects = detect(args.input, args.seconds, args.samples, args.limit, args.round_to, duration, failed
|
|
159
|
+
rects = detect(args.input, args.seconds, args.samples, args.limit, args.round_to, duration, failed,
|
|
160
|
+
square_pixels(meta["video"]))
|
|
157
161
|
# windows_failed: a window ffmpeg could not decode was skipped without a word, so a
|
|
158
162
|
# crop measured on one window of five read as well-founded as one measured on all five
|
|
159
163
|
result: Dict = {"file": args.input, "source_width": sw, "source_height": sh,
|