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.
- package/README.md +2 -2
- package/SKILL.md +3 -3
- package/docs/contract.md +30 -8
- package/package.json +1 -1
- package/references/gotchas.md +7 -1
- package/references/scripts.md +1 -1
- package/scripts/_common/__init__.py +2 -2
- package/scripts/_common/decision.py +125 -2
- package/scripts/_common/drawtext.py +14 -3
- package/scripts/_common/emit.py +145 -1
- package/scripts/_common/runner.py +258 -13
- package/scripts/_common/text.py +1 -1
- package/scripts/_contract.py +30 -5
- package/scripts/batch.py +30 -1
- package/scripts/export.py +10 -1
- package/scripts/graphics.py +6 -6
- package/scripts/render.py +23 -1
- package/scripts/waveform.py +19 -8
package/scripts/graphics.py
CHANGED
|
@@ -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
|
|
package/scripts/waveform.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|