ffmpeg-skill 2.5.0 → 2.6.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.
@@ -35,7 +35,7 @@ from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as
35
35
  from _common import (aac_args, add_common, brand_caption_style, script_font_for_text, apply_common, cfr_args,
36
36
  color_hex, default_font_file, default_output, die, emit, escape_drawtext, escape_filter_path,
37
37
  ffmpeg_base, info, load_brand, parse_time, probe, run, run_keeping_subtitles, video_args,
38
- drawtext_boxborderw, X264_PRESETS, time_arg, fmt_secs, STATE, drawtext_text_opts,
38
+ drawtext_boxborderw, drawtext_center_align, X264_PRESETS, time_arg, fmt_secs, STATE, drawtext_text_opts,
39
39
  LANGUAGE_NAMES, needs_shaping, detect_script, BIDI_SCRIPTS, font_family_of_file, font_family_for_script, has_emoji,
40
40
  emoji_clusters, emoji_codepoint_name, char_script, emoji_filter_chain, emoji_asset_for, emoji_support, resolve_emoji_assets,
41
41
  EMOJI_ASSET_HINT, text_width_em, drawtext_shaping, wrap_text, WRAP_MODES,
@@ -356,14 +356,14 @@ def main() -> int:
356
356
  h2 = int(base * 0.045)
357
357
  filters.append(f"drawbox=x=0:y=0:w=iw:h=ih:color={ff_color(bg, 0.55)}:t=fill:{en}")
358
358
  add_text(args.title,
359
- f"drawtext={drawtext_text_opts(args.title)}:{fo}:fontsize={h1}:fontcolor={ff_color(text_c)}:x=(w-text_w)/2:y=(h-text_h)/2-{h2 if args.subtitle else 0}:alpha='{fade_a}':{en}",
359
+ f"drawtext={drawtext_text_opts(args.title)}:{fo}:fontsize={h1}:fontcolor={ff_color(text_c)}{drawtext_center_align()}:x=(w-text_w)/2:y=(h-text_h)/2-{h2 if args.subtitle else 0}:alpha='{fade_a}':{en}",
360
360
  size=h1, color=text_c, font=ass_font_family(), align=5, x=W / 2,
361
361
  y=H / 2 - (h2 if args.subtitle else 0), outline=max(1.0, h1 / 20.0),
362
362
  outline_color="000000", start=s, end=e, fade=(300, 300))
363
363
  filters.append(f"drawbox=x=(iw-{int(base * 0.12)})/2:y=(ih)/2+{h1 // 2 + (0 if args.subtitle else 0)}:w={int(base * 0.12)}:h={max(2, int(base * 0.006))}:color={ff_color(primary)}:t=fill:{en}")
364
364
  if args.subtitle:
365
365
  add_text(args.subtitle,
366
- f"drawtext={drawtext_text_opts(args.subtitle)}:{fo}:fontsize={h2}:fontcolor={ff_color(primary)}:x=(w-text_w)/2:y=(h-text_h)/2+{h1 // 2 + int(base * 0.03)}:alpha='{fade_a}':{en}",
366
+ f"drawtext={drawtext_text_opts(args.subtitle)}:{fo}:fontsize={h2}:fontcolor={ff_color(primary)}{drawtext_center_align()}:x=(w-text_w)/2:y=(h-text_h)/2+{h1 // 2 + int(base * 0.03)}:alpha='{fade_a}':{en}",
367
367
  size=h2, color=primary, font=ass_font_family(), align=5, x=W / 2,
368
368
  y=H / 2 + h1 // 2 + int(base * 0.03), outline=max(1.0, h2 / 20.0),
369
369
  outline_color="000000", start=s, end=e, fade=(300, 300))
@@ -444,8 +444,8 @@ def main() -> int:
444
444
  filters.append(f"drawbox=x=0:y={y0}:w=iw:h={band_h}:color={ff_color(bg, 0.78)}:t=fill:{hen}")
445
445
  hook_title = wrapped(args.title, h1)
446
446
  add_text(hook_title,
447
- f"drawtext={drawtext_text_opts(hook_title)}:{fo}:fontsize={h1}:fontcolor={ff_color(text_c)}:"
448
- f"x=(w-text_w)/2:y=(h-text_h)/2:{hen}",
447
+ f"drawtext={drawtext_text_opts(hook_title)}:{fo}:fontsize={h1}:fontcolor={ff_color(text_c)}"
448
+ f"{drawtext_center_align()}:x=(w-text_w)/2:y=(h-text_h)/2:{hen}",
449
449
  size=h1, color=text_c, font=ass_font_family(), align=5, x=W / 2, y=H / 2,
450
450
  outline=max(1.0, h1 / 20.0), outline_color="000000", start=s, end=he)
451
451
  filters.append(f"drawbox=x=0:y=0:w='iw*max(0,1-(t-{s:.3f})/{max(0.001, he - s):.3f})':h={bar_h}:"
@@ -466,7 +466,7 @@ def main() -> int:
466
466
  meme_line = wrapped(text.upper(), fs)
467
467
  add_text(meme_line,
468
468
  f"drawtext={drawtext_text_opts(meme_line)}:{fo}:fontsize={fs}:fontcolor={white}:"
469
- f"borderw={bw}:bordercolor={black}:x=(w-text_w)/2:y={y}:{en}",
469
+ f"borderw={bw}:bordercolor={black}{drawtext_center_align()}:x=(w-text_w)/2:y={y}:{en}",
470
470
  size=fs, color="FFFFFF", font=ass_font_family(), bold=True,
471
471
  align=(8 if y == f"{m_top}" else 2), x=W / 2,
472
472
  y=(m_top if y == f"{m_top}" else H - m_bottom),
package/scripts/render.py CHANGED
@@ -65,6 +65,8 @@ from typing import Any, Dict, List, Optional, Sequence
65
65
  from export import PRESETS, PLATFORM_OF
66
66
  from _platforms import PLATFORMS, caption_defaults, resolve as resolve_platform
67
67
  from _common import STATE, add_common, aspect_ratio, brand_caption_style, load_brand, apply_common, child_args, die, emit, info, probe, require_tool, run, run_tool, place_output, refuse_output_is_input, _check_existing_output, _check_output_path, fingerprint, PLAN_VERSION, ffmpeg_version
68
+ from _common.runner import HW_ENV, HW_FORCED_ENV, add_hw_orchestrator_args
69
+ from _common.emit import absorb_stage_hw
68
70
  import subprocess
69
71
  from _contract import CONTRACT_VERSION
70
72
  from batch import file_key
@@ -354,6 +356,7 @@ def render_pack(names: List[str], args) -> int:
354
356
  if line.startswith("$ ") or line.startswith("[dry-run]"):
355
357
  STATE.commands.append(line[2:] if line.startswith("$ ") else line)
356
358
  STATE.commands.extend(str(c) for c in (doc.get("commands") or []))
359
+ absorb_stage_hw(name, doc)
357
360
  out = doc.get("output") or dest_out
358
361
  chk = doc.get("check") or {}
359
362
  ok = proc.returncode == 0 and doc.get("status") == "completed"
@@ -541,16 +544,29 @@ def sh(script: str, *argv: Any, extra: List[str] = None, stage: str = None) -> s
541
544
  # needs (a timeout inside audio.py is a timeout, not an "input" error of render.py).
542
545
  err = doc.get("error") or {}
543
546
  extra_fields = {"hint": err["hint"]} if err.get("hint") else {}
547
+ extra_fields.update(_stage_failure_hw(Path(script).stem, doc))
544
548
  die(f"{script} failed: {err.get('message') or (proc.stderr.strip().splitlines() or ['?'])[-1][:300]}",
545
549
  code=int(doc.get("exit_code") or 1), kind=err.get("kind") or "input", stage=script, **extra_fields)
546
550
  _LAST_DOC.clear()
547
551
  _LAST_DOC.update(doc if isinstance(doc, dict) else {})
552
+ absorb_stage_hw(Path(script).stem, doc) # a stage's GPU facts (ran on VideoToolbox, fell back and why) reach this result
548
553
  out_path = str(doc.get("output") or "")
549
554
  if key:
550
555
  cache_store(stage, key, out_path or (dest or ""), time.time() - started)
551
556
  return out_path
552
557
 
553
558
 
559
+ def _stage_failure_hw(stage: str, doc: Any) -> Dict[str, Any]:
560
+ """A failed stage's GPU facts, for render's own failure document: the stage's `hw` joins the
561
+ earlier stages' (a fallback before the failure is not lost), and its `encoder` -- the command
562
+ that failed -- is forwarded. die() then adds `hw` for a kind: ffmpeg failure, as a tool's own
563
+ run() failure carries it."""
564
+ if not isinstance(doc, dict):
565
+ return {}
566
+ absorb_stage_hw(stage, doc)
567
+ return {"encoder": doc["encoder"]} if doc.get("encoder") else {}
568
+
569
+
554
570
  # ------------------------------------------------------------------ the stage cache (1.17)
555
571
  #
556
572
  # Opt-in only: --cache DIR. There is no default directory -- a cache that appears on someone's
@@ -623,6 +639,10 @@ def cache_key(stage: str, script: str, argv: "Sequence[Any]", inputs: "Sequence[
623
639
  "inputs": [{"hash": _content_hash(p)} for p in inputs],
624
640
  "child": [a for a in child_args() if a != "--dry-run"],
625
641
  "codec": STATE.codec, "ext": Path(dest).suffix if dest else None,
642
+ # what a stage's encoder resolves from: $FFMPEG_SKILL_HW (inherited) and whether the outer
643
+ # command made it explicit (--hw/--no-hw, which reaches export.py too). A GPU encode is not
644
+ # the CPU's bytes, so a --hw artifact is never served to a CPU run or the other way round.
645
+ "hw": [os.environ.get(HW_ENV, "").strip().lower() in ("1", "true", "yes", "on"), os.environ.get(HW_FORCED_ENV) == "1"],
626
646
  "ffmpeg": CACHE.get("ffmpeg"), "skill": SKILL_VERSION, "contract": CONTRACT_VERSION,
627
647
  }
628
648
  return hashlib.sha1(json.dumps(payload, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
@@ -724,7 +744,8 @@ def execute_plan(plan: Dict[str, Any], path: str) -> int:
724
744
  if proc.returncode != 0 or doc.get("status") != "completed":
725
745
  err = doc.get("error") or {}
726
746
  die(f"{tool} failed while executing the plan: {err.get('message') or proc.stderr.strip()[-300:]}",
727
- kind=err.get("kind", "ffmpeg"), plan=path, tool=tool)
747
+ kind=err.get("kind", "ffmpeg"), plan=path, tool=tool, **_stage_failure_hw(tool, doc))
748
+ absorb_stage_hw(tool, doc) # the tool's GPU facts (fell back and why, ran on VideoToolbox) reach this result
728
749
  output = doc.get("output") or plan.get("output")
729
750
  check_result = None
730
751
  exit_code = 0
@@ -863,6 +884,7 @@ def main() -> int:
863
884
  "or 'all' its directory is where the pack is written")
864
885
  tpl.add_argument("--write-project", metavar="FILE", help="write the filled project.json for editing and stop (no render)")
865
886
  add_common(ap)
887
+ add_hw_orchestrator_args(ap)
866
888
  args = ap.parse_args()
867
889
  apply_common(args)
868
890
 
@@ -38,6 +38,7 @@ from _platforms import PLATFORMS, PLATFORM_CHOICES, resolve as resolve_platform
38
38
  from _common import (add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base,
39
39
  info, load_brand, pad_filters, probe, run, STATE, validate_color, video_args, X264_PRESETS,
40
40
  fmt_secs, child_args, run_tool, measured_level_dbfs)
41
+ from _common.emit import absorb_stage_hw
41
42
 
42
43
  WAVEFORM_MODES = ["point", "line", "p2p", "cline"]
43
44
 
@@ -307,20 +308,30 @@ def _child(script_name: str, argv: "list") -> None:
307
308
  """Run one of this skill's own tools as a second process, so the code path it owns (the ASS
308
309
  generator, the drawtext template) stays the only one there is. The shared flags (--overwrite,
309
310
  --timeout, --fast, --dry-run) reach it through child_args(), as render.py's stages get them,
310
- and a failure is re-raised with the child's own kind, exit code and hint."""
311
+ and a failure is re-raised with the child's own kind, exit code and hint.
312
+
313
+ The GPU choice reaches it too: an explicit --hw/--no-hw is passed on (the child would
314
+ otherwise resolve $FFMPEG_SKILL_HW for itself, and the last stage writes the deliverable),
315
+ and the child's commands and `hw` facts join this result, so `encoder`/`hw` describe the
316
+ file delivered rather than the intermediate render."""
311
317
  here = os.path.dirname(os.path.abspath(__file__))
312
- cmd = [os.path.join(here, script_name)] + [str(a) for a in argv] + child_args() + ["--json"]
318
+ hw = (["--hw"] if STATE.hw else ["--no-hw"]) if STATE.hw_source == "flag" else []
319
+ cmd = [os.path.join(here, script_name)] + [str(a) for a in argv] + hw + child_args() + ["--json"]
313
320
  info("-> " + " ".join(os.path.basename(c) if c.endswith(".py") else str(c) for c in cmd[:-1]))
314
321
  proc = run_tool(cmd)
322
+ try:
323
+ doc = json.loads(proc.stdout.strip() or "{}")
324
+ except ValueError:
325
+ doc = {}
326
+ if not isinstance(doc, dict):
327
+ doc = {}
328
+ STATE.commands.extend(str(c) for c in (doc.get("commands") or []))
329
+ absorb_stage_hw(os.path.splitext(script_name)[0], doc)
315
330
  if proc.returncode != 0:
316
- try:
317
- doc = json.loads(proc.stdout.strip() or "{}")
318
- except ValueError:
319
- doc = {}
320
- if not isinstance(doc, dict):
321
- doc = {}
322
331
  err = doc.get("error") or {}
323
332
  extra = {"hint": err["hint"]} if err.get("hint") else {}
333
+ if doc.get("encoder"):
334
+ extra["encoder"] = doc["encoder"]
324
335
  die(f"{script_name} failed: {err.get('message') or (proc.stderr.strip().splitlines() or ['?'])[-1][:300]}",
325
336
  code=int(doc.get("exit_code") or 1), kind=err.get("kind") or "input", stage=script_name, **extra)
326
337