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.
- package/README.md +14 -11
- package/SKILL.md +6 -1
- package/docs/contract.md +21 -8
- package/package.json +1 -1
- package/references/gotchas.md +15 -0
- package/references/scripts.md +127 -7
- package/scripts/_common/__init__.py +4 -4
- package/scripts/_common/alpha.py +465 -0
- package/scripts/_common/decision.py +153 -15
- package/scripts/_common/probe.py +175 -9
- package/scripts/_common/runner.py +3 -2
- package/scripts/_contract.py +69 -17
- package/scripts/_smartcut.py +430 -0
- package/scripts/background.py +33 -6
- package/scripts/check.py +64 -1
- package/scripts/color.py +147 -30
- package/scripts/cut.py +99 -8
- package/scripts/export.py +12 -81
- package/scripts/graphics.py +51 -10
- package/scripts/overlay.py +49 -6
- package/scripts/probe.py +47 -1
- package/scripts/scenes.py +46 -11
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
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)]
|
package/scripts/_common/probe.py
CHANGED
|
@@ -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
|
|
482
|
-
"""
|
|
483
|
-
-read_intervals so a long file is not scanned end
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
-
|
|
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
|