ffmpeg-skill 3.1.4 → 3.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/contract.md +4 -3
- package/package.json +1 -1
- package/references/ci-platform-pitfalls.md +3 -2
- package/references/gotchas.md +5 -1
- package/references/scripts.md +5 -4
- package/scripts/_common/__init__.py +9 -7
- package/scripts/_common/color.py +40 -1
- package/scripts/_common/decision.py +199 -14
- package/scripts/_common/emit.py +12 -1
- package/scripts/_common/probe.py +98 -4
- package/scripts/_common/runner.py +38 -3
- package/scripts/_common/timeline.py +5 -4
- package/scripts/_contract.py +2 -2
- package/scripts/broll.py +35 -14
- package/scripts/caption.py +49 -22
- 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 +11 -2
- package/scripts/fit.py +8 -4
- package/scripts/freeze.py +38 -10
- package/scripts/graphics.py +109 -68
- package/scripts/grid.py +49 -7
- package/scripts/insert.py +11 -2
- package/scripts/join.py +29 -19
- package/scripts/look.py +44 -14
- package/scripts/loop.py +45 -6
- package/scripts/multicam.py +16 -7
- package/scripts/overlay.py +70 -15
- package/scripts/proxy.py +8 -4
- package/scripts/redact.py +10 -5
- package/scripts/render.py +8 -0
- package/scripts/reverse.py +27 -2
- package/scripts/scenes.py +16 -5
- package/scripts/sequence.py +6 -2
- package/scripts/silence.py +61 -30
- package/scripts/speedramp.py +17 -5
- package/scripts/straighten.py +17 -5
- package/scripts/sync.py +8 -2
- package/scripts/verify.py +7 -2
- package/scripts/waveform.py +22 -10
package/scripts/_common/probe.py
CHANGED
|
@@ -13,7 +13,7 @@ import statistics
|
|
|
13
13
|
from fractions import Fraction
|
|
14
14
|
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
15
15
|
from _common.emit import die, warn
|
|
16
|
-
from _common.runner import STATE, dry_run_input_pending, require_tool, run, run_analysis
|
|
16
|
+
from _common.runner import STATE, dry_run_input_pending, require_tool, run, run_analysis, still_input_args
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
def fingerprint(path: str) -> Dict[str, Any]:
|
|
@@ -38,7 +38,8 @@ def decode_pcm_mono(path: str, sample_rate: int, seconds: Optional[float] = None
|
|
|
38
38
|
*, check: bool = True) -> List[float]:
|
|
39
39
|
"""Decode (part of) a file's audio to mono float samples in [-1, 1) at `sample_rate` via a
|
|
40
40
|
single ffmpeg pass under --timeout. Shared by scenes.py (audio envelope for cut scoring) and
|
|
41
|
-
sync.py (cross-correlation); an undecodable input is kind ffmpeg when check=True, else [].
|
|
41
|
+
sync.py (cross-correlation); an undecodable input is kind ffmpeg when check=True, else [].
|
|
42
|
+
The samples are on the file's clock: a sound that starts late is padded at its head."""
|
|
42
43
|
ffmpeg = require_tool("ffmpeg")
|
|
43
44
|
cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin"]
|
|
44
45
|
if start:
|
|
@@ -46,7 +47,10 @@ def decode_pcm_mono(path: str, sample_rate: int, seconds: Optional[float] = None
|
|
|
46
47
|
cmd += ["-i", path]
|
|
47
48
|
if seconds is not None:
|
|
48
49
|
cmd += ["-t", f"{seconds:.3f}"]
|
|
49
|
-
|
|
50
|
+
# first_pts=0 pads a sound that starts after the file's start (or after `start`) with silence,
|
|
51
|
+
# so sample i is at start + i / sample_rate on the file's clock: sync.py measured a track that
|
|
52
|
+
# starts 0.3 s late 0.279 s off, and --replace-audio placed it that far late (#409)
|
|
53
|
+
cmd += ["-vn", "-af", "aresample=async=1:first_pts=0", "-ac", "1", "-ar", str(sample_rate), "-f", "s16le", "-"]
|
|
50
54
|
proc = run_analysis(cmd, check=False, text=False)
|
|
51
55
|
if proc.returncode != 0 or not proc.stdout:
|
|
52
56
|
if check:
|
|
@@ -187,7 +191,8 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
|
|
|
187
191
|
die(f"input not found: {path}")
|
|
188
192
|
ffprobe = require_tool("ffprobe")
|
|
189
193
|
proc = run(
|
|
190
|
-
[ffprobe, "-v", "error",
|
|
194
|
+
[ffprobe, "-v", "error", *still_input_args(path),
|
|
195
|
+
"-print_format", "json", "-show_format", "-show_streams", "-show_chapters", path],
|
|
191
196
|
quiet=True,
|
|
192
197
|
check=False,
|
|
193
198
|
)
|
|
@@ -283,6 +288,12 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
|
|
|
283
288
|
rotation = int(video["tags"]["rotate"])
|
|
284
289
|
except ValueError:
|
|
285
290
|
pass
|
|
291
|
+
if not rotation and _is_still(fmt, video):
|
|
292
|
+
# an EXIF Orientation reaches ffmpeg as the decoded frame's display matrix, not as
|
|
293
|
+
# stream side data, so -show_streams has no rotation for a phone's portrait JPEG;
|
|
294
|
+
# ffmpeg still turns the picture upright on decode, and every tool that sizes the
|
|
295
|
+
# frame has to know it did (#411)
|
|
296
|
+
rotation = _still_rotation(path)
|
|
286
297
|
pix = video.get("pix_fmt") or ""
|
|
287
298
|
trc = video.get("color_transfer") or ""
|
|
288
299
|
prim = video.get("color_primaries") or ""
|
|
@@ -300,6 +311,10 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
|
|
|
300
311
|
"width": w,
|
|
301
312
|
"height": h,
|
|
302
313
|
"display_aspect": video.get("display_aspect_ratio") or _aspect_string(w, h),
|
|
314
|
+
# the stored pixel's shape (DV, HDV 1440x1080, DVD and XDCAM store non-square pixels);
|
|
315
|
+
# every tool that sizes or draws on the picture works on square pixels at the display
|
|
316
|
+
# aspect (square_pixels() / display_size() below)
|
|
317
|
+
"sample_aspect_ratio": _sar_string(video.get("sample_aspect_ratio")),
|
|
303
318
|
"fps": round(fps, 3) if fps else None,
|
|
304
319
|
"r_frame_rate": video.get("r_frame_rate"),
|
|
305
320
|
"avg_frame_rate": video.get("avg_frame_rate"),
|
|
@@ -368,6 +383,8 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
|
|
|
368
383
|
if role == "input" and getattr(STATE, "first_input", None) is None:
|
|
369
384
|
STATE.first_input = (path, len(out.get("audio_streams") or []),
|
|
370
385
|
(out.get("video") or {}).get("hdr10_static"))
|
|
386
|
+
if role == "input" and out["timecode"].get("value") and getattr(STATE, "source_timecode", None) is None:
|
|
387
|
+
STATE.source_timecode = (out["timecode"]["value"], duration)
|
|
371
388
|
return out
|
|
372
389
|
|
|
373
390
|
|
|
@@ -657,6 +674,83 @@ def _fraction(v: Optional[str]) -> Optional[Fraction]:
|
|
|
657
674
|
return None
|
|
658
675
|
|
|
659
676
|
|
|
677
|
+
def _sar_string(v: Optional[str]) -> str:
|
|
678
|
+
"""ffprobe's sample_aspect_ratio as "N:D", "1:1" when unset (0:1, N/A, missing)."""
|
|
679
|
+
f = _fraction((v or "").replace(":", "/"))
|
|
680
|
+
return f"{f.numerator}:{f.denominator}" if f else "1:1"
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def pixel_aspect(video: Optional[Dict[str, Any]]) -> Fraction:
|
|
684
|
+
"""The stored pixel's width over its height (the SAR); 1 when the stream does not say."""
|
|
685
|
+
return _fraction(str((video or {}).get("sample_aspect_ratio") or "").replace(":", "/")) or Fraction(1)
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _square_size(video: Optional[Dict[str, Any]]) -> "Tuple[int, int, Fraction]":
|
|
689
|
+
"""(width, height, SAR) of the frame the filter graph receives: rotation applied (ffmpeg
|
|
690
|
+
autorotates on decode, and its transpose turns the SAR over too), pixels not yet squared."""
|
|
691
|
+
video = video or {}
|
|
692
|
+
w, h = int(video.get("width") or 0), int(video.get("height") or 0)
|
|
693
|
+
sar = pixel_aspect(video)
|
|
694
|
+
if video.get("rotation") in (90, -90, 270, -270):
|
|
695
|
+
w, h, sar = h, w, 1 / sar
|
|
696
|
+
return w, h, sar
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def display_size(video: Optional[Dict[str, Any]]) -> "Tuple[int, int]":
|
|
700
|
+
"""The displayed frame in square pixels: rotation applied, and a non-square-pixel picture
|
|
701
|
+
widened or narrowed to its display aspect at its own height (1440x1080 at 4:3 is 1920x1080,
|
|
702
|
+
720x480 at 8:9 is 640x480). It is the frame square_pixels() hands the rest of a graph, so a
|
|
703
|
+
tool sizes, places and draws in it. 0x0 when unknown (a dry run's pending input)."""
|
|
704
|
+
w, h, sar = _square_size(video)
|
|
705
|
+
if w and h and sar != 1:
|
|
706
|
+
w = max(2, int(round(w * sar / 2)) * 2)
|
|
707
|
+
return w, h
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def square_pixels(video: Optional[Dict[str, Any]]) -> str:
|
|
711
|
+
"""The filter that makes a non-square-pixel picture square at its display aspect
|
|
712
|
+
("scale=640:360,setsar=1"), or "" when the pixels are square already. A tool that scales,
|
|
713
|
+
crops, places or draws starts its video chain with it, so every such tool agrees on one
|
|
714
|
+
frame (display_size()) and nothing is squashed or drawn stretched (#411). Tools that only
|
|
715
|
+
re-encode keep the stored size and its SAR, which the encoder writes through."""
|
|
716
|
+
w, h, sar = _square_size(video)
|
|
717
|
+
if not (w and h) or sar == 1:
|
|
718
|
+
return ""
|
|
719
|
+
dw, dh = display_size(video)
|
|
720
|
+
return f"scale={dw}:{dh},setsar=1"
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def square_pixels_note(video: Optional[Dict[str, Any]], what: str = "the input") -> Optional[str]:
|
|
724
|
+
"""The notes line for a square_pixels() scale, or None when there is none."""
|
|
725
|
+
if not square_pixels(video):
|
|
726
|
+
return None
|
|
727
|
+
w, h, _ = _square_size(video)
|
|
728
|
+
dw, dh = display_size(video)
|
|
729
|
+
return (f"{what} stores non-square pixels (SAR {(video or {}).get('sample_aspect_ratio')}): "
|
|
730
|
+
f"its {w}x{h} picture was scaled to {dw}x{dh} square pixels, its display aspect")
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _is_still(fmt: Dict[str, Any], video: Dict[str, Any]) -> bool:
|
|
734
|
+
"""A single picture read by an image demuxer (image2, jpeg_pipe, png_pipe, ...)."""
|
|
735
|
+
name = fmt.get("format_name") or ""
|
|
736
|
+
return name == "image2" or name.endswith("_pipe")
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _still_rotation(path: str) -> int:
|
|
740
|
+
"""The display rotation of a still's first decoded frame (an EXIF Orientation), 0 if none."""
|
|
741
|
+
proc = run([require_tool("ffprobe"), "-v", "error", *still_input_args(path), "-select_streams", "v:0",
|
|
742
|
+
"-read_intervals", "%+#1", "-show_entries", "frame_side_data=rotation", "-of", "json", path],
|
|
743
|
+
quiet=True, check=False)
|
|
744
|
+
try:
|
|
745
|
+
frames = json.loads(proc.stdout or "{}").get("frames") or []
|
|
746
|
+
for sd in (frames[0].get("side_data_list") or []) if frames else []:
|
|
747
|
+
if "rotation" in sd:
|
|
748
|
+
return int(round(float(sd["rotation"])))
|
|
749
|
+
except (ValueError, TypeError, AttributeError):
|
|
750
|
+
pass
|
|
751
|
+
return 0
|
|
752
|
+
|
|
753
|
+
|
|
660
754
|
def _aspect_string(w: Optional[int], h: Optional[int]) -> Optional[str]:
|
|
661
755
|
if not w or not h:
|
|
662
756
|
return None
|
|
@@ -324,7 +324,8 @@ class Context:
|
|
|
324
324
|
|
|
325
325
|
__slots__ = ("dry_run", "json", "json_brief", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting", "plan", "plan_written", "plan_inputs", "codec",
|
|
326
326
|
"hw", "hw_source", "hw_notes", "hw_swaps", "hw_fallback", "hw_env_ignored", "hw_stages", "hw_auto",
|
|
327
|
-
"notes", "warnings", "fallbacks", "skipped", "audit_log", "stage_verification", "first_input", "audio_inputs", "video_inputs"
|
|
327
|
+
"notes", "warnings", "fallbacks", "skipped", "audit_log", "stage_verification", "first_input", "audio_inputs", "video_inputs",
|
|
328
|
+
"source_timecode", "smpte_hint")
|
|
328
329
|
|
|
329
330
|
def __init__(self) -> None:
|
|
330
331
|
self.reset()
|
|
@@ -334,6 +335,8 @@ class Context:
|
|
|
334
335
|
self.json = False # emit() prints a JSON document instead of the output path
|
|
335
336
|
self.first_input = None # (path, audio track count) of the first input probed: emit() reports tracks the output left out
|
|
336
337
|
self.video_inputs = set() # real paths of the inputs probed that have a picture: with two or more, which one the output shows is the tool's choice (sync --trim-second, a join)
|
|
338
|
+
self.source_timecode = None # (start timecode, duration) of the first input probed that carries one (time_arg's SMPTE hint)
|
|
339
|
+
self.smpte_hint = None # what die() says when an hh:mm:ss:ff flag read past the end looks like a source timecode (#411)
|
|
337
340
|
self.audio_inputs = set() # real paths of the inputs probed that have sound: with two or more, whose tracks an output should keep is the tool's choice (grid --audio-from, a mix)
|
|
338
341
|
self.json_brief = False # --json-brief: the same document trimmed to the fields a caller acts on
|
|
339
342
|
self.progress = False # run() streams percent / ETA to stderr for ffmpeg
|
|
@@ -441,6 +444,13 @@ def apply_common(args: "argparse.Namespace") -> None:
|
|
|
441
444
|
STATE.progress = bool(getattr(args, "progress", False))
|
|
442
445
|
STATE.fast = bool(getattr(args, "fast", False))
|
|
443
446
|
STATE.overwrite = bool(getattr(args, "overwrite", False))
|
|
447
|
+
if STATE.plan:
|
|
448
|
+
# the plan is a file this run writes itself: the side-file guards apply (#408), so it
|
|
449
|
+
# never replaces an input, the output or an existing file without --overwrite
|
|
450
|
+
named = [getattr(args, k, None) for k in ("input", "inputs", "reference", "compare")]
|
|
451
|
+
inputs = [p for v in named for p in (v if isinstance(v, (list, tuple)) else [v]) if isinstance(p, str)]
|
|
452
|
+
out = getattr(args, "output", None)
|
|
453
|
+
guard_sidecar(STATE.plan, *inputs, siblings=(out,) if isinstance(out, str) else ())
|
|
444
454
|
if getattr(args, "timeout", None) is not None:
|
|
445
455
|
STATE.timeout = max(0.0, float(args.timeout))
|
|
446
456
|
else:
|
|
@@ -683,11 +693,15 @@ def video_encoder_of(cmd: Sequence[str]) -> Optional[str]:
|
|
|
683
693
|
# while every guard here reads it as a path. Two or more letters, so a Windows
|
|
684
694
|
# drive letter (`C:\x.mp4`) is a path; `pipe:` is the skill's own stdout/stdin and stays allowed.
|
|
685
695
|
_URL_SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]+:")
|
|
696
|
+
# Off Windows there are no drive letters, and ffmpeg reads a one-letter `x:y.mp4` as protocol `x`
|
|
697
|
+
# too (#408: it then failed as "Protocol not found", with no hint that ./x:y.mp4 works)
|
|
698
|
+
_URL_SCHEME_POSIX = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:")
|
|
686
699
|
|
|
687
700
|
|
|
688
701
|
def looks_like_url(value: str) -> bool:
|
|
689
702
|
"""True for a value ffmpeg would open through a protocol rather than as a local file."""
|
|
690
|
-
|
|
703
|
+
scheme = _URL_SCHEME if os.name == "nt" else _URL_SCHEME_POSIX
|
|
704
|
+
return bool(scheme.match(value)) and not value.startswith("pipe:")
|
|
691
705
|
|
|
692
706
|
|
|
693
707
|
_PLAYLIST_SUFFIXES = (".m3u8", ".m3u", ".ffconcat")
|
|
@@ -1705,13 +1719,34 @@ def place_output(src: str, dst: str, move: bool = False) -> None:
|
|
|
1705
1719
|
_remember_output(cmd)
|
|
1706
1720
|
|
|
1707
1721
|
|
|
1722
|
+
# extensions ffmpeg opens through the image2 demuxer, the only one that expands `%d` in a name
|
|
1723
|
+
# and the only one that has a `pattern_type` option (gif, avif, mp4... reject it: "Option
|
|
1724
|
+
# pattern_type not found")
|
|
1725
|
+
_IMAGE2_EXT = (".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tif", ".tiff", ".dpx", ".exr", ".tga",
|
|
1726
|
+
".ppm", ".pgm", ".pbm", ".pam", ".sgi", ".jp2", ".j2k", ".xbm", ".xpm", ".pcx", ".qoi")
|
|
1727
|
+
|
|
1728
|
+
|
|
1729
|
+
def still_input_args(path: str) -> List[str]:
|
|
1730
|
+
"""`-pattern_type none` for a still read through image2 whose path has a `%` in it (#408):
|
|
1731
|
+
image2 otherwise reads `logo%d.png` as a numbered sequence and opens `logo1.png`, a file the
|
|
1732
|
+
run's guards and fingerprints never looked at. Empty for every other path, a video or a GIF
|
|
1733
|
+
with a `%` in its name included: their demuxers never expand it and reject the option."""
|
|
1734
|
+
p = str(path)
|
|
1735
|
+
return ["-pattern_type", "none"] if "%" in p and os.path.splitext(p)[1].lower() in _IMAGE2_EXT else []
|
|
1736
|
+
|
|
1737
|
+
|
|
1708
1738
|
def sibling_temp(dst: str, tag: str) -> str:
|
|
1709
1739
|
"""A hidden path next to `dst`, with its extension (the muxer is chosen by it), for a file
|
|
1710
1740
|
place_output(..., move=True) renames into place: same directory, so the rename never
|
|
1711
1741
|
crosses a filesystem."""
|
|
1712
1742
|
d, base = os.path.split(dst)
|
|
1713
1743
|
stem, ext = os.path.splitext(base)
|
|
1714
|
-
|
|
1744
|
+
path = os.path.join(d, f".{stem}.ffskill-{tag}-{os.getpid()}{ext}")
|
|
1745
|
+
n = 1
|
|
1746
|
+
while os.path.lexists(path): # a leftover of an earlier run with this pid: not ours to replace
|
|
1747
|
+
n += 1
|
|
1748
|
+
path = os.path.join(d, f".{stem}.ffskill-{tag}-{os.getpid()}-{n}{ext}")
|
|
1749
|
+
return path
|
|
1715
1750
|
|
|
1716
1751
|
|
|
1717
1752
|
_DRAWTEXT_TMPDIR: "Optional[str]" = None
|
|
@@ -30,6 +30,7 @@ from xml.sax.saxutils import escape as _xml_escape
|
|
|
30
30
|
|
|
31
31
|
from _common.decision import MissingFpsError, aspect_ratio, frame_size, parse_time
|
|
32
32
|
from _common.emit import warn
|
|
33
|
+
from _common.probe import display_size
|
|
33
34
|
|
|
34
35
|
FORMATS = {".edl": "edl", ".fcpxml": "fcpxml", ".otio": "otio"}
|
|
35
36
|
# Common NTSC rates are stored as the exact ratio an editor expects, not as 29.97.
|
|
@@ -101,12 +102,12 @@ def _seconds(value: Any, what: str, fps: Optional[float]) -> float:
|
|
|
101
102
|
|
|
102
103
|
|
|
103
104
|
def _picture(meta: Dict[str, Any]) -> Optional[Tuple[int, int]]:
|
|
104
|
-
"""A source's displayed width x height
|
|
105
|
-
|
|
106
|
-
w, h =
|
|
105
|
+
"""A source's displayed width x height, as fit.py reads it: rotation metadata swaps them, and
|
|
106
|
+
non-square pixels are squared at the display aspect (probe.display_size(), #411)."""
|
|
107
|
+
w, h = display_size(meta.get("video") or {})
|
|
107
108
|
if not (w and h):
|
|
108
109
|
return None
|
|
109
|
-
return (
|
|
110
|
+
return (w, h)
|
|
110
111
|
|
|
111
112
|
|
|
112
113
|
def build(proj: Dict[str, Any], probes: Dict[str, Dict[str, Any]], rel) -> Dict[str, Any]:
|
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"),
|
|
@@ -454,7 +454,7 @@ DIAGNOSTIC_CODES = {
|
|
|
454
454
|
"audit_log_unwritable": ("warnings", "an audit record could not be appended; never fails the run; {path, event, error}"),
|
|
455
455
|
"plan_unbound_inputs": ("warnings", "render.py PLAN: inputs that did not exist when the plan was made, so were not checked; {paths}"),
|
|
456
456
|
# 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}"),
|
|
457
|
+
"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
458
|
"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
459
|
"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
460
|
"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}"),
|
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
|
@@ -44,7 +44,8 @@ from typing import Any, Dict, List, Optional, Tuple
|
|
|
44
44
|
from _platforms import (PLATFORMS, PLATFORM_CHOICES, ASS_SCRIPT_HEIGHT, ass_units,
|
|
45
45
|
resolve as resolve_platform)
|
|
46
46
|
from _ass_overlay import EMOJI_SENTINEL, emoji_placeholder, ass_escape
|
|
47
|
-
from _common import
|
|
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, 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
|
|
48
49
|
# The line breaker, lifted into _common/text.py in 1.16.0 so graphics.py can use the same rules.
|
|
49
50
|
# The ASR bridge and the SRT reader/writer live in _common.asr since 1.17 (silence.py --filler
|
|
50
51
|
# shares them). They stay caption.py's public names -- every caller and test that reached for
|
|
@@ -52,6 +53,7 @@ from _common import require_filter, emoji_filter_chain, EMOJI_ASSET_HINT, emoji_
|
|
|
52
53
|
from _common import asr as _asr
|
|
53
54
|
from _common.asr import ENGINE_CHOICES
|
|
54
55
|
from _common.emit import warn
|
|
56
|
+
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
55
57
|
from _common.fonts import font_choice, record_font_choice
|
|
56
58
|
from _common import (ASR_INSTALL_HINT, die_no_engine, parse_srt, transcribe, whisper_word_timings,
|
|
57
59
|
write_srt)
|
|
@@ -1138,9 +1140,9 @@ def main() -> int:
|
|
|
1138
1140
|
|
|
1139
1141
|
play_w = play_h = None
|
|
1140
1142
|
if meta and meta.get("video"):
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1143
|
+
# the displayed frame: rotation applied, non-square pixels squared (#411) -- the burn
|
|
1144
|
+
# below draws on the square-pixel picture, so a cue is not stretched by the player's SAR
|
|
1145
|
+
play_w, play_h = display_size(meta["video"])
|
|
1144
1146
|
# A --plan or --dry-run written before the input exists has no geometry to fit against, and a
|
|
1145
1147
|
# plan that describes a different FontSize from the run that executes it is not a plan. When
|
|
1146
1148
|
# --platform names a destination, that destination's frame IS the geometry the real run will
|
|
@@ -1276,28 +1278,52 @@ def main() -> int:
|
|
|
1276
1278
|
# next to the output was replaced on a re-run, even one ffmpeg's check then refused.
|
|
1277
1279
|
# Side files whose existence depends on the cues (an _adjusted.srt, an emoji-forced ASS) are
|
|
1278
1280
|
# checked again where they are written, still before that write.
|
|
1279
|
-
claimed:
|
|
1280
|
-
|
|
1281
|
-
def claim(
|
|
1282
|
-
|
|
1281
|
+
claimed: Dict[str, str] = {} # real path -> which artifact of this run holds it
|
|
1282
|
+
|
|
1283
|
+
def claim(items) -> None:
|
|
1284
|
+
"""Reserve (path, artifact) pairs: the same artifact claiming its path again is a no-op,
|
|
1285
|
+
another artifact of this run claiming it is refused (#413 review: --write-srt and
|
|
1286
|
+
--write-ass naming one file, the SRT then replaced by the ASS)."""
|
|
1287
|
+
todo = []
|
|
1288
|
+
for path, owner in items:
|
|
1289
|
+
if not path:
|
|
1290
|
+
continue
|
|
1291
|
+
real = os.path.realpath(path)
|
|
1292
|
+
held = claimed.get(real)
|
|
1293
|
+
if held is None and os.path.exists(real):
|
|
1294
|
+
# a second name for the same file (a hard link) is the same path (#413 review)
|
|
1295
|
+
held = next((o for r, o in claimed.items()
|
|
1296
|
+
if os.path.exists(r) and os.path.samefile(r, real)), None)
|
|
1297
|
+
if held is None:
|
|
1298
|
+
claimed[real] = owner
|
|
1299
|
+
todo.append(path)
|
|
1300
|
+
elif held != owner:
|
|
1301
|
+
refuse("input", "output_path_taken",
|
|
1302
|
+
f"{path!r} is named for both the {held} and the {owner} this run writes; give each its own path",
|
|
1303
|
+
output=path, other=held)
|
|
1283
1304
|
refuse_existing_outputs(todo)
|
|
1284
|
-
|
|
1305
|
+
# and the rest of the side-file guards (#408): never an input, the video, a missing
|
|
1306
|
+
# directory or an option-shaped name, even under --overwrite
|
|
1307
|
+
side_inputs = [q for q in (args.input, getattr(args, "text", None), getattr(args, "ass", None),
|
|
1308
|
+
getattr(args, "brand", None), *(t[0] for t in srt_tracks)) if isinstance(q, str)]
|
|
1309
|
+
for path in todo:
|
|
1310
|
+
guard_sidecar(path, *side_inputs)
|
|
1285
1311
|
|
|
1286
1312
|
planned_out = args.output or (default_output(args.input, "captioned") if args.input else None)
|
|
1287
|
-
up_front: List[str] = []
|
|
1313
|
+
up_front: List[Tuple[str, str]] = []
|
|
1288
1314
|
if args.transcribe:
|
|
1289
|
-
up_front.append(args.write_srt or os.path.splitext(planned_out)[0] + ".srt")
|
|
1315
|
+
up_front.append((args.write_srt or os.path.splitext(planned_out)[0] + ".srt", "SRT"))
|
|
1290
1316
|
elif args.text:
|
|
1291
|
-
up_front.append(args.write_srt or (os.path.splitext(planned_out)[0] + ".srt" if args.input
|
|
1292
|
-
|
|
1317
|
+
up_front.append((args.write_srt or (os.path.splitext(planned_out)[0] + ".srt" if args.input
|
|
1318
|
+
else os.path.splitext(args.text)[0] + ".srt"), "SRT"))
|
|
1293
1319
|
if args.input:
|
|
1294
1320
|
if (args.animate != "none" or args.karaoke) and not args.ass:
|
|
1295
|
-
up_front.append(args.write_ass or os.path.splitext(planned_out)[0] + ".ass")
|
|
1321
|
+
up_front.append((args.write_ass or os.path.splitext(planned_out)[0] + ".ass", "ASS"))
|
|
1296
1322
|
if args.ass and args.offset and os.path.exists(args.ass):
|
|
1297
|
-
up_front.append(os.path.splitext(planned_out)[0] + "_offset.ass")
|
|
1323
|
+
up_front.append((os.path.splitext(planned_out)[0] + "_offset.ass", "offset ASS"))
|
|
1298
1324
|
# -o naming the input itself keeps its own, more specific refusal (_check_no_overwrite_input)
|
|
1299
1325
|
if os.path.realpath(planned_out) != os.path.realpath(args.input):
|
|
1300
|
-
up_front.append(planned_out)
|
|
1326
|
+
up_front.append((planned_out, "video"))
|
|
1301
1327
|
claim(up_front)
|
|
1302
1328
|
if args.transcribe:
|
|
1303
1329
|
# the sidecar goes next to the output like the --text one, not into the source folder
|
|
@@ -1358,7 +1384,7 @@ def main() -> int:
|
|
|
1358
1384
|
adjusted, changed = lay_out(parse_srt(srt_path))
|
|
1359
1385
|
if changed:
|
|
1360
1386
|
new_srt = os.path.splitext(output)[0] + "_adjusted.srt"
|
|
1361
|
-
claim([new_srt])
|
|
1387
|
+
claim([(new_srt, "adjusted SRT")])
|
|
1362
1388
|
if STATE.dry_run:
|
|
1363
1389
|
info(f"[dry-run] would write {new_srt} ({len(adjusted)} cues, adjusted from {os.path.basename(srt_path)})")
|
|
1364
1390
|
else:
|
|
@@ -1563,10 +1589,8 @@ def main() -> int:
|
|
|
1563
1589
|
if args.karaoke and not getattr(args, "_word_timings", None):
|
|
1564
1590
|
args._word_timings = whisper_word_timings(srt_path)
|
|
1565
1591
|
ass_path = args.write_ass or os.path.splitext(output)[0] + ".ass"
|
|
1566
|
-
claim([ass_path])
|
|
1567
|
-
w, h = meta["video"]
|
|
1568
|
-
if meta["video"].get("rotation") in (90, -90, 270, -270):
|
|
1569
|
-
w, h = h, w
|
|
1592
|
+
claim([(ass_path, "ASS")])
|
|
1593
|
+
w, h = display_size(meta["video"])
|
|
1570
1594
|
if not STATE.dry_run: # the generated ASS is an artifact of this run: a plan writes nothing
|
|
1571
1595
|
write_ass(cues_for_ass, ass_path, args, w, h, video=args.input if meta.get("audio") else None)
|
|
1572
1596
|
info(f"wrote {ass_path} ({len(cues_for_ass)} cues, animate={args.animate}, karaoke={args.karaoke})")
|
|
@@ -1656,9 +1680,12 @@ def main() -> int:
|
|
|
1656
1680
|
vf = drawn_chain(vf, meta, [args.color, args.outline_color]
|
|
1657
1681
|
+ ([args.highlight_color, args.upcoming_color] if args.karaoke else []))
|
|
1658
1682
|
|
|
1683
|
+
if square_pixels((meta or {}).get("video")):
|
|
1684
|
+
vf = square_pixels(meta["video"]) + "," + vf
|
|
1685
|
+
side_notes.append(square_pixels_note(meta["video"]))
|
|
1659
1686
|
cmd = ffmpeg_base() + ["-i", args.input]
|
|
1660
1687
|
chains, emoji_inputs = (emoji_filter_chain(emoji_plan or {}, "vsub", "vout", to_yuv=rgb_chain_to_bt709(meta),
|
|
1661
|
-
base_chain=
|
|
1688
|
+
base_chain=layer_base_chain(meta))
|
|
1662
1689
|
if emoji_plan else ([], []))
|
|
1663
1690
|
if chains:
|
|
1664
1691
|
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)
|