ffmpeg-skill 3.2.1 → 3.2.3

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.
@@ -741,6 +741,13 @@ def _x264_raw(crf: int, preset: str, keep_bt709: bool = True) -> List[str]:
741
741
  return args
742
742
 
743
743
 
744
+ def keeps_source_tags() -> bool:
745
+ """True when the line being written is an editing master that keeps the source's colour tags
746
+ and converts no matrix: --codec prores, or an alpha line (--keep-alpha / --transparent,
747
+ _common.alpha). The BT.709 conversions and labels below are for lines tagged BT.709."""
748
+ return STATE.codec == "prores" or STATE.alpha_line is not None
749
+
750
+
744
751
  def x264_args(crf: int = 18, preset: str = "medium", keep_bt709: bool = True,
745
752
  source: Optional[Dict[str, Any]] = None) -> List[str]:
746
753
  """SDR H.264 encoder args -- or, when --codec named another encoder, that encoder's SDR args
@@ -753,7 +760,7 @@ def x264_args(crf: int = 18, preset: str = "medium", keep_bt709: bool = True,
753
760
  args = encoder_args(STATE.codec, crf, preset, None, keep_bt709)
754
761
  else:
755
762
  args = _maybe_hw("h264", crf, None, keep_bt709, _x264_raw(crf, preset, keep_bt709))
756
- return args + (bt709_matrix_args(source) if keep_bt709 and STATE.codec != "prores" else [])
763
+ return args + (bt709_matrix_args(source) if keep_bt709 and not keeps_source_tags() else [])
757
764
 
758
765
 
759
766
  def video_args(meta: Optional[Dict[str, Any]], crf: int = 18, preset: str = "medium", convert_matrix: bool = True,
@@ -777,7 +784,7 @@ def video_args(meta: Optional[Dict[str, Any]], crf: int = 18, preset: str = "med
777
784
  video_args(None)); its RGB or BT.601 pixels are converted to the BT.709 that line is tagged with.
778
785
  """
779
786
  v = (meta or {}).get("video") or {}
780
- fix = bt709_matrix_args(meta if source is None else source) if convert_matrix and STATE.codec != "prores" else []
787
+ fix = bt709_matrix_args(meta if source is None else source) if convert_matrix and not keeps_source_tags() else []
781
788
  if STATE.codec:
782
789
  return encoder_args(STATE.codec, crf, preset, meta) + fix
783
790
  if not v.get("bt2020_or_hdr"):
@@ -790,7 +797,7 @@ def matrix_chain(meta: Optional[Dict[str, Any]], line_meta: Optional[Dict[str, A
790
797
  video_args(line_meta, convert_matrix=False): the conversion one source's own chain starts
791
798
  with ("<filter>," -- bt709_matrix_filter), or "" when that source needs none or the line does
792
799
  not tag BT.709 (an HDR/BT.2020 line keeps its tags, --codec prores keeps the source's)."""
793
- if STATE.codec == "prores" or ((line_meta or {}).get("video") or {}).get("bt2020_or_hdr"):
800
+ if keeps_source_tags() or ((line_meta or {}).get("video") or {}).get("bt2020_or_hdr"):
794
801
  return ""
795
802
  f = bt709_matrix_filter(meta)
796
803
  return f + "," if f else ""
@@ -801,7 +808,7 @@ def label_chain(meta: Optional[Dict[str, Any]], line_meta: Optional[Dict[str, An
801
808
  cell beside a converted cell), encoded through video_args(line_meta): the BT.709 label an
802
809
  untagged one's chain starts with ("<setparams>," -- bt709_label_filter), or "" (a tagged, RGB or
803
810
  HDR picture, or a line that does not tag BT.709)."""
804
- if STATE.codec == "prores" or ((line_meta or {}).get("video") or {}).get("bt2020_or_hdr"):
811
+ if keeps_source_tags() or ((line_meta or {}).get("video") or {}).get("bt2020_or_hdr"):
805
812
  return ""
806
813
  f = bt709_label_filter(meta)
807
814
  return f + "," if f else ""
@@ -813,7 +820,7 @@ def range_chain(meta: Optional[Dict[str, Any]], line_meta: Optional[Dict[str, An
813
820
  takes after its matrix_chain()/label_chain() ("<filter>," -- limited_range_filter), or "" (a
814
821
  limited, RGB or HDR picture, or a line that does not tag BT.709). Every SDR picture such a
815
822
  tool mixes is then limited range, whatever each one was: the filters mix samples, not colours."""
816
- if STATE.codec == "prores" or ((line_meta or {}).get("video") or {}).get("bt2020_or_hdr"):
823
+ if keeps_source_tags() or ((line_meta or {}).get("video") or {}).get("bt2020_or_hdr"):
817
824
  return ""
818
825
  f = limited_range_filter(meta)
819
826
  return f + "," if f else ""
@@ -835,7 +842,12 @@ def rgb_chain_to_bt709(line_meta: Optional[Dict[str, Any]], pix_fmt: str = "yuva
835
842
  is encoded through video_args(line_meta): "," + the conversion to BT.709 YUV to end that chain
836
843
  with (rgb_to_bt709_filter), or "" when the line does not tag BT.709 (the lines matrix_chain()
837
844
  leaves alone). Without it the graph's own RGB-to-YUV step used FFmpeg's BT.601 default."""
838
- if STATE.codec == "prores" or ((line_meta or {}).get("video") or {}).get("bt2020_or_hdr"):
845
+ v = (line_meta or {}).get("video") or {}
846
+ if STATE.alpha_line is not None and v.get("color_space") == "bt709" and not v.get("bt2020_or_hdr"):
847
+ # an alpha line keeps the source's tags: on a BT.709-tagged picture an RGB layer is made
848
+ # BT.709 too (FFmpeg 6.1's own conversion is BT.601), in 4:4:4 with its alpha plane
849
+ return "," + rgb_to_bt709_filter("yuva444p")
850
+ if keeps_source_tags() or v.get("bt2020_or_hdr"):
839
851
  return ""
840
852
  return "," + rgb_to_bt709_filter(pix_fmt)
841
853
 
@@ -843,7 +855,7 @@ def rgb_chain_to_bt709(line_meta: Optional[Dict[str, Any]], pix_fmt: str = "yuva
843
855
  def draws_chroma(colours: Sequence[Any], line_meta: Optional[Dict[str, Any]]) -> bool:
844
856
  """True when one of `colours` has chroma (is_neutral_color) and the line video_args(line_meta)
845
857
  tags the output BT.709 -- when a drawn colour needs drawn_chain() at all."""
846
- return (not all(is_neutral_color(c) for c in colours if c is not None) and STATE.codec != "prores"
858
+ return (not all(is_neutral_color(c) for c in colours if c is not None) and not keeps_source_tags()
847
859
  and not ((line_meta or {}).get("video") or {}).get("bt2020_or_hdr"))
848
860
 
849
861
 
@@ -1220,25 +1232,128 @@ def _grid_score(onset_times: "Sequence[float]", strength_at: "Dict[int, float]",
1220
1232
  return total
1221
1233
 
1222
1234
 
1235
+ BEAT_ZERO_TOL_S = 0.01 # a fitted grid point this little before 0 s is the beat at 0.0
1236
+ BEAT_REFINE_TOL_NATS = 1.0 # onset refinement: the latest time within this of the best log ratio
1237
+
1238
+
1239
+ def refine_onset_times(onset_times: "Sequence[float]", step_s: float, pcm: "Sequence[float]",
1240
+ rate: int) -> "List[float]":
1241
+ """Each envelope-step onset time placed at the sample where the sound starts. Pure.
1242
+
1243
+ An onset picked on the step_s envelope is only known to the step: it is the start of the block
1244
+ the level rose in, so a click is reported up to one step early (5 ms on average at 10 ms).
1245
+ Around each one, over [t - step, t + 2 step], every candidate time c is scored by
1246
+ log(energy of [c, c + step]) - log(energy of [c - step, c]) -- the rise from the step before c
1247
+ to the step after it, which is largest where the sound starts. Ahead of a hit out of digital
1248
+ silence that score is flat (the window before is silent wherever it ends), so the LATEST
1249
+ candidate within BEAT_REFINE_TOL_NATS of the best is taken: past the start, the window before
1250
+ fills with the hit's own attack and the score falls off at once. Candidates are 0.25 ms apart.
1251
+ A time the PCM does not cover is returned unchanged.
1252
+ """
1253
+ rate = int(rate)
1254
+ w = max(1, int(round(step_s * rate)))
1255
+ hop = max(1, int(round(rate * 0.00025)))
1256
+ n = len(pcm)
1257
+ out: "List[float]" = []
1258
+ for t in onset_times:
1259
+ centre = int(round(t * rate))
1260
+ lo, hi = centre - w, centre + 2 * w
1261
+ if lo - w < 0 or hi + w > n:
1262
+ out.append(t)
1263
+ continue
1264
+ base = lo - w
1265
+ prefix = [0.0]
1266
+ acc = 0.0
1267
+ for x in pcm[base:hi + w + 1]:
1268
+ acc += x * x
1269
+ prefix.append(acc)
1270
+ scored = []
1271
+ for c in range(lo, hi + 1, hop):
1272
+ k = c - base
1273
+ before = (prefix[k] - prefix[k - w]) / w
1274
+ after = (prefix[k + w] - prefix[k]) / w
1275
+ scored.append((math.log(after + 1e-12) - math.log(before + 1e-12), c))
1276
+ best = max(sc for sc, _ in scored)
1277
+ c_best = max(c for sc, c in scored if sc >= best - BEAT_REFINE_TOL_NATS)
1278
+ out.append(c_best / rate)
1279
+ return out
1280
+
1281
+
1282
+ def _refine_grid(onset_times: "Sequence[float]", strengths: "Sequence[float]", interval: float,
1283
+ phase: float, lo_bpm: float, hi_bpm: float) -> "tuple":
1284
+ """(interval, phase) refitted by weighted least squares to the onsets that land on the grid.
1285
+
1286
+ Each grid point k is paired with the STRONGEST onset within interval/BEAT_ALIGN_DIVISOR of it
1287
+ (one per point: the weak onsets noise and a hit's own decay leave around a beat must not pull
1288
+ it), and onset_time = phase + k * interval is solved for both unknowns with each pair weighted
1289
+ by its onset strength squared; the pairing is then redone against the refitted grid, until it
1290
+ stops moving (three passes at most). This only sharpens the grid the autocorrelation chose --
1291
+ the tempo cannot leave `bpm_range`, an octave cannot flip (a refit that moves the interval by
1292
+ more than interval/BEAT_ALIGN_DIVISOR is discarded), and fewer than four paired grid points
1293
+ keep the original: the fit needs evidence, and an onset-free stretch gives it none.
1294
+ Returns phase in [0, interval).
1295
+ """
1296
+ iv, ph = float(interval), float(phase)
1297
+ for _ in range(3):
1298
+ tol = iv / BEAT_ALIGN_DIVISOR
1299
+ best: "Dict[int, tuple]" = {}
1300
+ for t, st in zip(onset_times, strengths):
1301
+ k = round((t - ph) / iv)
1302
+ if abs(t - (ph + k * iv)) <= tol and (k not in best or st > best[k][1]):
1303
+ best[k] = (t, st)
1304
+ pairs = [(k, t, max(st, 1e-9) ** 2) for k, (t, st) in best.items()]
1305
+ if len(pairs) < 4:
1306
+ break
1307
+ sw = sum(w for _, _, w in pairs)
1308
+ mk = sum(w * k for k, _, w in pairs) / sw
1309
+ mt = sum(w * t for _, t, w in pairs) / sw
1310
+ sxx = sum(w * (k - mk) ** 2 for k, _, w in pairs)
1311
+ if sxx <= 0:
1312
+ break
1313
+ new_iv = sum(w * (k - mk) * (t - mt) for k, t, w in pairs) / sxx
1314
+ if not (60.0 / hi_bpm <= new_iv <= 60.0 / lo_bpm) or abs(new_iv - interval) > interval / BEAT_ALIGN_DIVISOR:
1315
+ break
1316
+ new_ph = mt - new_iv * mk
1317
+ moved = abs(new_iv - iv) < 1e-9 and abs(new_ph - ph) < 1e-7
1318
+ iv, ph = new_iv, new_ph
1319
+ if moved:
1320
+ break
1321
+ return iv, ph % iv
1322
+
1323
+
1223
1324
  def beat_grid(envelope: "Sequence[float]", step_s: float, *,
1224
1325
  bpm_range: "Sequence[float]" = (60, 200),
1225
1326
  min_confidence: float = BEAT_MIN_CONFIDENCE,
1226
- duration: "Optional[float]" = None) -> "Dict[str, Any]":
1327
+ duration: "Optional[float]" = None,
1328
+ pcm: "Optional[Sequence[float]]" = None,
1329
+ pcm_rate: "Optional[int]" = None) -> "Dict[str, Any]":
1227
1330
  """A beat grid from an RMS envelope. Pure: numbers in, a dict out -- no ffmpeg, no I/O.
1228
1331
 
1229
1332
  Returns {"beats": [t, ...], "tempo_bpm": float|None, "interval": float|None,
1230
1333
  "confidence": 0..1, "onsets": [t, ...], "phase": float,
1231
1334
  "supported": int, "unsupported": int, "usable": bool,
1232
- "method": "rms-flux-autocorrelation", "step_s": step_s, "range_bpm": [lo, hi]}
1335
+ "method": "rms-flux-autocorrelation", "step_s": step_s, "range_bpm": [lo, hi],
1336
+ "onset_refinement": "pcm-window-ratio" | None}
1337
+
1338
+ `step_s` must be the envelope's real step (block samples / rate), not the one asked for:
1339
+ every time below is index * step_s. `pcm` and `pcm_rate`, when given, are the samples the
1340
+ envelope was built from; each onset is then placed on them (refine_onset_times) instead of at
1341
+ the start of its envelope block.
1233
1342
 
1234
1343
  The method, in full, so a report can quote it:
1235
1344
  1. onset strength = half-wave-rectified first difference of log(env + 1e-9);
1236
- 2. onsets = local maxima above median + 1.5 * MAD over a +/-1 s window, 60 ms refractory;
1345
+ 2. onsets = local maxima above median + 1.5 * MAD over a +/-1 s window, 60 ms refractory,
1346
+ each placed to 0.25 ms on `pcm` when it is given;
1237
1347
  3. tempo = the best autocorrelation lag of the onset signal inside `bpm_range`, with its
1238
1348
  half and double checked and the one whose onsets align better preferred (octave
1239
1349
  disambiguation: 60, 120 and 240 BPM all autocorrelate on a 120 BPM track);
1240
1350
  4. phase = the offset in [0, interval) at which the grid catches the most onset strength;
1241
- 5. beats = phase + n * interval across the duration. A grid must be regular, so a grid
1351
+ then interval and phase are refitted by least squares to the onsets within interval/8 of
1352
+ the grid (_refine_grid), so the tempo is not rounded to a whole number of envelope steps
1353
+ (until 3.2.0, at 10 ms, 90 BPM was measured as 0.67 s and the grid drifted 40-90 ms off the
1354
+ music over 30 s);
1355
+ 5. beats = phase + n * interval across the duration (a point within BEAT_ZERO_TOL_S, 10 ms,
1356
+ before 0 s is reported at 0.0; one further before is not reported). A grid must be regular, so a grid
1242
1357
  point with no measured onset within interval/4 is still reported -- and counted in
1243
1358
  `unsupported`, so a caller can see how much of the grid the audio actually supports.
1244
1359
  `supported_beats` is the subset that a measured onset does support: it is what a tool
@@ -1262,6 +1377,7 @@ def beat_grid(envelope: "Sequence[float]", step_s: float, *,
1262
1377
  "confidence": 0.0, "onsets": [],
1263
1378
  "phase": 0.0, "supported": 0, "unsupported": 0, "usable": False,
1264
1379
  "method": "rms-flux-autocorrelation", "step_s": step_s, "range_bpm": [lo_bpm, hi_bpm],
1380
+ "onset_refinement": None,
1265
1381
  }
1266
1382
  if len(env) < 4 or step_s <= 0:
1267
1383
  return out
@@ -1272,6 +1388,10 @@ def beat_grid(envelope: "Sequence[float]", step_s: float, *,
1272
1388
  return out
1273
1389
  idx = _pick_onsets(strength, step_s)
1274
1390
  onset_times = [i * step_s for i in idx]
1391
+ if pcm and pcm_rate:
1392
+ # the PCM the envelope came from places each onset to the sample (refine_onset_times)
1393
+ onset_times = refine_onset_times(onset_times, step_s, pcm, int(pcm_rate))
1394
+ out["onset_refinement"] = "pcm-window-ratio"
1275
1395
  out["onsets"] = [round(t, 4) for t in onset_times]
1276
1396
  if len(idx) < 2:
1277
1397
  return out
@@ -1312,11 +1432,29 @@ def beat_grid(envelope: "Sequence[float]", step_s: float, *,
1312
1432
  if alt_score > base_score * BEAT_OCTAVE_MARGIN:
1313
1433
  interval, phase, base_score = alt, alt_phase, alt_score
1314
1434
 
1435
+ # The autocorrelation lag is a whole number of envelope steps, so the interval it gives is the
1436
+ # true one rounded to step_s: 90 BPM (0.6667 s) comes out as 0.67 s at 10 ms, 3.3 ms per beat
1437
+ # that a phase+n*interval grid accumulates to tens of ms within a bar or two of either end
1438
+ # (40-90 ms over 30 s). Refit interval and phase to the onsets themselves (_refine_grid).
1439
+ interval, phase = _refine_grid(onset_times, [strength[j] for j in idx], interval, phase,
1440
+ lo_bpm, hi_bpm)
1441
+
1315
1442
  beats = []
1316
- t = phase
1317
- while t <= total_s + 1e-9:
1318
- beats.append(round(t, 4))
1319
- t += interval
1443
+ # The first grid point at or after 0, where "at" allows BEAT_ZERO_TOL_S (one 10 ms envelope
1444
+ # step, the accuracy the grid had before the refit): a track that starts on the beat has no
1445
+ # measurable onset at 0 (there is no step before it), so its phase is fitted from the later
1446
+ # beats and lands a few ms either side of 0 -- that is the beat at 0.0, not a dropped one. A
1447
+ # point further before 0 is not moved there: 0.0 would then be a time the grid does not mark
1448
+ # (a 120 BPM grid with phase 0.46 has its point at -0.04 s), and an onset at 0.08 s would make
1449
+ # it a "supported" snap target. Indexing by n instead of adding the interval up keeps the
1450
+ # float error of a long file out of the last beats.
1451
+ n = -1 if phase - interval >= -BEAT_ZERO_TOL_S else 0
1452
+ while True:
1453
+ t = phase + n * interval
1454
+ if t > total_s + 1e-9:
1455
+ break
1456
+ beats.append(round(max(0.0, t), 4))
1457
+ n += 1
1320
1458
  support_tol = interval / BEAT_SUPPORT_DIVISOR
1321
1459
  supported_beats = [b for b in beats
1322
1460
  if any(abs(b - o) <= support_tol for o in onset_times)]
@@ -478,25 +478,35 @@ def _bit_depth(pix_fmt: Optional[str]) -> int:
478
478
  return n if 8 <= n <= 16 else 8
479
479
 
480
480
 
481
- def keyframes_near(path: str, t: float, window: float = 5.0) -> List[float]:
482
- """Video keyframe timestamps within +-window seconds of t, ascending. Read with
483
- -read_intervals so a long file is not scanned end to end; empty when ffprobe cannot say."""
484
- ffprobe = require_tool("ffprobe")
485
- lo = max(0.0, t - window)
486
- proc = run([ffprobe, "-v", "error", "-select_streams", "v:0", "-skip_frame", "nokey",
487
- "-read_intervals", f"{lo:.3f}%{t + window:.3f}", "-show_entries", "frame=pts_time",
481
+ def keyframe_times(path: str, lo: float, hi: float, stream: str = "v:0") -> Optional[List[float]]:
482
+ """Keyframe timestamps of `stream` read from lo to hi seconds (file time), ascending and as
483
+ ffprobe prints them (microseconds). Read with -read_intervals so a long file is not scanned end
484
+ to end; the read starts at the keyframe at or before lo, so the first may lie before it. None
485
+ when ffprobe fails (unmeasured, not "no keyframes")."""
486
+ # an interval's end is exclusive (6.1: a keyframe at exactly hi is not read), so read a
487
+ # little past it and drop what lies beyond hi
488
+ proc = run([require_tool("ffprobe"), "-v", "error", "-select_streams", stream, "-skip_frame", "nokey",
489
+ "-read_intervals", f"{max(0.0, lo):.6f}%{hi + 0.01:.6f}", "-show_entries", "frame=pts_time",
488
490
  "-of", "csv=p=0", path], quiet=True, check=False)
489
491
  if proc.returncode != 0:
490
- return []
492
+ return None
491
493
  out: List[float] = []
492
494
  for line in proc.stdout.splitlines():
493
495
  try:
494
- out.append(round(float(line.strip().rstrip(",")), 3))
496
+ k = float(line.strip().rstrip(","))
495
497
  except ValueError:
496
498
  continue
499
+ if k <= hi + 1e-6:
500
+ out.append(k)
497
501
  return sorted(set(out))
498
502
 
499
503
 
504
+ def keyframes_near(path: str, t: float, window: float = 5.0) -> List[float]:
505
+ """Video keyframe timestamps within +-window seconds of t (to the ms), ascending; empty when
506
+ ffprobe cannot say (cut.py's best-effort hints; probe.py --keyframes-near uses keyframe_times)."""
507
+ return sorted(set(round(k, 3) for k in keyframe_times(path, t - window, t + window) or []))
508
+
509
+
500
510
  # frame timing: is a stream's frame rate really variable? (cut.py's VFR guard)
501
511
  TIMING_WINDOWS = 5 # sampled windows per file, fewer on a short one
502
512
  TIMING_WINDOW_SECONDS = 6
@@ -609,6 +619,162 @@ def measured_level_dbfs(path: str, seconds: Optional[float] = 120.0,
609
619
  return {"mean_dbfs": float(m_mean.group(1)), "peak_dbfs": float(m_max.group(1))}
610
620
 
611
621
 
622
+ # Legal signal range of limited-range Y'CbCr (check.py --levels, color.py --legalize). Each standard
623
+ # is stated in percent of the nominal swing, so the code limits follow from the bit depth: 0 % luma is
624
+ # code 16 << (depth - 8), 100 % is 235 << (depth - 8); chroma's nominal swing is 16..240 (scaled the
625
+ # same way) around the midpoint. A sample is legal when its code lies inside [lo, hi] inclusive.
626
+ # bt709 ITU-R BT.709 nominal range: luma 0..100 %, chroma 16..240 (8-bit) -- no excursion.
627
+ # ebu-r103 EBU R 103 tolerance: luma -1 %..103 %, RGB components -5 %..105 %. Chroma is held to the
628
+ # widest Cb/Cr an RGB triple inside -5 %..105 % can produce (|C| <= 0.55 of the RGB span,
629
+ # i.e. 8-bit 5..251); RGB gamut itself is not measured here (a per-plane Y'CbCr count).
630
+ LEGAL_RANGES: Dict[str, Dict[str, Any]] = {
631
+ "bt709": {"luma_pct": (0.0, 100.0), "chroma_half_swing": 0.5,
632
+ "reference": "ITU-R BT.709 nominal range: luma 16-235, chroma 16-240 (8-bit; 10-bit 64-940 / 64-960)"},
633
+ "ebu-r103": {"luma_pct": (-1.0, 103.0), "chroma_half_swing": 0.55,
634
+ "reference": "EBU R 103 tolerance: luma -1 %..103 % (8-bit 14-241), chroma as far as RGB -5 %..105 % "
635
+ "reaches (8-bit 5-251)"},
636
+ }
637
+ LEGAL_RANGE_CHOICES = sorted(LEGAL_RANGES)
638
+
639
+
640
+ def legal_limits(standard: str, bit_depth: int = 8) -> Dict[str, Tuple[int, int]]:
641
+ """{"y": (lo, hi), "c": (lo, hi)}: the inclusive legal code range of a limited-range sample at
642
+ `bit_depth`. A limit that falls between two codes is rounded inward (the code past it is out)."""
643
+ spec = LEGAL_RANGES[standard]
644
+ scale = 1 << (max(8, int(bit_depth)) - 8)
645
+ black, white = 16 * scale, 235 * scale
646
+ span = white - black
647
+ p_lo, p_hi = spec["luma_pct"]
648
+ y = (math.ceil(black + span * p_lo / 100.0 - 1e-9), math.floor(white + span * (p_hi - 100.0) / 100.0 + 1e-9))
649
+ mid, cspan = 128 * scale, 224 * scale
650
+ half = spec["chroma_half_swing"] * cspan
651
+ c = (max(0, math.ceil(mid - half - 1e-9)), min((256 * scale) - 1, math.floor(mid + half + 1e-9)))
652
+ return {"y": y, "c": c}
653
+
654
+
655
+ _COMPONENT_DEPTHS: Dict[str, int] = {}
656
+
657
+
658
+ def component_depth(pix_fmt: Optional[str]) -> int:
659
+ """Bits per luma sample of `pix_fmt`, from ffmpeg's own pixel-format table (`-pix_fmts`,
660
+ BIT_DEPTHS column): the depth the planes really hold, which the name does not always say --
661
+ nv12 is 8-bit 4:2:0 (the 12 is bits per pixel), p010le is 10-bit. Falls back to _bit_depth()
662
+ when ffmpeg does not list the format."""
663
+ name = str(pix_fmt or "")
664
+ if not _COMPONENT_DEPTHS:
665
+ proc = run_analysis([require_tool("ffmpeg"), "-hide_banner", "-pix_fmts"], check=False)
666
+ for line in (proc.stdout or "").splitlines():
667
+ cols = line.split()
668
+ if len(cols) == 5 and re.fullmatch(r"\d+(?:-\d+)*", cols[4]):
669
+ _COMPONENT_DEPTHS[cols[1]] = int(cols[4].split("-")[0])
670
+ return _COMPONENT_DEPTHS.get(name) or _bit_depth(name)
671
+
672
+
673
+ def _metadata_frames(text: str, keys: Optional[frozenset] = None) -> List[Tuple[float, Dict[str, float]]]:
674
+ """metadata=print's output: one (pts_time, {key: value}) per `frame:` block, signalstats keys only
675
+ (only those in `keys` when given: signalstats prints ~25 per frame and an hour is 100k frames)."""
676
+ frames: List[Tuple[float, Dict[str, float]]] = []
677
+ for line in text.splitlines():
678
+ if line.startswith("frame:"):
679
+ m = re.search(r"pts_time:\s*(-?[0-9.]+|nan)", line)
680
+ t = float(m.group(1)) if m and m.group(1) != "nan" else float("nan")
681
+ frames.append((t, {}))
682
+ elif "lavfi.signalstats." in line and "=" in line and frames:
683
+ key, val = line.split("lavfi.signalstats.", 1)[1].split("=", 1)
684
+ key = key.strip()
685
+ if keys is not None and key not in keys:
686
+ continue
687
+ try:
688
+ frames[-1][1][key] = float(val)
689
+ except ValueError:
690
+ pass
691
+ return frames
692
+
693
+
694
+ # the signalstats keys measure_signal_range reads: the frame's own extremes and BRNG, and the mask's means
695
+ _OWN_KEYS = frozenset({"BRNG", "YMIN", "YMAX", "UMIN", "UMAX", "VMIN", "VMAX"})
696
+ _MASK_KEYS = frozenset({"YAVG", "UAVG", "VAVG"})
697
+
698
+
699
+ def measure_signal_range(path: str, standard: str, window: float = 10.0,
700
+ bit_depth: Optional[int] = None, pix_fmt: Optional[str] = None) -> Optional[Dict[str, Any]]:
701
+ """Share of luma and chroma samples outside `standard`'s legal range, per clip and per `window`
702
+ seconds, plus min/max Y/U/V, from one decode of every frame of the first video stream.
703
+
704
+ The count is exact, not sampled from a histogram: each plane goes through lutyuv, which maps an
705
+ out-of-range code to the depth's top code (255 at 8 bits) and every other code to 0, and
706
+ signalstats' mean of that mask is the top code x the out-of-range share of that plane in that frame. Chroma planes are
707
+ counted at their own (subsampled) resolution, so "chroma" is the share of Cb and Cr samples. A
708
+ second signalstats ahead of the mask reads the frame's own MIN/MAX and BRNG (the share of pixels
709
+ with any component outside the BT.601/709 nominal range, signalstats' fixed limits). Every frame
710
+ holds the same number of samples, so a clip's share is the mean of its frames' shares.
711
+
712
+ The limits and the mask use `bit_depth`, or else the depth of `pix_fmt`'s planes
713
+ (component_depth(): nv12 is 8-bit, not 12). None when the measurement is not whole: nothing
714
+ decoded, ffmpeg failed part-way (a share from the frames read before the failure would grade a
715
+ file nobody measured to the end), or the two passes counted different frames."""
716
+ if standard not in LEGAL_RANGES:
717
+ die(f"unknown legal range {standard!r}; choose from {', '.join(LEGAL_RANGE_CHOICES)}")
718
+ import tempfile
719
+ from _common.decision import escape_filter_path
720
+ ffmpeg = require_tool("ffmpeg")
721
+ depth = int(bit_depth or component_depth(pix_fmt))
722
+ lim = legal_limits(standard, depth)
723
+ (ylo, yhi), (clo, chi) = lim["y"], lim["c"]
724
+ top = (1 << depth) - 1 # a literal: lutyuv's own maxval is 235 for limited-range luma, 240 for chroma
725
+ out = str(top) + "*(lt(val\\,{lo})+gt(val\\,{hi}))"
726
+ mask = (f"lutyuv=y='{out.format(lo=ylo, hi=yhi)}':u='{out.format(lo=clo, hi=chi)}'"
727
+ f":v='{out.format(lo=clo, hi=chi)}'")
728
+ with tempfile.TemporaryDirectory(prefix="ffskill_levels_") as tmp:
729
+ stats_file = os.path.join(tmp, "stats.txt")
730
+ vf = (f"signalstats=stat=brng,metadata=print:file={escape_filter_path(stats_file)},"
731
+ f"{mask},signalstats,metadata=print:file=-")
732
+ cmd = [ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-map", "0:v:0", "-an", "-sn", "-dn", "-vf", vf, "-f", "null", "-"]
733
+ proc = run_analysis(cmd, check=False, record=True)
734
+ try:
735
+ with open(stats_file, encoding="utf-8", errors="replace") as f:
736
+ own = _metadata_frames(f.read(), _OWN_KEYS)
737
+ except OSError:
738
+ own = []
739
+ masks = _metadata_frames(proc.stdout or "", _MASK_KEYS)
740
+ if proc.returncode != 0 or not own or len(own) != len(masks):
741
+ return None
742
+ t0 = next((t for t, _ in own if t == t), 0.0)
743
+ rows: List[Dict[str, Any]] = []
744
+ for (t, s), (_, m) in zip(own, masks, strict=True):
745
+ y, u, v = (m.get(k, 0.0) / top * 100.0 for k in ("YAVG", "UAVG", "VAVG"))
746
+ rows.append({"t": (t - t0) if t == t else None, "y": y, "u": u, "v": v, "brng": s.get("BRNG", 0.0) * 100.0,
747
+ "min": {k: s.get(k.upper() + "MIN") for k in "yuv"}, "max": {k: s.get(k.upper() + "MAX") for k in "yuv"}})
748
+
749
+ def summary(frames: List[Dict[str, Any]]) -> Dict[str, Any]:
750
+ k = len(frames)
751
+ share = {p: sum(f[p] for f in frames) / k for p in ("y", "u", "v", "brng")}
752
+ worst = max(frames, key=lambda f: max(f["y"], (f["u"] + f["v"]) / 2))
753
+ def ext(which: str, fn: Any) -> Dict[str, Any]:
754
+ vals = {p: [f[which][p] for f in frames if f[which][p] is not None] for p in "yuv"}
755
+ return {p: (int(fn(v)) if v else None) for p, v in vals.items()}
756
+ return {"frames": k,
757
+ "out_of_range_pct": {"y": round(share["y"], 4), "u": round(share["u"], 4), "v": round(share["v"], 4),
758
+ "chroma": round((share["u"] + share["v"]) / 2, 4)},
759
+ "brng_pct": round(share["brng"], 4),
760
+ "worst_frame": {"time": None if worst["t"] is None else round(worst["t"], 3),
761
+ "y_pct": round(worst["y"], 4), "chroma_pct": round((worst["u"] + worst["v"]) / 2, 4)},
762
+ "min": ext("min", min), "max": ext("max", max)}
763
+
764
+ doc = {"standard": standard, "reference": LEGAL_RANGES[standard]["reference"], "bit_depth": depth,
765
+ "legal": {"y": list(lim["y"]), "c": list(lim["c"])}, "window_seconds": window, **summary(rows)}
766
+ windows: List[Dict[str, Any]] = []
767
+ if window and window > 0:
768
+ buckets: Dict[int, List[Dict[str, Any]]] = {}
769
+ for i, f in enumerate(rows):
770
+ buckets.setdefault(int((f["t"] if f["t"] is not None else i) // window), []).append(f)
771
+ for idx in sorted(buckets):
772
+ w = summary(buckets[idx])
773
+ windows.append({"start": round(idx * window, 3), "end": round((idx + 1) * window, 3), **w})
774
+ doc["windows"] = windows
775
+ return doc
776
+
777
+
612
778
  def analyze_levels(path: str, seconds: float = 20.0) -> Dict[str, Any]:
613
779
  """Sample luma/saturation statistics (signalstats) and guess whether the picture is Log-encoded.
614
780
 
@@ -329,7 +329,7 @@ class Context:
329
329
  makes it obvious what run()/emit() depend on and lets tests reset it with ``STATE.reset()``.
330
330
  """
331
331
 
332
- __slots__ = ("dry_run", "json", "json_brief", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting", "plan", "plan_written", "plan_inputs", "codec",
332
+ __slots__ = ("dry_run", "json", "json_brief", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting", "plan", "plan_written", "plan_inputs", "codec", "alpha_line",
333
333
  "hw", "hw_source", "hw_notes", "hw_swaps", "hw_fallback", "hw_env_ignored", "hw_stages", "hw_auto",
334
334
  "notes", "warnings", "fallbacks", "skipped", "audit_log", "stage_verification", "first_input", "audio_inputs", "video_inputs",
335
335
  "source_timecode", "smpte_hint", "delivered")
@@ -359,6 +359,7 @@ class Context:
359
359
  self.plan_written = False # write_plan() ran (emit or the exit hook), so the hook does not write twice
360
360
  self.plan_inputs: List[str] = [] # side inputs (srt/ass/lut/font files) a tool named through escape_filter_path
361
361
  self.codec: Optional[str] = None # --codec: encoder for the re-encode (None = x264 for SDR, x265 for HDR)
362
+ self.alpha_line: Optional[str] = None # --keep-alpha / --transparent: the alpha line written (_common.alpha.ALPHA_CODECS)
362
363
  self.hw = False # --hw / $FFMPEG_SKILL_HW: VideoToolbox encoders where they apply
363
364
  self.hw_source: Optional[str] = None # "flag" (--hw/--no-hw given) or "env" (FFMPEG_SKILL_HW), None = off by default
364
365
  self.hw_notes: List[str] = [] # why an encode stayed on (or fell back to) the CPU, for the result document
@@ -2025,7 +2026,7 @@ def _read_ff_list(flag: str, pattern: str) -> set:
2025
2026
  why = f"{tool} {flag} could not run: {exc}"
2026
2027
  if why:
2027
2028
  _LIST_ERRORS[flag] = why
2028
- what = {"-encoders": "encoder", "-bsfs": "bitstream filter"}.get(flag, "filter")
2029
+ what = {"-encoders": "encoder", "-decoders": "decoder", "-bsfs": "bitstream filter"}.get(flag, "filter")
2029
2030
  warn(f"could not read this ffmpeg build's {what} list ({why}); nothing that needs it can be confirmed, which is not the same as the build lacking it",
2030
2031
  "ffmpeg_list_unreadable", list=flag.lstrip("-"), tool=tool, error=why)
2031
2032
  return names