ffmpeg-skill 3.6.4 → 3.6.6
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/docs/contract.md +2 -2
- package/package.json +1 -1
- package/scripts/_common/asr.py +78 -39
- package/scripts/_common/color.py +4 -3
- package/scripts/_common/decision.py +21 -5
- package/scripts/_common/probe.py +38 -8
- package/scripts/_common/runner.py +73 -3
- package/scripts/_contract.py +2 -0
- package/scripts/audio.py +22 -2
- package/scripts/batch.py +40 -6
- package/scripts/caption.py +31 -17
- package/scripts/check.py +5 -1
- package/scripts/crop.py +3 -1
- package/scripts/cropdetect.py +2 -2
- package/scripts/cut.py +40 -9
- package/scripts/export.py +13 -8
- package/scripts/join.py +5 -5
- package/scripts/look.py +4 -4
- package/scripts/loudness.py +8 -3
- package/scripts/metadata.py +1 -1
- package/scripts/render.py +35 -5
- package/scripts/report.py +4 -2
- package/scripts/scenes.py +2 -2
- package/scripts/silence.py +12 -7
- package/scripts/stabilize.py +5 -2
- package/scripts/sync.py +7 -3
package/scripts/render.py
CHANGED
|
@@ -75,7 +75,7 @@ from typing import Any, Dict, List, Optional, Sequence
|
|
|
75
75
|
from export import PRESETS, PLATFORM_OF
|
|
76
76
|
from _platforms import PLATFORMS, caption_defaults, resolve as resolve_platform
|
|
77
77
|
from _common import guard_sidecar, STATE, add_common, aspect_ratio, brand_caption_style, load_brand, apply_common, child_args, die, emit, info, option_args, probe, require_tool, run, run_tool, place_output, refuse_output_is_input, _check_existing_output, _check_output_path, fingerprint, PLAN_VERSION, ffmpeg_version, X264_PRESETS
|
|
78
|
-
from _common.runner import HW_ENV, HW_FORCED_ENV, add_hw_orchestrator_args, child_exit_code, found_exe, kept_clause, kept_deliverables, mark_delivered, remember_written
|
|
78
|
+
from _common.runner import HW_ENV, HW_FORCED_ENV, add_hw_orchestrator_args, child_exit_code, found_exe, kept_clause, kept_deliverables, mark_delivered, read_json, remember_written
|
|
79
79
|
from _common.emit import absorb_stage, audit, record_fallback, record_skip, refuse, warn
|
|
80
80
|
from _common.decision import rebase_project_paths
|
|
81
81
|
import subprocess
|
|
@@ -182,7 +182,7 @@ def load_template(name: str) -> Dict[str, Any]:
|
|
|
182
182
|
if not path.is_file():
|
|
183
183
|
die(f"unknown template {name!r}", hint="templates: " + ", ".join(template_names()) + " (or 'all')")
|
|
184
184
|
try:
|
|
185
|
-
return
|
|
185
|
+
return read_json(path)
|
|
186
186
|
except (OSError, ValueError) as exc:
|
|
187
187
|
die(f"cannot read template {path}: {exc}")
|
|
188
188
|
return {}
|
|
@@ -504,7 +504,7 @@ def project_input_files(proj: Dict[str, Any], rel: Any) -> List[str]:
|
|
|
504
504
|
# the brand's own logo and font_file are read by the overlay and caption stages, resolved
|
|
505
505
|
# against the brand file as load_brand() resolves them; an unreadable brand is left to them
|
|
506
506
|
try:
|
|
507
|
-
data =
|
|
507
|
+
data = read_json(rel(brand))
|
|
508
508
|
except (OSError, ValueError):
|
|
509
509
|
data = None
|
|
510
510
|
if isinstance(data, dict):
|
|
@@ -565,6 +565,14 @@ def validate_project(proj: Dict[str, Any], timeline: bool = False, stop_after: O
|
|
|
565
565
|
require_object(proj.get(name), name)
|
|
566
566
|
check_keys(proj.get(name), name, name)
|
|
567
567
|
check_keys((proj.get("audio") or {}).get("stems"), "audio.stems", "audio.stems")
|
|
568
|
+
# the values the run reads with .get() or as a dict key: a list there was a traceback (A13)
|
|
569
|
+
audio = proj.get("audio")
|
|
570
|
+
if "audio" in read and isinstance(audio, dict):
|
|
571
|
+
require_object(audio.get("stems"), "audio.stems")
|
|
572
|
+
export = proj.get("export")
|
|
573
|
+
if "export" in read and isinstance(export, dict) and export.get("preset") and not isinstance(export["preset"], str):
|
|
574
|
+
die(f"export.preset: must be a preset name (a string), got {type(export['preset']).__name__} "
|
|
575
|
+
f"{export['preset']!r:.60}")
|
|
568
576
|
if isinstance(proj.get("chapters"), list):
|
|
569
577
|
# Every other project error is raised here, before the first ffmpeg call; a chapter typo
|
|
570
578
|
# found inside the last stage costs a whole render and leaves an unchaptered file behind.
|
|
@@ -590,6 +598,14 @@ def validate_project(proj: Dict[str, Any], timeline: bool = False, stop_after: O
|
|
|
590
598
|
# every clip stage starts from rel(c["src"]): without one it was a KeyError traceback
|
|
591
599
|
if not item.get("src"):
|
|
592
600
|
die(f"clips[{i}]: no src")
|
|
601
|
+
if item.get("speed") and not timeline:
|
|
602
|
+
# the clips stage reads float(speed): "fast" or [2] was a traceback there (A13)
|
|
603
|
+
try:
|
|
604
|
+
spd = float(item["speed"])
|
|
605
|
+
except (TypeError, ValueError):
|
|
606
|
+
spd = float("nan")
|
|
607
|
+
if not (0 < spd < float("inf")):
|
|
608
|
+
die(f"clips[{i}]: speed must be a positive number, got {item['speed']!r:.60}")
|
|
593
609
|
if item.get("snap") is not None:
|
|
594
610
|
# the render reads a clip's snap only on a clip it cuts (in/out), the export never
|
|
595
611
|
if not timeline and (item.get("in") is not None or item.get("out") is not None):
|
|
@@ -984,6 +1000,19 @@ def execute_plan(plan: Dict[str, Any], path: str) -> int:
|
|
|
984
1000
|
_reject_plan(path, digest, "plan_version_unsupported",
|
|
985
1001
|
f"{path}: plan_version {plan.get('plan_version')!r} is not {PLAN_VERSION}",
|
|
986
1002
|
plan_version=plan.get("plan_version"), supported=[PLAN_VERSION])
|
|
1003
|
+
# the fields below are walked as lists of objects or strings: a wrong type was a traceback
|
|
1004
|
+
# with no audit record (A14), and is refused like every other plan error
|
|
1005
|
+
for field, want, item in (("inputs", list, None), ("unbound_inputs", list, None), ("verify", list, dict),
|
|
1006
|
+
("commands", list, str), ("cwd", str, None)):
|
|
1007
|
+
value = plan.get(field)
|
|
1008
|
+
if value is None:
|
|
1009
|
+
continue
|
|
1010
|
+
bad = not isinstance(value, want) or (item is not None and not all(isinstance(v, item) for v in value))
|
|
1011
|
+
if bad:
|
|
1012
|
+
shape = {"inputs": "a list", "unbound_inputs": "a list", "verify": "a list of objects",
|
|
1013
|
+
"commands": "a list of strings", "cwd": "a string"}[field]
|
|
1014
|
+
_reject_plan(path, digest, "plan_field_invalid", f"{path}: {field} must be {shape}, got {type(value).__name__}",
|
|
1015
|
+
field=field, field_type=type(value).__name__)
|
|
987
1016
|
tool = str(plan.get("tool") or "")
|
|
988
1017
|
script = HERE / f"{tool}.py"
|
|
989
1018
|
if not re.fullmatch(r"[a-z][a-z0-9_]*", tool) or not script.exists() or tool == "render":
|
|
@@ -1238,9 +1267,10 @@ def main() -> int:
|
|
|
1238
1267
|
if value:
|
|
1239
1268
|
die(f"{flag} belongs to --template NAME INPUT; a project.json states it in the project itself")
|
|
1240
1269
|
try:
|
|
1241
|
-
|
|
1270
|
+
# read_json(): nesting too deep is a ValueError too, refused here, not a RecursionError
|
|
1271
|
+
proj = read_json(args.project)
|
|
1242
1272
|
except (OSError, ValueError) as exc:
|
|
1243
|
-
die(f"cannot read project: {exc}")
|
|
1273
|
+
die(f"cannot read project: {exc}", kind="input")
|
|
1244
1274
|
if not isinstance(proj, dict):
|
|
1245
1275
|
die(f"{args.project}: not a project or plan object (top level is {type(proj).__name__})")
|
|
1246
1276
|
if "plan_version" in proj:
|
package/scripts/report.py
CHANGED
|
@@ -126,7 +126,7 @@ def pack_report(args) -> int:
|
|
|
126
126
|
text = read_text_or_die(args.pack, "--pack")
|
|
127
127
|
rows: List[List[str]] = []
|
|
128
128
|
header: List[str] = []
|
|
129
|
-
for line in text.
|
|
129
|
+
for line in text.split("\n"):
|
|
130
130
|
line = line.strip()
|
|
131
131
|
if not line.startswith("|"):
|
|
132
132
|
continue
|
|
@@ -233,7 +233,9 @@ def main() -> int:
|
|
|
233
233
|
if meta and meta.get("video"):
|
|
234
234
|
sheets[which], why = sheet_b64(path)
|
|
235
235
|
section("contact_sheet", which, why, f"{which} contact sheet")
|
|
236
|
-
|
|
236
|
+
# one per line, split at "\n" only (a command may hold U+2028 or \f; splitlines() split it)
|
|
237
|
+
command_text = read_text_or_die(args.commands, "--commands") if args.commands else ""
|
|
238
|
+
commands = command_text.removesuffix("\n").split("\n") if command_text else []
|
|
237
239
|
notes = read_text_or_die(args.notes, "--notes") if args.notes else ""
|
|
238
240
|
title = args.title or f"Delivery report — {Path(args.after).name}"
|
|
239
241
|
|
package/scripts/scenes.py
CHANGED
|
@@ -30,7 +30,7 @@ from _common import drawtext_fontfile # noqa: E402
|
|
|
30
30
|
from _common import ffmpeg_filters, filter_missing_hint # noqa: E402
|
|
31
31
|
from _common.emit import note, record_fallback # noqa: E402
|
|
32
32
|
from _common.color import bt709_label_filter # noqa: E402
|
|
33
|
-
from _common.probe import square_pixels # noqa: E402
|
|
33
|
+
from _common.probe import PROBED_VIDEO, square_pixels # noqa: E402
|
|
34
34
|
from _common import dry_run_input_pending # noqa: E402
|
|
35
35
|
|
|
36
36
|
SHOT_FPS = 4.0 # frames/second sampled for --shots' flow estimate
|
|
@@ -348,7 +348,7 @@ def main() -> int:
|
|
|
348
348
|
vf = (f"select='{expr}'," + (lab + "," if lab else "") + (square + "," if square else "")
|
|
349
349
|
+ f"scale={tile_w}:-2{stamp},"
|
|
350
350
|
f"tile={cols}x{rows}:padding=2:margin=2:color=0x202020")
|
|
351
|
-
run(ffmpeg_base() + ["-i", args.input, "-an", "-vf", vf, "-frames:v", "1", "-fps_mode", "vfr", "-update", "1", args.sheet])
|
|
351
|
+
run(ffmpeg_base() + ["-i", args.input, "-map", f"0:{PROBED_VIDEO}", "-an", "-vf", vf, "-frames:v", "1", "-fps_mode", "vfr", "-update", "1", args.sheet])
|
|
352
352
|
info(f"wrote {args.sheet}")
|
|
353
353
|
result["sheet"] = args.sheet
|
|
354
354
|
|
package/scripts/silence.py
CHANGED
|
@@ -28,11 +28,13 @@ from _common import (filler_spans, FILLER_WORDS, FILLER_AMBIGUOUS, FILLER_DISCOU
|
|
|
28
28
|
FILLER_PAD, read_text_or_die)
|
|
29
29
|
from _common import asr as _asr
|
|
30
30
|
from _common.emit import warn
|
|
31
|
-
from _common.
|
|
31
|
+
from _common.asr import _dicts
|
|
32
|
+
from _common.runner import parse_json, replace_checked
|
|
32
33
|
from _common import guard_sidecar, refuse_existing_outputs, refuse_output_is_input, _check_output_path
|
|
33
34
|
from _common.emit import refuse
|
|
34
35
|
from _common import detect_silences as detect, STATE, add_common, apply_common, default_output, die, emit, info, is_audio_output, print_json, probe, X264_PRESETS, measured_level_dbfs, fmt_secs
|
|
35
36
|
from _common.decision import frame_rate, stream_starts
|
|
37
|
+
from _common.probe import PROBED_AUDIO
|
|
36
38
|
|
|
37
39
|
|
|
38
40
|
|
|
@@ -115,7 +117,7 @@ def sentence_words(args) -> "Tuple[list, str]":
|
|
|
115
117
|
return usable(args._words, args._words_source)
|
|
116
118
|
if args.words:
|
|
117
119
|
try:
|
|
118
|
-
raw =
|
|
120
|
+
raw = parse_json(read_text_or_die(args.words, "--words"))
|
|
119
121
|
except ValueError as exc:
|
|
120
122
|
die(f"--words {args.words}: not readable JSON ({exc})", kind="input")
|
|
121
123
|
words, _had_segments, _untimed = _words_from_transcript(raw)
|
|
@@ -304,7 +306,8 @@ def cut_levels(path: str, tm: Dict[str, Any], duration: float,
|
|
|
304
306
|
out = []
|
|
305
307
|
with tempfile.TemporaryDirectory(prefix="silence-levels-") as tmp:
|
|
306
308
|
raw = os.path.join(tmp, "pcm.s16le")
|
|
307
|
-
proc = run_analysis([require_tool("ffmpeg"), "-hide_banner", "-loglevel", "error", "-nostdin", "-i", path,
|
|
309
|
+
proc = run_analysis([require_tool("ffmpeg"), "-hide_banner", "-loglevel", "error", "-nostdin", "-i", path,
|
|
310
|
+
"-map", f"0:{PROBED_AUDIO}", "-vn",
|
|
308
311
|
"-ac", str(channels), "-ar", str(CUT_LEVEL_RATE), "-f", "s16le", "-y", raw],
|
|
309
312
|
check=False, text=False)
|
|
310
313
|
if proc.returncode != 0 or not os.path.isfile(raw) or not os.path.getsize(raw):
|
|
@@ -348,7 +351,7 @@ def resolve_filler(args, meta):
|
|
|
348
351
|
untimed: list = [] # transcript words without a usable timing (a --words file can carry them)
|
|
349
352
|
if args.words:
|
|
350
353
|
try:
|
|
351
|
-
raw =
|
|
354
|
+
raw = parse_json(read_text_or_die(args.words, "--words"))
|
|
352
355
|
except ValueError as exc:
|
|
353
356
|
die(f"--words {args.words}: not readable JSON ({exc})", kind="input")
|
|
354
357
|
source = f"whisper-json:{args.words}"
|
|
@@ -484,11 +487,13 @@ def _words_from_transcript(data):
|
|
|
484
487
|
listed word it could not cut instead of dropping it unseen."""
|
|
485
488
|
raw, had_segments = [], False
|
|
486
489
|
if isinstance(data, dict):
|
|
487
|
-
|
|
488
|
-
|
|
490
|
+
# a "words" or "segments" that is not a list, and a segment that is not an object, carry no
|
|
491
|
+
# words: the file is then refused for having none, not by a traceback
|
|
492
|
+
raw = _dicts(data.get("words"))
|
|
493
|
+
segments = _dicts(data.get("segments"))
|
|
489
494
|
had_segments = bool(segments)
|
|
490
495
|
for seg in segments:
|
|
491
|
-
raw.extend((seg
|
|
496
|
+
raw.extend(_dicts(seg.get("words")))
|
|
492
497
|
elif isinstance(data, list):
|
|
493
498
|
raw = list(data)
|
|
494
499
|
out, untimed = [], []
|
package/scripts/stabilize.py
CHANGED
|
@@ -27,6 +27,8 @@ import sys
|
|
|
27
27
|
import tempfile
|
|
28
28
|
from pathlib import Path
|
|
29
29
|
|
|
30
|
+
from _common.probe import PROBED_VIDEO
|
|
31
|
+
from _common.runner import default_selection_maps
|
|
30
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
|
|
31
33
|
|
|
32
34
|
|
|
@@ -79,8 +81,9 @@ def main() -> int:
|
|
|
79
81
|
# A frame number, not a boolean: frame 1 is the standard reference for "lock to
|
|
80
82
|
# this fixed frame" (mirrors vidstabtransform's own tripod=1 below).
|
|
81
83
|
detect_vf += ":tripod=1"
|
|
84
|
+
# both passes read the picture probe() describes (its size chose the even scale above)
|
|
82
85
|
detect_cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i", args.input,
|
|
83
|
-
"-vf", detect_vf, "-f", "null", "-"]
|
|
86
|
+
"-map", f"0:{PROBED_VIDEO}", "-vf", detect_vf, "-f", "null", "-"]
|
|
84
87
|
# Pass 1 is a measurement into a temp file (the transforms), so it runs under --dry-run
|
|
85
88
|
# as well; only pass 2, the write, is skipped there.
|
|
86
89
|
if not dry_run_input_pending(args.input):
|
|
@@ -94,7 +97,7 @@ def main() -> int:
|
|
|
94
97
|
# Equivalent to relative=0:smoothing=0 -- overrides --smoothing, since averaging a
|
|
95
98
|
# camera path makes no sense once every frame is locked to one fixed reference.
|
|
96
99
|
transform_vf += ":tripod=1"
|
|
97
|
-
cmd = ffmpeg_base() + ["-i", args.input, "-vf", transform_vf]
|
|
100
|
+
cmd = ffmpeg_base() + ["-i", args.input] + default_selection_maps(meta, output) + ["-vf", transform_vf]
|
|
98
101
|
cmd += video_args(meta, args.crf, args.preset)
|
|
99
102
|
cmd += cfr_args(meta)
|
|
100
103
|
if has_audio:
|
package/scripts/sync.py
CHANGED
|
@@ -37,6 +37,7 @@ from _common import video_args, add_common, apply_common, emit, aac_args, audio_
|
|
|
37
37
|
from _common.emit import record_skip, warn
|
|
38
38
|
from _common.decision import SEEK_MARGIN, frame_rate, grid_at_or_after, stream_starts
|
|
39
39
|
from _common.timeline import output_timecode_args
|
|
40
|
+
from _common.runner import default_selection_maps
|
|
40
41
|
|
|
41
42
|
SR = 8000 # decode sample rate
|
|
42
43
|
|
|
@@ -392,14 +393,15 @@ def main() -> int:
|
|
|
392
393
|
# Even a keyframe on the trim point moved the picture by its B-frame delay (+69 ms).
|
|
393
394
|
# The re-encode below starts on the frame itself; a sound-only file is still copied.
|
|
394
395
|
if head_trim > 0 and not drift_af and not has_video:
|
|
395
|
-
|
|
396
|
+
# the second file's first audio track: the one decode_pcm_mono() measured
|
|
397
|
+
cmd = ffmpeg_base() + ["-ss", f"{head_trim:.4f}", "-i", args.second] + default_selection_maps(second_meta, output) + ["-c", "copy"]
|
|
396
398
|
if os.path.splitext(output)[1].lower() in (".mp4", ".mov", ".m4v"):
|
|
397
399
|
cmd += ["-movflags", "+faststart"]
|
|
398
400
|
cmd += ["-avoid_negative_ts", "make_zero", output]
|
|
399
401
|
proc = run(cmd, check=False)
|
|
400
402
|
fail_if_classified(proc)
|
|
401
403
|
if proc.returncode != 0:
|
|
402
|
-
cmd = ffmpeg_base() + ["-ss", f"{head_trim:.4f}", "-i", args.second] + (video_args(probe(args.reference) if args.replace_audio else probe(args.second), args.crf) if has_video else []) + audio_codec_for(output) + [output]
|
|
404
|
+
cmd = ffmpeg_base() + ["-ss", f"{head_trim:.4f}", "-i", args.second] + default_selection_maps(second_meta, output) + (video_args(probe(args.reference) if args.replace_audio else probe(args.second), args.crf) if has_video else []) + audio_codec_for(output) + [output]
|
|
403
405
|
run(cmd)
|
|
404
406
|
else:
|
|
405
407
|
# Seek SEEK_MARGIN early and drop the margin on the output side, as cut.py's
|
|
@@ -415,7 +417,9 @@ def main() -> int:
|
|
|
415
417
|
cmd += ["-ss", f"{head_trim - m:.6f}"]
|
|
416
418
|
if m > 0:
|
|
417
419
|
out_seek = ["-ss", f"{m / (drift_ratio if drift_af else 1.0):.6f}"]
|
|
418
|
-
|
|
420
|
+
# -map after the output-side -ss: both are output options, so the order is the
|
|
421
|
+
# same command, and the seek stays next to the -i it drops the margin from
|
|
422
|
+
cmd += ["-i", args.second] + out_seek + default_selection_maps(second_meta, output)
|
|
419
423
|
af_parts = on_clock + drift_af
|
|
420
424
|
if delay_ms > 0:
|
|
421
425
|
af_parts.append(f"adelay={delay_ms}:all=1")
|