v1.3.0: ProRes MOV export (422, 422 HQ, 4444, 4444 XQ) + ACES 1.2 auto-download fix
- Video Saver: add MOV ProRes 422, 422 HQ, 4444, and 4444 XQ formats via PyAV (prores_ks encoder; 10-bit YUV 4:2:2 / 4:4:4; alpha preserved for 4444 when input is 4-ch) - Fix ACES 1.2 auto-download not running on fresh installs: call download_aces12() and _refresh_aces12() from __init__.py so the config is fetched at startup - Fix BUILTIN_CONFIG_KEYS stale-reference bug: mutate the list in-place in _refresh_aces12() so nodes.py's imported binding always sees the updated keys Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
294312ca7f
commit
eed7db02fe
+17
@@ -44,6 +44,23 @@ if importlib.util.find_spec("PyOpenColorIO") is None:
|
||||
else:
|
||||
from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
||||
|
||||
# Auto-download ACES 1.2 config if not already present, then refresh the
|
||||
# built-in config list so the dropdown includes it on first run.
|
||||
try:
|
||||
from .install import download_aces12
|
||||
from .ocio_utils import _refresh_aces12
|
||||
download_aces12()
|
||||
_refresh_aces12()
|
||||
except Exception as _dl_e:
|
||||
_logger.warning(f"[ACES IO] Could not auto-download ACES 1.2 config: {_dl_e}")
|
||||
|
||||
from .wan_inverse_tonemap import (
|
||||
NODE_CLASS_MAPPINGS as _WAN_CM,
|
||||
NODE_DISPLAY_NAME_MAPPINGS as _WAN_DNM,
|
||||
)
|
||||
NODE_CLASS_MAPPINGS = {**NODE_CLASS_MAPPINGS, **_WAN_CM}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {**NODE_DISPLAY_NAME_MAPPINGS, **_WAN_DNM}
|
||||
|
||||
# Serve web/js to the ComfyUI frontend
|
||||
WEB_DIRECTORY = "web"
|
||||
|
||||
|
||||
@@ -1107,20 +1107,31 @@ class ACESIOVideoSaver:
|
||||
|
||||
Formats
|
||||
───────
|
||||
MP4 (H.264) Standard video via OpenCV — plays in any media player.
|
||||
Animated WebP High-quality lossless/near-lossless, plays in browsers and
|
||||
most modern viewers. Best for round-tripping back to ComfyUI.
|
||||
Animated GIF Universal compatibility; limited to 256 colours.
|
||||
MP4 (H.264) Standard video via OpenCV — plays in any media player.
|
||||
MOV ProRes 422 Apple ProRes 422 — 10-bit YUV 4:2:2, via PyAV.
|
||||
MOV ProRes 422 HQ Apple ProRes 422 HQ — higher bitrate 10-bit YUV 4:2:2.
|
||||
MOV ProRes 4444 Apple ProRes 4444 — 10-bit YUV 4:4:4 (+ alpha if 4-ch).
|
||||
MOV ProRes 4444 XQ Apple ProRes 4444 XQ — highest quality 4:4:4 (+ alpha).
|
||||
Animated WebP High-quality lossless/near-lossless, plays in browsers.
|
||||
Animated GIF Universal compatibility; limited to 256 colours.
|
||||
|
||||
output_path
|
||||
Full path including filename. The correct extension is appended
|
||||
automatically if missing (.mp4 / .webp / .gif).
|
||||
automatically if missing (.mp4 / .mov / .webp / .gif).
|
||||
|
||||
The node passes the IMAGE tensor through unchanged so it can sit inline
|
||||
in any graph without interrupting the flow.
|
||||
"""
|
||||
|
||||
FORMATS = ["MP4 (H.264)", "Animated WebP", "Animated GIF"]
|
||||
FORMATS = [
|
||||
"MP4 (H.264)",
|
||||
"MOV ProRes 422",
|
||||
"MOV ProRes 422 HQ",
|
||||
"MOV ProRes 4444",
|
||||
"MOV ProRes 4444 XQ",
|
||||
"Animated WebP",
|
||||
"Animated GIF",
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
@@ -1130,7 +1141,7 @@ class ACESIOVideoSaver:
|
||||
"output_path": ("ACES_PATH", {
|
||||
"default": os.path.expanduser("~/output.mp4"),
|
||||
"mode": "file",
|
||||
"placeholder": "/path/to/output.mp4 (.mp4 / .webp / .gif)",
|
||||
"placeholder": "/path/to/output (.mp4 / .mov / .webp / .gif)",
|
||||
}),
|
||||
"format": (cls.FORMATS, {"default": "MP4 (H.264)"}),
|
||||
"fps": ("FLOAT", {
|
||||
@@ -1157,6 +1168,11 @@ class ACESIOVideoSaver:
|
||||
if not path.lower().endswith(".mp4"):
|
||||
path += ".mp4"
|
||||
_write_mp4(images, path, fps)
|
||||
elif format in _PRORES_PROFILES:
|
||||
if not path.lower().endswith(".mov"):
|
||||
path += ".mov"
|
||||
profile, is_4444 = _PRORES_PROFILES[format]
|
||||
_write_prores_mov(images, path, fps, profile, is_4444)
|
||||
elif format == "Animated WebP":
|
||||
if not path.lower().endswith(".webp"):
|
||||
path += ".webp"
|
||||
@@ -1173,6 +1189,65 @@ class ACESIOVideoSaver:
|
||||
|
||||
# ── video-writing helpers ────────────────────────────────────────────────────
|
||||
|
||||
# ProRes profile numbers understood by FFmpeg's prores_ks encoder
|
||||
# profile 2 = ProRes 422, 3 = ProRes 422 HQ, 4 = ProRes 4444, 5 = ProRes 4444 XQ
|
||||
_PRORES_PROFILES = {
|
||||
"MOV ProRes 422": (2, False),
|
||||
"MOV ProRes 422 HQ": (3, False),
|
||||
"MOV ProRes 4444": (4, True),
|
||||
"MOV ProRes 4444 XQ": (5, True),
|
||||
}
|
||||
|
||||
|
||||
def _write_prores_mov(tensor: torch.Tensor, path: str, fps: float,
|
||||
profile: int, is_4444: bool):
|
||||
"""
|
||||
Write a ProRes .mov using PyAV / FFmpeg (prores_ks encoder).
|
||||
|
||||
422 profiles → 10-bit YUV 4:2:2 (yuv422p10le)
|
||||
4444 profiles → 10-bit YUV 4:4:4 (yuva444p10le, alpha preserved if C==4)
|
||||
|
||||
Input tensor is expected in float32 [0, 1] — converted to 16-bit before
|
||||
passing to PyAV which then downsamples to the correct 10-bit planar format.
|
||||
"""
|
||||
try:
|
||||
import av
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"ACESIOVideoSaver: PyAV (av) is required for ProRes/MOV export.\n"
|
||||
"Install with: pip install av"
|
||||
)
|
||||
|
||||
B, H, W, C = tensor.shape
|
||||
has_alpha = is_4444 and (C == 4)
|
||||
|
||||
# Intermediate packed format fed to PyAV; reformat() handles the
|
||||
# colour-space / bit-depth conversion via libswscale.
|
||||
src_fmt = "rgba64le" if has_alpha else "rgb48le"
|
||||
pix_fmt = "yuva444p10le" if is_4444 else "yuv422p10le"
|
||||
|
||||
container = av.open(path, mode="w", format="mov")
|
||||
stream = container.add_stream("prores_ks", rate=fps)
|
||||
stream.width = W
|
||||
stream.height = H
|
||||
stream.pix_fmt = pix_fmt
|
||||
stream.options = {"profile": str(profile)}
|
||||
|
||||
for i in range(B):
|
||||
frame_np = (tensor[i].cpu().float().numpy() * 65535.0).clip(0, 65535).astype(np.uint16)
|
||||
arr = frame_np if has_alpha else frame_np[:, :, :3]
|
||||
av_frame = av.VideoFrame.from_ndarray(arr, format=src_fmt)
|
||||
av_frame = av_frame.reformat(format=pix_fmt)
|
||||
av_frame.pts = i
|
||||
for pkt in stream.encode(av_frame):
|
||||
container.mux(pkt)
|
||||
|
||||
for pkt in stream.encode():
|
||||
container.mux(pkt)
|
||||
|
||||
container.close()
|
||||
|
||||
|
||||
def _write_webp(tensor: torch.Tensor, path: str, fps: float):
|
||||
frames = _tensor_to_pil_frames(tensor)
|
||||
duration_ms = max(1, int(1000.0 / fps))
|
||||
|
||||
+9
-3
@@ -50,6 +50,11 @@ BUILTIN_CONFIGS: Dict[str, str] = {
|
||||
}
|
||||
|
||||
# Inject ACES 1.2 if already downloaded
|
||||
# Defined as a mutable list so that after a post-import download + _refresh_aces12()
|
||||
# call from __init__.py, nodes.py's binding (same object) sees the updated keys.
|
||||
BUILTIN_CONFIG_KEYS: list = []
|
||||
|
||||
|
||||
def _refresh_aces12():
|
||||
key = "ACES 1.2 (colour-science / OCIO v1)"
|
||||
if os.path.isfile(_ACES12_CFG):
|
||||
@@ -64,11 +69,12 @@ def _refresh_aces12():
|
||||
BUILTIN_CONFIGS.update(ordered)
|
||||
else:
|
||||
BUILTIN_CONFIGS.pop(key, None)
|
||||
global BUILTIN_CONFIG_KEYS
|
||||
BUILTIN_CONFIG_KEYS = list(BUILTIN_CONFIGS.keys())
|
||||
# Mutate in-place so all importers of this list see the change
|
||||
BUILTIN_CONFIG_KEYS.clear()
|
||||
BUILTIN_CONFIG_KEYS.extend(BUILTIN_CONFIGS.keys())
|
||||
|
||||
|
||||
_refresh_aces12()
|
||||
BUILTIN_CONFIG_KEYS = list(BUILTIN_CONFIGS.keys())
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Channel-view matrices (row-major 4×4, same convention as Nuke viewer)
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "comfyui-aces-io"
|
||||
version = "1.2.0"
|
||||
version = "1.3.0"
|
||||
description = "OpenColorIO / ACES color-management nodes for ComfyUI — mirrors Nuke's OCIO node set with ACES 2.0, 1.3, and 1.2 support, EXR sequence loading, animated preview, and video export."
|
||||
license = { file = "LICENSE" }
|
||||
readme = "README.md"
|
||||
|
||||
Reference in New Issue
Block a user