update addons (for 5.2)
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ schema_version = "1.0.0"
|
||||
id = "basedplayblast"
|
||||
name = "BasedPlayblast"
|
||||
tagline = "Easily create playblasts from Blender and Flamenco"
|
||||
version = "2.6.3"
|
||||
version = "2.8.1"
|
||||
type = "add-on"
|
||||
|
||||
maintainer = "RaincloudTheDragon <raincloudthedragon@gmail.com>"
|
||||
|
||||
Binary file not shown.
@@ -5,8 +5,8 @@ Grouped here so Blender version/compatibility helpers stay isolated from the
|
||||
main add-on module.
|
||||
"""
|
||||
|
||||
from . import version, compat
|
||||
from . import version, compat, encode, ffmpeg_path
|
||||
|
||||
__all__ = ["version", "compat"]
|
||||
__all__ = ["version", "compat", "encode", "ffmpeg_path"]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""FFmpeg video encode helpers for BasedPlayblast."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
ENCODE_SPEED_ITEMS = [
|
||||
('FASTEST', "Fastest", "Fastest encode; lowest quality preset"),
|
||||
('FAST', "Fast", "Fast encode"),
|
||||
('MEDIUM', "Medium", "Balanced encode speed"),
|
||||
('SLOW', "Slow", "Slower encode with better compression efficiency"),
|
||||
('SLOWEST', "Slowest", "Slowest preset; best detail preservation (default)"),
|
||||
]
|
||||
|
||||
_NVENC_PRESETS = {
|
||||
'FASTEST': 'p1',
|
||||
'FAST': 'p3',
|
||||
'MEDIUM': 'p5',
|
||||
'SLOW': 'p6',
|
||||
'SLOWEST': 'p7',
|
||||
}
|
||||
|
||||
_LIBX264_PRESETS = {
|
||||
'FASTEST': 'ultrafast',
|
||||
'FAST': 'veryfast',
|
||||
'MEDIUM': 'medium',
|
||||
'SLOW': 'slow',
|
||||
'SLOWEST': 'veryslow',
|
||||
}
|
||||
|
||||
_NVENC_ENCODERS = ('av1_nvenc', 'hevc_nvenc', 'h264_nvenc')
|
||||
_ENCODER_PRIORITY = _NVENC_ENCODERS + ('libx264',)
|
||||
|
||||
_ENCODER_CACHE: dict[str, str] = {}
|
||||
|
||||
_NVENC_DRIVER_MARKERS = (
|
||||
'does not support the required nvenc api version',
|
||||
'minimum required nvidia driver',
|
||||
'no nvenc capable devices found',
|
||||
'driver does not support',
|
||||
)
|
||||
|
||||
|
||||
def _encoder_list_text(ffmpeg_path: str) -> str:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[ffmpeg_path, '-hide_banner', '-encoders'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
check=False,
|
||||
)
|
||||
return (result.stdout or '') + (result.stderr or '')
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return ''
|
||||
|
||||
|
||||
def list_encoder_candidates(ffmpeg_path: str) -> list[str]:
|
||||
"""Return available encoders in preference order (NVENC family, then libx264).
|
||||
|
||||
A previously successful encoder for this ffmpeg path is tried first.
|
||||
"""
|
||||
encoders = _encoder_list_text(ffmpeg_path)
|
||||
available = [name for name in _ENCODER_PRIORITY if name in encoders]
|
||||
if not available:
|
||||
available = ['libx264']
|
||||
|
||||
cached = _ENCODER_CACHE.get(ffmpeg_path)
|
||||
if cached and cached in available:
|
||||
return [cached] + [name for name in available if name != cached]
|
||||
return available
|
||||
|
||||
|
||||
def detect_video_encoder(ffmpeg_path: str) -> str:
|
||||
"""Pick the best available encoder: av1_nvenc, hevc_nvenc, h264_nvenc, then libx264."""
|
||||
return list_encoder_candidates(ffmpeg_path)[0]
|
||||
|
||||
|
||||
def remember_working_encoder(ffmpeg_path: str, encoder: str) -> None:
|
||||
"""Cache an encoder that successfully encoded on this machine."""
|
||||
_ENCODER_CACHE[ffmpeg_path] = encoder
|
||||
|
||||
|
||||
def is_nvenc_encoder(encoder: str) -> bool:
|
||||
return encoder in _NVENC_ENCODERS
|
||||
|
||||
|
||||
def is_nvenc_driver_error(stderr: str) -> bool:
|
||||
"""True when FFmpeg failed because the NVIDIA driver is too old / incompatible."""
|
||||
text = (stderr or '').lower()
|
||||
return any(marker in text for marker in _NVENC_DRIVER_MARKERS)
|
||||
|
||||
|
||||
def _vbr_bitrate_args(bitrate_limit_mbps: int) -> list[str]:
|
||||
"""Optional NVENC VBR bitrate cap. 0 = uncapped."""
|
||||
if bitrate_limit_mbps <= 0:
|
||||
return []
|
||||
return [
|
||||
'-b:v', f'{bitrate_limit_mbps}M',
|
||||
'-maxrate', f'{bitrate_limit_mbps}M',
|
||||
'-bufsize', f'{bitrate_limit_mbps * 2}M',
|
||||
]
|
||||
|
||||
|
||||
def _nvenc_encode_args(
|
||||
encoder: str,
|
||||
encode_speed: str,
|
||||
bitrate_limit_mbps: int,
|
||||
) -> list[str]:
|
||||
"""GigaMux-style NVENC VBR: hq tune, spatial AQ, cq 0; optional bitrate cap."""
|
||||
speed = encode_speed if encode_speed in _NVENC_PRESETS else 'SLOWEST'
|
||||
args = [
|
||||
'-c:v', encoder,
|
||||
'-preset', _NVENC_PRESETS[speed],
|
||||
'-tune', 'hq',
|
||||
'-rc', 'vbr',
|
||||
'-rc-lookahead', '32',
|
||||
'-spatial-aq', '1',
|
||||
'-aq-strength', '15',
|
||||
'-cq', '0',
|
||||
]
|
||||
args.extend(_vbr_bitrate_args(bitrate_limit_mbps))
|
||||
return args
|
||||
|
||||
|
||||
def build_video_encode_args_for(
|
||||
encoder: str,
|
||||
encode_speed: str = 'SLOWEST',
|
||||
bitrate_limit_mbps: int = 0,
|
||||
) -> list[str]:
|
||||
"""Build FFmpeg video encode args for a specific encoder."""
|
||||
if encoder in _NVENC_ENCODERS:
|
||||
return _nvenc_encode_args(encoder, encode_speed, bitrate_limit_mbps)
|
||||
|
||||
# CRF/QP 0 is true lossless and forces High 4:4:4 Predictive (breaks WMP).
|
||||
# CRF 1 is the lowest lossy setting that stays High + yuv420p — nearest to NVENC cq 0.
|
||||
speed = encode_speed if encode_speed in _LIBX264_PRESETS else 'SLOWEST'
|
||||
return [
|
||||
'-c:v', 'libx264',
|
||||
'-preset', _LIBX264_PRESETS[speed],
|
||||
'-crf', '1',
|
||||
'-profile:v', 'high',
|
||||
]
|
||||
|
||||
|
||||
def build_video_encode_args(
|
||||
ffmpeg_path: str,
|
||||
encode_speed: str = 'SLOWEST',
|
||||
bitrate_limit_mbps: int = 0,
|
||||
) -> list[str]:
|
||||
"""Build FFmpeg video encode args for visually lossless playblast output."""
|
||||
encoder = detect_video_encoder(ffmpeg_path)
|
||||
return build_video_encode_args_for(
|
||||
encoder,
|
||||
encode_speed=encode_speed,
|
||||
bitrate_limit_mbps=bitrate_limit_mbps,
|
||||
)
|
||||
|
||||
|
||||
def default_custom_ffmpeg_args(ffmpeg_path: str, encode_speed: str = 'SLOWEST') -> str:
|
||||
"""Serialize the auto-detected encode args for the custom-args text field."""
|
||||
return ' '.join(build_video_encode_args(ffmpeg_path, encode_speed=encode_speed))
|
||||
@@ -0,0 +1,166 @@
|
||||
"""Locate an ffmpeg executable for BasedPlayblast."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.request
|
||||
import zipfile
|
||||
|
||||
_FFMPEG_BOOTSTRAP_ATTEMPTED = False
|
||||
|
||||
# Official Windows essentials build recommended for Blender-adjacent tooling.
|
||||
_FFMPEG_ESSENTIALS_URL = (
|
||||
"https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip"
|
||||
)
|
||||
|
||||
|
||||
def addon_root() -> str:
|
||||
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def bundled_ffmpeg_path() -> str:
|
||||
return os.path.join(addon_root(), "tools", "ffmpeg.exe")
|
||||
|
||||
|
||||
def iter_blender_bundled_ffmpeg_paths():
|
||||
"""Yield likely paths to ffmpeg shipped with this Blender install."""
|
||||
try:
|
||||
import bpy # type: ignore
|
||||
|
||||
binary = bpy.app.binary_path
|
||||
if not binary:
|
||||
return
|
||||
binary_dir = os.path.dirname(binary)
|
||||
version = f"{bpy.app.version[0]}.{bpy.app.version[1]}"
|
||||
names = ("ffmpeg.exe", "ffmpeg") if sys.platform == "win32" else ("ffmpeg",)
|
||||
|
||||
roots = [binary_dir]
|
||||
parent = os.path.dirname(binary_dir)
|
||||
if parent:
|
||||
roots.append(parent)
|
||||
if sys.platform == "darwin":
|
||||
resources = os.path.join(os.path.dirname(binary_dir), "Resources")
|
||||
if os.path.isdir(resources):
|
||||
roots.append(resources)
|
||||
roots.append(os.path.join(resources, version))
|
||||
|
||||
subdirs = (
|
||||
"",
|
||||
version,
|
||||
"ffmpeg",
|
||||
os.path.join(version, "ffmpeg"),
|
||||
os.path.join(version, "ffmpeg", "bin"),
|
||||
os.path.join("ffmpeg", "bin"),
|
||||
)
|
||||
|
||||
seen = set()
|
||||
for root in roots:
|
||||
for sub in subdirs:
|
||||
for name in names:
|
||||
path = os.path.normpath(
|
||||
os.path.join(root, sub, name) if sub else os.path.join(root, name)
|
||||
)
|
||||
if path not in seen:
|
||||
seen.add(path)
|
||||
yield path
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
def _bootstrap_bundled_ffmpeg() -> str | None:
|
||||
"""Download ffmpeg essentials into addon tools/ when no executable is found."""
|
||||
global _FFMPEG_BOOTSTRAP_ATTEMPTED
|
||||
if _FFMPEG_BOOTSTRAP_ATTEMPTED or sys.platform != "win32":
|
||||
return None
|
||||
_FFMPEG_BOOTSTRAP_ATTEMPTED = True
|
||||
|
||||
target = bundled_ffmpeg_path()
|
||||
if os.path.isfile(target):
|
||||
return target
|
||||
|
||||
tools_dir = os.path.dirname(target)
|
||||
os.makedirs(tools_dir, exist_ok=True)
|
||||
|
||||
print("[BasedPlayblast] No ffmpeg found; downloading essentials build to addon tools/...")
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="basedplayblast_ffmpeg_") as tmp_dir:
|
||||
zip_path = os.path.join(tmp_dir, "ffmpeg-essentials.zip")
|
||||
with urllib.request.urlopen(_FFMPEG_ESSENTIALS_URL, timeout=120) as response:
|
||||
with open(zip_path, "wb") as handle:
|
||||
shutil.copyfileobj(response, handle)
|
||||
|
||||
with zipfile.ZipFile(zip_path) as archive:
|
||||
member = next(
|
||||
(name for name in archive.namelist() if name.endswith("/bin/ffmpeg.exe")),
|
||||
None,
|
||||
)
|
||||
if not member:
|
||||
print("[BasedPlayblast] ffmpeg bootstrap failed: ffmpeg.exe not found in archive")
|
||||
return None
|
||||
archive.extract(member, tmp_dir)
|
||||
extracted = os.path.join(tmp_dir, member.replace("/", os.sep))
|
||||
shutil.copy2(extracted, target)
|
||||
|
||||
if os.path.isfile(target):
|
||||
print(f"[BasedPlayblast] Installed bundled ffmpeg: {target}")
|
||||
return target
|
||||
except Exception as exc:
|
||||
print(f"[BasedPlayblast] ffmpeg bootstrap failed: {exc}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def resolve_ffmpeg_path() -> str:
|
||||
"""Resolve ffmpeg: addon override, addon bundle, Blender, bootstrap, then PATH."""
|
||||
try:
|
||||
import bpy # type: ignore
|
||||
|
||||
for addon_name in (
|
||||
"BasedPlayblast",
|
||||
"basedplayblast",
|
||||
"bl_ext.basedplayblast",
|
||||
"bl_ext.user_local.basedplayblast",
|
||||
):
|
||||
prefs = bpy.context.preferences.addons.get(addon_name)
|
||||
if not prefs or not prefs.preferences:
|
||||
continue
|
||||
if hasattr(prefs.preferences, "ffmpeg_path"):
|
||||
custom = getattr(prefs.preferences, "ffmpeg_path", "").strip()
|
||||
if custom and os.path.isfile(custom):
|
||||
return custom
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
bundled = bundled_ffmpeg_path()
|
||||
if os.path.isfile(bundled):
|
||||
return bundled
|
||||
|
||||
for candidate in iter_blender_bundled_ffmpeg_paths():
|
||||
if os.path.isfile(candidate):
|
||||
return candidate
|
||||
|
||||
bootstrapped = _bootstrap_bundled_ffmpeg()
|
||||
if bootstrapped and os.path.isfile(bootstrapped):
|
||||
return bootstrapped
|
||||
|
||||
exe = shutil.which("ffmpeg")
|
||||
if exe:
|
||||
print(
|
||||
f"[BasedPlayblast] Warning: no addon-bundled ffmpeg; falling back to PATH: {exe}"
|
||||
)
|
||||
return exe
|
||||
|
||||
for candidate in (
|
||||
r"C:\ProgramData\chocolatey\bin\ffmpeg.exe",
|
||||
r"C:\Program Files\ffmpeg\bin\ffmpeg.exe",
|
||||
r"C:\Program Files (x86)\ffmpeg\bin\ffmpeg.exe",
|
||||
r"C:\ffmpeg\bin\ffmpeg.exe",
|
||||
):
|
||||
if candidate and os.path.isfile(candidate):
|
||||
return candidate
|
||||
|
||||
return "ffmpeg"
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Deploy BasedPlayblast job compiler scripts to a Flamenco Manager install."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
MANAGER_CONFIG_NAME = "flamenco-manager.yaml"
|
||||
SCRIPTS_DIR_NAME = "scripts"
|
||||
|
||||
SCRIPT_FILENAMES = (
|
||||
"BasedPlayblast.js",
|
||||
"BasedPlayblast_Optix_GPU.js",
|
||||
)
|
||||
|
||||
# Flamenco 3.7+ loads job compiler scripts on demand (no Manager restart).
|
||||
LIVE_SCRIPTS_MIN_VERSION = (3, 7, 0)
|
||||
|
||||
|
||||
def addon_root() -> str:
|
||||
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def bundled_scripts_dir() -> str:
|
||||
return os.path.join(addon_root(), "flamenco")
|
||||
|
||||
|
||||
def manager_config_path(manager_dir: str) -> str:
|
||||
return os.path.join(manager_dir, MANAGER_CONFIG_NAME)
|
||||
|
||||
|
||||
def manager_scripts_dir(manager_dir: str) -> str:
|
||||
return os.path.join(manager_dir, SCRIPTS_DIR_NAME)
|
||||
|
||||
|
||||
def read_manager_config(manager_dir: str) -> str:
|
||||
path = manager_config_path(manager_dir)
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
return handle.read()
|
||||
|
||||
|
||||
def validate_manager_dir(manager_dir: str) -> tuple[bool, str]:
|
||||
manager_dir = os.path.normpath(manager_dir)
|
||||
if not manager_dir or not os.path.isdir(manager_dir):
|
||||
return False, "Selected path is not a directory"
|
||||
|
||||
config_path = manager_config_path(manager_dir)
|
||||
if not os.path.isfile(config_path):
|
||||
return (
|
||||
False,
|
||||
f"{MANAGER_CONFIG_NAME} not found. Select the folder that contains the Flamenco Manager executable.",
|
||||
)
|
||||
|
||||
return True, config_path
|
||||
|
||||
|
||||
def _parse_listen_host_port(config_text: str) -> tuple[str, int]:
|
||||
match = re.search(r"^listen:\s*['\"]?([^'\"\n#]+)", config_text, re.MULTILINE)
|
||||
listen = match.group(1).strip() if match else ":8080"
|
||||
|
||||
if listen.startswith(":"):
|
||||
return "127.0.0.1", int(listen[1:])
|
||||
|
||||
if "://" in listen:
|
||||
listen = listen.split("://", 1)[1]
|
||||
|
||||
if ":" in listen:
|
||||
host, port_text = listen.rsplit(":", 1)
|
||||
host = host or "127.0.0.1"
|
||||
return host, int(port_text)
|
||||
|
||||
return listen or "127.0.0.1", 8080
|
||||
|
||||
|
||||
def _parse_version_string(version_text: str) -> tuple[int, int, int] | None:
|
||||
match = re.search(r"(\d+)\.(\d+)(?:\.(\d+))?", version_text)
|
||||
if not match:
|
||||
return None
|
||||
major = int(match.group(1))
|
||||
minor = int(match.group(2))
|
||||
patch = int(match.group(3) or 0)
|
||||
return major, minor, patch
|
||||
|
||||
|
||||
def query_manager_version(manager_dir: str) -> tuple[int, int, int] | None:
|
||||
"""Best-effort Manager version lookup via the local HTTP API."""
|
||||
try:
|
||||
config_text = read_manager_config(manager_dir)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
host, port = _parse_listen_host_port(config_text)
|
||||
endpoints = (
|
||||
f"http://{host}:{port}/api/v3/meta/version",
|
||||
f"http://{host}:{port}/api/v2/meta/version",
|
||||
f"http://{host}:{port}/version",
|
||||
)
|
||||
|
||||
for url in endpoints:
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=2.0) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
except (OSError, urllib.error.URLError, json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
|
||||
for key in ("version", "Version", "manager_version"):
|
||||
if key in payload:
|
||||
parsed = _parse_version_string(str(payload[key]))
|
||||
if parsed:
|
||||
return parsed
|
||||
|
||||
if isinstance(payload, dict) and "name" in payload and "version" in payload:
|
||||
parsed = _parse_version_string(str(payload["version"]))
|
||||
if parsed:
|
||||
return parsed
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def needs_manager_restart(version: tuple[int, int, int] | None) -> bool:
|
||||
if version is None:
|
||||
return False
|
||||
return version < LIVE_SCRIPTS_MIN_VERSION
|
||||
|
||||
|
||||
def deploy_scripts(manager_dir: str) -> tuple[bool, str, list[str], bool, tuple[int, int, int] | None]:
|
||||
"""
|
||||
Copy bundled Flamenco scripts into <manager>/scripts.
|
||||
|
||||
Returns:
|
||||
ok, user_message, copied_files, restart_recommended, detected_version
|
||||
"""
|
||||
ok, detail = validate_manager_dir(manager_dir)
|
||||
if not ok:
|
||||
return False, detail, [], False, None
|
||||
|
||||
source_dir = bundled_scripts_dir()
|
||||
missing_sources = [
|
||||
name for name in SCRIPT_FILENAMES
|
||||
if not os.path.isfile(os.path.join(source_dir, name))
|
||||
]
|
||||
if missing_sources:
|
||||
return False, f"Missing bundled scripts: {', '.join(missing_sources)}", [], False, None
|
||||
|
||||
try:
|
||||
read_manager_config(manager_dir)
|
||||
except OSError as exc:
|
||||
return False, f"Could not read {MANAGER_CONFIG_NAME}: {exc}", [], False, None
|
||||
|
||||
scripts_dir = manager_scripts_dir(manager_dir)
|
||||
os.makedirs(scripts_dir, exist_ok=True)
|
||||
|
||||
copied: list[str] = []
|
||||
for name in SCRIPT_FILENAMES:
|
||||
shutil.copy2(os.path.join(source_dir, name), os.path.join(scripts_dir, name))
|
||||
copied.append(name)
|
||||
|
||||
version = query_manager_version(manager_dir)
|
||||
restart = needs_manager_restart(version)
|
||||
|
||||
if version and restart:
|
||||
version_label = ".".join(str(part) for part in version)
|
||||
message = (
|
||||
f"Deployed {len(copied)} script(s) to {scripts_dir}. "
|
||||
f"Flamenco Manager {version_label} requires a restart to load new scripts "
|
||||
f"(live reload was added in 3.7)."
|
||||
)
|
||||
elif version:
|
||||
version_label = ".".join(str(part) for part in version)
|
||||
message = (
|
||||
f"Deployed {len(copied)} script(s) to {scripts_dir}. "
|
||||
f"Flamenco Manager {version_label} will load them on the next job compile."
|
||||
)
|
||||
else:
|
||||
message = (
|
||||
f"Deployed {len(copied)} script(s) to {scripts_dir}. "
|
||||
"Flamenco 3.7+ loads scripts on demand; restart Manager if you are on an older version."
|
||||
)
|
||||
|
||||
return True, message, copied, restart, version
|
||||
Reference in New Issue
Block a user