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:
Bishoy Samaan
2026-03-15 15:16:24 +01:00
co-authored by Claude Sonnet 4.6
parent 294312ca7f
commit eed7db02fe
4 changed files with 109 additions and 11 deletions
+17
View File
@@ -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"
+82 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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"