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.
@@ -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
- cmd += ["-vn", "-ac", "1", "-ar", str(sample_rate), "-f", "s16le", "-"]
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", "-print_format", "json", "-show_format", "-show_streams", "-show_chapters", path],
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
- return bool(_URL_SCHEME.match(value)) and not value.startswith("pipe:")
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
- return os.path.join(d, f".{stem}.ffskill-{tag}-{os.getpid()}{ext}")
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 (rotation metadata swaps them, as fit.py reads it)."""
105
- video = meta.get("video") or {}
106
- w, h = int(video.get("width") or 0), int(video.get("height") or 0)
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 (h, w) if video.get("rotation") in (90, -90, 270, -270) else (w, h)
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]:
@@ -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="-stream_loop always re-encodes both streams; the audio codec is always AAC when present. --boomerang re-encodes the video through reverse/loop filters and drops the audio"),
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 STATE, add_common, aac_args, apply_common, cfr_args, default_output, die, draws_chroma, drawn_chain, emit, ffmpeg_base, info, time_arg, label_chain, matrix_chain, probe, run, validate_color, video_args, X264_PRESETS, fmt_secs
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
- w, h = meta_a["video"]["width"], meta_a["video"]["height"]
59
- # the cutaway is scaled onto the frame ffmpeg has already rotated, not the stored one
60
- if meta_a["video"].get("rotation") in (90, -90, 270, -270):
61
- w, h = h, w
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
- if convert[0]:
114
- parts.append(f"[0:v]{convert[0]}null[a709]")
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
- parts.append(f"[{i + 1}:a:0]atrim=start={c['from']:.3f}:duration={c['length']:.3f},asetpts=PTS-STARTPTS,"
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]")
@@ -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 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
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
- play_w, play_h = meta["video"]["width"], meta["video"]["height"]
1142
- if meta["video"].get("rotation") in (90, -90, 270, -270):
1143
- play_w, play_h = play_h, play_w
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: set = set()
1280
-
1281
- def claim(paths) -> None:
1282
- todo = [p for p in paths if p and os.path.realpath(p) not in claimed]
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
- claimed.update(os.path.realpath(p) for p in todo)
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
- else os.path.splitext(args.text)[0] + ".srt"))
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"]["width"], meta["video"]["height"]
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=label_chain(meta, meta) if rgb_chain_to_bt709(meta) else "")
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 (its own default)
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
- f"exposure=exposure={exposure:g}",
121
- f"colortemperature=temperature={temperature:g}",
122
- 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}",
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
- vf = f"split[o][g];[g]{lut}[g2];[o][g2]blend=all_mode=normal:all_opacity={args.lut_strength:g},format=yuv420p"
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},format=yuv420p"
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
- sw, sh = meta["video"]["width"], meta["video"]["height"]
55
- if meta["video"].get("rotation") in (90, -90, 270, -270):
56
- sw, sh = sh, sw
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)