Files

173 lines
5.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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]