ffmpeg-skill 2.23.0 → 2.24.0

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.
@@ -827,3 +827,89 @@ def detect_scenes(path: str, threshold: float, min_len: float, duration: float,
827
827
  if duration - cuts[-1] < min_len and len(cuts) > 1:
828
828
  cuts.pop()
829
829
  return cuts
830
+
831
+
832
+ # What a stream-copy join compares (cut.py --segments, join.py --copy).
833
+ IN_BAND_CONFIG = ("mp3", "mp2")
834
+ TS_EXTS = (".ts", ".m2ts", ".mts")
835
+ _SIG_FIELDS = {"video": ("codec_name", "profile", "pix_fmt", "width", "height", "r_frame_rate", "time_base",
836
+ "sample_aspect_ratio", "color_space", "color_range", "color_transfer", "color_primaries"),
837
+ "audio": ("codec_name", "profile", "sample_rate", "channels", "time_base")}
838
+
839
+
840
+ def stream_signature(doc: Dict[str, Any]) -> "List[Dict[str, Any]]":
841
+ """What a stream-copy join needs to be identical across parts, from one ffprobe document
842
+ (-show_streams with -show_data_hash sha256): each stream's type, codec parameters, rotation
843
+ and extradata hash. Shared by cut.py's --segments join and join.py --copy."""
844
+ sig = []
845
+ for st in doc.get("streams") or []:
846
+ kind = st.get("codec_type")
847
+ entry = {"type": kind, "extradata_hash": st.get("extradata_hash")}
848
+ entry.update({f: st.get(f) for f in _SIG_FIELDS.get(kind, ("codec_name",))})
849
+ if kind == "video":
850
+ entry["rotation"] = next((sd.get("rotation") for sd in st.get("side_data_list") or []
851
+ if "rotation" in sd), None)
852
+ sig.append(entry)
853
+ return sig
854
+
855
+
856
+ def signatures_match(sigs: list, ext: str) -> bool:
857
+ """True when every part can be joined by stream copy: same streams in the same order with the
858
+ same parameters. A missing extradata hash only matches another missing one where the codec (or
859
+ an MPEG-TS output) carries its configuration in-band -- PCM has none at all."""
860
+ if not sigs or any(sig is None for sig in sigs):
861
+ return False
862
+ first = sigs[0]
863
+ for sig in sigs[1:]:
864
+ if len(sig) != len(first):
865
+ return False
866
+ for a, b in zip(first, sig):
867
+ if {k: v for k, v in a.items() if k != "extradata_hash"} != {k: v for k, v in b.items() if k != "extradata_hash"}:
868
+ return False
869
+ ha, hb = a.get("extradata_hash"), b.get("extradata_hash")
870
+ if ha or hb:
871
+ if ha != hb:
872
+ return False
873
+ else:
874
+ codec = str(a.get("codec_name") or "")
875
+ if not (codec.startswith("pcm_") or codec in IN_BAND_CONFIG or ext in TS_EXTS):
876
+ return False
877
+ return True
878
+
879
+
880
+ # check.py --compare (2.24): the output's picture against a reference, frame by frame.
881
+ COMPARE_SSIM_WARN = 0.98 # mean SSIM below this: a visible difference somewhere is likely
882
+ _SSIM_SUMMARY = re.compile(r"SSIM Y:[0-9.]+ .*All:([0-9.]+)")
883
+ _PSNR_SUMMARY = re.compile(r"PSNR y:\S+ .*average:(\S+)")
884
+ _SSIM_FRAME = re.compile(r"n:(\d+) .*All:([0-9.]+)")
885
+
886
+
887
+ def compare_pictures(path: str, reference: str) -> "Optional[Dict[str, Any]]":
888
+ """SSIM (mean and the worst frame) and PSNR (mean) of `path`'s first video stream against
889
+ `reference`'s, frame by frame in decode order, in one pass. ffmpeg's ssim and psnr filters
890
+ need equal sizes; the caller checks that first. None when the pass fails."""
891
+ import tempfile
892
+ from _common.decision import escape_filter_path
893
+ with tempfile.TemporaryDirectory(prefix="ffskill_compare_") as tmp:
894
+ stats = os.path.join(tmp, "ssim.log")
895
+ # frames paired by index (settb/setpts=N): ssim and psnr otherwise pair by timestamp, so a
896
+ # shifted or irregular timestamp would meet the wrong reference frame
897
+ graph = ("[0:V:0]settb=1/1000,setpts=N,split[a0][a1];[1:V:0]settb=1/1000,setpts=N,split[b0][b1];"
898
+ f"[a0][b0]ssim=stats_file={escape_filter_path(stats, bind=False)};[a1][b1]psnr")
899
+ proc = run_analysis([require_tool("ffmpeg"), "-hide_banner", "-nostdin", "-i", path, "-i", reference,
900
+ "-filter_complex", graph, "-f", "null", "-"], check=False, record=True)
901
+ if proc.returncode != 0:
902
+ return None
903
+ ssim, psnr = _SSIM_SUMMARY.search(proc.stderr), _PSNR_SUMMARY.search(proc.stderr)
904
+ frames = []
905
+ try:
906
+ with open(stats, encoding="utf-8") as fh:
907
+ frames = [(int(m.group(1)), float(m.group(2))) for m in map(_SSIM_FRAME.search, fh) if m]
908
+ except OSError:
909
+ pass
910
+ if not ssim or not frames:
911
+ return None
912
+ worst = min(frames, key=lambda f: f[1])
913
+ mean_psnr = psnr.group(1) if psnr else None
914
+ return {"ssim": float(ssim.group(1)), "worst_ssim": worst[1], "worst_frame": worst[0] - 1, "frames": len(frames),
915
+ "psnr": None if mean_psnr is None else (float("inf") if mean_psnr == "inf" else float(mean_psnr))}
@@ -492,13 +492,42 @@ def parses(path: str, fmt: str) -> bool:
492
492
  return why is None
493
493
 
494
494
 
495
+ def _xml_root_tag(data: bytes) -> str:
496
+ """The root element of a well-formed XML document, read with expat directly.
497
+
498
+ ElementTree expands entities declared in an internal DTD, and on Python 3.9/3.10 (both
499
+ supported) that is an entity-expansion ("billion laughs") risk on a file the tool did not
500
+ write. A timeline never declares an entity (to_fcpxml writes a bare `<!DOCTYPE fcpxml>`), so
501
+ a declaration is refused rather than expanded. Errors are ElementTree's ParseError, so a
502
+ failure reads the same as before."""
503
+ from xml.etree.ElementTree import ParseError
504
+ from xml.parsers import expat
505
+ parser = expat.ParserCreate()
506
+ parser.SetParamEntityParsing(expat.XML_PARAM_ENTITY_PARSING_NEVER)
507
+ root: List[str] = []
508
+
509
+ def start(name: str, _attrs: Any) -> None:
510
+ if not root:
511
+ root.append(name)
512
+
513
+ def entity(name: str, *_rest: Any) -> None:
514
+ raise ParseError(f"the document declares the entity {name!r}; a timeline never declares one")
515
+
516
+ parser.StartElementHandler = start
517
+ parser.EntityDeclHandler = entity
518
+ try:
519
+ parser.Parse(data, True)
520
+ except expat.ExpatError as exc:
521
+ raise ParseError(str(exc)) from None
522
+ return root[0]
523
+
524
+
495
525
  def parse_error(path: str, fmt: str) -> Optional[str]:
496
526
  """Why `path` does not read back as `fmt`, or None when it does (parses() without the record)."""
497
527
  try:
498
528
  text = Path(path).read_text(encoding="utf-8")
499
529
  if fmt == "fcpxml":
500
- import xml.etree.ElementTree as ET
501
- tag = ET.fromstring(text.encode("utf-8")).tag
530
+ tag = _xml_root_tag(text.encode("utf-8"))
502
531
  return None if tag == "fcpxml" else f"the root element is <{tag}>, not <fcpxml>"
503
532
  if fmt == "otio":
504
533
  doc = json.loads(text)
@@ -290,7 +290,7 @@ REENCODE_META: Dict[str, Dict[str, str]] = {
290
290
  "audio": dict(video="never", audio="always", note="video stream is always -c:v copy when present; this tool's job is the audio"),
291
291
  "loudness": dict(video="never", audio="always"),
292
292
  "silence": dict(video="always", audio="always", note="removing gaps requires cutting on non-keyframe boundaries"),
293
- "join": dict(video="always", audio="always"),
293
+ "join": dict(video="conditional", audio="conditional", note="--copy --transition none is -c copy of the first video and audio stream (clips with matching parameters only); every other join re-encodes both"),
294
294
  "color": dict(video="conditional", audio="conditional", note="--strip-dovi and --retag (when the stream copy succeeds) are -c copy of both streams; --retag falls back to re-encoding only if the copy attempt fails or ffprobe still reads the old tags on the copy (a codec with no metadata bitstream filter here); --to-sdr / --lut / --correct always re-encode both"),
295
295
  "proxy": dict(video="always", audio="conditional", note="video is always re-encoded at proxy-grade quality; audio is re-encoded when present, dropped entirely with --no-audio or when the source has none"),
296
296
  "export": dict(video="conditional", audio="conditional", note="--preset copy is -c:v copy -c:a copy (no re-encode); every other preset re-encodes both"),
@@ -417,6 +417,7 @@ ERROR_REASONS = {
417
417
  "output_is_directory": "the output path is a directory; details: {output}",
418
418
  "media_path_is_playlist": "an input is a playlist (HLS .m3u8/.m3u or an ffconcat list, by name or by its first bytes) that ffmpeg would follow to the files it names; the skill takes media files only; details: {path}",
419
419
  "media_path_not_a_file": "an input exists but is not a regular file (a FIFO or a device), which ffmpeg would block on or read as a stream; details: {path}",
420
+ "join_copy_mismatch": "join.py --copy: a clip's streams do not match the first clip's, it hides pre-roll frames with an edit list, or its picture and sound end more than 50 ms apart; details: {clips: [{index?, path, differs}]}",
420
421
  "output_path_taken": "two files one run writes (a side file and the output, or two side files) resolve to the same path, so the second would replace the first; details: {output, other}",
421
422
  "output_looks_like_option": "the output starts with '-' (other than '-' for stdout), which ffmpeg would read as an option; ./name writes a file of that name; details: {output}",
422
423
  "media_path_is_url": "an input or output has a protocol prefix (tcp:, http://, file:, ...) that ffmpeg would open as a URL; the skill reads and writes local files only; details: {path, scheme}",
@@ -496,6 +497,8 @@ DIAGNOSTIC_CODES = {
496
497
  "colour_not_converted": ("warnings", "export.py: an HDR or wide-gamut source written by a preset that tags SDR BT.709 without converting it; {preset, hdr_format}"),
497
498
  "output_audio_silent": ("warnings", "export.py: the written audio is silent (-inf LUFS), so no gain meets the platform's loudness; {platform}"),
498
499
  "flash_pass_failed": ("warnings", "check.py --content: the flash pass failed, so the flashes row is not measured (WARN, could not measure); the other rows stand"),
500
+ "repair_incomplete": ("warnings", "export.py --repair: decode errors remain after the remux (a damaged frame needs a re-encode); {timestamp_warnings, decode_errors}"),
501
+ "size_over_limit": ("warnings", "export.py --max-mb: the written file is over the size asked for (the encoder overshot its bitrate); {size, limit}"),
499
502
  "loudness_outside_spec": ("warnings", "export.py: the written file's loudness is outside the platform's spec (normalized: after --normalize too); {platform, lufs, tp, normalized}"),
500
503
  "camera_not_started": ("fallbacks", "multicam.py: a range asked for a camera that had not started; camera 0 played it ({start, end, requested_camera, camera, starts_at}); `cuts` names the camera rendered"),
501
504
  "loudness_not_measured": ("warnings", "export.py: the written file's loudness could not be read, so the platform's loudness was not verified (loudness.ok null, a failing loudness verification step)"),
@@ -542,6 +545,7 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
542
545
  base = {"status": {"enum": ["completed"]}, "output": {"type": ["string", "null"], "description": "path written, or null"},
543
546
  "dry_run": {"type": "boolean"}, "commands": {"type": "array", "items": {"type": "string"}, "description": "every ffmpeg command line planned or run"},
544
547
  "probe": {"type": "object", "description": "probe of the output when a file was written"},
548
+ "output_identity": {"type": "object", "description": "2.24: {size, mtime_ns} of the file this run wrote, absent under --dry-run; pass it to check.py --expect-identity SIZE:MTIME_NS so the check grades this file and not whatever later sits at the path"},
545
549
  "encoder": {"type": "string", "description": "the video encoder the last ffmpeg command used (after any GPU->CPU fallback; under --dry-run the planned one; render.py and waveform.py: their stages' commands included), `copy` for a stream copy; absent when no command encoded video"},
546
550
  "hw": {"type": "object", "description": "present when VideoToolbox was asked for (--hw, $FFMPEG_SKILL_HW, a render.py stage, a batch.py item), on export.py when $FFMPEG_SKILL_HW=1 is set but its delivery preset does not take it, and when this ffmpeg build has no libx264/libx265 and an encode ran on VideoToolbox instead: {requested: whether this run's encodes were asked onto VideoToolbox (false for that export, and for the build without libx264/libx265), source: flag|env|auto (auto: nobody asked, the build has no libx264/libx265 and this Apple Silicon machine ran VideoToolbox in its place), used: true|false whether the encoder that ran last is VideoToolbox, null under --dry-run (nothing ran) and when this process ran no encode itself (batch.py: its stages are child processes; its top-level hw gathers the items' requested/source/fallback/notes and each results row carries its item's encoder and hw), fallback: a job VideoToolbox refused (encoder open or compression session failure; nothing else is retried) was re-encoded on the CPU, notes: why an encode stayed on or fell back to the CPU, and, when $FFMPEG_SKILL_HW chose the GPU and it ran, what that costs and that --no-hw opts out}. A VideoToolbox encode also adds a top-level notes line: its quality is not CRF-equivalent. A kind: ffmpeg failure under --hw / $FFMPEG_SKILL_HW carries encoder and hw too"},
547
551
  "plan": {"type": "string", "description": "with --plan FILE: the plan document written (the run itself is a dry run)"},
@@ -552,8 +556,9 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
552
556
  extra: Dict[str, Any] = {}
553
557
  if name == "check":
554
558
  extra = {"platform": {"type": "string"}, "ok": {"type": "boolean"}, "failed": {"type": "integer"}, "warnings": {"type": "integer"},
555
- "notes": {"type": "array", "items": {"type": "string"}, "description": "present when no --platform was named: youtube was assumed and judgement rows are WARN"},
556
- "checks": {"type": "array", "items": {"type": "object", "properties": {"check": {"type": "string", "description": "row name; --content adds black, frozen and silence; audio FAILs when the track is present but silent (peak <= -50 dBFS)"}, "status": {"enum": ["PASS", "WARN", "FAIL"]}, "value": {}, "expected": {}, "fix": {"type": "string"}, "kind": {"enum": ["format", "judgement"]}}}}}
559
+ "input_identity": {"type": "object", "description": "2.24: {size, mtime_ns} of the file graded, the same shape as a writing tool's output_identity"},
560
+ "notes": {"type": "array", "items": {"type": "string"}, "description": "present when no --platform was named (youtube was assumed and judgement rows are WARN), or when --compare paired a different number of frames than the reference has"},
561
+ "checks": {"type": "array", "items": {"type": "object", "properties": {"check": {"type": "string", "description": "row name; --content adds black, frozen and silence; --compare adds similarity; --expect-identity adds identity; audio FAILs when the track is present but silent (peak <= -50 dBFS)"}, "status": {"enum": ["PASS", "WARN", "FAIL"]}, "value": {}, "expected": {}, "fix": {"type": "string"}, "kind": {"enum": ["format", "judgement"]}}}}}
557
562
  elif name == "caption":
558
563
  extra = {"transcription": {"type": "object", "description": "with --transcribe: {engine, model, language, routing (why auto chose it), detected_language when auto detected it}; when auto ran the English-only Parakeet model on English assumed, notes says so"},
559
564
  "caption": {"type": "object", "description": "cue layout: shifted / wrapped / rebalanced / split / extended / dropped counts, plus wrap ('phrase' or 'measured'), phrase_breaks (1.16), broken_inside_word -- atoms hard-sliced at the column edge because they did not fit alone even at the size floor (1.18.4) -- and overlong -- now residual: a single character alone wider than the column. Burn mode: cues_burned -- non-blank cues that overlap [0, duration] and are drawn -- and cues_outside -- non-blank cues wholly outside it (2.2.6); a burn with no visible cue is refused, kind input. --ass: ass_malformed -- Dialogue lines that could not be read and are not counted -- and with --offset ass_shifted / ass_unshifted -- lines moved, and lines whose timestamps could not be read, copied as they were (each count above 0 is also a diagnostics.warnings entry)"},
@@ -613,7 +618,8 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
613
618
  elif name == "report":
614
619
  extra = {"report": {"type": "string"}, "check": {"type": ["object", "null"], "description": "check.py's document for --platform; null when it was not asked for or could not run (a failing compliance verification step then says why)"}}
615
620
  elif name == "export":
616
- extra = {"loudness": {"type": "object", "description": "platform presets with audio: the written file's lufs/tp against the platform's target_lufs/target_tp, ok true when inside the spec; normalized true when --normalize ran loudness.py on the file; silent true (lufs \"-inf\", ok false, no normalize) when the written audio is silent; measured false (lufs, tp and ok null, reason not_measured) when the measurement could not be read"},
621
+ extra = {"repair": {"type": "object", "description": "--repair (2.24): {before, after}, each {timestamp_warnings, decode_errors} counted over one full decode at -v warning"},
622
+ "loudness": {"type": "object", "description": "platform presets with audio: the written file's lufs/tp against the platform's target_lufs/target_tp, ok true when inside the spec; normalized true when --normalize ran loudness.py on the file; silent true (lufs \"-inf\", ok false, no normalize) when the written audio is silent; measured false (lufs, tp and ok null, reason not_measured) when the measurement could not be read"},
617
623
  "trimmed": {"type": "object", "description": "present when the source was longer than the preset's platform maximum and the export was cut to it (no --allow-long): {from, to} in seconds; notes says so too"},
618
624
  "non_av_streams": {"type": "object", "description": "the source's subtitle and data streams: {kept: [{type: subtitle|data, index (n-th of its type), codec, language (subtitle), output_codec}], dropped: [{type, index, codec, language, reason: write_failed|gif|not_in_output}]}, read back from the written file (under --dry-run, as planned). A subtitle track is copied when the container holds it as it is, else converted to the container's text track (mov_text in .mp4/.mov, SubRip in .mkv); a trimmed export cuts it at the same point. Both lists are empty for a source with neither"},
619
625
  "dropped_non_av_streams": {"type": "boolean", "description": "true when non_av_streams.dropped is not empty; a notes line says so too"},
package/scripts/batch.py CHANGED
@@ -49,7 +49,7 @@ ALLOWED_STEP_SCRIPTS = {p.name for p in HERE.glob("*.py") if not p.name.startswi
49
49
 
50
50
  def file_key(path: Path) -> str:
51
51
  st = path.stat()
52
- h = hashlib.sha1()
52
+ h = hashlib.sha1(usedforsecurity=False) # a change detector, not a secret
53
53
  h.update(f"{path.name}|{st.st_size}|{int(st.st_mtime)}".encode())
54
54
  with open(path, "rb") as fh: # first and last MB: cheap and good enough to detect changes
55
55
  h.update(fh.read(1 << 20))
@@ -75,7 +75,7 @@ def recipe_key(recipe: Dict[str, Any]) -> str:
75
75
  # content, so say so (each item that needs it then fails on reading it)
76
76
  warn(f"recipe.project {recipe['project']} could not be read ({exc}); the cache key covers the recipe only",
77
77
  "recipe_project_unreadable", path=str(recipe["project"]), error=str(exc))
78
- return hashlib.sha1((json.dumps(recipe, sort_keys=True) + "\0" + project_content).encode()).hexdigest()[:12]
78
+ return hashlib.sha1((json.dumps(recipe, sort_keys=True) + "\0" + project_content).encode(), usedforsecurity=False).hexdigest()[:12]
79
79
 
80
80
 
81
81
  def gpu_key() -> str:
package/scripts/check.py CHANGED
@@ -38,7 +38,9 @@ from fractions import Fraction
38
38
  from typing import Any, Dict, List, Optional
39
39
 
40
40
  from _platforms import PLATFORMS, PLATFORM_CHOICES, TP_TOLERANCE_DB, spec_of, resolve as resolve_platform
41
- from _common.probe import CONTENT_FLAT_STD, SILENT_PEAK_DBFS, detect_content, detect_flashes, first_frame_stats, presentation_times, measured_level_dbfs
41
+ from _common.probe import (COMPARE_SSIM_WARN, CONTENT_FLAT_STD, SILENT_PEAK_DBFS, compare_pictures, detect_content,
42
+ detect_flashes, first_frame_stats, presentation_times, measured_level_dbfs)
43
+ from _common.emit import file_identity
42
44
  from _common import STATE, add_common, apply_common, die, emit, info, probe, require_tool, run, run_analysis, dry_run_input_pending
43
45
 
44
46
  # The one delivery table (scripts/_platforms.py): check.py's rows, export.py's presets and the
@@ -71,20 +73,26 @@ FROZEN_FAIL = 0.95 # a frozen span covering this share =
71
73
  SILENCE_WARN, SILENCE_FAIL = 0.50, 0.95 # share of the duration below SILENT_PEAK_DBFS
72
74
 
73
75
 
76
+ STATE_NOTES: List[str] = []
77
+
78
+
74
79
  def flash_times(flashes: List[int], v: Dict[str, Any], meta: Dict[str, Any], pts: "Optional[List[float]]" = None) -> str:
75
80
  """The flashes row's first five times on look.py's timeline (seconds from the container
76
81
  start). A flash frame's own presentation time when `pts` has it (presentation_times(): exact,
77
82
  gaps in the timestamps included); otherwise index / average rate plus the video's lead over
78
83
  the container, marked "~" (approximate), and `frame N` when there is no frame rate either."""
84
+ return ", ".join(frame_time(n, v, meta, pts) for n in flashes[:5]) + (" ..." if len(flashes) > 5 else "")
85
+
86
+
87
+ def frame_time(n: int, v: Dict[str, Any], meta: Dict[str, Any], pts: "Optional[List[float]]" = None) -> str:
88
+ """Frame `n` (decode order) on look.py's timeline, as flash_times() describes: exact from
89
+ `pts`, else "~" index / average rate plus the video's lead, else `frame N`."""
79
90
  origin = float(meta.get("start_time") or 0.0)
91
+ if pts and n < len(pts):
92
+ return f"{max(0.0, pts[n] - origin):.2f}s"
80
93
  fps = float(v.get("fps") or 0.0)
81
94
  lead = max(0.0, float(v.get("start_time") or 0.0) - origin)
82
-
83
- def one(n: int) -> str:
84
- if pts and n < len(pts):
85
- return f"{max(0.0, pts[n] - origin):.2f}s"
86
- return f"~{lead + n / fps:.2f}s" if fps else f"frame {n}"
87
- return ", ".join(one(n) for n in flashes[:5]) + (" ..." if len(flashes) > 5 else "")
95
+ return f"~{lead + n / fps:.2f}s" if fps else f"frame {n}"
88
96
 
89
97
 
90
98
  def _span_total(spans: Any) -> float:
@@ -100,6 +108,7 @@ def aspect_name(w: int, h: int) -> str:
100
108
 
101
109
 
102
110
  def main() -> int:
111
+ STATE_NOTES.clear() # module state: a second main() in one process starts without the first's notes
103
112
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
104
113
  ap.add_argument("input")
105
114
  ap.add_argument("--platform", choices=PLATFORM_CHOICES, default=None, help="delivery spec to check against (default: youtube, with judgement rows reported as WARN because no platform was named)")
@@ -111,6 +120,12 @@ def main() -> int:
111
120
  ap.add_argument("--no-loudness", action="store_true", help="skip the loudness measurement (faster)")
112
121
  ap.add_argument("--content", action="store_true",
113
122
  help="also check the content (one decode pass, plus an audio-only and a video-only one): black, frozen, silence, dropouts, flashes and first frame rows")
123
+ ap.add_argument("--compare", metavar="REFERENCE",
124
+ help="also measure the picture against REFERENCE, frame by frame (SSIM and PSNR, one pass): a "
125
+ "similarity row, WARN when the mean SSIM is under 0.98, with the worst frame's time")
126
+ ap.add_argument("--expect-identity", metavar="SIZE:MTIME_NS",
127
+ help="the output_identity a writing tool reported ({size}:{mtime_ns}): an identity row FAILs when "
128
+ "the file at INPUT is not that file (a later run replaced it, or a failed run left an older one)")
114
129
  add_common(ap)
115
130
  args = ap.parse_args()
116
131
  apply_common(args)
@@ -132,6 +147,8 @@ def main() -> int:
132
147
  if args.max_mb is not None:
133
148
  spec["max_bytes"] = int(args.max_mb * 1024 * 1024)
134
149
 
150
+ # read before anything is measured and again after: a file replaced mid-check is not the one graded
151
+ identity_before = None if dry_run_input_pending(args.input) else file_identity(args.input)
135
152
  meta = probe(args.input)
136
153
  v, a = meta.get("video") or {}, meta.get("audio") or {}
137
154
  rows: List[Dict[str, Any]] = []
@@ -345,11 +362,65 @@ def main() -> int:
345
362
  "again); a noise gate makes these on purpose" if gaps else "",
346
363
  reason="a short stretch of digital silence in the middle of the sound is usually lost audio at a join or a glitched decode" if gaps else "")
347
364
 
365
+ if args.compare and not dry_run_input_pending(args.input) and not dry_run_input_pending(args.compare):
366
+ ref = probe(args.compare)
367
+ rv = ref.get("video") or {}
368
+ if not v or not rv:
369
+ row("similarity", "WARN", "not compared", "two pictures", "--compare needs a video stream in both files",
370
+ reason="one of the files has no picture")
371
+ elif (v.get("width"), v.get("height")) != (rv.get("width"), rv.get("height")):
372
+ row("similarity", "WARN", f"not compared: {v.get('width')}x{v.get('height')} vs {rv.get('width')}x{rv.get('height')}",
373
+ "the same frame size", "compare against a reference at the delivery size (fit.py or export.py it first)",
374
+ reason="SSIM and PSNR compare pixels, so the two frames must be the same size")
375
+ else:
376
+ cmp_ = compare_pictures(args.input, args.compare)
377
+ if cmp_ is None:
378
+ row("similarity", "WARN", "could not measure", f"mean SSIM >= {COMPARE_SSIM_WARN:g}",
379
+ "look.py --compare at a few times", reason="the comparison pass failed")
380
+ else:
381
+ pts = presentation_times(args.input, float(v.get("start_time") or 0.0))
382
+ at = frame_time(cmp_["worst_frame"], v, meta, pts)
383
+ seek = "0" if at.startswith("frame") else at.lstrip("~").rstrip("s")
384
+ psnr = "identical" if cmp_["psnr"] == float("inf") else (f"{cmp_['psnr']:.1f} dB" if cmp_["psnr"] is not None else "?")
385
+ low = cmp_["ssim"] < COMPARE_SSIM_WARN
386
+ row("similarity", "WARN" if low else "PASS",
387
+ f"SSIM {cmp_['ssim']:.4f} (worst {cmp_['worst_ssim']:.4f} at {at}), PSNR {psnr}, {cmp_['frames']} frames",
388
+ f"mean SSIM >= {COMPARE_SSIM_WARN:g}",
389
+ f"look.py --compare {args.compare} --at {seek}" if low else "",
390
+ reason="the picture differs visibly from the reference somewhere" if low else "")
391
+ # frames counted from the packets when a container does not state nb_frames
392
+ nout = v.get("nb_frames") or len(pts) or None
393
+ nref = rv.get("nb_frames") or len(presentation_times(args.compare, float(rv.get("start_time") or 0.0))) or None
394
+ if nref and nout and int(nref) != int(nout):
395
+ STATE_NOTES.append(f"--compare: {nout} frames against the reference's {nref}; frames were paired in "
396
+ f"order, so an added or dropped frame shifts every later pair")
397
+ # read after every measurement, --compare's included, so a replacement at any point shows
398
+ identity = None if dry_run_input_pending(args.input) else file_identity(args.input)
399
+ if identity is not None and identity != identity_before and not args.expect_identity:
400
+ STATE_NOTES.append("the file changed while it was being checked: input_identity is the file as it was "
401
+ "at the end, and the rows may describe an earlier one; check it again")
402
+ if args.expect_identity:
403
+ try:
404
+ size, mtime_ns = (int(x) for x in args.expect_identity.split(":"))
405
+ except ValueError:
406
+ die(f"--expect-identity takes SIZE:MTIME_NS from a tool's output_identity, got {args.expect_identity!r}")
407
+ if identity is not None:
408
+ changed = identity != identity_before
409
+ same = identity == {"size": size, "mtime_ns": mtime_ns} and not changed
410
+ row("identity", "PASS" if same else "FAIL", f"{identity['size']}:{identity['mtime_ns']}", f"{size}:{mtime_ns}",
411
+ "" if same else "re-run the tool that writes this file and check what it reports, not what the path holds",
412
+ reason="" if same else ("the file changed while it was being checked" if changed
413
+ else "the file at this path is not the one the run being checked wrote"))
414
+ elif not dry_run_input_pending(args.input):
415
+ row("identity", "FAIL", "no regular file at this path", f"{size}:{mtime_ns}",
416
+ "re-run the tool that writes this file and check what it reports, not what the path holds",
417
+ reason="the file at this path is not the one the run being checked wrote")
348
418
  failed = [r for r in rows if r["status"] == "FAIL"]
349
419
  warned = [r for r in rows if r["status"] == "WARN"]
350
420
  notes: List[str] = []
351
421
  if not named:
352
422
  notes.append("no --platform given: youtube's spec was assumed, so judgement rows (duration, aspect, fps, resolution, loudness, true peak) are WARN, not FAIL; name a platform to enforce them")
423
+ notes += STATE_NOTES
353
424
  if not args.json:
354
425
  width = max(len(r["check"]) for r in rows)
355
426
  print(f"{args.input} — {args.platform}" + ("" if named else " (assumed)"))
@@ -366,6 +437,8 @@ def main() -> int:
366
437
  for n in notes:
367
438
  print(f" note: {n}")
368
439
  extra: Dict[str, Any] = {"notes": notes} if notes else {}
440
+ if identity is not None:
441
+ extra["input_identity"] = identity
369
442
  if failed:
370
443
  die(f"{len(failed)} of {len(rows)} {args.platform} checks failed: {', '.join(r['check'] for r in failed)}",
371
444
  kind="verification", output=None, dry_run=STATE.dry_run,
package/scripts/color.py CHANGED
@@ -26,7 +26,9 @@ from typing import List, Optional
26
26
  from _common import STATE, add_common, analyze_levels, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, ffmpeg_bsfs, info, probe, require_tool, run, run_analysis, run_keeping_subtitles, x264_args, X264_PRESETS, fmt_secs
27
27
  from _common.emit import warn
28
28
 
29
- TONEMAPS = ["hable", "mobius", "reinhard", "bt2390", "clip", "linear", "gamma"]
29
+ # the curves of FFmpeg's CPU `tonemap` filter (every version from 5.1 to 9). bt2390 was listed
30
+ # until 2.24 but exists only in tonemap_opencl/libplacebo, so choosing it always failed.
31
+ TONEMAPS = ["hable", "mobius", "reinhard", "clip", "linear", "gamma"]
30
32
 
31
33
  # Typed primary correction: each flag is one option of one real, always-available libavfilter filter
32
34
  # (never a caller-supplied filter string). Range is this script's own safe subset of what the filter
@@ -164,7 +166,7 @@ def hdr_to_sdr_chain(meta: dict, tonemap: str, peak: float, desat: float) -> str
164
166
  f"zscale=tin={trc}:pin={prim}:min={space}:rin={v.get('color_range') or 'tv'}:t=linear:npl={peak:g}",
165
167
  "format=gbrpf32le",
166
168
  "zscale=p=bt709",
167
- f"tonemap=tonemap={tonemap}:desat={desat:g}" + (":peak=%g" % (peak / 100.0) if tonemap in ("bt2390",) else ""),
169
+ f"tonemap=tonemap={tonemap}:desat={desat:g}",
168
170
  "zscale=t=bt709:m=bt709:r=tv",
169
171
  "format=yuv420p",
170
172
  ]
package/scripts/cut.py CHANGED
@@ -57,6 +57,7 @@ from typing import List, Tuple
57
57
 
58
58
  from _common import (beat_grid, snap_points, decode_pcm_mono, rms_envelope, BEAT_MIN_CONFIDENCE)
59
59
  from _common import require_tool
60
+ from _common.probe import TS_EXTS, signatures_match, stream_signature
60
61
  from _common import sibling_temp, source_codec_video_args, STATE, add_common, apply_common, audio_codec_for, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, is_audio_output, time_arg, probe, run, X264_PRESETS, keyframes_near, MissingFpsError, concat_list_line, place_output, refuse_output_is_input, fmt_secs, measure_frame_timing, dry_run_input_pending
61
62
 
62
63
  # outputs whose re-encode dropped a subtitle/data stream (reported as dropped_non_av_streams)
@@ -73,7 +74,28 @@ def _t(value: str, fps, flag: str = "--segments") -> float:
73
74
  return time_arg(value, flag, fps)
74
75
 
75
76
 
76
- def parse_segments(spec: str, fps=None) -> List[Tuple[float, float]]:
77
+ def keep_ranges_after_removing(removed: List[Tuple[float, float]], total: float, fps=None) -> List[Tuple[float, float]]:
78
+ """--remove: what is left of [0, total] once `removed` is dropped (merged where they overlap,
79
+ clamped to the file). A leftover shorter than one frame (10 ms without a frame rate) is not a
80
+ piece anyone asked to keep. Refuses a removal that would leave nothing."""
81
+ least = 1.0 / fps if fps else 0.01
82
+ keep, cursor = [], 0.0
83
+ for s, e in sorted((max(0.0, s), min(total, e)) for s, e in removed):
84
+ if e <= cursor:
85
+ continue
86
+ if s - cursor >= least:
87
+ keep.append((cursor, s))
88
+ cursor = max(cursor, e)
89
+ if total - cursor >= least:
90
+ keep.append((cursor, total))
91
+ if not keep:
92
+ die(f"--remove drops the whole file ({total:.3f}s); nothing would be left to keep")
93
+ return keep
94
+
95
+
96
+ def parse_segments(spec: str, fps=None, clamp: bool = False) -> List[Tuple[float, float]]:
97
+ """START-END,... as seconds. `clamp` (--remove) lets a range start before 0, since the file
98
+ clamps it; --segments refuses that."""
77
99
  segs = []
78
100
  for raw in spec.split(","):
79
101
  raw = raw.strip()
@@ -83,7 +105,7 @@ def parse_segments(spec: str, fps=None) -> List[Tuple[float, float]]:
83
105
  die(f"segment '{raw}' must look like START-END (e.g. 0:05-0:12)")
84
106
  a, b = raw.rsplit("-", 1)
85
107
  start, end = _t(a, fps), _t(b, fps)
86
- if start < 0:
108
+ if start < 0 and not clamp:
87
109
  die(f"segment '{raw}': start must be >= 0")
88
110
  if end <= start:
89
111
  die(f"segment '{raw}': end must be after start")
@@ -927,13 +949,6 @@ DECLICK_S = 0.02
927
949
  # keyframe still decodes from the keyframe BEFORE it
928
950
  SEEK_MARGIN = 1.0
929
951
  # codecs whose configuration travels in the packets, so a part with no extradata is not missing any
930
- IN_BAND_CONFIG = ("mp3", "mp2")
931
- TS_EXTS = (".ts", ".m2ts", ".mts")
932
- _SIG_FIELDS = {"video": ("codec_name", "profile", "pix_fmt", "width", "height", "r_frame_rate", "time_base",
933
- "sample_aspect_ratio", "color_transfer", "color_primaries"),
934
- "audio": ("codec_name", "profile", "sample_rate", "channels", "time_base")}
935
-
936
-
937
952
  _PARTS: dict = {}
938
953
 
939
954
 
@@ -952,21 +967,10 @@ def _part_probe(path: str):
952
967
 
953
968
 
954
969
  def join_signature(path: str):
955
- """What a stream-copy join needs to be identical across parts: each stream's type, codec
956
- parameters, rotation and extradata hash. None when ffprobe cannot read the part."""
970
+ """What a stream-copy join needs to be identical across parts (_common.probe.stream_signature).
971
+ None when ffprobe cannot read the part."""
957
972
  doc = _part_probe(path)
958
- if doc is None:
959
- return None
960
- sig = []
961
- for st in doc["streams"]:
962
- kind = st.get("codec_type")
963
- entry = {"type": kind, "extradata_hash": st.get("extradata_hash")}
964
- entry.update({f: st.get(f) for f in _SIG_FIELDS.get(kind, ("codec_name",))})
965
- if kind == "video":
966
- entry["rotation"] = next((sd.get("rotation") for sd in st.get("side_data_list") or []
967
- if "rotation" in sd), None)
968
- sig.append(entry)
969
- return sig
973
+ return None if doc is None else stream_signature(doc)
970
974
 
971
975
 
972
976
  # how far a measured join step may be from the predicted one: ffprobe prints the parts' times
@@ -995,30 +999,6 @@ def predicted_join_steps(parts) -> list:
995
999
  return [None if a is None or b is None else (a[1] - a[3]) + (b[2] - b[0]) for a, b in zip(spans, spans[1:])]
996
1000
 
997
1001
 
998
- def signatures_match(sigs: list, ext: str) -> bool:
999
- """True when every part can be joined by stream copy: same streams in the same order with the
1000
- same parameters. A missing extradata hash only matches another missing one where the codec (or
1001
- an MPEG-TS output) carries its configuration in-band -- PCM has none at all."""
1002
- if not sigs or any(sig is None for sig in sigs):
1003
- return False
1004
- first = sigs[0]
1005
- for sig in sigs[1:]:
1006
- if len(sig) != len(first):
1007
- return False
1008
- for a, b in zip(first, sig):
1009
- if {k: v for k, v in a.items() if k != "extradata_hash"} != {k: v for k, v in b.items() if k != "extradata_hash"}:
1010
- return False
1011
- ha, hb = a.get("extradata_hash"), b.get("extradata_hash")
1012
- if ha or hb:
1013
- if ha != hb:
1014
- return False
1015
- else:
1016
- codec = str(a.get("codec_name") or "")
1017
- if not (codec.startswith("pcm_") or codec in IN_BAND_CONFIG or ext in TS_EXTS):
1018
- return False
1019
- return True
1020
-
1021
-
1022
1002
  def _fraction_rate(text: str) -> float:
1023
1003
  """"30000/1001" or "30" as a number."""
1024
1004
  num, _, den = str(text).partition("/")
@@ -1316,10 +1296,13 @@ def main() -> int:
1316
1296
  ap.add_argument("input")
1317
1297
  ap.add_argument("-o", "--output", help="output file (default: <name>_cut.<ext>)")
1318
1298
  g = ap.add_argument_group("range (single segment)")
1319
- g.add_argument("--start", default="0", help="start time (seconds, mm:ss or hh:mm:ss.ms). default 0")
1299
+ g.add_argument("--start", help="start time (seconds, mm:ss or hh:mm:ss.ms). default 0")
1320
1300
  g.add_argument("--end", help="end time")
1321
1301
  g.add_argument("--duration", help="duration instead of --end")
1322
1302
  ap.add_argument("--segments", help="comma separated START-END list, e.g. '0:05-0:12,1:00-1:20' (joined in order)")
1303
+ ap.add_argument("--remove", metavar="START-END[,...]",
1304
+ help="the inverse of --segments: drop these ranges and keep the rest, joined in order "
1305
+ "(e.g. '1:20-2:05,10:00-10:30'); overlapping ranges merge")
1323
1306
  ap.add_argument("--accurate", action="store_true", help="always re-encode for frame-accurate (video) / sample-accurate (audio) cuts (default: lossless -c copy, re-encoding only when the keyframe snap exceeds --tolerance)")
1324
1307
  ap.add_argument("--edit-list", action="store_true", help="a single-segment .mp4/.mov stream copy keeps the MP4 edit list that hides the keyframe's pre-roll, so the picture starts at --start (default: -avoid_negative_ts make_zero, the picture starts at the keyframe); a player that ignores edit lists shows the pre-roll")
1325
1308
  ap.add_argument("--keep-hevc", action="store_true", help="a re-encode of an SDR HEVC source stays HEVC (x265 8-bit BT.709; VideoToolbox under --hw) instead of x264 (default: x264 for SDR, x265 Main10 for HDR); --codec overrides it")
@@ -1369,10 +1352,21 @@ def main() -> int:
1369
1352
  forced.append("codec")
1370
1353
 
1371
1354
  fps = (meta.get("video") or {}).get("fps")
1355
+ if args.remove:
1356
+ if args.segments or args.end or args.duration or args.start is not None:
1357
+ die("--remove names what to drop from the whole file: it does not combine with --segments, --start, --end or --duration")
1358
+ if args.snap == "beats":
1359
+ # a snapped edge of what is kept could move into a range the caller asked to drop
1360
+ die("--remove drops exactly the ranges given: --snap beats would move the kept edges, possibly into them; "
1361
+ "snap the ranges first (scenes.py --beats) or cut with --segments --snap beats")
1362
+ if not total:
1363
+ die(f"--remove needs the input's duration, which {args.input} does not report"
1364
+ + (" yet (it is an earlier step's output)" if dry_run_input_pending(args.input) else ""))
1365
+ args.segments = ",".join(f"{s:.6f}-{e:.6f}" for s, e in keep_ranges_after_removing(parse_segments(args.remove, fps, clamp=True), total, fps))
1372
1366
  if args.segments:
1373
1367
  segments = parse_segments(args.segments, fps)
1374
1368
  else:
1375
- start = _t(args.start, fps, "--start")
1369
+ start = _t(args.start or "0", fps, "--start")
1376
1370
  if start < 0:
1377
1371
  die(f"--start must not be negative, got {args.start!r}")
1378
1372
  if args.end and args.duration: