173 lines
5.7 KiB
Python
173 lines
5.7 KiB
Python
"""HDR file I/O helpers: EXR read/write, path utilities.
|
||
|
||
Shared by Comfy HDR nodes (ingest + EXR delivery). Color math lives in ``hdr_color``;
|
||
node classes live in ``hdr_nodes``.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import math
|
||
import re
|
||
from pathlib import Path
|
||
|
||
import folder_paths
|
||
import numpy as np
|
||
import torch
|
||
from torch import Tensor
|
||
|
||
from .hdr_color import Primaries
|
||
|
||
logger = logging.getLogger("LTXVideo.hdr_io")
|
||
|
||
|
||
def _resolve_path(path: str) -> Path:
|
||
p = Path(path).expanduser()
|
||
if p.is_absolute() and p.exists():
|
||
return p
|
||
# Prefer Comfy input dir for relative paths.
|
||
candidate = Path(folder_paths.get_input_directory()) / path
|
||
if candidate.exists():
|
||
return candidate
|
||
if p.exists():
|
||
return p.resolve()
|
||
raise FileNotFoundError(
|
||
f"HDR path not found: {path!r} (tried {candidate} and {p.resolve()})"
|
||
)
|
||
|
||
|
||
def _natural_sort_key(name: str) -> tuple:
|
||
"""Sort key so ``frame_9.exr`` precedes ``frame_10.exr`` (not lexicographic)."""
|
||
parts = re.split(r"(\d+)", name)
|
||
key: list = []
|
||
for part in parts:
|
||
if part.isdigit():
|
||
key.append((0, int(part)))
|
||
else:
|
||
key.append((1, part.casefold()))
|
||
return tuple(key)
|
||
|
||
|
||
def _list_exr_files(path: Path) -> list[Path]:
|
||
"""Resolve a still ``.exr`` or an EXR folder to a sorted frame list."""
|
||
if path.is_file():
|
||
if path.suffix.lower() != ".exr":
|
||
raise ValueError(f"Expected a .exr file; got {path}")
|
||
return [path]
|
||
if path.is_dir():
|
||
# Case-insensitive match (Linux is case-sensitive; macOS usually is not).
|
||
files = sorted(
|
||
{p for p in path.iterdir() if p.is_file() and p.suffix.lower() == ".exr"},
|
||
key=lambda p: _natural_sort_key(p.name),
|
||
)
|
||
if not files:
|
||
raise RuntimeError(f"No .exr frames in {path}")
|
||
return files
|
||
raise ValueError(f"Path must be a .exr file or a directory of EXRs; got {path}")
|
||
|
||
|
||
def _silent_audio(num_frames: int, frame_rate: float, sample_rate: int = 44100) -> dict:
|
||
"""Stereo silence long enough for the LTX audio VAE mel path.
|
||
|
||
EXR plates have no soundtrack; V2V still freezes an audio latent. A 0-length
|
||
waveform crashes ``torchaudio.functional.resample`` / ``VAEEncodeAudio``.
|
||
"""
|
||
if frame_rate <= 0:
|
||
raise ValueError(f"frame_rate must be > 0; got {frame_rate}")
|
||
duration_s = max(float(num_frames) / float(frame_rate), 1.0)
|
||
# Keep a comfortable floor for mel/STFT (typical n_fft is 1024–2048).
|
||
n_samples = max(int(math.ceil(duration_s * sample_rate)), sample_rate, 8192)
|
||
return {
|
||
"waveform": torch.zeros(1, 2, n_samples, dtype=torch.float32),
|
||
"sample_rate": int(sample_rate),
|
||
}
|
||
|
||
|
||
def _read_exr(path: Path) -> Tensor:
|
||
"""Read one EXR as float32 ``[H,W,3]`` (unbounded linear or log codes as stored).
|
||
|
||
Requires OpenImageIO (same as ``ltx_pipelines`` media I/O). No OpenCV fallback.
|
||
"""
|
||
try:
|
||
import OpenImageIO as oiio
|
||
except ImportError as e:
|
||
raise RuntimeError(
|
||
"Reading EXR requires OpenImageIO. Install with: pip install openimageio"
|
||
) from e
|
||
|
||
buf = oiio.ImageBuf(str(path))
|
||
pixels = buf.get_pixels(oiio.FLOAT)
|
||
if pixels is None or buf.has_error:
|
||
raise RuntimeError(f"OpenImageIO failed on '{path}': {buf.geterror()}")
|
||
arr = np.ascontiguousarray(np.asarray(pixels, dtype=np.float32))
|
||
|
||
if arr.ndim == 2:
|
||
arr = np.repeat(arr[..., None], 3, axis=-1)
|
||
return torch.from_numpy(np.ascontiguousarray(arr[..., :3]))
|
||
|
||
|
||
def _save_exr_frame(
|
||
frame_hwc: Tensor,
|
||
path: Path,
|
||
*,
|
||
half_precision: bool,
|
||
primaries: Primaries | None,
|
||
color_space_tag: str | None,
|
||
) -> None:
|
||
"""Write one EXR via OpenImageIO (same backend as ``ltx_pipelines``).
|
||
|
||
Optional ``primaries`` / ``color_space_tag`` set EXR header attributes when
|
||
provided; ``exr_color_space=linear`` may omit them for legacy untagged plates.
|
||
"""
|
||
try:
|
||
import OpenImageIO as oiio
|
||
except ImportError as e:
|
||
raise RuntimeError(
|
||
"Saving EXR requires OpenImageIO. Install with: pip install openimageio"
|
||
) from e
|
||
|
||
rgb = frame_hwc.detach().float().cpu().numpy()
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
h, w, _ = rgb.shape
|
||
fmt = oiio.HALF if half_precision else oiio.FLOAT
|
||
spec = oiio.ImageSpec(w, h, 3, fmt)
|
||
spec.channelnames = ("R", "G", "B")
|
||
spec.attribute("compression", "zip")
|
||
if primaries is not None:
|
||
spec.attribute(
|
||
"chromaticities",
|
||
"float[8]",
|
||
primaries.exr_chromaticities,
|
||
)
|
||
if color_space_tag is not None:
|
||
spec.attribute("colorSpace", color_space_tag)
|
||
out = oiio.ImageOutput.create(str(path))
|
||
if out is None:
|
||
raise RuntimeError(
|
||
f"OpenImageIO cannot create writer for {path}. "
|
||
"Ensure OpenImageIO is built with OpenEXR support."
|
||
)
|
||
try:
|
||
if not out.open(str(path), spec):
|
||
raise RuntimeError(
|
||
f"Failed to access EXR output file at '{path}': {out.geterror()}"
|
||
)
|
||
# OIIO converts to HALF from float32 buffer when spec is HALF.
|
||
if not out.write_image(np.ascontiguousarray(rgb, dtype=np.float32)):
|
||
raise RuntimeError(f"Failed to write EXR '{path}': {out.geterror()}")
|
||
finally:
|
||
out.close()
|
||
|
||
|
||
def _trim_to_8k1(images: Tensor) -> Tensor:
|
||
"""Keep longest legal ``8k+1`` prefix (VAE temporal scale 8)."""
|
||
n = images.shape[0]
|
||
legal = ((n - 1) // 8) * 8 + 1
|
||
if legal < 1:
|
||
raise ValueError(f"Need at least 1 frame; got {n}")
|
||
if legal != n:
|
||
logger.warning(
|
||
"Trimmed EXR sequence from %d to %d frames (must be 8k+1)", n, legal
|
||
)
|
||
return images[:legal]
|