ffmpeg-skill 3.8.8 → 3.8.9

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/scripts/probe.py CHANGED
@@ -73,6 +73,14 @@ def main() -> int:
73
73
  for r in results:
74
74
  if r.get("video"):
75
75
  r["levels"] = analyze_levels(r["file"])
76
+ transfer = r["video"].get("color_transfer")
77
+ if r["video"].get("hdr") and "error" not in r["levels"]:
78
+ # the guess is "flat and desaturated, yet tagged SDR": a PQ/HLG stream is not
79
+ # tagged SDR, and its code values were read as if they were (#403)
80
+ r["levels"]["looks_like_log"] = None
81
+ r["levels"]["reason"] = "not_measured: the stream is tagged HDR (%s), and the Log guess is made for an SDR-tagged picture" % (transfer or r["video"].get("hdr_format") or "HDR")
82
+ r["levels"]["note"] = ("the stream is tagged HDR (%s): these are its code values on an 8-bit scale, not display-referred SDR levels, "
83
+ "and whether it looks like Log was not judged" % (transfer or r["video"].get("hdr_format") or "HDR"))
76
84
 
77
85
  if args.field:
78
86
  for r in results:
package/scripts/render.py CHANGED
@@ -1090,6 +1090,18 @@ def _from_plan_cwd(output: Any) -> Any:
1090
1090
  return os.path.abspath(str(output)) if isinstance(output, str) and output else output
1091
1091
 
1092
1092
 
1093
+ def check_not_run(check_result: Optional[Dict[str, Any]]) -> Optional[str]:
1094
+ """Why check.py graded nothing, or None when it did: a document with no FAIL count that is
1095
+ failed or carries an error is a check that could not run (an unparsed document, check.py's own
1096
+ refusal), not a check the delivery failed (#403: both were reported as "check failed")."""
1097
+ if not check_result or check_result.get("failed"):
1098
+ return None
1099
+ err = check_result.get("error")
1100
+ if not err and check_result.get("status") != "failed":
1101
+ return None
1102
+ return str((err.get("message") if isinstance(err, dict) else err) or "check.py reported status failed with no rows")
1103
+
1104
+
1093
1105
  def execute_plan(plan: Dict[str, Any], path: str) -> int:
1094
1106
  """Run a plan written by `<tool> --plan FILE`: refuse if any fingerprinted input changed since
1095
1107
  the plan was made (the plan's commands would then describe a different edit), run the tool
@@ -1189,7 +1201,9 @@ def execute_plan(plan: Dict[str, Any], path: str) -> int:
1189
1201
  # same command run by hand)
1190
1202
  if exit_code:
1191
1203
  failed_rows = [r["check"] for r in (check_result or {}).get("checks", []) if r.get("status") == "FAIL"]
1192
- info(f"plan done: {output}, but the {check_result.get('platform') or 'content'} check failed" + (f": {', '.join(failed_rows)}" if failed_rows else ""))
1204
+ unrun = check_not_run(check_result)
1205
+ info(f"plan done: {output}, but the {check_result.get('platform') or 'content'} check "
1206
+ + (f"could not run, so nothing was checked: {unrun}" if unrun else "failed" + (f": {', '.join(failed_rows)}" if failed_rows else "")))
1193
1207
  else:
1194
1208
  advised = guidance_warns(check_result)
1195
1209
  info(f"plan done: {output}" + (" — upload-limit guidance, the uploader checks the current limit: "
@@ -1197,7 +1211,9 @@ def execute_plan(plan: Dict[str, Any], path: str) -> int:
1197
1211
  # the tool's own steps (export's loudness, ...) come in through absorb_stage() above, tagged
1198
1212
  # with `stage`, so `verified` is theirs and the check's together
1199
1213
  emit(output, plan=path, plan_sha256=digest, unbound_inputs=unbound, tool=tool, stages=[tool] + (["check"] if check_result else []), check=check_result, tool_result=doc,
1200
- verification=([{"step": "check", "ok": not exit_code, "platform": check_result.get("platform")}] if check_result else []))
1214
+ verification=([dict({"step": "check", "ok": not exit_code, "platform": check_result.get("platform")},
1215
+ **({"reason": "not_measured: " + check_not_run(check_result)} if check_not_run(check_result) else {}))]
1216
+ if check_result else []))
1201
1217
  return 0
1202
1218
 
1203
1219
 
@@ -2084,7 +2100,9 @@ def main() -> int:
2084
2100
  # The deliverable is written and verified, but it does not meet the requested platform
2085
2101
  # spec (or the check itself could not run): a failed delivery, reported as one.
2086
2102
  failed_rows = [r["check"] for r in (check_result or {}).get("checks", []) if r.get("status") == "FAIL"]
2087
- die(f"rendered {output} but the {check_label} check failed" + (f": {', '.join(failed_rows)}" if failed_rows else ""),
2103
+ unrun = check_not_run(check_result)
2104
+ die(f"rendered {output} but the {check_label} check "
2105
+ + (f"could not run, so nothing was checked: {unrun}" if unrun else "failed" + (f": {', '.join(failed_rows)}" if failed_rows else "")),
2088
2106
  kind="verification", output=output, dry_run=STATE.dry_run, stages=stages_done, check=check_result,
2089
2107
  probe=probe(output, role="output"))
2090
2108
  info(f"rendered {output} via {' → '.join(stages_done)}")
@@ -121,7 +121,7 @@ def sentence_words(args) -> "Tuple[list, str]":
121
121
  raw = parse_json(read_text_or_die(args.words, "--words"))
122
122
  except ValueError as exc:
123
123
  die(f"--words {args.words}: not readable JSON ({exc})", kind="input")
124
- words, _had_segments, _untimed = _words_from_transcript(raw)
124
+ words, _had_segments, _untimed = _words_from_words_file(args.words, raw)
125
125
  if not words:
126
126
  die(f"no word timings in {args.words}: the sentence flags need "
127
127
  '{"words": [{"word": ..., "start": ..., "end": ...}]} (or the same inside "segments").',
@@ -356,7 +356,15 @@ def resolve_filler(args, meta):
356
356
  except ValueError as exc:
357
357
  die(f"--words {args.words}: not readable JSON ({exc})", kind="input")
358
358
  source = f"whisper-json:{args.words}"
359
- words, had_segments, untimed = _words_from_transcript(raw)
359
+ words, had_segments, untimed = _words_from_words_file(args.words, raw)
360
+ length = float(meta.get("duration") or 0.0)
361
+ late = [w for w in words if w["start"] >= length] if length > 0 else []
362
+ if late:
363
+ # nothing said it: the cuts were planned on times the input does not have (#404)
364
+ warn(f"--words {args.words}: {len(late)} of {len(words)} word timings start at or after the end of the input "
365
+ f"({length:.3f}s; the last at {max(w['start'] for w in late):g}s) -- a transcript of another file, or "
366
+ "times in milliseconds (a whisper JSON's start/end are seconds); no word is cut there",
367
+ "words_outside_input", count=len(late), words=len(words), duration=round(length, 3))
360
368
  if not words:
361
369
  if had_segments:
362
370
  die(f"the transcript in {args.words} has segment timings but no word timings; "
@@ -423,7 +431,11 @@ def resolve_filler(args, meta):
423
431
  if primary in names:
424
432
  return names[primary]
425
433
  return primary[:2] if len(primary) > 3 else primary
426
- spoken = _code((raw or {}).get("language")) if isinstance(raw, dict) else ""
434
+ # openai-whisper's top-level `language`, or whisper.cpp's `result.language` (its
435
+ # --output-json-full document is read for its words too: _words_from_words_file())
436
+ result = raw.get("result") if isinstance(raw, dict) else None
437
+ spoken = _code(raw.get("language") or (result.get("language") if isinstance(result, dict) else None)) \
438
+ if isinstance(raw, dict) else ""
427
439
  spoken = spoken or _code(transcription.get("language") or transcription.get("detected_language"))
428
440
  if spoken and spoken not in FILLER_WORDS and not args.filler_words:
429
441
  die(f"the transcript is in {spoken}, which has no built-in filler list. The languages with one are "
@@ -482,6 +494,18 @@ def resolve_filler(args, meta):
482
494
  return block, [(s["start"], s["end"]) for s in spans]
483
495
 
484
496
 
497
+ def _words_from_words_file(path, data):
498
+ """_words_from_transcript() for a --words file, plus whisper.cpp's own shape: its
499
+ --output-json-full document (transcription[].tokens[], offsets in milliseconds) is what the
500
+ refusals here tell a caller to make, and it was then refused for having no word timings (#404).
501
+ Read as --transcribe reads the same file."""
502
+ words, had_segments, untimed = _words_from_transcript(data)
503
+ if not words and isinstance(data, dict) and isinstance(data.get("transcription"), list):
504
+ words = _asr._words_from_whisper_cpp_json(path)
505
+ had_segments = had_segments or bool(data["transcription"])
506
+ return words, had_segments, untimed
507
+
508
+
485
509
  def _words_from_transcript(data):
486
510
  """([{word,start,end}], whether the document had segments at all, [{word}] of the words
487
511
  that carried no usable timing) from a whisper JSON. The third list lets --filler count a
@@ -28,7 +28,7 @@ import tempfile
28
28
  from pathlib import Path
29
29
 
30
30
  from _common.probe import PROBED_VIDEO
31
- from _common.runner import default_selection_maps
31
+ from _common.runner import ffmpeg_failure_extra, default_selection_maps, require_filter
32
32
  from _common import STATE, add_common, apply_common, aac_args, cfr_args, default_output, die, emit, escape_filter_path, ffmpeg_base, info, probe, require_tool, run, video_args, X264_PRESETS, run_analysis, dry_run_input_pending, fmt_secs, record_fallback, EVEN_SCALE
33
33
 
34
34
 
@@ -57,6 +57,10 @@ def main() -> int:
57
57
  meta = probe(args.input)
58
58
  if not meta.get("video"):
59
59
  die("input has no video stream")
60
+ # pass 1 is a measurement that runs under --dry-run too: refused here, with the reason a run
61
+ # that met "No such filter" gets, instead of as an unclassified pass-1 failure (#402)
62
+ for name in ("vidstabdetect", "vidstabtransform"):
63
+ require_filter(name, "stabilizing (vid.stab)")
60
64
  has_audio = bool(meta.get("audio"))
61
65
  output = args.output or default_output(args.input, "stab")
62
66
 
@@ -89,7 +93,7 @@ def main() -> int:
89
93
  if not dry_run_input_pending(args.input):
90
94
  proc = run_analysis(detect_cmd, check=False, record=True)
91
95
  if proc.returncode != 0:
92
- die(f"stabilization analysis (pass 1) failed:\n{proc.stderr.strip()[-1500:]}", kind="ffmpeg")
96
+ die(f"stabilization analysis (pass 1) failed:\n{proc.stderr.strip()[-1500:]}", kind="ffmpeg", **ffmpeg_failure_extra(proc.stderr))
93
97
 
94
98
  crop_mode = {"keep": 0, "black": 1}[args.crop]
95
99
  transform_vf = f"{even}vidstabtransform=input={trf_arg}:smoothing={args.smoothing}:crop={crop_mode}:zoom={args.zoom:g}:optzoom=1"
package/scripts/sync.py CHANGED
@@ -33,7 +33,7 @@ import sys
33
33
  from typing import Dict, List
34
34
 
35
35
  from _common.runner import fail_if_classified
36
- from _common import video_args, add_common, apply_common, emit, aac_args, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, x264_args, decode_pcm_mono, rms_envelope
36
+ from _common import STATE, video_args, add_common, apply_common, emit, aac_args, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, x264_args, decode_pcm_mono, rms_envelope
37
37
  from _common.emit import record_skip, warn
38
38
  from _common.decision import SEEK_MARGIN, frame_rate, grid_at_or_after, length_output_t, silence_filter, stream_starts
39
39
  from _common.timeline import output_timecode_args
@@ -259,8 +259,13 @@ def measure_one(reference: str, path: str, args) -> Dict:
259
259
  offset = offset + (drift_ratio - 1.0) * head_mid
260
260
  drift_info = {"residual_at_end_seconds": round(residual, 4), "measured_over_seconds": round(elapsed, 2),
261
261
  "drift_ppm": round((drift_ratio - 1) * 1e6, 1),
262
- "meaning": "second file runs %.1f ppm %s (%.3fs over %.0fs); it will be resampled to match" % (
263
- abs(drift_ratio - 1) * 1e6, "long/slow" if drift_ratio > 1 else "short/fast", abs(residual), elapsed),
262
+ # a measure-only run writes nothing (#403: it said "will be resampled")
263
+ "meaning": "second file runs %.1f ppm %s (%.3fs over %.0fs); %s" % (
264
+ abs(drift_ratio - 1) * 1e6, "long/slow" if drift_ratio > 1 else "short/fast", abs(residual), elapsed,
265
+ ("it would be resampled to match in the file this run plans (--dry-run writes nothing)" if STATE.dry_run
266
+ else "it is resampled to match in the file this run writes")
267
+ if args.replace_audio or args.trim_second else
268
+ "nothing is resampled in this measure-only run (--replace-audio or --trim-second writes the corrected file)"),
264
269
  "confidence": round(end_score, 3)}
265
270
  else:
266
271
  skipped_reason = "low_confidence"
@@ -36,7 +36,7 @@ import os
36
36
  import sys
37
37
 
38
38
  from _platforms import PLATFORMS, PLATFORM_CHOICES, resolve as resolve_platform
39
- from _common.probe import SILENT_PEAK_DBFS, square_pixels, square_pixels_note
39
+ from _common.probe import SILENT_PEAK_DBFS, refuse_not_a_still, square_pixels, square_pixels_note
40
40
  from _common import still_input_args # noqa: E402
41
41
  from _common import (add_common, apply_common, aac_args, default_output, die, draws_chroma, emit, ffmpeg_base,
42
42
  info, load_brand, pad_filters, probe, rgb_chain_to_bt709, run, STATE, validate_color, video_args, X264_PRESETS,
@@ -119,10 +119,13 @@ def main() -> int:
119
119
  "the image locally and pass its path", kind="input")
120
120
  if not os.path.exists(args.image):
121
121
  die(f"--image file not found: {args.image}", kind="input")
122
- if not STATE.dry_run:
123
- plate = probe(args.image, role="input")
124
- if not (plate.get("video") or {}).get("width"):
125
- die(f"--image is not an image ffmpeg can decode: {args.image}", kind="input")
122
+ # read under --dry-run too (the file exists, checked above): a plate the real run refuses
123
+ # is refused by the plan (#404: a dry run completed on a video given as --image)
124
+ plate = probe(args.image, role="input")
125
+ if not (plate.get("video") or {}).get("width"):
126
+ die(f"--image is not an image ffmpeg can decode: {args.image}", kind="input")
127
+ refuse_not_a_still(args.image, plate, "--image",
128
+ "`look.py FILE --at T` writes one frame of a video as a PNG")
126
129
  if args.background != "black":
127
130
  die("--image and a non-default --background exclude each other: the plate is either "
128
131
  "the picture or the colour", kind="input")
@@ -375,8 +378,12 @@ def _child(script_name: str, argv: "list") -> None:
375
378
  extra = {"hint": err["hint"]} if err.get("hint") else {}
376
379
  if doc.get("encoder"):
377
380
  extra["encoder"] = doc["encoder"]
381
+ # the stage's own reason and details ride along (#402: a caller keyed on error.reason saw
382
+ # filter_missing from caption.py and graphics.py run by hand, and nothing from here)
383
+ why = {k: err[k] for k in ("reason", "details") if err.get(k) is not None}
378
384
  die(f"{script_name} failed: {err.get('message') or (proc.stderr.strip().splitlines() or ['?'])[-1][:300]}",
379
- code=int(doc.get("exit_code") or 1), kind=err.get("kind") or "input", stage=script_name, **extra)
385
+ code=int(doc.get("exit_code") or 1), kind=err.get("kind") or "input", stage=script_name,
386
+ error_extra=why or None, **extra)
380
387
 
381
388
  if __name__ == "__main__":
382
389
  sys.exit(main())