From 1a2ece6f12d51b3a55cf0eae76370f581d40a060 Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Sat, 4 Jul 2026 15:54:19 +0200 Subject: [PATCH] Initial Commit --- PROMPT.md | 162 ++++ __pycache__/gui.cpython-312.pyc | Bin 0 -> 46170 bytes __pycache__/jobs.cpython-312.pyc | Bin 0 -> 2408 bytes __pycache__/nuke_runner.cpython-312.pyc | Bin 0 -> 4975 bytes __pycache__/parser.cpython-312.pyc | Bin 0 -> 6621 bytes __pycache__/scanner.cpython-312.pyc | Bin 0 -> 5371 bytes app.py | 25 + config.json | 6 + gui.py | 944 ++++++++++++++++++++++++ jobs.py | 61 ++ nuke_runner.py | 111 +++ parser.py | 177 +++++ process.py | 172 +++++ proxy_template.nk | 54 ++ proxy_template.nkind | 1 + proxy_template.nkind~ | 1 + scanner.py | 130 ++++ 17 files changed, 1844 insertions(+) create mode 100644 PROMPT.md create mode 100644 __pycache__/gui.cpython-312.pyc create mode 100644 __pycache__/jobs.cpython-312.pyc create mode 100644 __pycache__/nuke_runner.cpython-312.pyc create mode 100644 __pycache__/parser.cpython-312.pyc create mode 100644 __pycache__/scanner.cpython-312.pyc create mode 100644 app.py create mode 100644 config.json create mode 100644 gui.py create mode 100644 jobs.py create mode 100644 nuke_runner.py create mode 100644 parser.py create mode 100644 process.py create mode 100644 proxy_template.nk create mode 100644 proxy_template.nkind create mode 100644 proxy_template.nkind~ create mode 100644 scanner.py diff --git a/PROMPT.md b/PROMPT.md new file mode 100644 index 0000000..a590a92 --- /dev/null +++ b/PROMPT.md @@ -0,0 +1,162 @@ +Create a Python desktop application using PySide6 called "VFX Pull Processor". + +Purpose: +This application scans one or more selected folders recursively for EXR image sequences, then uses Nuke in terminal mode to render proxy movies using a template Nuke script. + +Architecture: + +- PySide6 GUI +- Separate modules + - app.py + - gui.py + - scanner.py + - parser.py + - jobs.py + - nuke_runner.py + - process.py (runs inside Nuke) + - config.json + +The GUI should contain: + +------------------------------------------------ +VFX Pull Processor + +Pull Folder +[_________________________] [Browse] + +Output Root +[ V:\_FOOTAGE ] [Browse] + +Nuke Executable +[ C:\Program Files\Nuke16.0v1\Nuke16.0.exe ] [Browse] + +Template Script +[ proxy_template.nk ] [Browse] + +------------------------------------------------ + +Sequences Found + +(QTableWidget) + +Columns: + +Checkbox +Shot +Frames +Resolution +Status + +------------------------------------------------ + +[ Scan ] + +[ Render Selected ] + +Progress Bar + +Log Window + +------------------------------------------------ + +Requirements + +When Scan is pressed: + +- recursively search every selected folder +- identify EXR sequences +- group frames into sequences +- detect missing frames +- determine first and last frame +- populate the table + +Filename format is always: + +SHOW_EPISODE_SEQUENCE_SHOT_ELEMENT_TASK_VERSION.FRAME.exr + +Example: + +UNG_101_012_010_BG01_TT_v001.01001.exr + +Parse into: + +show = UNG +episode = 101 +sequence = 012 +shot = 010 +element = BG01 +task = TT +version = v001 + +Create a Shot object containing: + +show +episode +sequence +shot +element +task +version +basename +firstFrame +lastFrame +inputSequence +outputFolder +outputMovie + +basename should equal: + +UNG_101_012_010_BG01_TT_v001 + +Output folder should be: + +V:/_FOOTAGE/ + {show}/ + {show}_{episode}/ + {basename}/ + +Output files should be: + +{basename}.exr +{basename}.mov + +When Render Selected is pressed: + +Launch Nuke ONCE using: + +Nuke.exe -t process.py jobs.json + +Do NOT launch Nuke once per shot. + +Instead: + +- create jobs.json +- pass it to process.py + +process.py should: + +- load proxy_template.nk +- locate nodes named: + Read_Input + Write_Output +- for each job: + set Read_Input.file + set Write_Output.file + execute render +- clear caches between jobs if needed +- print progress to stdout + +The GUI should capture stdout from Nuke and display it in the log window. + +Design the code cleanly using dataclasses where appropriate. + +Keep parsing logic, scanning logic, GUI, and Nuke execution completely separate. + +The code should be easy to extend later for: +- JPG proxies +- PNG proxies +- Contact sheets +- Thumbnail generation +- Validation reports + +Do not put any pipeline logic inside the Nuke script. The Nuke script is only a visual template. \ No newline at end of file diff --git a/__pycache__/gui.cpython-312.pyc b/__pycache__/gui.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e3c03f7ae075aa89955cb79cce5e40dff2750106 GIT binary patch literal 46170 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 literal 0 HcmV?d00001 diff --git a/__pycache__/nuke_runner.cpython-312.pyc b/__pycache__/nuke_runner.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..814787e8cb50a8173ed1c9a52687bb4f82e05b76 GIT binary patch literal 4975 zcmb_gOKcm*8J;DVNMX+hbt>@=+01;rgnWXN4* zb{RcNH9DlI3^<6KrjS#kh;oVz6^MK4p*=Y0AxU$nSg9qw@gZme6uvQ$g9h%U|IG3s z%L?M69YQnz%s=zbKQrI|SbgO8dk~b%_(ZZlfY7J3Q!WmR;I>`|Vg)HEiWE+$ zYU3zvPuZh(*78vvT0Z4SJEP9DE9&Bq4PkpyEDNi)iertldQtb+&T&@ecx>ndQe3Yf zMZnHAdugS2&K`1SPJ$qhnt1`sB%`XBgs11Ee=NQs{V9+xRF6kD6cE-24JJg25iF%KnFL96#|e$h#E1gDR6@nQ<8!^Vm0n83 zq%(%3CniBZ*jtl~DeO6M?v#|$CSi9~(PpH~v=TG0PLBd=rz9hh#$ic1O|(e@YDfvn znt)9~`UKI^Oa>e=6E`w+%sL|5SO;G0z5`5?glgzv&mDTRAm9y05++RG&6-&{1OHWpu7|{ z6L(thW;Jz!@W*Y90r4wt1sN6QMQ0Yh3fpEYu;);g%ObgC9>B3dk4j)AYlmLR_<+Yl zujKX-nnTyQNXThA^vpCSrXb63V}>l7f=XR3_(C&Kf~>5^Nn+YC-EaV1o=Bvy>9FA7 zGI<@RCP*!8N9t%5q*XmsrgIvcqzbEKIi8B?x-9E17^gs$q{VytPV|qRKmMcBM4O!( zV_R0dJfU%AK1lIlnN+x}++)Dd`=I)25#1G$J8;pJ6Pm8<*%YKgleENN@)!2^Eb*H{ zb75~gbOVKkeM|hSo-J5{;+0#14yD;GQ>|C5TLj7~bBI8=1b@j+60(s7=rLNS>m-RZ zL1VggY*=@D-FZTSt#x^w8# z$40o!Pk!T#!nJ@gB|3%AX?ouBQo;3OR4x!L3pNZYho(K86?rTdv@!(9w z{{p;9^={@+2QsMRWTg|4<7|(IwPASi-8{!t^c!1s518|iIxFt#s^=kA7}VjFH5*uf zxbo26#gn!Ff3#50g8gUqIrvne?F=`AX6?_T87>rv%)5GfA)8L%d0VeB@3p=VXu>Mp zj`T}+sE@uw12CafsCj2-6kyckY5r&ONiCt89z8Q&inFHU zv^I@ZlRtgx^blm^ql3feM$Ove5O5=!ah$3!M2JQRb+4vt_^j3Y7VS9+q=JcQh~tC9 zks*0x$aJJ*MtsV&Lt1A#CW)4rW)Y0g2tcU2htSWK_+fTraA@RI5`etWniE>Dq_4%_ow`1CLtjhh_J4mKY42UHvp%piSnNc5Ti^1&>D@wg?!!wb3&G}M zcJz+}H@Wv+@3}tU{^a??@CRr9^4y2dt)DypQFK+f(y;6*w6-tvZ+Hq~eO~O$iJf^d zloLbO9=Y+oP4P(>UGZP`=f(D%*uHxBog;4_+30wDQ+xt+`Alrhiyb+!V|8#t3@sfq zYs5lx%Ug%uJhb|qtNqIYlPKjxDKB>A#IDt;chtAljc*_PyLhPB1?MYvBe(xG_eJ+5 zFT2Nz%&0`)-`cwIBSOaOkVM$2cUmeSsYAVB!Ar7wVq4G5Y%pzq@c_ zcwHR4Yq$9WMJMvt=6zDmC*^%zIbYW*-t_g{^3~n0+4BXmKOkbg^O>8E-|Sj%Ke_2U z#l-JedfQj`@1hG9-qR`0->W}(;wXCm=rP~3P4*A{0<9aKd^TwRbI<`aq-xacB7jz! zlA3lvxRsY4g|3yHGKxA{LgNM)4#T*~M<6o~@s^EH4tbhZ$DjhKRl1I*%JfqPQe}@U z!#^D|DN6ff`Nd2uRpPj0$n@i~%svtb+mcy+jVy2b0tY_ZiJ2rd56doj`gy{PoMpnK znQ~|{Y5GgKbR4HrvJClYBaG_Qy**d4v*y({pDN3O*PzR&_2z2Rn>CZw{v~wKyxy{Y-U)Ce8<5T4R7}fq0Jql{q yHZR*&JeNJ|%>x_a;G)0aZCLv5YyB7d*Y`ZR;q6}(?mEwN+~MWMFA;6oUjG3W&|`%F literal 0 HcmV?d00001 diff --git a/__pycache__/parser.cpython-312.pyc b/__pycache__/parser.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4c439450632306005b4a2662ee32e94e713d499b GIT binary patch literal 6621 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 Kl0d1B!}|~Yya`eO literal 0 HcmV?d00001 diff --git a/app.py b/app.py new file mode 100644 index 0000000..7954225 --- /dev/null +++ b/app.py @@ -0,0 +1,25 @@ +""" +app.py — Entry point for VFX Pull Processor. +""" +from __future__ import annotations + +import sys + +from PySide6.QtWidgets import QApplication + +from gui import MainWindow + + +def main() -> None: + app = QApplication(sys.argv) + app.setApplicationName("VFX Pull Processor") + app.setOrganizationName("VFX") + + window = MainWindow() + window.show() + + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/config.json b/config.json new file mode 100644 index 0000000..f8203a9 --- /dev/null +++ b/config.json @@ -0,0 +1,6 @@ +{ + "pull_folder": "V:/_FOOTAGE/UNG/_PULLS/UNG_111_029_SERIES_EL01_TT_GATE_ADDITIONAL_v001", + "output_root": "V:\\_FOOTAGE", + "nuke_executable": "C:/Program Files/Nuke17.0v1/Nuke17.0.exe", + "template_script": "proxy_template.nkind" +} \ No newline at end of file diff --git a/gui.py b/gui.py new file mode 100644 index 0000000..bb56b81 --- /dev/null +++ b/gui.py @@ -0,0 +1,944 @@ +""" +gui.py — PySide6 main window for VFX Pull Processor. + +Dark, Nuke-adjacent theme with a single amber accent. + +All pipeline behaviour (scan, jobs file, Nuke launch) is unchanged from +the original implementation — this module only changes presentation: + + * top bar: pull folder + Scan, with rarely-used paths in a + collapsible settings panel behind the gear button + * sequences table with colour-coded status pills and a + missing-frames warning in the Frames column + * primary Render button with a live selection count + * progress bar with a "n of m · shot" label + * collapsible, timestamped, colour-coded log that auto-opens + on the first error + * drag-and-drop a folder anywhere on the window to set Pull Folder +""" +from __future__ import annotations + +import html +import json +import os +import tempfile +from datetime import datetime +from pathlib import Path + +from PySide6.QtCore import Qt, Slot +from PySide6.QtGui import QColor, QFont +from PySide6.QtWidgets import ( + QApplication, + QCheckBox, + QFileDialog, + QFrame, + QHBoxLayout, + QHeaderView, + QLabel, + QLineEdit, + QMainWindow, + QMessageBox, + QProgressBar, + QPushButton, + QSizePolicy, + QStackedWidget, + QTableWidget, + QTableWidgetItem, + QTextEdit, + QToolButton, + QVBoxLayout, + QWidget, +) + +from jobs import build_jobs_payload, write_jobs_file +from nuke_runner import NukeRunner +from parser import Shot +from scanner import ScanWorker + +# ────────────────────────────────────────────────────────────────────────────── +# Palette / stylesheet +# ────────────────────────────────────────────────────────────────────────────── + +ACCENT = "#f5a623" +ACCENT_HOVER = "#ffb840" +COL_TEXT = "#e4e6e8" +COL_DIM = "#9a9ea3" +COL_FAINT = "#6b6f74" +COL_GREEN = "#4caf7d" +COL_BLUE = "#4a9eda" +COL_AMBER = "#e0a83c" +COL_RED = "#e05c5c" + +_QSS = f""" +QMainWindow {{ + background-color: #232527; +}} +QWidget {{ + color: {COL_TEXT}; + font-size: 13px; +}} +QLabel {{ + background: transparent; +}} +QLabel#sectionLabel {{ + color: {COL_FAINT}; + font-size: 11px; + font-weight: 600; + letter-spacing: 1px; +}} +QLabel#fieldLabel {{ + color: {COL_DIM}; + font-size: 12px; +}} +QLabel#hintLabel {{ + color: {COL_FAINT}; + font-size: 12px; +}} +QLineEdit {{ + background-color: #1c1e20; + border: 1px solid #3a3d40; + border-radius: 6px; + padding: 7px 10px; + color: {COL_TEXT}; + selection-background-color: {ACCENT}; + selection-color: #1a1a1a; +}} +QLineEdit:focus {{ + border-color: {ACCENT}; +}} +QPushButton {{ + background-color: #2b2d30; + border: 1px solid #3a3d40; + border-radius: 6px; + padding: 8px 18px; + color: {COL_TEXT}; +}} +QPushButton:hover {{ + background-color: #333639; + border-color: #46494d; +}} +QPushButton:pressed {{ + background-color: #1c1e20; +}} +QPushButton:disabled {{ + color: {COL_FAINT}; + background-color: #26282a; + border-color: #313437; +}} +QPushButton#primaryButton {{ + background-color: {ACCENT}; + border: 1px solid {ACCENT}; + color: #1a1a1a; + font-weight: 600; + padding: 9px 22px; +}} +QPushButton#primaryButton:hover {{ + background-color: {ACCENT_HOVER}; + border-color: {ACCENT_HOVER}; +}} +QPushButton#primaryButton:disabled {{ + background-color: #4d4a42; + border-color: #4d4a42; + color: #8a8a8a; +}} +QToolButton {{ + background-color: #2b2d30; + border: 1px solid #3a3d40; + border-radius: 6px; + padding: 7px 10px; + color: {COL_DIM}; + font-size: 14px; +}} +QToolButton:hover {{ + background-color: #333639; +}} +QToolButton:checked {{ + background-color: #333639; + border-color: {ACCENT}; + color: {ACCENT}; +}} +QToolButton#logToggle {{ + background: transparent; + border: none; + color: {COL_DIM}; + font-size: 12px; + font-weight: 600; + padding: 4px 6px; +}} +QToolButton#logToggle:hover {{ + color: {COL_TEXT}; +}} +QToolButton#logToggle:checked {{ + background: transparent; + border: none; + color: {COL_TEXT}; +}} +QFrame#card {{ + background-color: #2b2d30; + border: 1px solid #313437; + border-radius: 8px; +}} +QFrame#settingsCard {{ + background-color: #26282b; + border: 1px solid #313437; + border-radius: 8px; +}} +QTableWidget {{ + background-color: #2b2d30; + alternate-background-color: #292b2e; + border: none; + gridline-color: transparent; + outline: none; + selection-background-color: #3a3d42; + selection-color: {COL_TEXT}; +}} +QTableWidget::item {{ + padding: 4px 10px; + border: none; +}} +QTableWidget::item:selected {{ + background-color: #3a3d42; +}} +QTableWidget::indicator {{ + width: 16px; + height: 16px; + border: 1px solid #4a4d51; + border-radius: 4px; + background-color: #1c1e20; +}} +QTableWidget::indicator:checked {{ + background-color: {ACCENT}; + border-color: {ACCENT}; +}} +QHeaderView::section {{ + background-color: #2b2d30; + color: {COL_FAINT}; + border: none; + border-bottom: 1px solid #3a3d40; + padding: 8px 10px; + font-size: 11px; + font-weight: 600; +}} +QCheckBox {{ + color: {COL_DIM}; + font-size: 12px; + spacing: 7px; +}} +QCheckBox::indicator {{ + width: 16px; + height: 16px; + border: 1px solid #4a4d51; + border-radius: 4px; + background-color: #1c1e20; +}} +QCheckBox::indicator:checked {{ + background-color: {ACCENT}; + border-color: {ACCENT}; +}} +QProgressBar {{ + background-color: #1c1e20; + border: none; + border-radius: 3px; + max-height: 6px; + text-align: center; +}} +QProgressBar::chunk {{ + background-color: {ACCENT}; + border-radius: 3px; +}} +QTextEdit#logView {{ + background-color: #151617; + border: none; + border-top: 1px solid #313437; + color: {COL_DIM}; + padding: 6px; +}} +QScrollBar:vertical {{ + background: transparent; + width: 10px; + margin: 0; +}} +QScrollBar::handle:vertical {{ + background: #46494d; + border-radius: 5px; + min-height: 24px; +}} +QScrollBar::handle:vertical:hover {{ + background: #55585c; +}} +QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ + height: 0; +}} +QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{ + background: transparent; +}} +QMessageBox {{ + background-color: #2b2d30; +}} +QToolTip {{ + background-color: #1c1e20; + color: {COL_TEXT}; + border: 1px solid #3a3d40; + padding: 4px 8px; +}} +""" + +# Status → (text colour, pill background) +_BADGE_STYLES: dict[str, tuple[str, str]] = { + "Ready": (COL_DIM, "rgba(154, 158, 163, 30)"), + "Queued": (COL_AMBER, "rgba(224, 168, 60, 33)"), + "Rendering": (COL_BLUE, "rgba(74, 158, 218, 33)"), + "Done": (COL_GREEN, "rgba(76, 175, 125, 33)"), + "Failed": (COL_RED, "rgba(224, 92, 92, 33)"), +} + + +class StatusBadge(QLabel): + """Small rounded pill showing a job status in its own colour.""" + + def __init__(self, status: str = "Ready", parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._status = status + self.set_status(status) + + @property + def status(self) -> str: + return self._status + + def set_status(self, status: str) -> None: + self._status = status + colour, bg = _BADGE_STYLES.get(status, _BADGE_STYLES["Ready"]) + self.setText(("● " if status == "Rendering" else "") + status) + self.setStyleSheet( + f"color: {colour}; background-color: {bg};" + "border-radius: 9px; padding: 2px 10px;" + "font-size: 11px; font-weight: 600;" + ) + + +def _badge_cell(status: str) -> tuple[QWidget, StatusBadge]: + """Wrap a StatusBadge in a container so it sits left-aligned in a cell.""" + container = QWidget() + layout = QHBoxLayout(container) + layout.setContentsMargins(8, 3, 8, 3) + layout.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) + badge = StatusBadge(status) + layout.addWidget(badge) + return container, badge + + +# ────────────────────────────────────────────────────────────────────────────── +# Config helpers +# ────────────────────────────────────────────────────────────────────────────── + +_CONFIG_PATH = Path(__file__).parent / "config.json" + +_DEFAULTS: dict = { + "pull_folder": "", + "output_root": r"V:\_FOOTAGE", + "nuke_executable": r"C:\Program Files\Nuke16.0v1\Nuke16.0.exe", + "template_script": str(Path(__file__).parent / "proxy_template.nk"), +} + + +def _load_config() -> dict: + if _CONFIG_PATH.exists(): + try: + with _CONFIG_PATH.open(encoding="utf-8") as fh: + return {**_DEFAULTS, **json.load(fh)} + except Exception: + pass + return dict(_DEFAULTS) + + +def _save_config(cfg: dict) -> None: + try: + with _CONFIG_PATH.open("w", encoding="utf-8") as fh: + json.dump(cfg, fh, indent=2) + except Exception: + pass + + +# ────────────────────────────────────────────────────────────────────────────── +# Main window +# ────────────────────────────────────────────────────────────────────────────── + +class MainWindow(QMainWindow): + def __init__(self) -> None: + super().__init__() + self.setWindowTitle("VFX Pull Processor") + self.resize(1000, 800) + + app = QApplication.instance() + if app is not None: + app.setStyle("Fusion") + self.setStyleSheet(_QSS) + self.setAcceptDrops(True) + + self._shots: list[Shot] = [] + self._basename_row: dict[str, int] = {} + self._badges: dict[str, StatusBadge] = {} + self._runner: NukeRunner | None = None + self._scan_worker: ScanWorker | None = None + self._config = _load_config() + self._updating_checks = False + self._error_count = 0 + + self._build_ui() + self._apply_config() + + # ── UI construction ─────────────────────────────────────────────── + + def _build_ui(self) -> None: + central = QWidget() + self.setCentralWidget(central) + root = QVBoxLayout(central) + root.setSpacing(12) + root.setContentsMargins(16, 16, 16, 16) + + root.addLayout(self._build_top_bar()) + root.addWidget(self._build_settings_card()) + root.addWidget(self._build_table_card(), stretch=1) + root.addLayout(self._build_action_bar()) + root.addWidget(self._build_log_card()) + + def _build_top_bar(self) -> QHBoxLayout: + bar = QHBoxLayout() + bar.setSpacing(8) + + lbl = QLabel("Pull Folder") + lbl.setObjectName("fieldLabel") + bar.addWidget(lbl) + + self._pull_field = QLineEdit() + self._pull_field.setPlaceholderText( + "Folder to scan for EXR sequences — or drop a folder here…" + ) + bar.addWidget(self._pull_field, stretch=1) + + browse = QPushButton("Browse") + browse.clicked.connect( + lambda: self._browse(self._pull_field, is_dir=True, file_filter="") + ) + bar.addWidget(browse) + + self._scan_btn = QPushButton("Scan") + self._scan_btn.clicked.connect(self._on_scan) + bar.addWidget(self._scan_btn) + + self._settings_btn = QToolButton() + self._settings_btn.setText("⚙") # gear + self._settings_btn.setToolTip("Settings") + self._settings_btn.setCheckable(True) + self._settings_btn.toggled.connect( + lambda on: self._settings_card.setVisible(on) + ) + bar.addWidget(self._settings_btn) + + return bar + + def _build_settings_card(self) -> QFrame: + self._settings_card = QFrame() + self._settings_card.setObjectName("settingsCard") + self._settings_card.setVisible(False) + + layout = QVBoxLayout(self._settings_card) + layout.setSpacing(8) + layout.setContentsMargins(14, 12, 14, 12) + + self._output_field = self._add_path_row( + layout, "Output Root", is_dir=True + ) + self._nuke_field = self._add_path_row( + layout, + "Nuke Executable", + is_dir=False, + file_filter="Executables (*.exe);;All Files (*)", + ) + self._tmpl_field = self._add_path_row( + layout, + "Template Script", + is_dir=False, + file_filter="Nuke Scripts (*.nk);;All Files (*)", + ) + return self._settings_card + + def _build_table_card(self) -> QFrame: + card = QFrame() + card.setObjectName("card") + layout = QVBoxLayout(card) + layout.setSpacing(0) + layout.setContentsMargins(0, 0, 0, 0) + + # Header bar + head = QHBoxLayout() + head.setContentsMargins(14, 10, 14, 10) + + title = QLabel("SEQUENCES FOUND") + title.setObjectName("sectionLabel") + head.addWidget(title) + + head.addStretch(1) + + self._select_all = QCheckBox("Select all") + self._select_all.clicked.connect(self._on_select_all) + self._select_all.setVisible(False) + head.addWidget(self._select_all) + + self._count_label = QLabel("") + self._count_label.setObjectName("hintLabel") + head.addWidget(self._count_label) + + layout.addLayout(head) + + # Stacked: empty state ↔ table + self._stack = QStackedWidget() + + empty = QLabel( + "Choose a pull folder and hit Scan to find EXR sequences." + ) + empty.setObjectName("hintLabel") + empty.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._stack.addWidget(empty) + + self._table = QTableWidget(0, 5) + self._table.setHorizontalHeaderLabels( + ["", "SHOT", "FRAMES", "RESOLUTION", "STATUS"] + ) + hh = self._table.horizontalHeader() + hh.setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed) + hh.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) + hh.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents) + hh.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents) + hh.setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents) + hh.setHighlightSections(False) + self._table.setColumnWidth(0, 34) + self._table.setAlternatingRowColors(True) + self._table.setShowGrid(False) + self._table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) + self._table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) + self._table.verticalHeader().setVisible(False) + self._table.verticalHeader().setDefaultSectionSize(34) + self._table.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self._table.itemChanged.connect(self._on_item_changed) + self._stack.addWidget(self._table) + + self._stack.setCurrentIndex(0) + layout.addWidget(self._stack, stretch=1) + return card + + def _build_action_bar(self) -> QHBoxLayout: + bar = QHBoxLayout() + bar.setSpacing(12) + + self._render_btn = QPushButton("Render Selected") + self._render_btn.setObjectName("primaryButton") + self._render_btn.setEnabled(False) + self._render_btn.clicked.connect(self._on_render) + bar.addWidget(self._render_btn) + + self._progress = QProgressBar() + self._progress.setTextVisible(False) + self._progress.setValue(0) + self._progress.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + bar.addWidget(self._progress, stretch=1) + + self._progress_label = QLabel("") + self._progress_label.setObjectName("hintLabel") + self._progress_label.setMinimumWidth(220) + self._progress_label.setAlignment( + Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter + ) + bar.addWidget(self._progress_label) + + return bar + + def _build_log_card(self) -> QFrame: + card = QFrame() + card.setObjectName("card") + layout = QVBoxLayout(card) + layout.setSpacing(0) + layout.setContentsMargins(0, 0, 0, 0) + + head = QHBoxLayout() + head.setContentsMargins(8, 4, 14, 4) + + self._log_toggle = QToolButton() + self._log_toggle.setObjectName("logToggle") + self._log_toggle.setCheckable(True) + self._log_toggle.setChecked(False) + self._log_toggle.setText("▸ Log") + self._log_toggle.toggled.connect(self._on_log_toggled) + head.addWidget(self._log_toggle) + + head.addStretch(1) + + self._log_hint = QLabel("") + self._log_hint.setObjectName("hintLabel") + head.addWidget(self._log_hint) + + layout.addLayout(head) + + self._log = QTextEdit() + self._log.setObjectName("logView") + self._log.setReadOnly(True) + mono = QFont("Cascadia Code") + mono.setStyleHint(QFont.StyleHint.Monospace) + mono.setPointSize(9) + self._log.setFont(mono) + self._log.setFixedHeight(170) + self._log.setVisible(False) + layout.addWidget(self._log) + + return card + + def _add_path_row( + self, + layout: QVBoxLayout, + label: str, + is_dir: bool, + file_filter: str = "", + ) -> QLineEdit: + """Add a labelled path field + Browse button row to *layout*.""" + row = QHBoxLayout() + row.setSpacing(8) + + lbl = QLabel(label) + lbl.setObjectName("fieldLabel") + lbl.setFixedWidth(130) + row.addWidget(lbl) + + edit = QLineEdit() + row.addWidget(edit) + + btn = QPushButton("Browse") + # Use default-argument binding to capture loop variables correctly + btn.clicked.connect( + lambda _checked=False, e=edit, d=is_dir, f=file_filter: + self._browse(e, d, f) + ) + row.addWidget(btn) + layout.addLayout(row) + return edit + + # ── Drag & drop ─────────────────────────────────────────────────── + + def dragEnterEvent(self, event) -> None: # noqa: N802 (Qt naming) + if event.mimeData().hasUrls(): + for url in event.mimeData().urls(): + if os.path.isdir(url.toLocalFile()): + event.acceptProposedAction() + return + event.ignore() + + def dropEvent(self, event) -> None: # noqa: N802 (Qt naming) + for url in event.mimeData().urls(): + path = url.toLocalFile() + if os.path.isdir(path): + self._pull_field.setText(path) + self._save_config() + break + + # ── Config / browsing ───────────────────────────────────────────── + + def _browse(self, edit: QLineEdit, is_dir: bool, file_filter: str) -> None: + if is_dir: + path = QFileDialog.getExistingDirectory( + self, "Select Folder", edit.text() + ) + else: + path, _ = QFileDialog.getOpenFileName( + self, "Select File", edit.text(), file_filter + ) + if path: + edit.setText(path) + self._save_config() + + def _apply_config(self) -> None: + self._pull_field.setText(self._config.get("pull_folder", "")) + self._output_field.setText(self._config.get("output_root", "")) + self._nuke_field.setText(self._config.get("nuke_executable", "")) + self._tmpl_field.setText(self._config.get("template_script", "")) + + def _save_config(self) -> None: + self._config.update( + { + "pull_folder": self._pull_field.text(), + "output_root": self._output_field.text(), + "nuke_executable": self._nuke_field.text(), + "template_script": self._tmpl_field.text(), + } + ) + _save_config(self._config) + + # ── Scan ────────────────────────────────────────────────────────── + + @Slot() + def _on_scan(self) -> None: + folder = self._pull_field.text().strip() + if not folder or not os.path.isdir(folder): + QMessageBox.warning(self, "Invalid Folder", "Please set a valid Pull Folder.") + return + + output_root = self._output_field.text().strip() or r"V:\_FOOTAGE" + + # Reset state + self._shots.clear() + self._basename_row.clear() + self._badges.clear() + self._table.setRowCount(0) + self._render_btn.setEnabled(False) + self._log.clear() + self._error_count = 0 + self._log_hint.setText("") + self._scan_btn.setEnabled(False) + self._save_config() + + # Indeterminate bar while scanning + self._progress.setRange(0, 0) + self._progress_label.setText("Scanning…") + + self._scan_worker = ScanWorker([folder], output_root) + self._scan_worker.log_message.connect(self._append_log) + self._scan_worker.scan_complete.connect(self._on_scan_complete) + self._scan_worker.start() + + @Slot(list) + def _on_scan_complete(self, shots: list) -> None: + self._shots = shots + self._updating_checks = True + self._table.setRowCount(len(shots)) + + for row, shot in enumerate(shots): + self._basename_row[shot.basename] = row + + # Column 0 — checkbox + chk = QTableWidgetItem() + chk.setFlags( + Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsEnabled + ) + chk.setCheckState(Qt.CheckState.Checked) + self._table.setItem(row, 0, chk) + + # Column 1 — shot basename + self._table.setItem(row, 1, QTableWidgetItem(shot.basename)) + + # Column 2 — frame range (amber when frames are missing) + frames_item = QTableWidgetItem(shot.display_frames) + if shot.missing_frames: + frames_item.setForeground(QColor(COL_AMBER)) + frames_item.setToolTip( + f"Missing frames: {_summarise_frames(shot.missing_frames)}" + ) + self._table.setItem(row, 2, frames_item) + + # Column 3 — resolution + self._table.setItem(row, 3, QTableWidgetItem(shot.display_resolution)) + + # Column 4 — status badge + cell, badge = _badge_cell(shot.status) + self._badges[shot.basename] = badge + self._table.setCellWidget(row, 4, cell) + + self._updating_checks = False + self._scan_btn.setEnabled(True) + self._progress.setRange(0, 1) + self._progress.setValue(0) + self._progress_label.setText( + f"{len(shots)} sequence(s) found" if shots else "No sequences found" + ) + + self._stack.setCurrentIndex(1 if shots else 0) + self._select_all.setVisible(bool(shots)) + self._update_selection_ui() + + # ── Selection bookkeeping ───────────────────────────────────────── + + def _checked_rows(self) -> list[int]: + return [ + row + for row in range(self._table.rowCount()) + if self._table.item(row, 0) + and self._table.item(row, 0).checkState() == Qt.CheckState.Checked + ] + + @Slot(QTableWidgetItem) + def _on_item_changed(self, item: QTableWidgetItem) -> None: + if item.column() == 0 and not self._updating_checks: + self._update_selection_ui() + + @Slot(bool) + def _on_select_all(self, checked: bool) -> None: + self._updating_checks = True + state = Qt.CheckState.Checked if checked else Qt.CheckState.Unchecked + for row in range(self._table.rowCount()): + item = self._table.item(row, 0) + if item: + item.setCheckState(state) + self._updating_checks = False + self._update_selection_ui() + + def _update_selection_ui(self) -> None: + total = self._table.rowCount() + selected = len(self._checked_rows()) + + self._count_label.setText( + f"{total} sequence(s) · {selected} selected" if total else "" + ) + self._select_all.setChecked(bool(total) and selected == total) + + rendering = self._runner is not None and self._runner.isRunning() + self._render_btn.setText( + f"Render Selected ({selected})" if selected else "Render Selected" + ) + self._render_btn.setEnabled(selected > 0 and not rendering) + + # ── Render ──────────────────────────────────────────────────────── + + @Slot() + def _on_render(self) -> None: + selected = [self._shots[row] for row in self._checked_rows()] + if not selected: + QMessageBox.warning(self, "Nothing Selected", "No sequences are checked.") + return + + nuke_exe = self._nuke_field.text().strip() + template = self._tmpl_field.text().strip() + + if not os.path.isfile(nuke_exe): + QMessageBox.warning( + self, + "Nuke Not Found", + f"Nuke executable not found:\n{nuke_exe}", + ) + return + if not os.path.isfile(template): + QMessageBox.warning( + self, + "Template Not Found", + f"Template script not found:\n{template}", + ) + return + + process_script = str(Path(__file__).parent / "process.py") + tmp_dir = tempfile.mkdtemp(prefix="vfxproxy_") + jobs_file = os.path.join(tmp_dir, "jobs.json") + + payload = build_jobs_payload(selected, template) + write_jobs_file(payload, jobs_file) + + self._append_log(f"Jobs file: {jobs_file} ({len(selected)} job(s))") + self._progress.setRange(0, len(selected)) + self._progress.setValue(0) + self._progress_label.setText(f"0 of {len(selected)}") + + for shot in selected: + self._set_row_status(shot.basename, "Queued") + + self._runner = NukeRunner( + nuke_exe=nuke_exe, + process_script=process_script, + jobs_file=jobs_file, + total=len(selected), + ) + self._runner.log_message.connect(self._append_log) + self._runner.progress_update.connect(self._on_progress) + self._runner.job_started.connect(self._on_job_started) + self._runner.job_done.connect(self._on_job_done) + self._runner.finished_signal.connect(self._on_render_finished) + + self._scan_btn.setEnabled(False) + self._render_btn.setEnabled(False) + self._runner.start() + + @Slot(int, int) + def _on_progress(self, current: int, total: int) -> None: + self._progress.setMaximum(total) + self._progress.setValue(current) + self._progress_label.setText(f"{current} of {total}") + + @Slot(str) + def _on_job_started(self, basename: str) -> None: + self._set_row_status(basename, "Rendering") + label = basename if len(basename) <= 32 else basename[:31] + "…" + self._progress_label.setText( + f"{self._progress.value()} of {self._progress.maximum()} · {label}" + ) + + @Slot(str) + def _on_job_done(self, basename: str) -> None: + self._set_row_status(basename, "Done") + + @Slot(bool) + def _on_render_finished(self, success: bool) -> None: + self._scan_btn.setEnabled(True) + if success: + self._progress.setValue(self._progress.maximum()) + self._progress_label.setText( + f"Complete · {self._progress.maximum()} job(s)" + ) + self._append_log("── Render complete ──") + else: + # Anything still queued or mid-render did not make it + for basename, badge in self._badges.items(): + if badge.status in ("Queued", "Rendering"): + self._set_row_status(basename, "Failed") + self._progress_label.setText("Finished with errors") + self._append_log("ERROR: Render finished with errors") + self._update_selection_ui() + + # ── Helpers ─────────────────────────────────────────────────────── + + def _set_row_status(self, basename: str, status: str) -> None: + badge = self._badges.get(basename) + if badge is not None: + badge.set_status(status) + + @Slot(bool) + def _on_log_toggled(self, open_: bool) -> None: + self._log.setVisible(open_) + self._log_toggle.setText(("▾" if open_ else "▸") + " Log") + + def _append_log(self, msg: str) -> None: + stamp = datetime.now().strftime("%H:%M:%S") + lower = msg.lower() + + colour = COL_DIM + if "error" in lower or "traceback" in lower or "failed" in lower: + colour = COL_RED + self._error_count += 1 + self._log_hint.setText(f"{self._error_count} error(s)") + self._log_hint.setStyleSheet(f"color: {COL_RED}; font-size: 12px;") + if not self._log_toggle.isChecked(): + self._log_toggle.setChecked(True) # auto-open on first error + elif "done:" in lower or "complete" in lower: + colour = COL_GREEN + elif "missing" in lower or "skipping" in lower or "warning" in lower: + colour = COL_AMBER + + self._log.append( + f'{stamp}  ' + f'{html.escape(msg)}' + ) + + +def _summarise_frames(frames: list[int], limit: int = 12) -> str: + """Compact string for a sorted frame list, e.g. '1104–1106, 1210'.""" + if not frames: + return "" + runs: list[tuple[int, int]] = [] + start = prev = frames[0] + for f in frames[1:]: + if f == prev + 1: + prev = f + continue + runs.append((start, prev)) + start = prev = f + runs.append((start, prev)) + parts = [f"{a}" if a == b else f"{a}–{b}" for a, b in runs[:limit]] + if len(runs) > limit: + parts.append("…") + return ", ".join(parts) diff --git a/jobs.py b/jobs.py new file mode 100644 index 0000000..37c7c31 --- /dev/null +++ b/jobs.py @@ -0,0 +1,61 @@ +""" +jobs.py — Build and serialise the jobs.json payload passed to process.py. + +The jobs file is the sole interface between the GUI/host Python environment +and the Nuke Python environment. Keeping it as plain JSON means process.py +has no dependency on this project's other modules. +""" +from __future__ import annotations + +import json +from pathlib import Path + +from parser import Shot + + +# ────────────────────────────────────────────────────────────────────────────── +# Public API +# ────────────────────────────────────────────────────────────────────────────── + +def build_jobs_payload(shots: list[Shot], template_script: str) -> dict: + """Return the dict that will be serialised to jobs.json. + + Parameters + ---------- + shots: + Shots selected for rendering. + template_script: + Absolute path to the Nuke template (.nk) file. + """ + return { + "template_script": Path(template_script).as_posix(), + "jobs": [_shot_to_job(s) for s in shots], + } + + +def write_jobs_file(payload: dict, path: str) -> None: + """Write *payload* to *path* as indented JSON.""" + with open(path, "w", encoding="utf-8") as fh: + json.dump(payload, fh, indent=2) + + +def read_jobs_file(path: str) -> dict: + """Read and return the jobs payload from *path*.""" + with open(path, encoding="utf-8") as fh: + return json.load(fh) + + +# ────────────────────────────────────────────────────────────────────────────── +# Internal helpers +# ────────────────────────────────────────────────────────────────────────────── + +def _shot_to_job(shot: Shot) -> dict: + return { + "basename": shot.basename, + "input_sequence": shot.input_sequence, + "first_frame": shot.first_frame, + "last_frame": shot.last_frame, + "output_folder": shot.output_folder, + "output_movie": shot.output_movie, + "output_exr": shot.output_exr, + } diff --git a/nuke_runner.py b/nuke_runner.py new file mode 100644 index 0000000..f701e5b --- /dev/null +++ b/nuke_runner.py @@ -0,0 +1,111 @@ +""" +nuke_runner.py — Launch Nuke in terminal mode and stream its output. + +Nuke is started ONCE with: + Nuke.exe -t process.py jobs.json + +stdout/stderr are merged and forwarded line-by-line via Qt signals so the +GUI log window updates in real time. Progress is inferred from the +structured output lines that process.py prints. +""" +from __future__ import annotations + +import re +import subprocess + +from PySide6.QtCore import QThread, Signal + +# Lines emitted by process.py that carry progress information: +# [3/10] Rendering: UNG_101_012_010_BG01_TT_v001 +# [3/10] Done: UNG_101_012_010_BG01_TT_v001 +_RENDERING_RE = re.compile(r"^\[(\d+)/(\d+)\]\s+Rendering:\s+(.+)$") +_DONE_RE = re.compile(r"^\[(\d+)/(\d+)\]\s+Done:\s+(.+)$") + + +class NukeRunner(QThread): + """Runs a single Nuke process and emits its output as Qt signals. + + Signals + ------- + log_message(str) + Each non-empty output line from Nuke. + progress_update(int, int) + (jobs_done, jobs_total) whenever a job completes. + job_started(str) + basename of the job Nuke has just started rendering. + job_done(str) + basename of the job Nuke has just finished. + finished_signal(bool) + True if Nuke exited with code 0, False otherwise. + """ + + log_message: Signal = Signal(str) + progress_update: Signal = Signal(int, int) + job_started: Signal = Signal(str) + job_done: Signal = Signal(str) + finished_signal: Signal = Signal(bool) + + def __init__( + self, + nuke_exe: str, + process_script: str, + jobs_file: str, + total: int = 0, + parent=None, + ) -> None: + super().__init__(parent) + self.nuke_exe = nuke_exe + self.process_script = process_script + self.jobs_file = jobs_file + self.total = total + + def set_total(self, total: int) -> None: + self.total = total + + # ── Thread entry point ──────────────────────────────────────────── + + def run(self) -> None: + cmd = [self.nuke_exe, "--indie", "-t", self.process_script, self.jobs_file] + self.log_message.emit("Launching: " + " ".join(cmd)) + + try: + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + except FileNotFoundError: + self.log_message.emit( + f"ERROR: Nuke executable not found: {self.nuke_exe}" + ) + self.finished_signal.emit(False) + return + except OSError as exc: + self.log_message.emit(f"ERROR: Could not launch Nuke: {exc}") + self.finished_signal.emit(False) + return + + for raw_line in proc.stdout: + line = raw_line.rstrip() + if not line: + continue + + self.log_message.emit(line) + + m = _DONE_RE.match(line) + if m: + current, total, basename = int(m.group(1)), int(m.group(2)), m.group(3) + self.total = total + self.progress_update.emit(current, total) + self.job_done.emit(basename.strip()) + continue + + m = _RENDERING_RE.match(line) + if m: + basename = m.group(3) + self.job_started.emit(basename.strip()) + + proc.wait() + self.finished_signal.emit(proc.returncode == 0) diff --git a/parser.py b/parser.py new file mode 100644 index 0000000..df7aa5d --- /dev/null +++ b/parser.py @@ -0,0 +1,177 @@ +""" +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") diff --git a/process.py b/process.py new file mode 100644 index 0000000..7444433 --- /dev/null +++ b/process.py @@ -0,0 +1,172 @@ +""" +process.py — Nuke terminal-mode batch renderer. + +This script runs INSIDE Nuke and must not import anything outside the +Python standard library or Nuke's own modules. + +Usage (called by nuke_runner.py): + Nuke.exe -t process.py + +jobs.json schema +---------------- +{ + "template_script": "/abs/path/to/proxy_template.nk", + "jobs": [ + { + "basename": "SHOW_EP_SEQ_SHOT_ELEM_TASK_v001", + "input_sequence": "/path/SHOW_..._%05d.exr", + "first_frame": 1001, + "last_frame": 1100, + "output_folder": "/output/SHOW/SHOW_EP/basename", + "output_movie": "/output/.../basename.mov", + "output_exr": "/output/.../basename.%05d.exr" + }, + ... + ] +} + +Expected template nodes +----------------------- + Read_Input — source EXR sequence + Overlay — text burn-in; knob 'bottomleft' is set to the shot basename + Write_4k — EXR sequence output (file = output_exr) + Write_h264 — MOV / H.264 output (file = output_movie) + +Both Write nodes are executed in a single nuke.execute() call so the +graph is only evaluated once per frame. + +Progress lines printed to stdout (parsed by nuke_runner.py): + [1/10] Rendering: + [1/10] Done: + [1/10] ERROR: : +""" +from __future__ import annotations + +import json +import os +import sys + + +def _log(msg: str) -> None: + """Print *msg* and flush immediately so the parent process receives it.""" + print(msg) + sys.stdout.flush() + + +def main() -> None: + # ── Locate jobs file ────────────────────────────────────────────── + # When launched as: Nuke.exe -t process.py jobs.json + # sys.argv is typically: ['process.py', 'jobs.json'] + if len(sys.argv) < 2: + _log("ERROR: Usage: Nuke.exe -t process.py ") + sys.exit(1) + + jobs_path = sys.argv[1] + if not os.path.isfile(jobs_path): + _log(f"ERROR: Jobs file not found: {jobs_path}") + sys.exit(1) + + try: + with open(jobs_path, encoding="utf-8") as fh: + payload = json.load(fh) + except Exception as exc: + _log(f"ERROR: Cannot parse jobs file: {exc}") + sys.exit(1) + + template_script = payload.get("template_script", "") + jobs = payload.get("jobs", []) + total = len(jobs) + + if not jobs: + _log("No jobs to process.") + return + + # ── Import Nuke ─────────────────────────────────────────────────── + try: + import nuke # noqa: PLC0415 + except ImportError: + _log("ERROR: 'nuke' module not available. This script must run inside Nuke.") + sys.exit(1) + + # ── Open template ───────────────────────────────────────────────── + if not os.path.isfile(template_script): + _log(f"ERROR: Template script not found: {template_script}") + sys.exit(1) + + _log(f"Loading template: {template_script}") + nuke.scriptOpen(template_script) + + read_node = nuke.toNode("Read_Input") + overlay_node = nuke.toNode("Overlay") + write_4k = nuke.toNode("Write_4k") + write_h264 = nuke.toNode("Write_h264") + + if read_node is None: + _log("ERROR: Node 'Read_Input' not found in template.") + sys.exit(1) + if write_4k is None: + _log("ERROR: Node 'Write_4k' not found in template.") + sys.exit(1) + if write_h264 is None: + _log("ERROR: Node 'Write_h264' not found in template.") + sys.exit(1) + if overlay_node is None: + _log("WARNING: Node 'Overlay' not found in template — bottomleft will not be set.") + + # ── Process jobs ────────────────────────────────────────────────── + for idx, job in enumerate(jobs, start=1): + basename = job.get("basename", f"job_{idx}") + _log(f"[{idx}/{total}] Rendering: {basename}") + + try: + first = int(job["first_frame"]) + last = int(job["last_frame"]) + input_seq = job["input_sequence"] + output_mov = job["output_movie"] + output_exr = job["output_exr"] + output_folder = job.get("output_folder", "") + + # Ensure output directory exists + target_dir = output_folder or os.path.dirname(output_mov) + if target_dir: + os.makedirs(target_dir, exist_ok=True) + + # Configure Read node + read_node["file"].setValue(input_seq) + read_node["first"].setValue(first) + read_node["last"].setValue(last) + read_node["origfirst"].setValue(first) + read_node["origlast"].setValue(last) + + # Set burn-in text on the Overlay node + if overlay_node is not None: + overlay_node["bottomleft"].setValue(basename) + + # Configure both Write nodes + write_4k["file"].setValue(output_exr) + write_h264["file"].setValue(output_mov) + + # Render both Write nodes (sequential calls; list form not supported in all versions) + nuke.execute(write_4k, first, last, 1) + nuke.execute(write_h264, first, last, 1) + + _log(f"[{idx}/{total}] Done: {basename}") + + except Exception as exc: # noqa: BLE001 + _log(f"[{idx}/{total}] ERROR: {basename}: {exc}") + + # Free memory caches between jobs to avoid RAM build-up + for fn in ("clearRAMCache", "memory2", "memory"): + func = getattr(nuke, fn, None) + if func is not None: + try: + func("free") + except Exception: + pass + break + + _log("All jobs complete.") + + +if __name__ == "__main__": + main() diff --git a/proxy_template.nk b/proxy_template.nk new file mode 100644 index 0000000..46fa24c --- /dev/null +++ b/proxy_template.nk @@ -0,0 +1,54 @@ +# proxy_template.nk +# +# VFX Pull Processor — Nuke proxy render template +# +# process.py sets the following knobs at render time: +# Read_Input.file, Read_Input.first, Read_Input.last, +# Read_Input.origfirst, Read_Input.origlast +# Write_Output.file +# +# You may insert any intermediate nodes (grades, reformats, LUT transforms, +# etc.) between Read_Input and Write_Output. process.py only touches the +# two named nodes, so your pipeline can be as complex as needed. +# +# Codec / format notes: +# The Write_Output node below is pre-set to Apple ProRes 422 HQ (apch) +# inside a QuickTime container. Change "codec" to suit your facility: +# apco = ProRes 422 Proxy +# apcs = ProRes 422 LT +# apcn = ProRes 422 +# apch = ProRes 422 HQ +# ap4h = ProRes 4444 +# hap1 = HAP +# jpeg = Photo-JPEG +# On Windows you may need mov64 instead of mov32 — swap the codec +# knob accordingly. +# +Root { + inputs 0 + name proxy_template.nk + first_frame 1001 + last_frame 1100 + fps 24 + lock_range false + colorManagement Nuke + format "2048 1152 0 0 2048 1152 1 2K_Super_35(full-ap)" +} +Read { + inputs 0 + file "" + name Read_Input + first 1001 + last 1100 + origfirst 1001 + origlast 1100 + on_error nearest +} +Write { + file "" + file_type mov + codec apch + mov32_codec apch + name Write_Output + create_directories true +} diff --git a/proxy_template.nkind b/proxy_template.nkind new file mode 100644 index 0000000..bc5da38 --- /dev/null +++ b/proxy_template.nkind @@ -0,0 +1 @@ +Nuke Indie mode encrypted text (commercial mode uses plain text) URFPX36FbEDY74fHQWo:RUg\\HDB_;l?LXM8W8m>hn30O`b0e0b??cA?4U^9m`lj2Q1cJa0`KKj5KH8B8oA@EiR?NC5g2:B\O8JBjBAU]M]=619B4jG=Nmo[^Oamn:1L=d8@\VHM>4cASTd:kLBNFkoAcjii;5HYK\TCN>c\lRf6PNL1c8^3Y;8R?4f60AYn>93WWAf\lTgk_0YUCUFG6USPTX7a]EB5M8YV\^1L\=8Fd2c>A1=e3MoOIA1;Gl8OkleGI?kUDinO77e`c?[mWQW1SJQ\0[H_kn@mjOIEblI20TVZ`74[;DVI6kXNPhnFg:A>3`Okko8eQ2G]gST3KneN[8=;c8DN^6K@oBKiU7aWI3j2h5G3S7BY?bh:2dj;Y\>]hfZ=IlBiXbOnGFKR8I\cgc283m_kO5AA3^0UFabkbYNSG@ajk^m[nok?S=`EFL?]7CObdFBS63g3L9Tg;oK;Z>38@OM3O8eU:C=HAn;@3ea22fFf]J^Ze_X:=Tl@8T\6TG?iJ]mbS0NEB=l0h;2WVa^mad:JVafJf?mBbTIYWTY?k;<8L\bIOad29Y?\g`L9<;bihbSFG3F\jmX5CSL6o@^[N0[N>=Okh8j2I^[mY2nV4DSgY[88;\Ondn@MZMPn:1XP;5ckIeE@RhhfLT>4VCDBZG552Xmm\LN834e21_RJlGnbLTO@E_^j9J`12mJW@`7a`Mj;VNl:b>9[0\ggAh0iT5cA6mJgYP^YHWKP>WeQ460o>[cU3Dk\PBI[6CQE8XVeaC@>JX7]^[BjK^YgHbIo7ac[lfB\ROL=00b3:4LY2`@hG>cK;BI;X9^cd3JDeNM_K9D`g]OU;Wl`nZ8goCdP3geYe@[>]WZPcQ0mIA@_iIDMNYiJChgU`Mab3^b3EO0e@OYZfMUCD`0eJ8?We7PQTN9>a;d:bh^9B7C8cm:NN;hm[WQcl8MWVQnhgNEl@XR>M93\]SGTdiWaCBBSH07IQfJZa0^Pgk[E?IE]@bREC@=53;IeQ3h3?Rn5hHN7E\:PL?8WKiWkMEHJa_\okl1gcRJG\KgdFPAc]c]A=FIcYhVjS8k:<\95_QnYNkm0A`2F@X^7jGDJJQ@5U3;o7EVC8oZ=TePE9^lj6SlMTb;W=M7jM6dKNBT`jIbNRMK6F2n:WD_Y\L_m6FYXb?L4=EA@`H`gTBK7793KmT^_B>hIeh21Oi7h0D:2LhZUX57WIha=]9onT9XWLQZfK^4DY@m2cTo`TW2QRd;j<@Ea::m38g81YZ9MZmXa7V=V3iGS:dU0Wgl[ZomR?]cLWhM3Fk8MFGTXR\@88D8<7jEiW1PbVhOU4E[JC98G3ZF0M69cahDIKW>1P7>LVLZOnKWk@\=dCYMDQ]DhC`VgkcLVP2ThT_B95^VN2Fc\4O`CfO9NkS08V=[ZV9`Um=EI0Qa5;JdEhTkaNLE[DC=o_8@M5c=d7UfUG_ZeFG@X>?Y6TafOmU]oG:7Y8nNS8Xc42C_T=i6KJA@85^i5@6Cb@dLa=XW;[6H2n]\`K=cPE<87=5\ZP=1=RcN9KlSQlJM?jO;n764[=MioZA_L1?In[l>936eY@K?5M@=Y4jS@DHgWV[?B?TVC^=i:_[WMSej8]dUi=ZKKZQkSeAR3b]3\So\I>9?Q;DBPeU=mX8a`SUffe`gT^cLYhGT9aGVLYSF[CUmIV;SV2mhodNkW5ggIQYT:G<\d6ZYV3[3DiQM1Wik28BIjT^F4NkGXQa?eaFZH37>L0d6aVHf5U5RXYfOM4`O2QVaH^7P^clkU0Sadn:BJMaE4>:DM[SnYed;X5=EAB241^TVng:FNML=9cc]coTQ6@i4JYG1IW_j0=TGKlh`Q7RQ5F]eTQ_4`>`TEO5Vi;Ai7>7RH:5mJHV8W5H`m>K?ao[3US2ICb0]^LGCVX2PGk_BQMThM1X:d^QKXT0lnI?1\hLPWA\KF=hf;HbZlW]3Q71YB4UgjMR4Qa@7WWLbj03M6[Rk`2^>WgA2adGfCSHRT1Hi_0SQn1Uf:NdQC73VjdbNk2CaL;KX7>2iS0UJHNEBl@SjYbgd^Q`4NY>0kB6oXB`C`ggE=7VV]^58HFHc@3?AKLb_b7`E0>`1W_JU_diXR>2S[Ui<@jF4=;6TDk`?db;P8gkQ@AL3;[GFYiNWRMf\`8814kEW]g45oZUlhAoRZhJ6OU=^_nho^DMQfiXh[W_T3mXYDc]mPj4UWeP8m?hGe@eYW0YOT8MKVTH:WlfIIGHHBfhbkfUkeG50^K6i17;Kj8`k@_U@IdiemaE`@[CFilkEG`lmi1o@BEc`k]Y@kP@Oeh612dO6iE_JlF687A3>CQ:HL\b01K:=OegclI24fKaP[bM7cLVR9KFnBLdOKDoHGnbT\dDcM8i`T@CbBAa7kejec@o3S[]j`cU6\PK>VDK8>mG`H:b`L6i2a3SNmL[<4X>:PkBoZZ22llAkQ\DL`lR\CQm5\eibMG8RC]BnP0=Z4N1`MVY6aBJ]P:Z?oKO0:ibG\aXncE[M\R3G37nPFM9KfJZVelcI4R1[=IGiBB07ZTdl\chQVA5WB6==`VLlg6LIeil[E[cGbEGNT_f0omIlk]7<3KI13b6`Bj?Z]Vga5S5`7;di:1_dVVS2o6Wc\nZ9ckhjbZ`J>ABn3jV;meAmDB8@6V`0DOnKiMJ[e`a\NChB:N@Y>N>e_OH26QW5P\8gSUA7VPeT89K0F3?Ac?GYR2ni__j_9h^kOR=d8nn5F6VUZJn85AZeiO0`;AEQmQZ04TmKkY2Vfifo[mNdhRBRJb8bEOLahWJ0o8GUh`3jHkc:K30jhI4LZg^1Rme=SQn[XS4D41]GPj:MD\6>JKf>cPKj;eZmB0flYK\NQ<[JJN01Fk3F@KZ=R]16WbDc[HDDjVM]mFilbUlE1@aSXBU]^UILGUjG14EmoM?6GmW;EGhhXP>hEP`n5`7oXLmXgR1K\L<1bRK7AnkC52HM[0Lm`fBak_WOS[kXlkG1NR>590eWZ`nQ>E421N^nNnP0LkKNH=KRdJMMeJP^JIN=efj5\WL>MfWf0CjR5JRA5;?`09:_GdUCDKXcIQ_X6Y:hkB5c3]Zai4h>De[LXYGjK43:BGfjbMJ\>g7GkHcPQIl:n6fUYcdgfb8jb<59F2\4OPh1VV3IRA53J[hU9\PhZe5jEKEC4Bl_i58AdIR@^1W]HkW0=EISX=GFTGQ7hSWJcdi\YA<3nhP?eBcXkh]<]UlK=EdSmOfEFCl>lR[`TQH:i<60fM31al__dV9JXbm8W;WJJJ[I65D\DInW]n819XS3QWhoTAYQej_i:_HVWUlM9hBV[]O49NDADbeaaIhFHUM`[bA625aFoAm3P@1@8:C^;Moeh;F`aGE9>1D8@VoYW;biGKQZRU3MEhj5o`>VChAgH\FIKGCnB[EV3cR[?D^f2AbP^^ab3jbdP?GYBFdKRl5Zn7@n=n92F3^S<^kSnj_dLiJE_K[`M;]4G9QdTXm1jAhAL7WJe067j^LTbGk4HH\DOh;YebWmZfk5V5HZbSJbjn@\jVn<1:`81JXnX^;S279XjX1jO43_Jad>ZChSeKBnJ[h2?kQaaPmDHF7EH53nFHEf9jo5h;AC>Q;n]^Bgd0ac0YjTFmHA\cCVEnl@]f7MCc89;C^\h[S>e7EnD^@4@AIJNFk1bH4AE9GE2dZTFZh3AG\Cemi2jclQ97DX2>`1m@\NTMnET?@1cc@1aeJ@j]jF073449=g`3eO_7GX26_@DG7QVV[RcjaY;hDKTS;RTmgZ5bhCZB28\U@O?9m`0[Q7PGOj8fN0k?J`e>LTP8H3@7kn3\LB4]O=nflZij<]WnM4IdflVAgn9`QWiB1SbK]DKM2l@:?8>]Nm_6LkcEeUIc@>@f3Y5V[i=neT5]6mn]Io`^aWDHPn@G5D3ZD2Ik54FG@Eh79:\0Bj0T@k]3IHiO0WQYENlh:QU_hHa5mcIn8K>TX_19OFVKakIiBe0H60bF95<7LOUCdMFF4>00fFdAB5GRjg5LFHV;b\3bE>2ALF616RQ5DR\4o8FHa?=jh08SIO]F^_OIR4C?e`7ZnaW:_mM5`gEJ=?M\jG1>:^RZh^Q9ko86LIi[CYi9>PeTdZT^Z0@i`^8FT`YILOkRg;kB^mD9E?kDOF=][[Ybbo8Bj]Gccd[@<6C3R4]8Xc_lT^6a`IS`feUe?dn3=QfB=MJeUb@^=lao\JogS>bFWD55VBVh@T^[UZB@TPLS0klCOPCo8Fb^laf@5oRD;b6kANoAibnQDIRTF4AW=86Q0L6hHE_FoAo130H1\^ikCj_M=7TYMH]mfI^QOoHlm]?TA[mB3VffNO27:YVAOhjSSO;4[k?0iTTM7Iamn^XYoS3:YEPfWVUTb3@Uaf9PYbA4V_LGiWJPYbcWQWZoGnXKb5bI^nTDkN;WOofWcI\T=_1jDV\<>kanna9c<^=aQ\@:I:>GI_9QBIdCleWTg]haF^5T\hR:hJScmUNoRc??4oj<5hdZCkjgRllRQ2CRBjac:]_F>:8GRN6EXj`P=ejZdJ@DfMA[;Bl_=L511I8Uc>CZkXjfWOYD;Qg::]NZPE;23F6k7C@4;nG3bnNc[1LLkUL4FA`KK]A?hCPoF;HalGNEf_YL`X]WgZ^P64=l]\Qkim^\miU37BFC9Z_4:oLY7if5WOhTWV1^m?73OSVTk0imV^T:Bn1eFfaejG_XXJ9[oTmQ5`]IbFAmA;:FPGg6kg]bEmiE[fi3ngOMX`Rh]Va]F2TdgUcET8GS7CUW2mNocD^QL91;ZS0B5PfoTbKfPW[T@NgVYmnD>5EjiHPd`hR]ba^0I5cjjbPd_mhgfCZngCFD=2d0jZZ5b0Ia0[d3;J^_7B0gn?d]TiSEC<4=EQ^dib\oONVj6kGii=<_KSWV^aYEW;NXYcLil^]]3^3ncW;ZHBZn3hhE0NP@SY_?5nKllPEF2O3=8DQQgghngF`NRAd6L_V7FZGdT0Jacj\0ZU@BcB2hnfd[VTBF3WPgE\S;m`ZgD?APO;Oe8C:XcQ5eNIDXG4DTfUld`2]d]`1hac:3FOX?[H;\Ba_FT4`[a`W0WEC0YfDoPOYK65kZo>A0_FS;0XQWR>S\^nEP3A^[B5QT6[>`Yg;FRZMZZ[J^hNJCmYAGBgK7O:Sd@f2Ke:;><8_1GA5`KbF\EW4HGF5]9aRh?V7SEMF6TFWOo1U5FBBdl[3g8h=Q`Fh?\bdmG>b5:hZB;7=30_DOFf^k>oj^ZIU0An\f6`>B5258QHl^6HOIE=e9h:\4Fj9=f>NZJX?NelLJ\9AWQil2aFPnVojCIKc4Vo2I\dPcj;=;M^Q3:KOXP=VE>3H;;55TQ@_PVSoH2GBGVTG^TO^2VhmOZ^ClRkcL65>Ff]4m]1@YPo:j;?aMeB26[ZPangDRCaF6g7hI[_8UWB?QlEFgIQPE>DYOlf:hkHl7Ra1@ak<400b9eKD4Y5aKLm44aYUG7onfHHKA8O2ieo\nRXD`kXSlN7nW>HW^j^2WkmYBQ9X6:XMWK7HYS]i]H;`gcNnJ8DM=HDWS;Ih4NV8C3f^WX87Qa3hKUYZ\8bFSSVTKJ>627dQ[6WBjHc=k_L=Pnh:>1glV[AaPNG7J9;<_f0_Z]_^AD1N<01S^ogj[9m_^OJk_li5l09bS=c4?BOIM@`PJaBY84\aQl_VW0K46aY3WUbD3MXn3EocVSXXc[EU\P<:X:\91TdlE@d@1Ib1D9;gNERn?9lMb`TnSbKc[n_?daM[0H4OI\<0MW1^^6le26ZHj2E8Bnjl^XaWCeb[DIaRMZ?MJO_`[CGj@6BAk?^mGjm8FJHUMM]G\kl?Kb:QjlP?8F4jRaI[6eXdIWWoa[dd]OEnCbeUIG??H]1;;:JN75I@G[oaP4HSjOWY4MLm1Y4ZiQglAEJ7kBeS01GD2TFE[0Mm2VaOMlBgjT_kJ]Q8I7Q]gU>DQ=UR60ZN^c0FED7VWYlj_=7EU_5`OZGWicoS2\K1Db?0S0:fYm?:c0cB\7jhlkenO>4`dN\C_@dkmkcG8A_S[ZaY\n^Z5\I1anE9OWEie4V3IcDB=?jGOQoL[EoFCY_gloEe]X8f>8LThQ4[iIcH?b=gla2CXmlbTPO_\IILo\N9@WW^CWQL=B^CTl?>jf78V:IdJVdB9Vg^QR;k93F9A>=^Dh3FP6_0YTf=5_HN?m_CVC=n;Iac?cFjcP_XJV3NA4]X;7ih7K>DQ:DN2B181>k;eSkPj1V?f>oPo;D97X]\0LjSddYIXECk^l:AN]1bRVDg=`g]_agZM]8dUHnaVa0Uj`\h^Yh_5;6nVK:T<`JVaa@7hMO]c@i[9BNdNKR>FS1]Y2IH?Vl9jUYG@6YJmI3UCM>ogMW_]nA6b`\29Rd7OKc7n74>Ad^\L@F6anTV:HSLX`]Dl:WWHS6ON9acS8jhJQJ4oBXcYPIgm33=D0][BXF]SW>h\g59oFYb[HamLTI^C?=eEQoVUgJbL6BVOOmH@=@BHj;AlF:I@2goBWCOL9l:LZc?^=@UMdal\?P@JCHZYnKLBTEm]ZHE=IE]V][BaEMNMa3>8oMhUW3T0ECdheo7Gnj:A2K4lBabkfjQGKgG96Y[gHOQ;GgcCkR_e=06WZI_mIOCI]j0c5E6NBV3f4D4m^[@@Pm9]2[RQCJFNP[=GLW]6=9WQDM]nNJSVj<@i1dl:a2bBCX^WWS8Vb42=4MTB5bN5<^9\Um35eo^?8f0imD5JMIhZ5SialVA?kSLo0;41_flZ]<8;_XSeXMJf^Fm[PeGNAccKg85?G^3iH?1RO8miAjK?>B8;9fI3V^d[YP?@7M@QIXiH>O;DdSE925f9k?=YZMCcgPVA=3LE_^NR4?W^Do<4AM?2`c@RYmcFeC[aGe`7fchO[DA9XRKf:BNjC@V]V:5m9>]_`CTBcXXbWJXCO1]Ue4V0HhU4`]RPh21kC=hn@lR92=>UWB:1njkgjmlI28g[AJ=4mBC5QBkg1Q?TJIAWVVPk?FW24Ba@]?4Ve8F;LL0WDcMaOH>Yc=]I^:[c67L;^N:8BEaThCTUB]Vmg`T6cGQClFAhi>f5[R`1WhCb:MBK0PbZJE2SnU>Yaa?4ZQQD<54\0lL=TIF;>fVjETgPU:XIPcOAF_U4>=WZHf5I4`=H?n_;GRooNSDRS_1ILF^]YOR`3`Y5[PDBeAdRXj@C?EIW4[i?igSWZ;B\Z\b>2TYcnM_36Cd;=D^LG8m?gejQ7TEM@k8Vofn;AnbI6\l0@bWI\JmVcTHBc@Z\oNdKh\@gL@M>mgLRZFn:@8:AAAAJgmWQfT5?4S3H914_47bTnLBeL8ZS4eJWgfeVCM<;KCNWZC^J5jDTTm44MA>>o1k@BWojCDEMoUBJEVc_iJP@VXhT3S81_:BC\oLQP0nVfnQ54953NlV0\oFXOP4M;9GoN>`;_VFU8bY?A194m5i>6;=B3DVfjTGeZcdAboNQ04TChm^[5fb7Xnkl`I<>VBbSn[9ffH`JYGCmY:9EMn;aT@6990h_FnmHMefU3g7M7kYB7=9V2__TkMS_D4V_7<7Cm2;n4QJnMD8fcg>][a:[N:]DfN>9XPVj6olIZKm8HE]AeDA^4dJKb;8Z5lKBOn0AUNCRWXbW=]>aQbl\ZYEiT;QLd2ZkBVZm?;R`:beVPb]Q2;0aZ^JcN?1@h9b4cl3`[N8@CA0D>c[V[bbR2H77@f5cBdC9TI=o=4m9j@C_1FBfZWG[9glBRkOgQ0:o_ag=k9cDI6@aY>XA1XQFKo;7odmmMa>YiG6hUc=:@3WA\@B?YC9352=R8\G`^NlgFh?[B^h6c3BoHDCZj=`4BD[\G7cDkfbAUD>Ug?6Yd=>TZ?O080D[0MoKZK4C>CcQWBUITEBRHD@iEnHS4Jfn=oYnhoh]3:Hc=UT3<;4bOQEok98MU983g0SGjHabg_DlG;2VQVe=[5ZC>_4_DO`OS_YB@HCEO>eo2kTh6L0T9;GjEA:7Z^:K8YPR47:=\DO;LB5aH_=USJ4A9Q:3Lo^oXkEg=g6CF5Z?P=W]N^QYFSli]P;C>db9Y=I_^Z>UIGj@m>Da\e[[gN7SIR]a=kbJj9F8c2G>a?COBSQ;iU24HZWASU\C\YV?cWY04=8K`G0afeg=XR>6D?PL1D5c9HLj[5]_5_f?Wlam`nai9]UOPUMZU]hg1R[B[7PSeYDfDUK08ilC\37SJ;TYH]\4nf2[g2\>gY4KCJ1OX]F?XO6UDF=iQ6PAR9f7;R96ihDHLcJW2`o_K5]N4Jg60Q2RRhfZ\nmdlP@HSgdcF_kYiG;Pg9]PZX1geQ>MUA5X^C\O?TPS\dN1iW[XWcGHlZFYKX^Y5;J`?MA?:`lc8EFh;=`@j6>BOY\oGd?;O^mU7]5P<5b6O=52EP`S<`81DKf:O_2E0:?4O6LZ8i9P?46BP1eYBALHBXb\S8:MERMGd09hbg[oADKk3E:4WMbSigLO86`4>6Y>6CQZ2IE^i^EXC?5`HVH1X3L;KkK2GeX;37@2dX]e;HNGhaZ3@NmdEOZJ]PB0XjhNAO?gSPUfSljojfXN3Glo8PaWI?da5O0h9;]BDfJIWCTmFKZjfZCbnD`1Y:@QZ;=>OlWe9O5BeleNBgRR5QkI]TAiYVS;M]nRGGVX4MF8oGf\@2a3oGF_bb]fm\8]EKcV8g1hB_XKJH^1?0gVW>VSifg`l@nH8^D6BF0XF>mW?UXZleB6ck\Rc<6BL_FC1?NlI5Gg?i:0QA\B`KfR=R:]]3lMd1ofWULDkVMNDW=OWMRZLe[W0S]6GK3aR]?I@n_J]D7Ue=FNfIh;oj[FjmmFo47k48GEiKJY16X3\8kYDX;SI6Kl8OmdOP_?B_<^1;1J0S;d>1\KkIQcD[C<>nhRY]6ZZOPLh2kcogZ^J;ZhER0h>B@Pm[gd10K4B4[PPm9\QR2EEZX;;RV>JSfDMJJN?W=NHg=>3LDbeoTDF^FShRM?^Ud?f?YBiRIiiXM7CCI9@``A1cV=iXcmIUU`:S`46ZGV;^R9YLTYKni7SfAn1c:B@7N1K`2FKknSO_=JgmB[Q5N4=>hl@E[OWGnn4UlkWEX6L=b?2L>\X6gN[cR3VOU0XHemHXn@iCBg8CJ8gJm3bc>^hUbmdXk]Le?VakBcl>HdED12T861m^22DV3N\a55MH1TJ;[`1K0@`jF[gbML:X8P9V=IMf?k=RLWZmEdCW22Sn3L\Jil`iinLUTF;ma3iYBLSEfHQ5jmmG\4JYVT\S\IbFd8oB@OHCPWF;P`hHfUenbYnEoc1Xf4[9=`a31@YajVKab?8mKLk?9gUUUX3^biSIC@@F4=4nKXZ]=gakb7m[P^eaaGIVHWQZEIU>X[LkM4h[BQ6o8_28NkkVJF@EMW?Q6U=APC7X]LV386\J]ANI`L0eJ[3S0HFD?n0F[Sdon`Tm^_DM4YdS_\6D?3J^i;G@<]m6:5VYUUH5JP[hAKTM1E60aaN>H4E714^9OV7FTQ:9mJ73NAo=h;[eR1]dfV=>WZeXI66Zed]mTPf\M_03P<1LfVJ^mW?OQ]GQ_S;cdD7XiRdma=a]K04G[0j<9bo45kJ1QAI]k5[WkTf6_>UhebfO4UKNi6UaFXdG1fNR3]3[lUB=a?cgVcoMG9Q5U5CVgU@j:E[mRXO>H1:1bd;]0S[naC3\`0B?7P5f]Sf@4ImAJRS:U:Xb;abOBJhI:J6RGC78ZhSa?T0>An;UhFm82:RbAF4jkITQNoJYW?dRQhXhC\FbRA0K^e6k]1^dQ^6l;4;c>NX19n[oA=Jjiag81RX^eFV0QHe8QmSGbY`eH2G@GNM\_D6oMd:NOLNHWNk[cGYZgV5o]5>R52gH1_::H@NnTGJI>dZa9I?TCWL3f_SLc6`PAJYg72FE=Vl=RhWQWV6I5^>22SSX^2NIUdUVA@3?fb17]fH=Q0K_;=oN;>j8jK_o[GHAXMKF?RFOYSPP9Vn[cnD@J1_f_?AKSdfS3VceMAC7R[9c9M]og_E[IdU_TC77V>mWF3\Z[_b4=m?kkF:38Mc?R2WK>X@fR?81aD1ZA<;n@BgOkU<8bcU\kG3`ReW_I`\9nVPQcG:[XbjNXoIPb?GH21FI9E4LkFF]M6ZLF3f<:KbOT_4:XoHWC[2_VF`UX8G4^V_=8B?ZO8B[VmcdiY`h_fgV@\NIYnWO\MjS6309]3CAj4^nP:a8AK:f6iMe9@h=;gh;2lKjS9KEeM[N8I74hDFacHalPFS]E2BI\Z[^TDE6SBM:1Cbb19IHGVkNa9bHO7U88h1?1oZ5AmR]@XkL9;Ff:jSnTofRN43nlATU@_LDUC6VlEN;97NSJP4H1kFP[g7]RHJd4SESOCNRIfO5>?If?:hDi`0DNh]8`VVUood[R@>RL5WAEYXA\0Q2H^P1:?bNI3G]H8NR6L<^HfNldIafkO6`cH@mZPdERo5WDh?2:4YU:ji:[o`3E6ehbES4m=RE=FUkYRcj=FfCc4GXBkDDioV?P0D5e5]@PlE:1X^1]HGk08T^6A77`EDKAanoDNh[jGA4@dJAgRE74f=4JAaNR0PLaDUl__[E=[nSogI9GF;e]K@kbLkk9kOFRe]0DA^KCa35=9iJEE0C[gCb6bKPnU<5h^:XCYMW\IdHaL0dC@>?]_j3G_2nU>fY[DQJ[\R0nLJY7[X`iQmQ3TeFE6hO4m85`VbbLYM`W1J[B?=;?4BDM_1hmSSDT1OVJWV3]4;P4hg6gJP\=M3=AL8^39GW6mD\MhK9X6h9:1cTkcF:4I[SYjAg9c7OKjH7c9g?_h6[aTITR6ZDdVaGFig<`4NP?>9d>[JZbaJfld@[SOecCe7EgPn6WoIhfYEJEOSXJ>\E7PI>KE;GigAf^@2VcC`NnE9NfCJImCJWNh`L^Alb_RZ69AF9=TSONT6WBnHI\6cSJi0]35nl:ZndAJ9_bi>1@e5_IdM:ohS>HH4B4AUCHRc[eKCn0lgHY<`o>JP8SLjbHVaXQJL4hSj_e?RE0=jWaXR_?Da?1J:C[lL??:\;;8>=KA]TTGh[:kkk9QAi4keJ<5CCX>ln[D7TJ:?IF;6dCOQ\T1QhCMf;8E3[HmlVnFEc2kFjhN2jMD;^2aW6knkjam4bm:NY3M9LF_S1Mm5[K2GW]XFHT1DmkH]SBU\N76a[l<;AmhE`SlNPEO`jW\VlbUcLh`Qmm2;BFc68jD6[E\>`cH]7\bZW8bME]>aKo2\MXRM2QFA_mfDNGICaLVo_MA^l;P6EVHJl_k7ae@R?NcMaC:4924L81CGj>>\5AX8J8bm`kXhIfm2^i[7mKCiaP8[j2H`D@fKG76o\TFSd5d21c`beYCWoHW4gG_hFRYeoaYjHFjVM]caKe]Tien>4Gm1aROUF1KX6[?c[=4WjaESSHm`70d_F`^@HE4G0N\Glb6kE?6JmX\1lU6\7W7E4Z`J`cCH3C0]9J^I2;]1fh\MW>8cde>CaG7lS\5kDQ2>bj\e1V75J;G7TG5EIk9BNk:BRXnoXBZhO3>k>P_>lMB4L@^a==`O3W8kAj><2@bXOMEWI2j^1fZ4Gh]=0_MH:c:132?U:1e=RSn3C9d`?L7SA_dRiEXg>[;G9W\n \ No newline at end of file diff --git a/proxy_template.nkind~ b/proxy_template.nkind~ new file mode 100644 index 0000000..56a7545 --- /dev/null +++ b/proxy_template.nkind~ @@ -0,0 +1 @@ +Nuke Indie mode encrypted text (commercial mode uses plain text) URFRP09]GZT32L<][1kB3Jn^kgde@XCJR9JX0f\dbF?IdTI<a=6gK0i5nb5B8=2EFaYU>kKPH`AmkOf@_Ik1Ea27Qm[[=b6]Xb9]D=CR[a6L;>4UVCB8ZOD:E:FgA<>`4R3obWhG?m3N?4]aHB_BYSNRleg8XSO^CN6<6T;S9H8YZbTKB=AiH4`]E:[_iX^mKb@h0^LEL3`D<K^fC?XJhSDo9B9cjnSW9M2G3DdXo=;OFAX4YePF4kImo\nR>iZ=Q9=WS[j[fITbhdg>K09KG=TZaJ4FIeK=74TgC9P8K0;g^3MFlDiEi^4MPaHiPOVbSLXhQ^40h>BVLn_>HQ]VMRS@0]?OZJ;MM@lVATm1?XF1PR`j:7b^f>mYh24dRmbINZ=cQD?SS?6D^@i4AciHR7dS5E_oHDB:;0:07HET@k6BRcaGFJ`nd;ke@EAaFP^WTRMhYJ1RfMS8bO@]VP3gK8B^@HDM1:>Lk6Q88M84d>h=GA=_MklNEh\5GJ1hS>V;H]l9>fNl:e_OkofFGdMl66LJ_]C43>[hJC:HTN_V1SPY\B_ZaTERdP]7k^0?L[ILmS4N?hhdCjXa]U7:c3JE6V<_ZCB[Mm8?MLi02QoHdL@c?Nf9m=Mg00U]@91[1IDFUA1FPR=JY9lW]V^OV9MHkgH\bG@Z06EQXea[IcC9EM@MSl?Pe>h:9aYOK7U]J>EW9^:94DRP[gh\lZM5Y760dA6SM1BK<_UJ9lnk9:jMR09Nia0[i]e]aMOZ=4o6SD3b5GIG997H\A9kKBcjK^f>S\d:Vmf7W5F6]W_=7R7HPZP45nIb7o659hVjIjMAIbJlQ@>?O_X9VIG@?QOL`QFiUI?]H@N?TV]RP?RCg]4O^1jG0h1SjSaLAao@`7PEI>0X`oQhINXgb1diCQLdLGoBJnG;C^E:;2P7AEgXRB76R_4C_bnGcR4oXl?3OaJ?DnD^i`^Pbl_[5EMUnCng29kZY\R_W;CVVSL]?l\`Un10_NUf?f9_K5l]J3=]ad6FVfF[=Y^Udaa3T^UEY59f^`AYbDJ3o5BM84AI;0E;oG?J9L;fOjm@CB\1l0KX=:hlD\_a>mK7Pj1cb147jWB;[khIfT^B?VOa4LI0==RL[D<^T64h=ahNJomb5e4Xi`>AZj9R;d=VSnPhDJLQK^gEAKPQ2ck1COb;5a5b]0KoDhT9_9NSE4\EM50=[Qf4ac;RPfIH;hmQ>TI25Wec=9eCo2N2VAb57=6;MoPNSkhG_i4P\aGYW6:7eiL;7AL5gWj29KK^iFbHmEb@PjR<<4^U31eRYKMD0CR`=GfhfSUSN`JL1j?60?2aWKSMf0P`:jN7S?M0GG?T9G4?eMBKOMgUYUcUP40X^ZFSMcaCT\Z\VJJ]0SVIZMY]9[5n7n:>:8QE[1^0k4n]]ZMg3N2;P0mGK>gT9N^_:YIN?_1kfOCWcl6EPL12K45\m7e_ZFC8XCZ^4f[:mm3O;j3\WLeL>Y>X3>OonG9lboV3:>7L`6gedel1;fmWIlC72nl3b^DU;B41XV80;TSR36VB8UP]f;eM3g=cC;ZP0LBQN1P^NioQl4kT2k>PSc`JMHQ8KhLMV?OZW3IG`O91Cd9GMO@oKTR8\K@HB6E9nJU4nbK2JEP94W5ZgXUV8=9@R_h;Un8J_D6=G]Cj3k666FK]V:PD?<>^?mZZh_LU6PVR`NC\a:2JGlchL?k8bncB_VD>2XlEk<<193H^D=ChkJA]fd>]9CY>QZ`@Aeb=[iVMTSJHO3VTL;W9=JaCiTU2RSGOf3Xc1FOlH2if>6Tbo>0\;DL3O1MEkMi:hS_[@IhdgW[EQ4k]_lRZL`2U7JINfK@1Mm;W4ja37HRD^8j`GIKGXHPHR@O9eAB>=omS9@k[PbKYIfkV;YOB^6gk6_i2A@Lo4DjB:D=cAOPEA^<_8d2XCn1c\F\md^ZilZ2US?8A=CNj0UBHLkK@YMTL]eMchc`5UB>b_M\8gb>m;RQ]6j8>Hcb<@4nfX[VOC5ROTkWcO1nQEoVK]o?UlOii?98_@nIo5=n3oY_5jjITBBWEh=m6MQ@[I;2U:9XA@2>EOSgG\b@N2GFIC0iW1kd^iWJ6@c1O0Vd=EEbo7XOOIn57Y`fD^=MeN:JT8`Ckm;;OH4g?^SZO^QZX9VPOoGCL]SAJ?i2HjJMWoISgi6^GOkdVM5@;VMgJG?GoD;=]Ci9X:_n?m4UgJn[_g1@CG]4:P\cWSc4N8Ck1?CAK9:iBTfOIXEddmaQ\HaST8VBOdaE?kb=DcN]nTA=6dS91<4ZNj[=\EocEKZJG3JZ6KnNHQR`GjnIOnon<]O7=BC\4\Xm^6G\jABVdjDVESdFi>\JTLgclQ]h@40hKUFJ^8B?20Ed];`T4V5hP:3VUcN]=R`h1ff>2_80`F2RBgA\\`<]cc13S19`\`J4KJHaK;^U>Q1aYj6PV1Gc?i4PCdoXS\5H;C1B@ZH5>X4nhGE?]EIVQ?`IMgBH`JYj[?Tg?nmI9Dk4jD2IjJR9C_C9ROkM0omolhY[:ZX3m4]EnISGjINMXW6>VaFdbN7VS\0]Vf_o:RJ=\QVZ?`;OF:G5[YPA6ZBIW]FCe?lcd=3E=L>dQlLjnmWOdLQY4hLj7Nc`[nACA=amI0ZD_lPH]2nlgg;YGWHR[^d9QZ1_5P1NgO_chfe7KRUER8h>96LeU6>NBUUKYViV\aMam0JD:;lI6m7]1:3ecYB8eTW8B\Jj9X;bC4?E<70lSfBREe1@F?CE\mUDjmk0K:n8V4[Vf9loI\?j`^XVDM7WJPe7n_SbW5X[g@k:>ica8NfnX6j=?nbDdgdFg07I^_Qod9]WCGFTK3AB>DW4J7\93DhPH5OjBii<7m78KNkXIo@`bMAZ0>`j6cJa3R\ZWPMj:Hid?FhG[SL1AcIgO4XnoTFmi^0]XndI1TA4jloY5gN2DDhoKIV;V2oMM2co=Cn3JF:<41Km40CGECHh97BKKSIOKa0WH]JB5A>=:jCf5m0k4oBF>?hGJl[U2Lf_RA5=T;\UO6NRc:caZhdJ9l@QUoV8h8gNjLWVaIDe?AdXR9h8HJgBf3`oYlbM\ngb?h:JTU`TVJb1:BVCRkl^Q]4=\I\Xd_5m]\MWbWQT^P]4IjmkLeX4PfB5mZ[lU:oB1KL;N3n=[>T6KA7h]E_@aYU_N^L2gNf??GjBO0jHDDLdlc^Vhj1>OSZmg1V;SRf;lU\f=UHdb@goM1JHIlADoBUf2Ri1?ENO=CdN305L?fc?CZ59>MIM_UdIG=;[Z>Y=iVUdZDc=7U>7oY>[:M\i?ZfHUEFXhPI]k\dnA31QRF]OCocEhQ8_b:J3nI18F=b_LYXamo\jagYd>ARUQ5Hb:=18371o7o9_FERGdJNCm^iY=M38[dE^hLa281D@QM81:NY>LC?mSWmGdnA;>n6Y=Jn7BlYmNDaRC6BK]HfDGL\6_k;1>h4?OXjm6MZoS7eCY@\[_oVn\79fP0C`;YEDf[?Df75\Zf0XKkggC4iaM@P`]nh8>R4h4h9CmA_B3`j^8hN;nAGS[A^FP_5I1?eCEFB6ci[e>n3jmmjJH:JZ96YO>nZQ90DfT`PMGSJgAW6OC]f]VeiR8n6=J7_b4GjG33NK0HKWU`HQ3D32Mf]Pn3c20gdSkk2MAn]n>R@=cKAL6D8\CU5YoHO0eb\8HOT6kC0O3GLaVSXoF_Ch3?E]7E?\>OS7;^VZmLmlhIMXHMWn1UAZZ=A@d=Z\`GT_V\DQX`?U^8Z5GoIgVG6ldZ^4>KXoI[;1bkY>4nB[A=3:bWN2Ld:6oQB^77Uo5C^SH]@`W1cPgRYBG^o4_?f96lSFnKHWW\S7oVLE8VA`VZNZ^eOC^9S@]dYJF@^^NX0=Gf=ZgM:K?k4;U?m@QP3L3G9CbfG2^IJKiQoMg1WEjPQLPEA9KJcb;87d\4DSKV>D5TX^hkVZ9jhZ2@4cX[K=k2FC9T3_YmW7TMQMC4GfSMV5lRP1BWUTMXNbMFOc:bLIOQJ7DMbSG[LHk0Yb=BS[^DL7e=;3CF[b[]Fj]DXbH6fkCQ5]jJ5:L\BL0CGD3mQZe?VBUk7NLN?0CbB?4Z;hJTijiEP>mhl?ad72XJcgj\ARJ]<^K^Q4\ISQ_B1<5EE9?m\MQbKUE91l`L5K0O6o1Hd_^lR<2iOf>]JJG@nD@T_775dWF@`=\H4KX:o_f_i?9caL^d;bPK4gK1=ja]6I7n[hA8^lC2TgV6l>ARM@QQfgiZJcDlSoIZMMoe^MoKbfhUQWlKe8I5TgGO@X1\Fai82_OEV`c6YH<<\=bb`VoOD9XN`BK\oDBGeHd4E=Ijnk7]0MW=7T]dHoFkFL5O9oQ[nYGnUFn7LaQ9G\hUYkda\C@EfDZlJcP0eU2^\Y^J5T21k`4=INh\98Z29?K8C8S=NMaNmWfXUVFjOfVJENJQeYki9aiDYHoUWbRCND^_lJCE^4W3=iHTEl2h9Q1^T1O=nb1S\JRL_2ekbhWIckN19[]RXP8gFRK^AI_PBI@R7XRDNP1PPUlRmgQk@j[0k\QkD:Mk2e4RV^FXH:0ePJ2PZ=M@fmKXm9[FgC932anG^_XBgedUbXOeRe2`WS6J<<^5gJ=b@HUDcm:^8Lcj?2a?FNG6g?Z\f<;iXni\:`B58J2Toak:@LQUS>R3`8nLUcC;\OD?loMYJ[7Ud:P>HeQ2;NnF_?l:in5YIMSNPC8G;JgQ`i4Ci1^4;YPgL>LT;=g<[`4nN\4no65kicjQ893DFVNe;]YdUHD944<1occ>U]O0j;UfD_[\Vg15YE7K6NTeXP6YPgLgXe=W]g^@88P2JgcMm^XcHlECjcX_dLU3ZY2j_Qm>K?G3KQ6jKFX:ZcjbBYdG`;O`9_\h0o2kdDh7CX[;FDmJB82@LBH9cnjVBKgPgYFTOd63i5Rl0=:E@Fc2^4O2emO^TOeIFkiHX;KdRPfeMN1:dE7PaR?;0DADgb4n8E_M4lM1H6YK;kMKVfRnP4c\6GM=WbaR3fm]MPGkaXGa]7FngY5AmUH2Ub[G5A>U`ng[jaIRhmoXbc5GXV\[3=fbInF_gokToXBG4U`9Fdk8P>h^BM:1UO`ohiB4mFili>3eK0`N[[b@;^7\fGI7kY5U=FG[db4>_34WVb=OZIP\cK4HoI\6m>TZ;]R>TfEfQOT3b`APmP=j94TgO2lGlog^i6iKJTL5^j8Xe\]Q7nWgm<\O:@h8YOcBlNWYKXemG5QQe1f4JoXmO7NN;Yc\kTMYj@`3XNMWWAJ\hIDH;A8]O;e_nO:oEKPekgYW=K^XD7^Aa^K^e4nmlW[]3ZPi5<8dXEhJGLIONS]KOeikn6nBMEW0WR8_0K4H9:5M5`k<11>fXLfZC3lFU]4UFn]6Y3M9:oLE8904gYPhKd[4k\Iob3KPn8ZT;oY[GmPR5D2=G^lL0fR_SFaFIIVZ:dDI>n1jAHQQSC5^11:3LK3a63j?N_XVh6=c3KV>\lAF[PB5kB<0QQ[:YU?UBVS;eB`99jK^BgfL0Sj_S3?=NjgFbdF0BBP[g^T?jVi?@I`oDI;_YCdGNVa?N]Q4N11SVYi4SGSRj[m_c<2D7k>[1on\[c4Dl9hEbnj:JLIL`M@=IF=R_MFDeKV1KVNFB?NYZYdfWJhUC7IWbBVACgIgemRlU^:S=80jjL_2iL]1cc>lhaZS84_eV`o743[]ZL_=E_IkfS4o84M6jWR>Ue>9hj@28i9@IAE_QAaPRSL^hHiE3^JD<>GEM6^H@@3LOQU@0fM>cgFLhI^cGV9=60=MVl]0bVQLXcN6]G57dJif2fZgKjbXcX8XBi1fNO@Q_5W``W1\lOT@Gk?\<@AkF76`bci\bGS9==04n;;GT]lLnRN95@B0K]>UadZ`?JHTCg6XVk`_1F@R]]A]^dRB6J3WDUWT1Y6DV5[H^D^TZ91n=NjbSmN3`_J_9\Nf1H29hJ0HUL:P0F3=^=A\i7k>Wm_bYi;WJ`_i?K5]08m4L`0IV8ZBLo:cFD2da[o:=5EemQGcP[EGaM1@dDn2WA4HQF]?k>O4>KR1WgAL2o_Xm[o[0REAJa?D?S^gekI42Q7m=K=7Z148JRnln1L8W9RISAT\aH;\\>LoUQGYifB3C[XKKbaEGBXI]?2A@Mej3225l_V@O[;eYPSH6cVbM^o1LA6_YH2Uo>lQ^g]hIFH\BbMg?AYcZGJhA]O6g;`@e]RaiUC1<`eJO8`9::15NkI6:\U`AVRO>Lg?f5RQ83R=Y;6n1@YUJGTUmkC1Ta]28nHdXoRL9SfCbjOUg@eXER6KC^b?fBB8WWi_Q^oAljXcO24FPXM@1mCXnG;EjHPiQkZKMQb_4DP9Sd=JmH\`\K5V:`XaK;@c;C5o2@WG0`AGP=4NS1mgINGPdLo:LX;HgA??>=1VIBEMZU5HhP^lJa87h;bYlU[GBlQhnjae2fg00EfAncb]c74E1Ie[2PiW=SC7oNf=Wob>44HQ1P3^JgMRRa\Qif>_dX=C>H1X2jX<33kCmGM>:TT^`k8a5Zf?3De=4Ec]FW_=5:aD?W[T^nh;6T3XmSaOL:1S0ZBRd5lm^g5WLcL5;1;l8D@O:bHGc4PU`j7o9^hOo`QM:IOEk^9a@MW_RNIZi0\2cAJ[6Wfaob?7NPRE=XO@5OD5gSmLVcM^J`SaQ?f\P;8nXoHJ02OiaVO3g5UE:@e_PV8Z_A9if1X:XkM@a5mH>U;k6_BDfKJ2oa`hom:QPWiNi_VAik7c55RB]AQdG3Gn@J=0j;GQ2LP[3eLfIg;jWVVm3f:CElcilGR6cXd@IVME>fcON:9RRHFe87i>KZ21P42HfL8OQI80[B[7^Z_V]`BSCPXlLQ=K]Y7iKKMWHV0E?6[Qbh8F8Z7C>Cf2V^:l\^SHM\LJmhAA568jU7gmY=Oo[\^KPeTHN2Xo6UejgeMP9E`62kM5HAgC0F5IB1C87M66f8M?^M3o6g3md?5;8QJHRJU\0d3m2QHFD:YPYo3g8Go46e:5Z6191DP3k5@gn_P9hfPB2d>[JATnjoY=e5Sa>VWYFWkNfIjoMmX1Bi\^YAl]HZHTI>Yi;2D7UC?A99P>mOR:]O]@Da0:2jHPaQUgn6:A7Z=Z1jLIObWNk5jdYI^P7@CUiRBZUehRj>F?TEjfT>\Q0`[fkH[cD@cA97LHcH^a9E5B5nO2aW_h[DeCOjOCb@_a]H8OY4mcjA_EeJQCi?YEX:B2CD`aBYofj8F[H4Pe[M[9_9eIIDaU^JUH[U1cEFKVG@`B`K\E99R:?l=EHBG5_DJh12W1X_c4gDJh:on6a;89F`N2:T0^o\I?2n1Nd>5KVI8WDdagQORR3^MhE]iQ630k@NeNb[Al`\7kJ[Bk6fUKdaN;QId>NodfbH]h3=IZEm5<[0;_22KMM[Ccdn9;MOdTOUXQVWaaN99QZ36XJ8M]2OiR@X;V>C]=gcW5Tf^Cnh<34kJ0>89;CDkiSgj6>bNeTJbY\A0`e:@4>UJ[8kEn9;55\TI=Z^iJPL^M5=j<[OZ`EHBA@ec2jX]ecZ=M`ZhJiZnCFb2>j[EH[nm2FECl9MbVU=_=dI;BWN\PV72j9DYZP>>olUd3>HA8@WULNAL:R1?VHFoFRGkb]U9L9bVFOP1UF_R>]DGa9G;?E8913m0iUMAJKFo93Jg>M[15mIaa0l1H8PMfS6Y9_R?J3]28IUVU87WZD[oYR7:c13]XkZnO9oB;KGM3>Sb:OZ32[@`oa`A37WlLn^ScokFjEP0[]Jjm6?ci:k=nULZ5;5_QFo]nK1bh8HGeYVj<6_OMahD[Z0dlIOET5F]NVIjnZ\MCXPBJaZ5ZPcETFGid0HRbBk4k]?oDTIFYj_8J?QVOSJSBM^fF`eKC0JD=EY82]MlAf898l==dUn9mI_W[Sd2m@cGTA3RCjLJTIjHO9B2jIkTSSngL>6BgJGi?^4CjnZoKG8Z2gQU8Pl@gm?P;?274g;>@N9UFm9>g_i5YPXFZCjneF\WEUlI[RUH2^V^V^RDG3JPPna_=mQ2BFj]@;37Q75d:Y_LIm]LZEjQYPI8eKEeVKU5YIP8ZTPBaV6okgakZ5lc=nUU6X^MBW@2`6jKE\df57lToiGXb_92g0I_BVTmcEQf@jf;9YRKUeNonej>h^BR]O6j^0@_kRAh1ciJi`fS1JgGSAVi]j^>jSigE>aMH1TH5Ue5n?RbmZB1=3LaQi81d8[O>45GZ`lFG3Jda;7UiFEj@DJRUID1lY^5IV>[cecnc=Q:1CbYS77I_Me_ac3o0n1I@dg=kDh@O`aM;@@J6UogQ4O`@:f=`ca6bcJ;k=@O=7>K90En]>?K;DhUQnGMS]Ek@[]?`hhc[TDJ3_F[SO;73Le]Wh^Gd4GZ:[^2cDN9KV?7SAg24k9RN?0UW]L5ZS\OQMM;F9h2M>=3Q6O?[RC1g[[>E9m@[o[aVb@4]A=k6\g3amdIK1]CjCW@A8;UJeFS35lWTZMXm@VA8V8BHZib;^Il>`cC4Fh]hf>9OmCVDNJDW_P>ZTo>C]JLPf<74iJOjj9b[_3n80>db2L`o2f?[[42AZA>PEP\C:MhgWjG2i[h[J6TS98SK6f6UB91=[e2L5JV[JZ5m<@V5Y=N_f2>aTWE6L_04[JB9Q@_gXahNdI;RD@il7h?Qn8m^NE>a3SF:ob6o=09[HBE>m2>PCE=`ghY6@c>PC@5k1^W]DDUjaMR7C5DHAj50SIGWNPjljDgLfGefFf:2:O@n1hd>?Z\Ue:BW6e53ShQ\N2f1aHJ0jo4cLTK]]eLI4R\ggg>J[gk\M]WD?BU8IO7A9PDV^9gYmhHg8eD_Qb0I9YmifKT]Eb8PYFPU2ehMX6>ii@1>hMZBNVmTALiC2?1C2Oii5C>TSo:d:Kg62Hcl5mZlU2<5QP8j^:C@`AS2R4LQP8eE7>e5LfFQiAlH[cn6bmB>55=biPoY?gMRO@N:1U5_^k\eN>XLe`?JQ^8J=IHLV>8SN0BCIP<5;A?1>fN7S4foeXNU1P?X]O`YZkI@Ooj\c7oodkc14PSYPeDch5I]SFlRHPM6o7D?oLk1QleG<>m[Rc>CN=HN^oYdB^KT^93j;`O>YfFZ;?4AAoAThgD]Qm[2BMA1bP65XDlo[gJPZl9WlVn9WCm[X^Jj_DU5k_8`S4lE[enKS93RB@>4:_bbUd0REfo=KDRn0R6\BljU07jIHYA>bTZaeUaUAoM2mP1j_2i7T^7TA[jjFQ4ZRKBgb:O4cZRTSUjloTBkdin6Wh6YP\PeJh3[[hQJHIZ1@EmHS8I^ld9N3]kWohcjCCIYC?lAJ@Sd3eeTP85L3WADUJ;53H05bJ\I\bj2O3PO;`df82GJXGbKk4]kaRa09^D[8[PIM`_Kd6SQnM75WZ3jWiB;`6o@o0TDT12G7Z3R\GVkdhJeB5CQGjY3mR6NIOVC5gBiFM`EXILkYIiC0NIB;PQ`e2a:BJb;HnZ1R?JYH;Y2CQ4biJc13_iLbQP=j;M1nL9o>3Uja^E@Gnbi_mmA_WLA:k^L3Pm]Ug486Q2Th3VXHd^1QlBOUKbd184_Q5GbPfB8>A:?k05U_m?c2KH;Y8dNnoo:Sl^fiUN9HL0hfKXnb7W9hc:`_X`@A@YVn02BkdU8o9ZXglC^a\=JcfMRLamd9@m@@6KCeZ=KHJ[J00=;F64AK3n`oaJ26`0mne?[BE5KfW<\TTbn=7ScNYF;_1MInbYg]h;P^bk0f\YEZ5PR;QDXIKlI;CAE`Xg:7ed7jn5jen?JiYm_@GM\hniR4iHNRL1@cK7W]AbV[FB?ibD;E9YQ3>oULXE[O`>9:D=5\g0kHNVlI8OTFEMS0I;R\HCjX=]c>YZDAche?4;;LjXi@XPXgLoJI0[`TPHH@A?]l;43Q<>mCIRTaA5_R\YjX?5I?=[85kDALb;UaTI8R@HBgB[dU1Wb@Hn1I>EQhiDR15WObEcfL3VGW;`X:BMN6G@CkDUaVH;YiLQ7iJ>icU4h\N][j1Ad>eQomSiCl:`aZa`Z^e3R8GUWl`>VRRQDMjgd3BRRhUBM1j]5k?9oC71KAT9NMXVFad^UZhFT[ZF?5T7l25mKIfS`N?G@F8gF7;eZJI4llL[o06g`7cE>?7Tf@eB4^gI5XSE`1?ceh31RJ=iP01ESeF[HW3V0d>:edh]lF5P@01?g;UbLg7[jR>ZAKnZ5gV8W2ZjNI0P=QmRC56cF3dB@o_g1YXTHU;[Wn>lh0SbE9cL7KM8AaZm`g@08CkNeWghhoP]QmWj<^fXW=iDmIhjVbDYf9GUNcAnY:h?cOi_jEf`hc0d`eW@2@7o[Cj3eGTA2PPMmYCLKMD;YVj@MQRdn\;ej<>`?ni0^AaP7on9TbkJIi@G;1fZleZPQb6mi0^Nd:LdVV15>9kBPWXeE?ZHVAQmn5Cbk4BcQ5`ehJW4UK>M_8==DYIF@KhSPV[e5EM90eUiPaARnEhfihjm^XkjUbcl=X\07\R`3gJ^^8YMnKFSf^0Fa104T`c]Rk0mk9GHPnMkjj1Mfi=lB3IkW9e5bGjo>^dNL;R\4eZZQ@G;`Z_YWOSG4lb?^4fm\8L0Z\oSg5@0`ib>;FK7S=YgLag6cQjb>7IP[@b[A?J48=@gT3?HB5Go\Ygmj51g]IhKPR65g9C6DfhSKNRgPX`cV]VZB`g05]5d9bDh\b17kaX8;dVZ3[eolMUWE_:e8V[kWJd0i_fZKX\bYNb\88D1O=3Q1k6JQX?lCIh1Lj;bKZl^TT6dl599n`D_5XiE4NZ5KoFK5RngnCK8a]^llM=6f[iCE[T9W]3FemZ]GRijYjd?\VDm4B4hOiPRnjZDI17f;5KbWifc=iMBlN=GTGG>@J_\m?X16H68749N@=7N5E>P`^Xli>>HdBbDlbaNSfn9jR;Fj@R6ac8hU4=IJjN]ZNFd;0B4^TYK6iW]TRT]:Z]4f;0K?PL10;4[>IoED0fcOiPboMM6EK`4L:be8G1FAm;_]=OeDNG0jkDX7YKj>2<[MhB>_CWdbd[h_lKD2LJTT[0ElXEFHd?gFP6ZAV=2Ve@P:`cEST@[^e_gGVQMkESH=D_Q0OO:g9RM8obO^[kai25=Nd]ODJLMJDi\I;cogWKm09U4iA9<3JZB[19cX@57N>:@9LI<9DgHUgX4k4][\i38>:;27lTL`RF4fOUBj]S1@KQ8FNWm@Ah1ZP2Cc:ESM8:6Xl7DUDg5eN`dP72?VWTd4cLX2>l1W;?IjYa7GYZ4QC=0c]C1YHjIf5OVG8dkFQ<;h]ASJmCBlPIbg?6X_Pn=?04oLXUeWYbfTo8F\^:VjS7WPckH:RB1eK_Ui[?TjCeOac4_ja^o:Z]HNPCN=hZME6U`oNjgcZ[H2^Uc:_:k>Bl>6eRLR5a2n_iWXZQk_^oDiLHMd<4oE8L2>:]S0m[UYYJ7SZ04WG^SV]Q39\h:6dUC11j\IQ?Flh18[>D_I42I0FCfEkem]1YOjInU^3?OFhmZU[>J<:gNcL9XFh=W=iOB0@LcJAAbM4d5YS@nV8ZC[:YiRhiW\V]8eGlXDmg>NS5W9YmNO;]kEgE>MWI:on]jg3m7TkF]LQ56UK1:@BUVSKBN_iI0doZ1WGc\B0lTFnef;jTG9C6<6nWo7IG79ACNTWK7QEImkdR@FJnXhD:b5`7m<=;[Uk8gJAOb[ZgV=:PjN_S`df7XG86j0YWe[90\iDDeSe[E5eSPaI::e=F\KOeT_>m>2Jb8akLn=:0?mkeM6_2bZP0EY;0f\C=aL6;AD\52PHbl0`4UZSoV[mK]@:_=GVZj9cg\mF7jn>@0on^WEZgLEc9J1@6P8dULO7\:Yh]ogg]HALG2JmcGX=6>YidL:5GR\c[5RRR6T\8JkB@Na1[hN]N9R2ilh4iDZMR[CV]22CAfCl7GTghH=6BjCO\EIMQgf]6OBK1]aoR\mVLIOnb6haMFdbJ2cbCh]fBf][J5DDkoiA_W8_=8:6kSnW=JoPDD`1Ioi5iUG`A;DV7n?V1RP0Ofn?SGhZfPHLQXid6=MZE:5MmWECI]Q9\O9e7bCa[:QS3KQa`P7l5b^^a?X1MGjV@JiD1`YVc@``d4=WJo^0<;S:Z@UkPFLmXJS][RN]8d_Xe9_FmFhgjc6i[>8A^gdKdI4c None: + super().__init__(parent) + self.folders = folders + self.output_root = output_root + + def run(self) -> None: + shots: list[Shot] = [] + try: + for folder in self.folders: + self.log_message.emit(f"Scanning: {folder}") + found = scan_folder(folder, self.output_root, self.log_message.emit) + shots.extend(found) + except Exception as exc: + self.log_message.emit(f"ERROR during scan: {exc}") + finally: + self.log_message.emit(f"Scan complete — {len(shots)} sequence(s) found.") + self.scan_complete.emit(shots) + + +# ────────────────────────────────────────────────────────────────────────────── +# Core scan logic (Qt-free, testable standalone) +# ────────────────────────────────────────────────────────────────────────────── + +def scan_folder( + root: str, + output_root: str, + log: Optional[Callable[[str], None]] = None, +) -> list[Shot]: + """Recursively scan *root* for EXR sequences. + + Parameters + ---------- + root: + Top-level directory to walk. + output_root: + Base path used to derive output folder/movie/exr paths on each Shot. + log: + Optional callable that receives progress strings (e.g. ``print``). + + Returns + ------- + list[Shot] + One Shot per discovered sequence, sorted by basename. + """ + + def _log(msg: str) -> None: + if log: + log(msg) + + # Collect files: dir → basename → [(frame_int, frame_str)] + dir_seqs: dict[str, dict[str, list[tuple[int, str]]]] = defaultdict( + lambda: defaultdict(list) + ) + + for dirpath, _dirs, files in os.walk(root): + for fname in files: + m = SEQ_FILE_RE.match(fname) + if not m: + continue + basename, frame_str = m.group(1), m.group(2) + dir_seqs[dirpath][basename].append((int(frame_str), frame_str)) + + shots: list[Shot] = [] + + for dirpath in sorted(dir_seqs): + for basename in sorted(dir_seqs[dirpath]): + parsed = parse_basename(basename) + if parsed is None: + _log(f" Skipping (unrecognised name): {basename}") + continue + + frame_list = dir_seqs[dirpath][basename] + frames = [f for f, _ in frame_list] + # Detect padding length from the first filename encountered + frame_padding = len(frame_list[0][1]) + + _log( + f" Found: {basename} " + f"({len(frames)} frames, padding={frame_padding})" + ) + + shot = build_shot( + basename=basename, + source_dir=dirpath, + frames=frames, + output_root=output_root, + parsed=parsed, + frame_padding=frame_padding, + ) + shots.append(shot) + + return shots