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/README.md +1 -1
- package/docs/contract.md +13 -3
- package/package.json +1 -1
- package/references/gotchas.md +5 -3
- package/references/scripts.md +11 -6
- package/scripts/_common/asr.py +12 -1
- package/scripts/_common/drawtext.py +11 -0
- package/scripts/_common/emit.py +3 -1
- package/scripts/_common/emoji.py +28 -5
- package/scripts/_common/probe.py +27 -7
- package/scripts/_common/runner.py +133 -5
- package/scripts/_contract.py +14 -9
- package/scripts/caption.py +39 -3
- package/scripts/color.py +36 -0
- package/scripts/cropdetect.py +24 -6
- package/scripts/cut.py +13 -5
- package/scripts/export.py +3 -2
- package/scripts/graphics.py +31 -12
- package/scripts/join.py +20 -2
- package/scripts/loudness.py +2 -1
- package/scripts/metadata.py +11 -1
- package/scripts/overlay.py +11 -6
- package/scripts/probe.py +8 -0
- package/scripts/render.py +21 -3
- package/scripts/silence.py +27 -3
- package/scripts/stabilize.py +6 -2
- package/scripts/sync.py +8 -3
- package/scripts/waveform.py +13 -6
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
|
-
|
|
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")}
|
|
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
|
-
|
|
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)}")
|
package/scripts/silence.py
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
package/scripts/stabilize.py
CHANGED
|
@@ -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
|
-
|
|
263
|
-
|
|
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"
|
package/scripts/waveform.py
CHANGED
|
@@ -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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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,
|
|
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())
|