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/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 json.loads(path.read_text(encoding="utf-8"))
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 = json.loads(Path(rel(brand)).read_text(encoding="utf-8"))
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
- proj = json.loads(Path(args.project).read_text(encoding="utf-8"))
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.splitlines():
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
- commands = read_text_or_die(args.commands, "--commands").splitlines() if args.commands else []
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
 
@@ -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.runner import replace_checked
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 = json.loads(read_text_or_die(args.words, "--words"))
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, "-vn",
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 = json.loads(read_text_or_die(args.words, "--words"))
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
- raw = list(data.get("words") or [])
488
- segments = data.get("segments") or []
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 or {}).get("words") or [])
496
+ raw.extend(_dicts(seg.get("words")))
492
497
  elif isinstance(data, list):
493
498
  raw = list(data)
494
499
  out, untimed = [], []
@@ -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
- cmd = ffmpeg_base() + ["-ss", f"{head_trim:.4f}", "-i", args.second, "-c", "copy"]
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
- cmd += ["-i", args.second] + out_seek
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")