""" 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)