""" parser.py — Filename parsing and Shot data model. Filename format: SHOW_EPISODE_SEQUENCE_SHOT_ELEMENT_TASK_VERSION.FRAME.exr Example: UNG_101_012_010_BG01_TT_v001.01001.exr """ from __future__ import annotations import re import struct from dataclasses import dataclass, field from pathlib import Path # ────────────────────────────────────────────────────────────────────────────── # Regex patterns # ────────────────────────────────────────────────────────────────────────────── # Matches the basename (no frame, no extension): # SHOW_EPISODE_SEQUENCE_SHOT_ELEMENT_TASK_VERSION BASENAME_RE = re.compile( r"^(?P[^_]+)_" r"(?P[^_]+)_" r"(?P[^_]+)_" r"(?P[^_]+)_" r"(?P[^_]+)_" r"(?P[^_]+)_" r"(?Pv\d+)$", re.IGNORECASE, ) # Matches a full EXR sequence filename: {basename}.{4-7 digit frame}.exr SEQ_FILE_RE = re.compile(r"^(.+)\.(\d{4,7})\.exr$", re.IGNORECASE) # ────────────────────────────────────────────────────────────────────────────── # Data model # ────────────────────────────────────────────────────────────────────────────── @dataclass class Shot: show: str episode: str sequence: str shot: str element: str task: str version: str basename: str first_frame: int last_frame: int frame_count: int missing_frames: list[int] = field(default_factory=list) width: int = 0 height: int = 0 input_sequence: str = "" # Nuke-style path, e.g. /path/basename.%05d.exr output_folder: str = "" output_movie: str = "" output_exr: str = "" status: str = "Ready" @property def display_frames(self) -> str: s = f"{self.first_frame}\u2013{self.last_frame} ({self.frame_count}f)" if self.missing_frames: s += f" [{len(self.missing_frames)} missing]" return s @property def display_resolution(self) -> str: return f"{self.width}\u00d7{self.height}" if self.width and self.height else "\u2014" # ────────────────────────────────────────────────────────────────────────────── # Parsing helpers # ────────────────────────────────────────────────────────────────────────────── def parse_basename(basename: str) -> dict | None: """Return a dict of named fields for *basename*, or None if it does not match.""" m = BASENAME_RE.match(basename) return m.groupdict() if m else None def build_shot( basename: str, source_dir: str, frames: list[int], output_root: str, parsed: dict, frame_padding: int = 4, ) -> Shot: """Construct a :class:`Shot` from parsed filename parts and frame list.""" frames_sorted = sorted(frames) first = frames_sorted[0] last = frames_sorted[-1] missing = sorted(set(range(first, last + 1)) - set(frames_sorted)) show = parsed["show"] episode = parsed["episode"] # Nuke requires forward slashes even on Windows input_dir = Path(source_dir).as_posix() input_seq = f"{input_dir}/{basename}.%0{frame_padding}d.exr" out_folder = Path(output_root) / show / f"{show}_{episode}" / basename out_folder_str = out_folder.as_posix() # Attempt to read resolution from the first frame of the sequence first_exr = Path(source_dir) / f"{basename}.{str(first).zfill(frame_padding)}.exr" width, height = read_exr_resolution(str(first_exr)) return Shot( show=show, episode=episode, sequence=parsed["sequence"], shot=parsed["shot"], element=parsed["element"], task=parsed["task"], version=parsed["version"], basename=basename, first_frame=first, last_frame=last, frame_count=len(frames_sorted), missing_frames=missing, width=width, height=height, input_sequence=input_seq, output_folder=out_folder_str, output_movie=f"{out_folder_str}/{basename}.mov", output_exr=f"{out_folder_str}/{basename}.%0{frame_padding}d.exr", ) # ────────────────────────────────────────────────────────────────────────────── # EXR header reader (no external dependencies) # ────────────────────────────────────────────────────────────────────────────── _EXR_MAGIC = b"\x76\x2f\x31\x01" def read_exr_resolution(filepath: str) -> tuple[int, int]: """Read (width, height) from an OpenEXR file header without external libraries. Returns (0, 0) if the file cannot be read or is not a valid EXR. """ try: with open(filepath, "rb") as fh: if fh.read(4) != _EXR_MAGIC: return 0, 0 fh.read(4) # version + feature flags while True: name = _read_cstring(fh) if not name: break # end of header type_str = _read_cstring(fh) size = struct.unpack(" str: """Read a null-terminated ASCII string from a binary file handle.""" buf = bytearray() while True: c = fh.read(1) if not c or c == b"\x00": break buf.extend(c) return buf.decode("latin-1")