diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4f986ca..e6d807c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -39,8 +39,8 @@ jobs: run: python -m pip install --upgrade pip "pytest==9.1.0" "pytest-timeout==2.4.0" "ruff==0.15.17" "mypy==2.1.0" PySide6==6.11.1 - name: Ruff (whole repo) run: ruff check . - - name: Mypy (core + workers + UI layer) - run: mypy core workers.py app_qt.py panels.py icons.py ui_widgets.py media.py theme.py dialogs.py app_window + - name: Mypy (file set owned by pyproject [tool.mypy] files — whole repo) + run: mypy - name: Pytest env: QT_QPA_PLATFORM: offscreen @@ -150,13 +150,160 @@ jobs: # shipped binary would be extractable, so we deliberately ship none.) - name: Build with PyInstaller run: python -m PyInstaller --noconfirm --clean BlenderVideoMapper.spec + # ── macOS code-signing + notarization ────────────────────────────────── + # Requires four repository secrets (Settings → Secrets → Actions): + # MACOS_CERTIFICATE base64-encoded Developer ID Application .p12 + # export: base64 -i cert.p12 | pbcopy + # MACOS_CERTIFICATE_PWD password you chose when exporting the .p12 + # MACOS_CERTIFICATE_NAME "Developer ID Application: Your Name (TEAMID)" + # NOTARIZATION_APPLE_ID your Apple ID email + # NOTARIZATION_TEAM_ID 10-char Team ID from developer.apple.com + # NOTARIZATION_PWD app-specific password from appleid.apple.com + # All steps are no-ops (with a warning) when the secrets are absent so the + # build still produces a working (unsigned) app during development. + - name: Import signing certificate (macOS) + if: runner.os == 'macOS' + env: + MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} + MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }} + run: | + if [ -z "$MACOS_CERTIFICATE" ]; then + echo "::warning::MACOS_CERTIFICATE not set — app will be unsigned" + exit 0 + fi + KEYCHAIN_PATH="$RUNNER_TEMP/app-signing.keychain-db" + KEYCHAIN_PWD=$(openssl rand -base64 32) + security create-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN_PATH" + CERT_PATH="$RUNNER_TEMP/certificate.p12" + echo "$MACOS_CERTIFICATE" | base64 --decode > "$CERT_PATH" + security import "$CERT_PATH" -P "$MACOS_CERTIFICATE_PWD" \ + -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" + security list-keychains -d user -s "$KEYCHAIN_PATH" + security set-key-partition-list -S apple-tool:,apple: -s \ + -k "$KEYCHAIN_PWD" "$KEYCHAIN_PATH" 2>/dev/null || true + - name: Sign app (macOS) + if: runner.os == 'macOS' + env: + MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} + MACOS_CERTIFICATE_NAME: ${{ secrets.MACOS_CERTIFICATE_NAME }} + run: | + if [ -z "$MACOS_CERTIFICATE" ]; then exit 0; fi + APP="dist/Render Mapper Pro.app" + ENT="installer/entitlements.plist" + # Sign EVERY Mach-O inside the bundle (dylib/so/bare executables like + # the bundled ffmpeg), each with a secure timestamp + hardened runtime + # — Apple rejects anything missing either. Failures are loud: no + # silenced stderr, no `|| true`. (--deep is deprecated for signing + # and does not apply the runtime option to nested code.) + find "$APP" -type f -print0 | while IFS= read -r -d '' f; do + if file -b "$f" | grep -q "Mach-O"; then + codesign --force --timestamp --options runtime \ + --entitlements "$ENT" --sign "$MACOS_CERTIFICATE_NAME" "$f" + fi + done + # Seal framework bundles (if any), then the .app itself last. + find "$APP/Contents/Frameworks" -maxdepth 1 -type d -name "*.framework" -print0 2>/dev/null \ + | while IFS= read -r -d '' fw; do + codesign --force --timestamp --options runtime \ + --entitlements "$ENT" --sign "$MACOS_CERTIFICATE_NAME" "$fw" + done + # PyInstaller strips Qt header dirs but leaves their symlinks dangling; + # codesign refuses to seal a bundle containing broken symlinks + # ("No such file or directory" on the .app itself). + find "$APP" -type l ! -exec test -e {} \; -print -delete + codesign --force --timestamp --options runtime \ + --entitlements "$ENT" --sign "$MACOS_CERTIFICATE_NAME" "$APP" + codesign --verify --deep --strict --verbose=2 "$APP" + echo "Code-signing complete" - name: Package (macOS) if: runner.os == 'macOS' run: | + bash installer/make_dmg.sh "RenderMapperPro-${{ matrix.label }}.dmg" + - name: Notarize and staple (macOS) + if: runner.os == 'macOS' + env: + MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} + NOTARIZATION_APPLE_ID: ${{ secrets.NOTARIZATION_APPLE_ID }} + NOTARIZATION_TEAM_ID: ${{ secrets.NOTARIZATION_TEAM_ID }} + NOTARIZATION_PWD: ${{ secrets.NOTARIZATION_PWD }} + run: | + # An unsigned app can never notarize — don't waste a round-trip to + # Apple when the signing secrets are absent/empty. + if [ -z "$NOTARIZATION_APPLE_ID" ] || [ -z "$MACOS_CERTIFICATE" ]; then + [ -z "$MACOS_CERTIFICATE" ] && \ + echo "::warning::MACOS_CERTIFICATE is empty — app is unsigned, skipping notarization" + [ -z "$NOTARIZATION_APPLE_ID" ] && \ + echo "::warning::NOTARIZATION_APPLE_ID not set — skipping notarization" + ditto -c -k --sequesterRsrc --keepParent \ + "dist/Render Mapper Pro.app" \ + "RenderMapperPro-${{ matrix.label }}.zip" + exit 0 + fi + # Submit the DMG; Apple validates every binary inside it. Parse the + # result ourselves: notarytool --wait can exit 0 on an "Invalid" + # verdict, and a bare stapler failure hides the actual reason. + if [ -z "$NOTARIZATION_PWD" ]; then + echo "::error::NOTARIZATION_PWD is empty — create the repo secret with the app-specific password from account.apple.com" + exit 1 + fi + xcrun notarytool submit "RenderMapperPro-${{ matrix.label }}.dmg" \ + --apple-id "$NOTARIZATION_APPLE_ID" \ + --team-id "$NOTARIZATION_TEAM_ID" \ + --password "$NOTARIZATION_PWD" \ + --wait --output-format json | tee notarize.json || true + # Parse defensively: on bad credentials notarytool emits a plain-text + # error, not JSON — surface a clean verdict either way. + status=$(python3 -c "import json;print(json.load(open('notarize.json')).get('status','Unknown'))" 2>/dev/null || echo Unknown) + sub_id=$(python3 -c "import json;print(json.load(open('notarize.json')).get('id',''))" 2>/dev/null || echo "") + if [ "$status" != "Accepted" ]; then + echo "::error::Notarization verdict: ${status:-Unknown} (see notarytool output above — 401 means a bad Apple ID / app-specific password)" + if [ -n "$sub_id" ]; then + xcrun notarytool log "$sub_id" \ + --apple-id "$NOTARIZATION_APPLE_ID" \ + --team-id "$NOTARIZATION_TEAM_ID" \ + --password "$NOTARIZATION_PWD" || true + fi + exit 1 + fi + # Staple the ticket to the DMG and to the .app (so the zip is clean too) + xcrun stapler staple "RenderMapperPro-${{ matrix.label }}.dmg" + xcrun stapler staple "dist/Render Mapper Pro.app" + # Build the zip from the now-stapled .app ditto -c -k --sequesterRsrc --keepParent \ "dist/Render Mapper Pro.app" \ "RenderMapperPro-${{ matrix.label }}.zip" - bash installer/make_dmg.sh "RenderMapperPro-${{ matrix.label }}.dmg" + echo "Notarization and stapling complete" + # ── Windows code-signing (Authenticode) ──────────────────────────────── + # Optional, mirroring the macOS pattern: set two repository secrets and + # the exe + installer get signed; leave them unset and the build stays + # unsigned (SmartScreen "More info → Run anyway"). + # WINDOWS_CERTIFICATE base64-encoded code-signing .pfx + # export: base64 -w0 cert.pfx + # WINDOWS_CERTIFICATE_PWD the .pfx password + # (Azure Trusted Signing is the modern cert-less alternative — swap this + # step for azure/trusted-signing-action if you go that route.) + - name: Sign app (Windows) + if: runner.os == 'Windows' + shell: pwsh + env: + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + WINDOWS_CERTIFICATE_PWD: ${{ secrets.WINDOWS_CERTIFICATE_PWD }} + run: | + if (-not $env:WINDOWS_CERTIFICATE) { + Write-Host "::warning::WINDOWS_CERTIFICATE not set - Windows build will be unsigned" + exit 0 + } + $pfx = Join-Path $env:RUNNER_TEMP "signing-cert.pfx" + [IO.File]::WriteAllBytes($pfx, [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE)) + $signtool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin\*\x64\signtool.exe" | + Sort-Object FullName | Select-Object -Last 1 + & $signtool.FullName sign /f $pfx /p $env:WINDOWS_CERTIFICATE_PWD ` + /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 ` + "dist/Render Mapper Pro/Render Mapper Pro.exe" + if ($LASTEXITCODE -ne 0) { exit 1 } + Write-Host "App executable signed" - name: Package (Windows) if: runner.os == 'Windows' shell: pwsh @@ -167,6 +314,23 @@ jobs: $ver = (Select-String -Path app_version.py -Pattern '__version__ = "([^"]+)"').Matches[0].Groups[1].Value & "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe" "/DMyAppVersion=$ver" installer\windows.iss Copy-Item "installer\Output\RenderMapperPro-Windows-x64-Setup.exe" . + - name: Sign installer (Windows) + if: runner.os == 'Windows' + shell: pwsh + env: + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + WINDOWS_CERTIFICATE_PWD: ${{ secrets.WINDOWS_CERTIFICATE_PWD }} + run: | + if (-not $env:WINDOWS_CERTIFICATE) { exit 0 } + $pfx = Join-Path $env:RUNNER_TEMP "signing-cert.pfx" + [IO.File]::WriteAllBytes($pfx, [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE)) + $signtool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin\*\x64\signtool.exe" | + Sort-Object FullName | Select-Object -Last 1 + & $signtool.FullName sign /f $pfx /p $env:WINDOWS_CERTIFICATE_PWD ` + /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 ` + "RenderMapperPro-Windows-x64-Setup.exe" + if ($LASTEXITCODE -ne 0) { exit 1 } + Write-Host "Installer signed" # Signed SLSA build-provenance for each shipped artifact (verifiable with # `gh attestation verify --repo /`). Skipped on PRs # (this whole job is push/tag-only). @@ -248,7 +412,7 @@ jobs: | Windows (x64) | `RenderMapperPro-Windows-x64-Setup.exe` | `RenderMapperPro-Windows-x64.zip` | | macOS (Apple Silicon) | `RenderMapperPro-macOS-arm64.dmg` | `RenderMapperPro-macOS-arm64.zip` | - **Windows:** run the **Setup.exe** (installs to Program Files + Start-Menu shortcut). SmartScreen may warn — **More info → Run anyway** (the build isn't code-signed). - **macOS:** open the **.dmg** and drag the app to Applications. First launch: right-click → **Open** → **Open** (it isn't notarized). Apple-Silicon only. + **Windows:** run the **Setup.exe** (installs to Program Files + Start-Menu shortcut). Windows SmartScreen may prompt on first run. + **macOS:** open the **.dmg** and drag the app to Applications. The app is signed and notarized — it opens normally on first launch. Apple Silicon only. **Verify your download:** `SHA256SUMS.txt` lists the checksum of every artifact (`shasum -a 256 -c SHA256SUMS.txt`). Each binary also carries signed build provenance — verify with `gh attestation verify --repo ${{ github.repository }}`. diff --git a/README.md b/README.md index 25d754f..1d5db12 100644 --- a/README.md +++ b/README.md @@ -16,13 +16,20 @@ Grab the latest from the [**Releases**](../../releases) page. The **installer** | macOS (Apple Silicon) | `RenderMapperPro-macOS-arm64.dmg` | `RenderMapperPro-macOS-arm64.zip` | - **Windows:** run **Setup.exe** — it installs to Program Files with a Start‑Menu shortcut. If SmartScreen warns, **More info → Run anyway** (the build isn't code‑signed). -- **macOS:** open the **.dmg** and drag the app to **Applications**. First launch only: right‑click the app → **Open** → **Open** (it isn't notarized). +- **macOS:** open the **.dmg** and drag the app to **Applications**. The app is **Developer‑ID signed and notarized by Apple** — it opens normally, no Gatekeeper warnings. - **Updates are automatic:** on launch the app checks Releases and, with one click, downloads and runs the right installer for you — no manual replace. **Nothing else to install.** The app is self-contained: **ffmpeg** is bundled, the `.glb` web renderer fetches its own headless Chromium on first use, and if **Blender** isn't already on the machine the app offers to **download a managed Blender runtime** for you (one click, with a progress bar). The only "bring your own" is **Cinema 4D + Redshift** for `.c4d` scenes — that's licensed commercial software, so you point the app at your own install. Every push to `main` also publishes the builds as downloadable **workflow artifacts** under the GitHub Actions run. +## Documentation + +The full **[User Guide](docs/user-guide.md)** covers everything with close-up +screenshots — quick start, the Watch & Auto-render pipeline, the filename +pattern reference, queue, engines, and troubleshooting. The same guide ships +inside the app under **Help → User Guide**. + ## Quick start 1. **Add a scene** — drag a `.blend` / `.c4d` / `.glb` (or `.fbx`, `.usd`, …) onto the Scene box, then click **Scan Scene**. diff --git a/app_qt.py b/app_qt.py index 1cb24f4..d982661 100644 --- a/app_qt.py +++ b/app_qt.py @@ -82,9 +82,10 @@ from app_window.queue_mixin import QueueMixin from app_window.reporting_mixin import ReportMixin from app_window.runtime_mixin import RuntimeMixin -from app_window.update_mixin import UpdateMixin +from app_window.update_mixin import GITHUB_REPO, UpdateMixin +from app_window.watch_mixin import WatchMixin +from core import crash as crash_capture from core.asset_grouping import GroupingConfig as AssetGroupingConfig -from core.asset_grouping import group_clips, parse_clip from core.jobs import disk_space_warnings, migrate_profile from core.logging_setup import get_logger from core.metrics import ( @@ -109,7 +110,6 @@ _runtime_download_spec, ) from core.utils import ( - IMAGE_MEDIA_EXTENSIONS, OUTPUT_PROFILES, VIDEO_EXTENSIONS, atomic_write_text, @@ -157,6 +157,7 @@ # Branded file extensions (JSON underneath) for user-facing Save/Open. PROJECT_EXT = ".rmproj" # full project: scene, clips, mappings, queue LOG_PATH = Path.home() / ".blender_video_mapper" / "logs" / "app_qt.log" +CRASH_DIR = Path.home() / ".blender_video_mapper" / "crashes" APP_NAME = app_version.APP_NAME APP_VERSION: str = app_version.__version__ # single source of truth (see app_version.py) PROFILE_VERSION = 3 @@ -272,8 +273,21 @@ def _resolve_runtime_script(name: str) -> str: raise FileNotFoundError(f"Runtime script not found: {name}") +def _help_image_dir() -> str: + """The bundled User Guide screenshots (assets/help/img), '' if absent — + the guide degrades to text-only rather than showing broken images.""" + roots = [Path(__file__).parent, Path.cwd()] + if getattr(sys, "frozen", False): + roots.insert(0, Path(getattr(sys, "_MEIPASS", ""))) + for root in roots: + c = root / "assets" / "help" / "img" + if c.is_dir(): + return str(c) + return "" + + class BlenderVideoMapperQt(QMainWindow, QueueMixin, PresetMixin, DeadlineMixin, UpdateMixin, RuntimeMixin, - ReportMixin): + ReportMixin, WatchMixin): _update_checked = Signal(object, bool, str) # (manifest dict | None, was-manual, error-text) _delivery_log = Signal(str, str) # (message, kind) from the delivery-copy thread _sheets_built = Signal(int) # count of contact sheets generated post-render @@ -393,6 +407,7 @@ def __init__(self) -> None: # turned the on-launch check off). A newer version pops the offer dialog. QTimer.singleShot(3000, self._launch_update_check) QTimer.singleShot(300, self._maybe_first_run) # one-time welcome on first launch + QTimer.singleShot(900, self._offer_crash_reports) # did the last run crash? # Size/position the window once it's shown: restore the user's last # adjustment if it's reasonable, otherwise default to 70% of the screen # centered. @@ -681,9 +696,12 @@ def _show_user_guide(self) -> None: tabs.setDocumentMode(True) pal = self._palette css = self._help_css() + img_dir = _help_image_dir() for title, body in self._guide_sections(): browser = QTextBrowser() browser.setOpenLinks(False) + if img_dir: + browser.setSearchPaths([img_dir]) # resolves the guide's tags browser.setStyleSheet( f"QTextBrowser {{ border: none; background: {pal.surface}; padding: 20px 26px; }}") browser.setHtml(css + body) @@ -707,6 +725,7 @@ def _guide_sections() -> list[tuple[str, str]]:

Render Mapper Pro maps your videos onto a 3D scene's materials and renders them — on your machine or a render farm. Each tab above covers one part of the app; here's the whole flow first.

+

" - f"" - f"") - rows.append( - f"

Asset A{g.asset:03d} " - f"· setup {g.setup} · scene {scene_name}
" - f"{out}

" - f"
1Add a scene — drag a 3D file onto the Scene box, then click Scan Scene. See the @@ -855,89 +874,85 @@ def _guide_sections() -> list[tuple[str, str]]: """), ("Watch & Auto-render", """

Watch & Auto-render

-

Hands-off mode: point the app at a folder, mark the screens - that matter, and every time fresh footage lands it imports, maps, and - renders on its own — ideal for review loops and "drop a new cut, get a new - preview" pipelines.

- -

1 · The watch folder

-

Click the clock button under the Videos list (or set - it in Properties → Watch & Auto-render) - and pick a folder. Anything you — or another app — drops there imports and - auto-maps to a material by name. It's:

+

Hands-off mode: point the app at a folder and every clip that + lands there imports, maps and renders on its own. Everything lives in the + Watch & Auto-render panel (View → Watch & Auto-render) — one + rule that reads top to bottom, ① Source → ⑤ Activity.

+

+ +

① Source — what to watch

+

+

Choose… a folder and press Start. The knobs:

- - - - + + +
Version-awareDrop Screen_v2.mp4 - next to Screen_v1.mp4 and the newer version takes over the - mapping — latest wins, no re-linking.
Instant + reliableLocal drops are picked up - the moment they land; folders on a network share or - Dropbox/cloud are polled as a backstop, so nothing is missed.
Copy-safeA file is only imported once it has - finished writing (its size holds steady) — never a half-copied clip.
Cloud-awareDropbox/OneDrive "online-only" - placeholders are skipped until their contents are actually downloaded.
WaitHow long a file must stop changing before + it's imported — never a half-copied clip. Raise it for very large files + on slow copies.
Re-scan everyBackstop poll for network shares + and cloud folders; local drops are caught instantly by filesystem + events.
Include subfoldersAlso watch every folder + inside — clips dropped into per-day or per-setup subfolders are picked + up too. The render output folder is always excluded.
+

Dropbox/OneDrive online-only placeholders are skipped + until their bytes are actually on disk.

-

2 · Mark render targets

-

Tell the app which screens an auto-render must cover: - right-click a material → Mark as Render Target, or click the coloured - stripe on the material's left edge. Targets are what the next step - waits for.

- -

3 · Auto-render

-

In Properties → Watch & - Auto-render, turn on "Auto-render once every render-target screen has a - clip". As soon as every target has footage, a single multi-screen - render is created automatically (a burst of new versions is coalesced into - one render, not one per file). Choose how it behaves:

+

② Mode — what happens to a clip

- - - + +
Start automaticallyOn = it renders straight - away. Off = the job is just added to the Queue for you to start.
Output folderWhere renders go. Blank = a - PREVIZ subfolder inside the watch folder (kept out of the - scan, so previews never re-trigger themselves).
NameFilename pattern with tokens - {clip} · {scene} · {date}.
Auto-mapThe clip maps onto the current + scene's material with the matching name; once every render-target + screen has a clip, one multi-screen render is queued. Drop + Screen_v2.mp4 next to v1 and the newer version + takes over — latest wins.
Assemble previzFor shows with a filename + convention: clips group into one render per asset, each routed to + the right scene file — drop 10 clips, get 5 assembled previz jobs.
-

4 · Delivery (optional)

-

Set a Copy to folder under Delivery and every - finished render is also copied there — e.g. a synced review/hand-off folder — - so collaborators get it without you lifting a finger. Blank = off.

- -

Tuning

-

Two knobs in Properties: poll interval (how often a - network/cloud folder is re-checked) and the settle window (how long a - file's size must hold steady before import). Defaults suit most setups; raise - the settle time for very large files copied slowly.

-

Tip: watch + targets + auto-start + a delivery folder = - a fully automatic "new footage → rendered preview in the review folder" - pipeline.

- -

Asset grouping → previz auto-export (advanced)

-

If your show uses a structured filename convention, the watch - folder can export one multi-screen previz render per asset instead of - mapping onto the current scene — drop 10 clips, get 5 assets. Turn it on in - Properties → Watch & Auto-render → Asset - grouping.

-

From a name like - PRJ001_D01_S01_A017_CENTER_ANIM_V003 it reads:

+

③ Naming — teach it your convention (previz)

+

+

Build the pattern from chips — no regex. + {Field} is text, {Field#} a number, + {Field#?} optional. Hyphenated codes like + TC-MASTER or War-Treaty are fine. Paste a real + filename into the sample box and the live preview shows exactly what parses + — or where it stopped and what it expected.

+ + + + +
Screen → MaterialWhich scene material each + screen code drives (defaults to the same name).
Setup # → SceneRoutes each setup number to + its own scene file (D1.c4d, D2.c4d, …); unmapped setups use the + current scene.
VersionNewest wins — a newer version + updates that asset's existing job in place instead of piling up.
+

Preview (dry run) shows exactly what WOULD assemble — + per asset: screen → material → clip → version — plus every skipped clip and + why, before anything renders:

+

+ +

④ Output & delivery

+

Output name supports tokens; blank output folder = a + PREVIZ subfolder inside the watch folder (always excluded from + scanning). Start renders automatically = fully hands-off; off = jobs + just queue. Set a delivery folder and every finished render is also + copied there for review/hand-off.

+ +

⑤ Activity — trust, but verify

+

- - - - - + +
Setup (S##)routes the render to that setup's - scene (or the current scene if none is mapped).
Asset (A###)the group key — every screen - of one asset assembles into a single render.
Screenmaps to the material of the same name - (or an override you set).
Type (ANIM)only this content type feeds a render; - stills/maps are ignored.
Version (V###)newest wins; a newer version - updates that asset's job in place instead of piling up.
Skipped badgeClips that did not become + jobs (wrong name, unmapped screen, superseded version) are counted, not + swallowed — click “N clip(s) skipped — why?” for the per-file + explanation.
Ingest historyThe feed is saved to + logs/ingest_history.log and reloaded on launch, so “did + that clip get picked up last night?” always has an answer. Clear + empties the visible list only.
-

Each asset exports as - {prj}_D{day}_S{setup}_A{asset}_PREVIZ_V{ver} (the name template, - parser regex, content type, screen→material map and per-setup scene are all - editable). Queue-only by default, or auto-start with the toggle above.

+

Tip: watch + previz mode + auto-start + a delivery + folder = a fully automatic “clip lands on the share → assembled preview in + the review folder” pipeline.

"""), ("Render Farm", """

Render Farm (Deadline)

@@ -1257,6 +1272,9 @@ def _build_layout(self) -> None: self.watch_panel.pick_deliver_dir_requested.connect(self._pick_watch_deliver_dir) self.watch_panel.preview_requested.connect(self._preview_watch_assembly) self.watch_panel.first_run_dismissed.connect(self._on_watch_first_run_dismissed) + # Ingest history: the activity feed writes through to disk and preloads + # its tail, so "did that clip get picked up last night?" is answerable. + self.watch_panel.set_history_path(Path(LOG_PATH).parent / "ingest_history.log") self._load_watch_panel() self.scene_panel.targets_changed.connect(lambda *_: self._save_profile()) self.scene_panel.assignments_cleared.connect( @@ -2107,317 +2125,6 @@ def _on_assignments_changed(self, _asn: list[MaterialVideoAssignment]) -> None: self._request_auto_preview() - def _on_target_set_ready(self, assignments: list) -> None: - """All target screens have clips (and the set changed) — queue one - multi-screen render named after the clips with a PREVIZ suffix.""" - if not self._autorender_enabled or not assignments: - return - scene = self.scene_panel.scene_edit.text().strip() - if not scene: - return - job = RenderJob(id=self._next_job_id) - self._next_job_id += 1 - job.video_path = assignments[0].video_path - self._make_job_snapshot(job, assignments) - - primary = Path(assignments[0].video_path).stem - base = (self._autorender_pattern or "{clip}_PREVIZ") \ - .replace("{clip}", primary) \ - .replace("{scene}", Path(scene).stem) \ - .replace("{date}", datetime.now().strftime("%Y-%m-%d")) - out_fmt, _codec = OUTPUT_PROFILES.get(job.output_profile or "H264 MP4", ("MPEG4", "H264")) - ext = ext_for_format(out_fmt) or ".mp4" - watch_folder, _en = self.scene_panel.get_watch_folder() - out_dir = self._autorender_output or ( - os.path.join(watch_folder, "PREVIZ") if watch_folder - else str(Path(assignments[0].video_path).parent / "PREVIZ")) - self.scene_panel.set_watch_ignore_dir(out_dir) # never re-ingest our own renders - job.output_path = str(Path(out_dir) / f"{base}{ext}") - job.output_input = job.output_path - job.custom_label = True - job.label = f"Auto · {base}" - self._jobs.insert(0, job) - self._refresh_queue_view() - self._schedule_save() - screens = ", ".join(a.material_name for a in assignments) - self._append_log(f"[app] Auto-render queued: {job.label} ({len(assignments)} screens: {screens})") - if self._autorender_start: - if self._is_rendering: - self._pending_autorender_ids.add(job.id) # a render is busy — start it when that finishes - self._append_log("[app] Auto-render will start when the current render finishes.") - else: - self._start_render(only_job_ids={job.id}) # start just this auto-render job - - def _sync_grouping_mode(self) -> None: - """Switch the watch folder between auto-map (normal) and asset-grouping.""" - if hasattr(self, "scene_panel"): - self.scene_panel.set_grouping_mode(self._asset_grouping.enabled) - if hasattr(self, "watch_panel"): - self.watch_panel.set_mode(self._asset_grouping.enabled) - - def _refresh_watch_panel(self, *_a) -> None: - """Mirror the watch engine's folder/enabled/mode into the Watch panel.""" - if not hasattr(self, "watch_panel"): - return - folder, enabled = self.scene_panel.get_watch_folder() - self.watch_panel.set_folder_state(folder, enabled) - self.watch_panel.set_mode(self._asset_grouping.enabled) - - def _load_watch_panel(self) -> None: - """Populate the Watch panel from the current config (folder + grouping + - auto-render + file-stability). The panel suppresses signals while loading.""" - if not hasattr(self, "watch_panel"): - return - ag = self._asset_grouping - folder, enabled = self.scene_panel.get_watch_folder() - interval_ms, settle = self.scene_panel.get_watch_options() - self.watch_panel.set_folder_state(folder, enabled) - self.watch_panel.load_config( - grouping_enabled=ag.enabled, - pattern=ag.pattern, - content_type=ag.content_type, - output_template=ag.output_template, - autorender_pattern=self._autorender_pattern, - screen_to_material=dict(ag.screen_to_material), - setup_to_scene=dict(ag.setup_to_scene), - settle_s=settle, - poll_interval_s=interval_ms / 1000.0, - autorender_start=self._autorender_start, - output_dir=self._autorender_output, - deliver_dir=self._deliver_dir, - ) - # First-run banner: only until acknowledged, and only before any folder is set. - seen = getattr(self, "_watch_first_run_seen", False) - self.watch_panel.show_first_run(not seen and not folder) - - def _apply_watch_panel(self) -> None: - """Write the Watch panel's settings back onto the live config + engine and - persist. Connected to the panel's ``config_changed`` (fires on any edit).""" - if not hasattr(self, "watch_panel"): - return - c = self.watch_panel.get_config() - ag = self._asset_grouping - ag.enabled = bool(c["grouping_enabled"]) - ag.pattern = c["pattern"] or ag.pattern - ag.content_type = c["content_type"] - ag.output_template = c["output_template"] or ag.output_template - ag.screen_to_material = dict(c["screen_to_material"]) - ag.setup_to_scene = dict(c["setup_to_scene"]) - self._autorender_pattern = c["autorender_pattern"] or self._autorender_pattern - self._autorender_output = c["output_dir"] - self._autorender_start = bool(c["autorender_start"]) - self._deliver_dir = c["deliver_dir"] - # In auto-map mode the checkbox also gates whether dropped clips render at - # all; in previz mode jobs are always built, so it only gates auto-start. - if not ag.enabled: - self._autorender_enabled = bool(c["autorender_start"]) - self.scene_panel.set_watch_options(int(max(1.0, float(c["poll_interval_s"])) * 1000), - float(c["settle_s"])) - self.scene_panel.set_grouping_mode(ag.enabled) - self._save_profile() - - def _pick_watch_output_dir(self) -> None: - cur = self._autorender_output or str(Path.home()) - d = QFileDialog.getExistingDirectory(self, "Choose render output folder", cur) - if d: - self.watch_panel.out_dir_edit.setText(d) # triggers config_changed → apply - - def _pick_watch_deliver_dir(self) -> None: - cur = self._deliver_dir or str(Path.home()) - d = QFileDialog.getExistingDirectory(self, "Choose delivery folder", cur) - if d: - self.watch_panel.deliver_edit.setText(d) # triggers config_changed → apply - - def _preview_watch_assembly(self) -> None: - """Dry-run the previz grouping from the panel's current config.""" - self._apply_watch_panel() # make sure _asset_grouping reflects the panel - self._preview_assembly(self._asset_grouping) - - def _open_watch_render_panel(self) -> None: - """Reveal + focus the Watch & Auto-render dock (the editable home).""" - self.watch_dock.show() - self.watch_dock.raise_() - self.watch_panel.setFocus() - - def _on_watch_first_run_dismissed(self) -> None: - self._watch_first_run_seen = True - self._save_profile() - - def _pick_watch_folder(self) -> None: - folder, _en = self.scene_panel.get_watch_folder() - d = QFileDialog.getExistingDirectory(self, "Choose watch folder", folder or str(Path.home())) - if d: - self.scene_panel.set_watch_folder(d, True) - self._refresh_watch_panel() - self.watch_panel.add_activity("Watch", f"Watching {d}") - - def _toggle_watch_from_panel(self, on: bool) -> None: - folder, _en = self.scene_panel.get_watch_folder() - if on and not folder: - self._pick_watch_folder() # nothing to watch yet — pick one first - return - self.scene_panel.set_watch_folder(folder, on) - self._refresh_watch_panel() - self.watch_panel.add_activity("Watch", "Started" if on else "Stopped") - - def _on_watch_clips_ready(self, paths: list) -> None: - """Asset-grouping watch path: parse the ready clips by the naming - convention and build one previz render job per (setup, asset). The newest - version of each screen wins; an existing job for that asset is updated - in place when a newer version lands, so re-renders don't pile up.""" - if not self._asset_grouping.enabled or not paths: - return - try: - groups = group_clips(list(paths), self._asset_grouping) - except Exception as exc: - self._append_log(f"[app] Asset grouping failed: {exc}") - return - if not groups: - return - cur_scene = self.scene_panel.scene_edit.text().strip() - watch_folder, _en = self.scene_panel.get_watch_folder() - touched: set[int] = set() - created = updated = 0 - for g in groups: - scene = (self._asset_grouping.setup_to_scene.get(g.setup) or cur_scene).strip() - if not scene: - self._append_log( - f"[app] {g.prj} S{g.setup:02d} A{g.asset:03d}: no scene mapped for this " - f"setup — set one in Properties → Watch & Auto-render. Skipped.") - continue - key = (g.setup, g.asset) - prev = self._asset_group_jobs.get(key) - existing = None - if prev is not None: - prev_id, prev_ver = prev - existing = next((j for j in self._jobs if j.id == prev_id), None) - if existing is not None and prev_ver >= g.version: - continue # already queued at this version or newer - asn = [MaterialVideoAssignment(mat, clip, VIDEO_MAPPING_MODE_EMISSION) - for mat, clip in g.material_assignments(self._asset_grouping.screen_to_material)] - if not asn: - continue - if existing is None: - job = RenderJob(id=self._next_job_id) - self._next_job_id += 1 - self._jobs.insert(0, job) - created += 1 - else: - job = existing - updated += 1 - job.video_path = asn[0].video_path - self._make_job_snapshot(job, asn) - job.scene_path = scene # override: this setup's scene, not the loaded one - base = g.output_name(self._asset_grouping.output_template) - out_fmt, _c = OUTPUT_PROFILES.get(job.output_profile or "H264 MP4", ("MPEG4", "H264")) - ext = ext_for_format(out_fmt) or ".mp4" - out_dir = self._autorender_output or ( - os.path.join(watch_folder, "PREVIZ") if watch_folder - else str(Path(scene).parent / "PREVIZ")) - self.scene_panel.set_watch_ignore_dir(out_dir) # never re-ingest our own renders - job.output_path = str(Path(out_dir) / f"{base}{ext}") - job.output_input = job.output_path - job.custom_label = True - job.label = base - job.status, job.progress, job.error, job.selected = "idle", 0.0, "", True - self._asset_group_jobs[key] = (job.id, g.version) - touched.add(job.id) - if not touched: - return - self._refresh_queue_view() - self._schedule_save() - self._append_log( - f"[app] Asset grouping: {created} new + {updated} updated previz job(s) " - f"from {len(groups)} asset group(s).") - self.watch_panel.add_activity( - "Previz", f"{created} new + {updated} updated job(s) from {len(groups)} group(s)") - if self._autorender_start and not self._is_rendering: - self._start_render(only_job_ids=touched) - - def _preview_assembly(self, cfg: AssetGroupingConfig | None = None) -> None: - """Dry-run the asset-grouping on the watch folder's current clips and show - exactly what WOULD be assembled — per asset: screen → material → clip → - version — plus any skipped clips and why. Makes the auto-assembly trustable - before it ever fires a render. ``cfg`` lets the Properties dialog preview its - in-progress edits; defaults to the saved grouping config.""" - cfg = cfg or self._asset_grouping - folder, _en = self.scene_panel.get_watch_folder() - if not folder or not os.path.isdir(folder): - inform(self, "Preview Assembly", - "Choose a watch folder first (the watch button in the Scene panel).") - return - exts = VIDEO_EXTENSIONS | IMAGE_MEDIA_EXTENSIONS - try: - clips = sorted(str(p) for p in Path(folder).iterdir() - if p.is_file() and p.suffix.lower() in exts) - except OSError: - clips = [] - groups = group_clips(clips, cfg) - known_mats = set(self._discovered_materials) - cur_scene = self.scene_panel.scene_edit.text().strip() - - pal = self._palette - rows = [] - used: set[str] = set() - for g in groups: - scene = (cfg.setup_to_scene.get(g.setup) or cur_scene).strip() - scene_name = Path(scene).name if scene else "⚠ no scene mapped" - out = g.output_name(cfg.output_template) - screen_rows = [] - for material, clip in g.material_assignments(cfg.screen_to_material): - used.add(clip) - pc = parse_clip(clip, cfg.pattern) - ver = f"V{pc.version:03d}" if pc else "?" - screen = pc.screen if pc else "?" - warn = "" if (not known_mats or material in known_mats) else \ - f" (not in scene)" - screen_rows.append( - f"
{screen}{material}{warn}← {Path(clip).name} · {ver}
{''.join(screen_rows)}
") - - skipped = [] - for c in clips: - if c in used: - continue - pc = parse_clip(c, cfg.pattern) - if pc is None: - why = "doesn't match the filename pattern" - elif (pc.type or "").upper() != (cfg.content_type or "ANIM").upper(): - why = f"type={pc.type} (only {cfg.content_type} is grouped)" - else: - why = "superseded by a newer version" - skipped.append(f"
  • {Path(c).name} — {why}
  • ") - - summary = (f"{len(groups)} asset(s) from {len(clips)} clip(s) in " - f"{folder}") - body = "".join(rows) if rows else f"

    No assets matched the pattern.

    " - skip_html = (f"

    Skipped ({len(skipped)})

    " - f"
      {''.join(skipped)}
    ") if skipped else "" - html = (f"

    {summary}

    {body}{skip_html}
    ") - - dlg = QDialog(self) - dlg.setWindowTitle("Preview Assembly — dry run") - dlg.setMinimumSize(560, 480) - lay = QVBoxLayout(dlg) - view = QTextBrowser() - view.setHtml(html) - view.setStyleSheet(f"QTextBrowser{{border:1px solid {pal.border}; border-radius:8px; " - f"background:{pal.surface}; padding:10px;}}") - lay.addWidget(view) - close = QPushButton("Close") - close.clicked.connect(dlg.accept) - row = QHBoxLayout() - row.addStretch(1) - row.addWidget(close) - lay.addLayout(row) - dlg.exec() - def _request_auto_preview(self) -> None: """Debounced trigger: when Auto is on, re-render the preview a moment after the user stops changing settings/mappings.""" @@ -3954,6 +3661,7 @@ def _profile_dict(self) -> dict: "watch_enabled": watch_enabled, "watch_interval_ms": watch_interval_ms, "watch_settle": watch_settle, + "watch_recursive": self.scene_panel.get_watch_recursive(), "autorender_enabled": self._autorender_enabled, "autorender_start": self._autorender_start, "autorender_output": self._autorender_output, @@ -4230,6 +3938,7 @@ def note_missing(vp: str, vn: str) -> None: int(d.get("watch_interval_ms", 3000)), float(d.get("watch_settle", 2.0))) except (TypeError, ValueError): _log.debug("invalid watch-folder values in profile; using defaults", exc_info=True) + self.scene_panel.set_watch_recursive(bool(d.get("watch_recursive", False))) self._autorender_enabled = bool(d.get("autorender_enabled", False)) self._autorender_start = bool(d.get("autorender_start", False)) self._autorender_output = str(d.get("autorender_output", "") or "") @@ -4519,6 +4228,46 @@ def _is_headless() -> bool: # where platformName() lives. return isinstance(app, QGuiApplication) and app.platformName() == "offscreen" + def _offer_crash_reports(self) -> None: + """If the previous run crashed (native fault, or an exception the user + never saw), offer the report once: view it, or open a prefilled GitHub + issue. Nothing leaves the machine unless the user submits it.""" + if self._is_headless(): + return + try: + crash_capture.collect_faults(CRASH_DIR, version=APP_VERSION) + reports = crash_capture.pending_reports(CRASH_DIR) + except Exception: + _log.debug("crash-report sweep failed", exc_info=True) + return + if not reports: + return + report = reports[0] + detail = crash_capture.summarize(report) + extra = f" (+{len(reports) - 1} older)" if len(reports) > 1 else "" + clicked = ask( + self, f"{APP_NAME} crashed last time", + f"The previous session ended unexpectedly{extra}.\n\n" + + (f"{detail}\n\n" if detail else "") + + "You can look at the report, or open a prefilled GitHub issue — " + "nothing is sent unless you submit it yourself.", + kind="warning", + buttons=[("Dismiss", "dismiss", "neutral"), + ("View Report", "view", "neutral"), + ("Report on GitHub…", "report", "primary")], + default="dismiss", + ) + if clicked == "view": + try: + reveal_in_file_manager(report) + except Exception: + _log.debug("could not reveal crash report", exc_info=True) + elif clicked == "report": + QDesktopServices.openUrl(QUrl(crash_capture.github_issue_url( + GITHUB_REPO, report, version=APP_VERSION))) + for r in reports: + crash_capture.acknowledge(r) + def _maybe_first_run(self) -> None: """A one-time welcome on the very first launch: show what's detected and point new users at setup + Quick Start.""" @@ -4677,6 +4426,9 @@ def _hook(exc_type, exc, tb): f.write(f"\n[{datetime.now().isoformat()}] UNHANDLED EXCEPTION\n{text}\n") except Exception: _log.warning("failed to write crash traceback to the log file", exc_info=True) + # Also keep a structured report: if the dialog below is shown, it's + # acknowledged right away; if the app dies first, next launch offers it. + report = crash_capture.write_crash_report(CRASH_DIR, text, version=APP_VERSION) if showing["active"]: return showing["active"] = True @@ -4693,6 +4445,8 @@ def _hook(exc_type, exc, tb): box.addButton(QMessageBox.StandardButton.Close) box.exec() clicked = box.clickedButton() + if report is not None: # surfaced live — don't re-offer next launch + crash_capture.acknowledge(report) if clicked is copy_btn: QApplication.clipboard().setText(text) elif clicked is log_btn: @@ -4724,6 +4478,11 @@ def run_qt_app() -> None: app.setOrganizationName("Toy Robot Media") app.setWindowIcon(_make_app_icon()) + # Native-crash evidence: faulthandler writes a per-pid dump that a clean + # exit removes; a dump left behind means the next launch offers a report. + crash_capture.enable_fault_capture(CRASH_DIR) + app.aboutToQuit.connect(crash_capture.disable_fault_capture) + # ── Single-instance guard ──────────────────────────────────────────── # If another copy is already running, ask it to surface its window and # exit this one instead of opening a second window. diff --git a/app_version.py b/app_version.py index d3e32d2..5485b34 100644 --- a/app_version.py +++ b/app_version.py @@ -4,5 +4,5 @@ CI, so the version lives in exactly one place. CI fails a ``v`` release tag whose value doesn't match this string. """ -__version__ = "1.8.32" +__version__ = "1.8.33" APP_NAME = "Render Mapper Pro" # display name; single source shared by the app + mixins diff --git a/app_window/base.py b/app_window/base.py index 0e8736d..7628af4 100644 --- a/app_window/base.py +++ b/app_window/base.py @@ -16,17 +16,27 @@ from PySide6.QtCore import QThread, SignalInstance from PySide6.QtGui import QAction - from PySide6.QtWidgets import QDialog, QLabel, QWidget - - from core.models import RenderJob - from panels import DeadlinePanel, PresetBrowserPanel, QueuePanel, RenderPanel, ScenePanel - from theme import Palette - from workers import DeadlineQueryThread, FuncThread + from PySide6.QtWidgets import QDialog, QDockWidget, QLabel # For typing, mixins ARE a QWidget (the concrete window is a QMainWindow), so # `QMessageBox(self, …)` type-checks. At runtime the base is plain ``object`` # — no second QWidget in the MRO — and the real window supplies QMainWindow. - _Base = QWidget + # Imported under the alias (not assigned) so every mypy version accepts it + # as a base class. + from PySide6.QtWidgets import QWidget as _Base + + from core.asset_grouping import GroupingConfig + from core.models import RenderJob + from panels import ( + DeadlinePanel, + PresetBrowserPanel, + QueuePanel, + RenderPanel, + ScenePanel, + WatchPanel, + ) + from theme import Palette + from workers import DeadlineQueryThread, FuncThread else: _Base = object @@ -68,6 +78,19 @@ class _WindowMembers(_Base): _last_html_report_path: str _open_report_action: QAction _open_html_action: QAction + # ── Watch & auto-render (WatchMixin) ─────────────────────────────── + _asset_grouping: GroupingConfig + _asset_group_jobs: dict[tuple[int, int], tuple[int, int]] + _pending_autorender_ids: set[int] + _autorender_enabled: bool + _autorender_pattern: str + _autorender_output: str + _autorender_start: bool + _deliver_dir: str + _watch_first_run_seen: bool + _discovered_materials: list[str] + watch_panel: WatchPanel + watch_dock: QDockWidget scene_panel: ScenePanel render_panel: RenderPanel deadline_panel: DeadlinePanel @@ -88,6 +111,8 @@ def _save_profile(self) -> None: ... def _push_undo(self, desc: str, restore) -> None: ... def _update_status_bar(self) -> None: ... def _update_progress_caption(self) -> None: ... + def _make_job_snapshot(self, job: RenderJob, assignments: list) -> None: ... + def _start_render(self, render_all: bool = ..., only_job_ids: set | None = ...) -> None: ... def _unsaved_floating_changes(self) -> bool: ... def _effective_chunk_size(self, job: RenderJob) -> int: ... def _fmt_dur(self, seconds: float) -> str: ... # window (ReportMixin uses) diff --git a/app_window/watch_mixin.py b/app_window/watch_mixin.py new file mode 100644 index 0000000..12821bc --- /dev/null +++ b/app_window/watch_mixin.py @@ -0,0 +1,351 @@ +"""Watch folder & auto-render — the ingest pipeline: panel load/apply wiring, +auto-map and previz (asset-grouping) job builders, and the dry-run assembly +preview. Extracted verbatim from BlenderVideoMapperQt (operates on ``self``).""" +from __future__ import annotations + +import os +from datetime import datetime +from pathlib import Path + +from PySide6.QtWidgets import ( + QDialog, + QFileDialog, + QHBoxLayout, + QPushButton, + QTextBrowser, + QVBoxLayout, +) + +from app_window.base import _WindowMembers +from core.asset_grouping import GroupingConfig as AssetGroupingConfig +from core.asset_grouping import group_clips, parse_clip +from core.models import VIDEO_MAPPING_MODE_EMISSION, MaterialVideoAssignment, RenderJob +from core.utils import ( + IMAGE_MEDIA_EXTENSIONS, + OUTPUT_PROFILES, + VIDEO_EXTENSIONS, + ext_for_format, +) +from ui_dialogs import inform + + +class WatchMixin(_WindowMembers): + + def _on_target_set_ready(self, assignments: list) -> None: + """All target screens have clips (and the set changed) — queue one + multi-screen render named after the clips with a PREVIZ suffix.""" + if not self._autorender_enabled or not assignments: + return + scene = self.scene_panel.scene_edit.text().strip() + if not scene: + return + job = RenderJob(id=self._next_job_id) + self._next_job_id += 1 + job.video_path = assignments[0].video_path + self._make_job_snapshot(job, assignments) + + primary = Path(assignments[0].video_path).stem + base = (self._autorender_pattern or "{clip}_PREVIZ") \ + .replace("{clip}", primary) \ + .replace("{scene}", Path(scene).stem) \ + .replace("{date}", datetime.now().strftime("%Y-%m-%d")) + out_fmt, _codec = OUTPUT_PROFILES.get(job.output_profile or "H264 MP4", ("MPEG4", "H264")) + ext = ext_for_format(out_fmt) or ".mp4" + watch_folder, _en = self.scene_panel.get_watch_folder() + out_dir = self._autorender_output or ( + os.path.join(watch_folder, "PREVIZ") if watch_folder + else str(Path(assignments[0].video_path).parent / "PREVIZ")) + self.scene_panel.set_watch_ignore_dir(out_dir) # never re-ingest our own renders + job.output_path = str(Path(out_dir) / f"{base}{ext}") + job.output_input = job.output_path + job.custom_label = True + job.label = f"Auto · {base}" + self._jobs.insert(0, job) + self._refresh_queue_view() + self._schedule_save() + screens = ", ".join(a.material_name for a in assignments) + self._append_log(f"[app] Auto-render queued: {job.label} ({len(assignments)} screens: {screens})") + if self._autorender_start: + if self._is_rendering: + self._pending_autorender_ids.add(job.id) # a render is busy — start it when that finishes + self._append_log("[app] Auto-render will start when the current render finishes.") + else: + self._start_render(only_job_ids={job.id}) # start just this auto-render job + + def _sync_grouping_mode(self) -> None: + """Switch the watch folder between auto-map (normal) and asset-grouping.""" + if hasattr(self, "scene_panel"): + self.scene_panel.set_grouping_mode(self._asset_grouping.enabled) + if hasattr(self, "watch_panel"): + self.watch_panel.set_mode(self._asset_grouping.enabled) + + def _refresh_watch_panel(self, *_a) -> None: + """Mirror the watch engine's folder/enabled/mode into the Watch panel.""" + if not hasattr(self, "watch_panel"): + return + folder, enabled = self.scene_panel.get_watch_folder() + self.watch_panel.set_folder_state(folder, enabled) + self.watch_panel.set_mode(self._asset_grouping.enabled) + + def _load_watch_panel(self) -> None: + """Populate the Watch panel from the current config (folder + grouping + + auto-render + file-stability). The panel suppresses signals while loading.""" + if not hasattr(self, "watch_panel"): + return + ag = self._asset_grouping + folder, enabled = self.scene_panel.get_watch_folder() + interval_ms, settle = self.scene_panel.get_watch_options() + self.watch_panel.set_folder_state(folder, enabled) + self.watch_panel.load_config( + grouping_enabled=ag.enabled, + pattern=ag.pattern, + content_type=ag.content_type, + output_template=ag.output_template, + autorender_pattern=self._autorender_pattern, + screen_to_material=dict(ag.screen_to_material), + setup_to_scene=dict(ag.setup_to_scene), + settle_s=settle, + poll_interval_s=interval_ms / 1000.0, + autorender_start=self._autorender_start, + output_dir=self._autorender_output, + deliver_dir=self._deliver_dir, + recursive=self.scene_panel.get_watch_recursive(), + ) + # First-run banner: only until acknowledged, and only before any folder is set. + seen = getattr(self, "_watch_first_run_seen", False) + self.watch_panel.show_first_run(not seen and not folder) + + def _apply_watch_panel(self) -> None: + """Write the Watch panel's settings back onto the live config + engine and + persist. Connected to the panel's ``config_changed`` (fires on any edit).""" + if not hasattr(self, "watch_panel"): + return + c = self.watch_panel.get_config() + ag = self._asset_grouping + ag.enabled = bool(c["grouping_enabled"]) + ag.pattern = c["pattern"] or ag.pattern + ag.content_type = c["content_type"] + ag.output_template = c["output_template"] or ag.output_template + ag.screen_to_material = dict(c["screen_to_material"]) + ag.setup_to_scene = dict(c["setup_to_scene"]) + self._autorender_pattern = c["autorender_pattern"] or self._autorender_pattern + self._autorender_output = c["output_dir"] + self._autorender_start = bool(c["autorender_start"]) + self._deliver_dir = c["deliver_dir"] + # In auto-map mode the checkbox also gates whether dropped clips render at + # all; in previz mode jobs are always built, so it only gates auto-start. + if not ag.enabled: + self._autorender_enabled = bool(c["autorender_start"]) + self.scene_panel.set_watch_options(int(max(1.0, float(c["poll_interval_s"])) * 1000), + float(c["settle_s"])) + self.scene_panel.set_watch_recursive(bool(c["recursive"])) + self.scene_panel.set_grouping_mode(ag.enabled) + self._save_profile() + + def _pick_watch_output_dir(self) -> None: + cur = self._autorender_output or str(Path.home()) + d = QFileDialog.getExistingDirectory(self, "Choose render output folder", cur) + if d: + self.watch_panel.out_dir_edit.setText(d) # triggers config_changed → apply + + def _pick_watch_deliver_dir(self) -> None: + cur = self._deliver_dir or str(Path.home()) + d = QFileDialog.getExistingDirectory(self, "Choose delivery folder", cur) + if d: + self.watch_panel.deliver_edit.setText(d) # triggers config_changed → apply + + def _preview_watch_assembly(self) -> None: + """Dry-run the previz grouping from the panel's current config.""" + self._apply_watch_panel() # make sure _asset_grouping reflects the panel + self._preview_assembly(self._asset_grouping) + + def _open_watch_render_panel(self) -> None: + """Reveal + focus the Watch & Auto-render dock (the editable home).""" + self.watch_dock.show() + self.watch_dock.raise_() + self.watch_panel.setFocus() + + def _on_watch_first_run_dismissed(self) -> None: + self._watch_first_run_seen = True + self._save_profile() + + def _pick_watch_folder(self) -> None: + folder, _en = self.scene_panel.get_watch_folder() + d = QFileDialog.getExistingDirectory(self, "Choose watch folder", folder or str(Path.home())) + if d: + self.scene_panel.set_watch_folder(d, True) + self._refresh_watch_panel() + self.watch_panel.add_activity("Watch", f"Watching {d}") + + def _toggle_watch_from_panel(self, on: bool) -> None: + folder, _en = self.scene_panel.get_watch_folder() + if on and not folder: + self._pick_watch_folder() # nothing to watch yet — pick one first + return + self.scene_panel.set_watch_folder(folder, on) + self._refresh_watch_panel() + self.watch_panel.add_activity("Watch", "Started" if on else "Stopped") + + def _on_watch_clips_ready(self, paths: list) -> None: + """Asset-grouping watch path: parse the ready clips by the naming + convention and build one previz render job per (setup, asset). The newest + version of each screen wins; an existing job for that asset is updated + in place when a newer version lands, so re-renders don't pile up.""" + if not self._asset_grouping.enabled or not paths: + return + try: + groups = group_clips(list(paths), self._asset_grouping) + except Exception as exc: + self._append_log(f"[app] Asset grouping failed: {exc}") + return + # Silent-ignore fix: clips that are present but did NOT land in any + # group's assignments (pattern/type mismatch, unmapped screen, superseded + # version) are surfaced as a badge — the dry run explains each one. + used = {clip for g in groups + for _m, clip in g.material_assignments(self._asset_grouping.screen_to_material)} + self.watch_panel.set_ignored(len([p for p in paths if p not in used])) + if not groups: + return + cur_scene = self.scene_panel.scene_edit.text().strip() + watch_folder, _en = self.scene_panel.get_watch_folder() + touched: set[int] = set() + created = updated = 0 + for g in groups: + scene = (self._asset_grouping.setup_to_scene.get(g.setup) or cur_scene).strip() + if not scene: + self._append_log( + f"[app] {g.prj} S{g.setup:02d} A{g.asset:03d}: no scene mapped for this " + f"setup — set one in Properties → Watch & Auto-render. Skipped.") + continue + key = (g.setup, g.asset) + prev = self._asset_group_jobs.get(key) + existing = None + if prev is not None: + prev_id, prev_ver = prev + existing = next((j for j in self._jobs if j.id == prev_id), None) + if existing is not None and prev_ver >= g.version: + continue # already queued at this version or newer + asn = [MaterialVideoAssignment(mat, clip, VIDEO_MAPPING_MODE_EMISSION) + for mat, clip in g.material_assignments(self._asset_grouping.screen_to_material)] + if not asn: + continue + if existing is None: + job = RenderJob(id=self._next_job_id) + self._next_job_id += 1 + self._jobs.insert(0, job) + created += 1 + else: + job = existing + updated += 1 + job.video_path = asn[0].video_path + self._make_job_snapshot(job, asn) + job.scene_path = scene # override: this setup's scene, not the loaded one + base = g.output_name(self._asset_grouping.output_template) + out_fmt, _c = OUTPUT_PROFILES.get(job.output_profile or "H264 MP4", ("MPEG4", "H264")) + ext = ext_for_format(out_fmt) or ".mp4" + out_dir = self._autorender_output or ( + os.path.join(watch_folder, "PREVIZ") if watch_folder + else str(Path(scene).parent / "PREVIZ")) + self.scene_panel.set_watch_ignore_dir(out_dir) # never re-ingest our own renders + job.output_path = str(Path(out_dir) / f"{base}{ext}") + job.output_input = job.output_path + job.custom_label = True + job.label = base + job.status, job.progress, job.error, job.selected = "idle", 0.0, "", True + self._asset_group_jobs[key] = (job.id, g.version) + touched.add(job.id) + if not touched: + return + self._refresh_queue_view() + self._schedule_save() + self._append_log( + f"[app] Asset grouping: {created} new + {updated} updated previz job(s) " + f"from {len(groups)} asset group(s).") + self.watch_panel.add_activity( + "Previz", f"{created} new + {updated} updated job(s) from {len(groups)} group(s)") + if self._autorender_start and not self._is_rendering: + self._start_render(only_job_ids=touched) + + def _preview_assembly(self, cfg: AssetGroupingConfig | None = None) -> None: + """Dry-run the asset-grouping on the watch folder's current clips and show + exactly what WOULD be assembled — per asset: screen → material → clip → + version — plus any skipped clips and why. Makes the auto-assembly trustable + before it ever fires a render. ``cfg`` lets the Properties dialog preview its + in-progress edits; defaults to the saved grouping config.""" + cfg = cfg or self._asset_grouping + folder, _en = self.scene_panel.get_watch_folder() + if not folder or not os.path.isdir(folder): + inform(self, "Preview Assembly", + "Choose a watch folder first (the watch button in the Scene panel).") + return + exts = VIDEO_EXTENSIONS | IMAGE_MEDIA_EXTENSIONS + try: + clips = sorted(str(p) for p in Path(folder).iterdir() + if p.is_file() and p.suffix.lower() in exts) + except OSError: + clips = [] + groups = group_clips(clips, cfg) + known_mats = set(self._discovered_materials) + cur_scene = self.scene_panel.scene_edit.text().strip() + + pal = self._palette + rows = [] + used: set[str] = set() + for g in groups: + scene = (cfg.setup_to_scene.get(g.setup) or cur_scene).strip() + scene_name = Path(scene).name if scene else "⚠ no scene mapped" + out = g.output_name(cfg.output_template) + screen_rows = [] + for material, clip in g.material_assignments(cfg.screen_to_material): + used.add(clip) + pc = parse_clip(clip, cfg.pattern) + ver = f"V{pc.version:03d}" if pc else "?" + screen = pc.screen if pc else "?" + warn = "" if (not known_mats or material in known_mats) else \ + f" (not in scene)" + screen_rows.append( + f"{screen}" + f"→ {material}{warn}" + f"← {Path(clip).name} · {ver}") + rows.append( + f"

    Asset A{g.asset:03d} " + f"· setup {g.setup} · scene {scene_name}
    " + f"{out}

    " + f"{''.join(screen_rows)}
    ") + + skipped = [] + for c in clips: + if c in used: + continue + pc = parse_clip(c, cfg.pattern) + if pc is None: + why = "doesn't match the filename pattern" + elif (pc.type or "").upper() != (cfg.content_type or "ANIM").upper(): + why = f"type={pc.type} (only {cfg.content_type} is grouped)" + else: + why = "superseded by a newer version" + skipped.append(f"
  • {Path(c).name} — {why}
  • ") + + summary = (f"{len(groups)} asset(s) from {len(clips)} clip(s) in " + f"{folder}") + body = "".join(rows) if rows else f"

    No assets matched the pattern.

    " + skip_html = (f"

    Skipped ({len(skipped)})

    " + f"
      {''.join(skipped)}
    ") if skipped else "" + html = (f"

    {summary}

    {body}{skip_html}
    ") + + dlg = QDialog(self) + dlg.setWindowTitle("Preview Assembly — dry run") + dlg.setMinimumSize(560, 480) + lay = QVBoxLayout(dlg) + view = QTextBrowser() + view.setHtml(html) + view.setStyleSheet(f"QTextBrowser{{border:1px solid {pal.border}; border-radius:8px; " + f"background:{pal.surface}; padding:10px;}}") + lay.addWidget(view) + close = QPushButton("Close") + close.clicked.connect(dlg.accept) + row = QHBoxLayout() + row.addStretch(1) + row.addWidget(close) + lay.addLayout(row) + dlg.exec() diff --git a/assets/help/img/main-window.png b/assets/help/img/main-window.png new file mode 100644 index 0000000..fa27542 Binary files /dev/null and b/assets/help/img/main-window.png differ diff --git a/assets/help/img/preview-assembly.png b/assets/help/img/preview-assembly.png new file mode 100644 index 0000000..d61ff8b Binary files /dev/null and b/assets/help/img/preview-assembly.png differ diff --git a/assets/help/img/queue.png b/assets/help/img/queue.png new file mode 100644 index 0000000..0dcd35f Binary files /dev/null and b/assets/help/img/queue.png differ diff --git a/assets/help/img/scene-panel.png b/assets/help/img/scene-panel.png new file mode 100644 index 0000000..9b5db54 Binary files /dev/null and b/assets/help/img/scene-panel.png differ diff --git a/assets/help/img/watch-activity.png b/assets/help/img/watch-activity.png new file mode 100644 index 0000000..559063f Binary files /dev/null and b/assets/help/img/watch-activity.png differ diff --git a/assets/help/img/watch-mode.png b/assets/help/img/watch-mode.png new file mode 100644 index 0000000..ee3e5be Binary files /dev/null and b/assets/help/img/watch-mode.png differ diff --git a/assets/help/img/watch-naming.png b/assets/help/img/watch-naming.png new file mode 100644 index 0000000..13ee7fe Binary files /dev/null and b/assets/help/img/watch-naming.png differ diff --git a/assets/help/img/watch-output.png b/assets/help/img/watch-output.png new file mode 100644 index 0000000..e2eaeb6 Binary files /dev/null and b/assets/help/img/watch-output.png differ diff --git a/assets/help/img/watch-panel.png b/assets/help/img/watch-panel.png new file mode 100644 index 0000000..79da11f Binary files /dev/null and b/assets/help/img/watch-panel.png differ diff --git a/assets/help/img/watch-source.png b/assets/help/img/watch-source.png new file mode 100644 index 0000000..3809f21 Binary files /dev/null and b/assets/help/img/watch-source.png differ diff --git a/core/crash.py b/core/crash.py new file mode 100644 index 0000000..351baf6 --- /dev/null +++ b/core/crash.py @@ -0,0 +1,203 @@ +"""Crash capture and next-launch reporting — pure, UI-free. + +Two capture paths feed one report format: + +* Unhandled Python exceptions: the UI-thread excepthook (app_qt) calls + :func:`write_crash_report` in addition to showing its live dialog, so the + crash survives as a structured file even if the user closes the app. +* Hard native crashes (segfault in Qt/a driver/ffmpeg bindings): nothing + Python-side runs at crash time, so :func:`enable_fault_capture` points + ``faulthandler`` at a per-pid dump file up front. On the next launch, + :func:`collect_faults` turns any dump left behind by a dead process into a + normal crash report (a clean exit removes its own dump). + +Reports are plain-text files in a crash directory. The UI offers to open a +prefilled GitHub issue (:func:`github_issue_url`) — nothing is ever sent +anywhere without the user clicking that link themselves. +""" +from __future__ import annotations + +import faulthandler +import os +import platform +import re +import sys +import urllib.parse +from datetime import datetime +from pathlib import Path +from typing import IO + +from core.logging_setup import get_logger + +_log = get_logger(__name__) + +_FAULT_PREFIX = "fault-" # fault-.dump: armed faulthandler target +_REPORT_PREFIX = "crash-" # crash-.txt: a report to surface +_SEEN_SUFFIX = ".seen" # acknowledged reports keep the file, renamed + +_fault_file: IO[str] | None = None +_fault_path: Path | None = None + + +def _header(version: str, kind: str) -> str: + return ( + f"Render Mapper Pro crash report\n" + f"kind: {kind}\n" + f"version: {version}\n" + f"os: {platform.platform()}\n" + f"python: {sys.version.split()[0]}\n" + f"time: {datetime.now().isoformat(timespec='seconds')}\n" + f"{'-' * 60}\n" + ) + + +def write_crash_report(crash_dir: Path, text: str, *, version: str, + kind: str = "exception") -> Path | None: + """Persist a crash as a structured report file; never raises.""" + try: + crash_dir.mkdir(parents=True, exist_ok=True) + stamp = datetime.now().strftime("%Y%m%d-%H%M%S-%f") + path = crash_dir / f"{_REPORT_PREFIX}{stamp}.txt" + path.write_text(_header(version, kind) + text, encoding="utf-8") + return path + except Exception: + _log.warning("could not write crash report", exc_info=True) + return None + + +def enable_fault_capture(crash_dir: Path) -> None: + """Arm faulthandler into a per-pid dump file so a native crash (which never + reaches Python) still leaves evidence for the next launch.""" + global _fault_file, _fault_path + try: + crash_dir.mkdir(parents=True, exist_ok=True) + _fault_path = crash_dir / f"{_FAULT_PREFIX}{os.getpid()}.dump" + _fault_file = _fault_path.open("w", encoding="utf-8") + faulthandler.enable(file=_fault_file, all_threads=True) + except Exception: + _log.warning("could not enable fault capture", exc_info=True) + _fault_file, _fault_path = None, None + + +def disable_fault_capture() -> None: + """Clean-exit path: disarm faulthandler and remove this pid's dump so the + next launch doesn't mistake it for a crash.""" + global _fault_file, _fault_path + try: + if faulthandler.is_enabled(): + faulthandler.disable() + if _fault_file is not None: + _fault_file.close() + if _fault_path is not None and _fault_path.exists(): + _fault_path.unlink() + except Exception: + _log.debug("fault-capture cleanup failed", exc_info=True) + finally: + _fault_file, _fault_path = None, None + + +def _pid_alive(pid: int) -> bool: + if pid <= 0: + return False + if sys.platform == "win32": + # os.kill(pid, 0) TERMINATES on Windows — query the process instead. + import ctypes + PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 + STILL_ACTIVE = 259 + kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined] + handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid) + if not handle: + return False + try: + code = ctypes.c_ulong() + ok = kernel32.GetExitCodeProcess(handle, ctypes.byref(code)) + return bool(ok) and code.value == STILL_ACTIVE + finally: + kernel32.CloseHandle(handle) + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True + + +def collect_faults(crash_dir: Path, *, version: str) -> list[Path]: + """Turn fault dumps left by dead processes into crash reports (next-launch + sweep). Empty leftovers are just removed; live pids are left alone.""" + reports: list[Path] = [] + try: + dumps = sorted(crash_dir.glob(f"{_FAULT_PREFIX}*.dump")) + except OSError: + return reports + for dump in dumps: + m = re.fullmatch(rf"{_FAULT_PREFIX}(\d+)\.dump", dump.name) + if not m or _pid_alive(int(m.group(1))): + continue + try: + text = dump.read_text(encoding="utf-8", errors="replace").strip() + if text: + report = write_crash_report(crash_dir, text, version=version, + kind="native-fault") + if report is not None: + reports.append(report) + dump.unlink() + except OSError: + _log.debug("could not sweep fault dump %s", dump, exc_info=True) + return reports + + +def pending_reports(crash_dir: Path) -> list[Path]: + """Unacknowledged crash reports, newest first.""" + try: + found = [p for p in crash_dir.glob(f"{_REPORT_PREFIX}*.txt") + if not p.name.endswith(_SEEN_SUFFIX)] + except OSError: + return [] + return sorted(found, reverse=True) + + +def acknowledge(report: Path) -> None: + """Mark a report as seen (kept on disk for reference, no longer surfaced).""" + try: + report.rename(report.with_name(report.name + _SEEN_SUFFIX)) + except OSError: + _log.debug("could not acknowledge crash report %s", report, exc_info=True) + + +def summarize(report: Path, *, limit: int = 200) -> str: + """One-line human summary for the next-launch dialog: the last traceback + line (``TypeError: …``) for exceptions, the signal line for native faults.""" + try: + lines = [ln.strip() for ln in + report.read_text(encoding="utf-8", errors="replace").splitlines() + if ln.strip()] + except OSError: + return "" + for ln in reversed(lines): + # Exception reports end with "SomeError: message"; fault dumps contain + # a "Fatal Python error: Segmentation fault"-style line. + if re.match(r"^[A-Za-z_][A-Za-z0-9_.]*(Error|Exception|Interrupt)\b", ln) \ + or ln.startswith("Fatal Python error"): + return ln[:limit] + return lines[-1][:limit] if lines else "" + + +def github_issue_url(repo: str, report: Path, *, version: str, + body_limit: int = 5000) -> str: + """Prefilled new-issue URL. The report tail is inlined (most recent frames + matter most); clipped to keep the URL within browser/GitHub limits.""" + try: + text = report.read_text(encoding="utf-8", errors="replace") + except OSError: + text = "(crash report could not be read)" + if len(text) > body_limit: + text = "…(truncated)…\n" + text[-body_limit:] + title = f"Crash: {summarize(report, limit=80) or 'unhandled error'} (v{version})" + body = ( + "**What were you doing when it crashed?**\n\n_(please describe)_\n\n" + f"**Crash report** (`{report.name}`):\n\n```\n{text}\n```\n" + ) + query = urllib.parse.urlencode({"title": title, "body": body}) + return f"https://github.com/{repo}/issues/new?{query}" diff --git a/docs/user-guide.md b/docs/user-guide.md new file mode 100644 index 0000000..8405111 --- /dev/null +++ b/docs/user-guide.md @@ -0,0 +1,224 @@ +# Render Mapper Pro — User Guide + +Map video clips onto a 3D scene's materials and render them headlessly — via +**Blender**, **Cinema 4D + Redshift**, or the built-in **three.js** web +renderer — locally or on a Thinkbox Deadline farm. + +![Main window](../assets/help/img/main-window.png) + +This guide is also built into the app: **Help → User Guide** (with the same +screenshots and clickable links into the relevant settings). + +--- + +## Table of contents + +1. [Quick start](#quick-start) +2. [Watch & Auto-render](#watch--auto-render) + - [① Source](#-source--what-to-watch) + - [② Mode](#-mode--what-happens-to-a-clip) + - [③ Naming](#-naming--teach-it-your-convention) + - [④ Output & delivery](#-output--delivery) + - [⑤ Activity](#-activity--trust-but-verify) +3. [Filename pattern reference](#filename-pattern-reference) +4. [The queue](#the-queue) +5. [Render engines](#render-engines) +6. [Crash reports](#crash-reports) +7. [Troubleshooting & FAQ](#troubleshooting--faq) + +--- + +## Quick start + +1. **Add a scene** — drag a `.blend`, `.c4d`, `.glb`/`.gltf` file onto the + Scene box and click **Scan Scene**. Its materials appear in the list. +2. **Add clips** — drag videos into the Videos list, pick a **Camera**. +3. **Map** — drag a clip onto a material (or use **Auto-match**, which pairs + clips to materials by name). +4. **Render** — check the output settings in the Render panel and press + **Render**. Progress, ETA and logs are live; finished renders can open, + reveal, or copy themselves to a delivery folder automatically. +5. **Automate it** — open **View → Watch & Auto-render** and let the app do + steps 2–4 by itself whenever a clip lands in a folder. That's the rest of + this guide. + +--- + +## Watch & Auto-render + +The Watch panel is a single automation rule that reads top to bottom: +**① Source → ② Mode → ③ Naming → ④ Output → ⑤ Activity.** + +Watch & Auto-render panel + +### ① Source — what to watch + +Source card + +Click **Choose…**, pick the folder, press **Start**. + +| Setting | What it does | +|---|---| +| **Wait** | How long a file must stop changing before it's imported — so a clip that's still copying is never grabbed half-written. Raise it for very large files arriving over slow links. | +| **Re-scan every** | The backstop poll. Local drops are caught instantly by filesystem events; the poll covers network shares (SMB/NFS) and cloud folders where events don't fire. It backs off automatically when the folder is quiet. | +| **Include subfolders** | Watch the whole tree — clips dropped into per-day/per-setup subfolders are picked up too. The render output folder is always excluded, so renders never re-trigger themselves. | + +**Cloud-aware:** Dropbox/OneDrive *online-only* placeholder files are detected +and skipped until their bytes are actually on disk. + +### ② Mode — what happens to a clip + +| Mode | Behaviour | +|---|---| +| **Auto-map** | Each clip maps onto the *current scene's* material with the matching name. Once every render-target screen has a clip, one multi-screen render is queued (a burst of arrivals coalesces into a single render). `Screen_v2.mp4` landing next to `v1` takes over the mapping — **latest wins**. | +| **Assemble previz** | For shows with a filename convention: clips group into **one render per asset**, each routed to its setup's scene file. Drop 10 clips → get 5 assembled previz jobs. | + +### ③ Naming — teach it your convention + +*(Previz mode only — the card hides in auto-map mode.)* + +Naming card with chip builder and live preview + +Build the filename pattern from **chips** — no regex. Paste a real filename +into the sample box and the live preview shows exactly which fields parse: + +``` +Pattern: {ID#}_D{Day#}_{Section}_{Cue}_{Screen}_v{Version#} +Sample: 80230_D2_War-Treaty_MusicH_TC-MASTER_v001.mp4 +Result: ✓ matches ID=80230 · Day=2 · Section=War-Treaty · Cue=MusicH · + Screen=TC-MASTER · Version=1 +``` + +If it doesn't match, the preview says *where* it stopped and what it expected +(`Stopped after "80230_D" — expected a number for 'Day'`) — so fixing the +pattern never requires understanding regular expressions. + +| Table | What it does | +|---|---| +| **Screen → Material** | Which scene material each screen code drives. Leave a screen out and it defaults to the material with the same name. | +| **Setup # → Scene** | Routes each setup number to its own scene file (`D1.c4d`, `D2.c4d`, …). Setups without a mapping use the currently loaded scene. | + +**Versioning:** newest wins. A `v002` landing after `v001` *updates the +existing job in place* — jobs never pile up per version. + +**Preview (dry run)** shows exactly what *would* assemble — per asset: +screen → material → clip → version — plus every skipped clip and the reason, +before anything renders: + +Preview assembly dry run + +### ④ Output & delivery + +Output card + +- **Output name** supports tokens (e.g. `{prj}_D{day}_S{setup}_A{asset}_PREVIZ_V{ver}`). +- **Output folder** blank = a `PREVIZ` subfolder inside the watch folder + (always excluded from scanning). +- **Start renders automatically** on = fully hands-off; off = jobs queue and + wait for you. +- **Delivery folder** — every finished render is also copied there (a synced + review folder, a hand-off share…). Blank = off. + +### ⑤ Activity — trust, but verify + +Activity card with skipped badge + +- **Skipped badge** — clips that did **not** become jobs (name doesn't match, + screen not mapped, superseded by a newer version) are *counted, not + swallowed*: click **"N clip(s) skipped — why?"** and the dry run explains + every file. +- **Ingest history** — the feed writes through to + `~/.blender_video_mapper/logs/ingest_history.log` and reloads on launch, so + *"did that clip get picked up last night?"* always has an answer. **Clear** + empties the visible list only; the on-disk history stays. + +> **The full pipeline:** watch folder on the production share + previz mode + +> auto-start + delivery folder = clip lands → parsed → assembled → rendered → +> copy appears in the review folder. Zero clicks. + +--- + +## Filename pattern reference + +| Token | Meaning | Example match | +|---|---|---| +| `{Name}` | Text field (letters, digits, hyphens) | `TC-MASTER`, `War-Treaty` | +| `{Name#}` | Number field (leading zeros fine, parsed as int) | `001` → 1 | +| `{Name?}` / `{Name#?}` | Optional field — may be absent | — | +| anything else | Literal text, matched case-insensitively | `_`, `D`, `v` | + +- The file extension is ignored automatically (`.mp4`, `.mov`, …). +- Each field name can be used once per pattern. +- Special field names the previz assembler understands: **Screen** (→ + material), **Setup** (→ scene routing), **Version** (→ newest wins), + **Type** (→ "Only ingest type" filter), **Asset**/**Project**/**Day** + (grouping + output-name tokens). Missing Setup/Asset fields simply group + everything into one asset per (project, day) — fine for simpler shows. + +**Real-world example** (a touring show's `_PREVIEW-MOTION` folder): + +``` +{ID#}_D{Day#}_{Section}_{Cue}_{Screen}_v{Version#} + +80230_D1_Day-1-Pre_Matte-TransA_DJBS_v001.mp4 +80230_D2_War-Treaty_MusicH_TC-MASTER_v001.mp4 +80230_D2_War-Treaty_MusicH_HOUSE-MASTER_v002.mp4 +``` + +--- + +## The queue + +Queue panel + +Every render is a job: reorder, duplicate, rename, set priority, requeue. +Labels show the **resolved output filename**. Batch-queue the current mapping +across many clips, or let the watch folder feed the queue for you. Finished +jobs report duration, per-frame metrics and estimated power cost, and can +notify you (desktop notification or Discord webhook) when done. + +## Render engines + +| Engine | Scene files | Notes | +|---|---|---| +| **Blender** (Cycles/Eevee) | `.blend` | The app can download and manage its own Blender — nothing to install. | +| **Cinema 4D + Redshift** | `.c4d` | Uses your installed C4D (`c4dpy`); Redshift settings honoured. | +| **Web (three.js)** | `.glb` / `.gltf` | Fully built-in headless renderer — no external DCC needed. | +| **Deadline farm** | both | Submit Blender and C4D jobs to a Thinkbox Deadline repository. | + +## Crash reports + +If the app ever crashes — even a hard native crash — the next launch offers +the report: view it, or open a prefilled GitHub issue. **Nothing is sent +anywhere unless you submit it yourself.** Reports live in +`~/.blender_video_mapper/crashes/`. + +## Troubleshooting & FAQ + +**A clip sits in the watch folder and nothing happens.** +Look at the Activity card — if the skipped badge shows, click it: the dry run +names every skipped file and the reason (pattern mismatch, unmapped screen, +superseded version). Also check: is the clip inside a subfolder while +*Include subfolders* is off? Is it a cloud placeholder that hasn't downloaded? + +**Renders re-trigger themselves.** +They can't from the default output location — the output folder is excluded +from scanning. If you point the output *outside* the watch folder and then +back in via symlinks, don't. + +**Half-copied files get imported.** +Raise the **Wait** (settle) time in ① Source — a file must hold its size for +that window before it's touched. + +**The pattern won't match hyphenated codes.** +It does — text fields accept hyphens (`TC-MASTER`, `Day-1-Pre`). Use the live +preview with a real filename; it pinpoints the first divergence. + +**Where are my settings stored?** +`~/.blender_video_mapper/` — profile, presets, logs, ingest history and crash +reports. Delete the folder for a factory reset. + +**Is the download safe?** +Releases ship a `SHA256SUMS.txt`, signed build provenance +(`gh attestation verify --repo hayhamLT/RenderMapperPro`), and macOS +builds are Developer-ID signed + notarized by Apple. diff --git a/installer/entitlements.plist b/installer/entitlements.plist new file mode 100644 index 0000000..ad149de --- /dev/null +++ b/installer/entitlements.plist @@ -0,0 +1,14 @@ + + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + + com.apple.security.cs.disable-library-validation + + + diff --git a/installer/make_dmg.sh b/installer/make_dmg.sh index c695cf8..1547bd8 100755 --- a/installer/make_dmg.sh +++ b/installer/make_dmg.sh @@ -7,8 +7,8 @@ # styling step fails (e.g. no Finder/window-server session), it falls back to a # plain dmg so a release build never breaks. # -# Unsigned — Gatekeeper still shows a one-time "unidentified developer" prompt -# (right-click → Open) until the .app is signed + notarized with an Apple cert. +# The .app should be signed + notarized before calling this script so Gatekeeper +# opens it without prompts. The script works with unsigned builds too (CI dev). set -euo pipefail OUT="${1:?usage: make_dmg.sh [app]}" diff --git a/panels.py b/panels.py index aaa44a3..fe63006 100644 --- a/panels.py +++ b/panels.py @@ -163,6 +163,7 @@ def __init__(self, parent: QWidget | None = None) -> None: self._watch_interval_ms = 3000 # poll cadence (configurable in Properties) self._watch_settle = 2.0 # seconds a file must be quiet before ingest self._watch_ignore = "" # a dir to skip while scanning (auto-render output) + self._watch_recursive = False # also scan subfolders of the watch folder self._targets: list[str] = [] # materials marked as render targets self._autorender_last: frozenset | None = None # last target version-set emitted self._watch_idle_polls = 0 # consecutive no-change polls (drives back-off) @@ -869,6 +870,18 @@ def set_watch_options(self, interval_ms: int, settle_s: float) -> None: def get_watch_options(self) -> tuple[int, float]: return self._watch_interval_ms, self._watch_settle + def set_watch_recursive(self, on: bool) -> None: + """Also scan subfolders (the poll covers them; FS events still watch the + top folder only, which is fine — the poll is the reliable backstop).""" + on = bool(on) + if on != self._watch_recursive: + self._watch_recursive = on + self._watch_seen = {} # force a fresh scan under the new scope + self._watch_sizes = {} + + def get_watch_recursive(self) -> bool: + return self._watch_recursive + def set_grouping_mode(self, on: bool) -> None: """When on, the watch folder emits ready clips for asset-grouping instead of auto-mapping them onto the current scene.""" @@ -886,25 +899,40 @@ def _scan_watch_folder(self) -> None: self._watch_scanning = True exts = VIDEO_EXTENSIONS | IMAGE_MEDIA_EXTENSIONS ignore = os.path.normpath(self._watch_ignore) if self._watch_ignore else "" + recursive = self._watch_recursive + + def _ignored(path: str) -> bool: + # Skip the auto-render output dir (whole subtree) so rendered PREVIZ + # files are never re-ingested (no feedback loop). + if not ignore: + return False + p = os.path.normpath(path) + return p == ignore or p.startswith(ignore + os.sep) + + def _collect(listing: list, path: str, name: str) -> None: + try: + if Path(name).suffix.lower() in exts: + st = os.stat(path) + # Flag cloud "online-only" placeholders so we don't ingest a + # file whose bytes aren't on disk yet. + dataless = is_cloud_placeholder(path, st) + listing.append((path, st.st_size, st.st_mtime, dataless)) + except OSError: + _log.debug("watch folder: failed to stat an entry", exc_info=True) def work(): - listing = [] + listing: list = [] try: - with os.scandir(folder) as it: - for e in it: - try: - # Skip the auto-render output dir so rendered PREVIZ - # files are never re-ingested (no feedback loop). - if ignore and os.path.normpath(e.path) == ignore: - continue - if e.is_file() and Path(e.name).suffix.lower() in exts: - st = e.stat() - # Flag cloud "online-only" placeholders so we don't - # ingest a file whose bytes aren't on disk yet. - dataless = is_cloud_placeholder(e.path, st) - listing.append((e.path, st.st_size, st.st_mtime, dataless)) - except OSError: - _log.debug("watch folder: failed to stat an entry", exc_info=True) + if recursive: + for root, dirs, files in os.walk(folder): + dirs[:] = [d for d in dirs if not _ignored(os.path.join(root, d))] + for name in files: + _collect(listing, os.path.join(root, name), name) + else: + with os.scandir(folder) as it: + for e in it: + if not _ignored(e.path) and e.is_file(): + _collect(listing, e.path, e.name) except OSError: listing = [] self._watch_scanned.emit(listing) # queued → delivered on the UI thread @@ -923,10 +951,14 @@ def _apply_watch_scan(self, listing: list) -> None: # All listed files count as "present" — including cloud placeholders — so # a clip that's evicted to online-only after ingest isn't treated as gone. present = {os.path.normpath(p) for p, _s, _m, _d in listing} - # Clips that came from the watch folder but are gone now (deleted, or - # renamed away) — drop them so a rename doesn't leave a stale duplicate. + # Clips that came from the watch folder (any depth, for recursive mode) + # but are gone now (deleted, or renamed away) — drop them so a rename + # doesn't leave a stale duplicate. + def _under_folder(v: str) -> bool: + d = os.path.normpath(os.path.dirname(v)) + return d == folder or d.startswith(folder + os.sep) gone = {v for v in self._videos - if os.path.normpath(os.path.dirname(v)) == folder and os.path.normpath(v) not in present} + if _under_folder(v) and os.path.normpath(v) not in present} ready, mtimes, sizes = [], {}, {} for path, size, mtime, dataless in listing: @@ -3640,6 +3672,11 @@ def _build_source_card(self) -> QFrame: ph.setObjectName("HintLabel") prow.addWidget(ph, 1) v.addLayout(prow) + self.recursive_check = QCheckBox("Include subfolders") + self.recursive_check.setToolTip("Also watch every folder inside the watch folder — " + "clips dropped in subfolders (per-day, per-setup…) are picked up too") + self.recursive_check.toggled.connect(lambda *_: self._emit_changed()) + v.addWidget(self.recursive_check) return f def _build_mode_card(self) -> QFrame: @@ -3757,6 +3794,16 @@ def _build_activity_card(self) -> QFrame: head = QHBoxLayout() head.addWidget(_step_label("⑤", "ACTIVITY")) head.addStretch() + # Silent-ignore fix: clips sitting in the folder that DIDN'T become jobs + # are surfaced, not swallowed — click opens the dry-run with per-file why. + self.ignored_btn = QPushButton("") + self.ignored_btn.setObjectName("SmallButton") + self.ignored_btn.setStyleSheet(f"color:{active_palette().warning};") + self.ignored_btn.setToolTip("Some clips in the watch folder didn't become jobs — " + "click to see each one and why (dry run)") + self.ignored_btn.clicked.connect(self.preview_requested.emit) + self.ignored_btn.setVisible(False) + head.addWidget(self.ignored_btn) clear_btn = QPushButton("Clear") clear_btn.setObjectName("SmallButton") clear_btn.clicked.connect(self.clear_activity) @@ -3811,6 +3858,7 @@ def _on_mode_changed(self, *_a) -> None: def _refresh_mode_ui(self) -> None: self.naming_card.setVisible(self.previz_radio.isChecked()) + self.set_ignored(getattr(self, "_ignored_count", 0)) # badge is previz-only def _on_pattern_changed(self, *_a) -> None: self._update_preview() @@ -3874,7 +3922,7 @@ def load_config(self, *, grouping_enabled: bool, pattern: str, content_type: str output_template: str, autorender_pattern: str, screen_to_material: dict, setup_to_scene: dict, settle_s: float, poll_interval_s: float, autorender_start: bool, - output_dir: str, deliver_dir: str) -> None: + output_dir: str, deliver_dir: str, recursive: bool = False) -> None: """Populate every control from the persisted config without emitting.""" self._suppress = True try: @@ -3891,6 +3939,7 @@ def load_config(self, *, grouping_enabled: bool, pattern: str, content_type: str self.autostart_cb.setChecked(bool(autorender_start)) self.settle_combo.setCurrentIndex(self._nearest(self._SETTLE_OPTS, settle_s)) self.poll_combo.setCurrentIndex(self._nearest(self._POLL_OPTS, poll_interval_s)) + self.recursive_check.setChecked(bool(recursive)) finally: self._suppress = False self._refresh_mode_ui() @@ -3912,25 +3961,63 @@ def get_config(self) -> dict: "setup_to_scene": self.setup_table.get_pairs(), "settle_s": self._SETTLE_OPTS[self.settle_combo.currentIndex()][1], "poll_interval_s": self._POLL_OPTS[self.poll_combo.currentIndex()][1], + "recursive": self.recursive_check.isChecked(), "deliver_dir": self.deliver_edit.text().strip(), "autorender_start": self.autostart_cb.isChecked(), "output_dir": self.out_dir_edit.text().strip(), } - # ── activity feed ──────────────────────────────────────────────────── + # ── ignored-clips badge ────────────────────────────────────────────── + def set_ignored(self, count: int) -> None: + """Surface clips that were seen but did NOT become jobs (pattern/type + mismatch, unmapped screen, superseded). Zero (or auto-map mode) hides.""" + self._ignored_count = max(0, int(count)) + show = self._ignored_count > 0 and self.previz_radio.isChecked() + if show: + self.ignored_btn.setText(f"⚠ {self._ignored_count} clip(s) skipped — why?") + self.ignored_btn.setVisible(show) + + # ── activity feed (persisted) ──────────────────────────────────────── + def set_history_path(self, path) -> None: + """Point the feed at an on-disk ingest history and preload the recent + tail, so 'did that clip get picked up last night?' survives a restart.""" + self._history_path = Path(path) + try: + lines = self._history_path.read_text(encoding="utf-8").splitlines()[-100:] + except OSError: + return + for line in lines: # oldest→newest; newest ends on top + parts = line.split("\t", 2) + if len(parts) == 3: + stamp, event, detail = parts + self._insert_activity_row(stamp.split(" ")[-1], event, detail, + tooltip=f"{stamp} — {detail}") + def add_activity(self, event: str, detail: str) -> None: + self._insert_activity_row(time.strftime("%H:%M:%S"), event, detail, tooltip=detail) + hist = getattr(self, "_history_path", None) + if hist is not None: + try: + hist.parent.mkdir(parents=True, exist_ok=True) + with hist.open("a", encoding="utf-8") as f: + stamp = time.strftime("%Y-%m-%d %H:%M:%S") + f.write(f"{stamp}\t{event}\t{detail}\n".replace("\r", " ")) + except OSError: + pass # history is best-effort, never blocks ingest + + def _insert_activity_row(self, when: str, event: str, detail: str, *, tooltip: str) -> None: self.activity.insertRow(0) - cells = (time.strftime("%H:%M:%S"), event, detail) - for col, text in enumerate(cells): + for col, text in enumerate((when, event, detail)): item = QTableWidgetItem(text) item.setFlags(Qt.ItemFlag.ItemIsEnabled) if col == 2: - item.setToolTip(detail) + item.setToolTip(tooltip) self.activity.setItem(0, col, item) while self.activity.rowCount() > 200: self.activity.removeRow(self.activity.rowCount() - 1) def clear_activity(self) -> None: + # Clears the visible feed only — the on-disk history stays (audit trail). self.activity.setRowCount(0) diff --git a/pyproject.toml b/pyproject.toml index e8f8491..fc99f27 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,4 +76,9 @@ ignore_missing_imports = true # Type-check the bodies of un-annotated functions too — catches deprecated Qt # enum accesses (Qt.AlignCenter → Qt.AlignmentFlag.AlignCenter) that hide there. check_untyped_defs = true -exclude = ["build/", "dist/", "vendor/"] +# The config owns the file set (bare `mypy` checks everything, tests included) +# so a newly added module can't silently drift out of CI's type-checking. +files = ["."] +# deadline/ runs inside Deadline's own (old) Python host on the farm — same +# deliberate exclusion as ruff above. +exclude = ["build/", "dist/", "vendor/", "deadline/"] diff --git a/tests/test_archive.py b/tests/test_archive.py index 435b3f0..d0868f8 100644 --- a/tests/test_archive.py +++ b/tests/test_archive.py @@ -40,6 +40,7 @@ def test_sidecar_feeds_verify(tmp_path): digest = sha256_file(f) sidecar = f"{digest} blender-5.1.0-windows-x64.zip\n" expected = expected_sha256_from_sidecar(sidecar, f.name) + assert expected is not None verify_sha256(f, expected) # must not raise with pytest.raises(RuntimeError): verify_sha256(f, "0" * 64) diff --git a/tests/test_crash.py b/tests/test_crash.py new file mode 100644 index 0000000..536a16a --- /dev/null +++ b/tests/test_crash.py @@ -0,0 +1,93 @@ +"""Crash capture / next-launch reporting (core.crash): the pure pieces — report +files, fault-dump sweeping, acknowledgement, summaries, and the prefilled +GitHub-issue URL. The UI dialog on top is exercised by the smoke layer.""" +from __future__ import annotations + +import os +import urllib.parse + +from core import crash + + +def test_write_report_has_header_and_body(tmp_path): + p = crash.write_crash_report(tmp_path, "Traceback ...\nValueError: boom", + version="1.2.3") + assert p is not None and p.name.startswith("crash-") + text = p.read_text() + assert "version: 1.2.3" in text + assert "kind: exception" in text + assert "ValueError: boom" in text + + +def test_pending_newest_first_and_acknowledge(tmp_path): + a = crash.write_crash_report(tmp_path, "AError: one", version="1") + b = crash.write_crash_report(tmp_path, "BError: two", version="1") + assert a is not None and b is not None + pending = crash.pending_reports(tmp_path) + assert pending == [b, a] # newest first + crash.acknowledge(b) + assert crash.pending_reports(tmp_path) == [a] # seen ones drop out + assert b.with_name(b.name + ".seen").exists() # but stay on disk + + +def test_pending_on_missing_dir_is_empty(tmp_path): + assert crash.pending_reports(tmp_path / "nope") == [] + + +def test_fault_capture_clean_exit_leaves_nothing(tmp_path): + crash.enable_fault_capture(tmp_path) + try: + assert list(tmp_path.glob("fault-*.dump")), "dump file not armed" + finally: + crash.disable_fault_capture() + assert not list(tmp_path.glob("fault-*.dump")), "clean exit must remove dump" + + +def test_collect_faults_converts_dead_pid_dump(tmp_path): + # A non-empty dump from a pid that no longer exists = a native crash. + (tmp_path / "fault-999999999.dump").write_text( + "Fatal Python error: Segmentation fault\n\nThread 0x01 (most recent call first):") + reports = crash.collect_faults(tmp_path, version="9.9.9") + assert len(reports) == 1 + assert "kind: native-fault" in reports[0].read_text() + assert not list(tmp_path.glob("fault-*.dump")) # dump consumed + + +def test_collect_faults_removes_empty_leftovers_silently(tmp_path): + (tmp_path / "fault-999999998.dump").write_text("") + assert crash.collect_faults(tmp_path, version="1") == [] + assert not list(tmp_path.glob("fault-*.dump")) + + +def test_collect_faults_leaves_live_pid_alone(tmp_path): + mine = tmp_path / f"fault-{os.getpid()}.dump" + mine.write_text("not a crash, I'm still running") + assert crash.collect_faults(tmp_path, version="1") == [] + assert mine.exists() + + +def test_summarize_finds_exception_line(tmp_path): + p = crash.write_crash_report( + tmp_path, "Traceback (most recent call last):\n ...\nTypeError: bad thing", + version="1") + assert p is not None + assert crash.summarize(p) == "TypeError: bad thing" + + +def test_summarize_finds_fatal_error_line(tmp_path): + p = crash.write_crash_report( + tmp_path, "Fatal Python error: Segmentation fault\n\nThread 0x01:", + version="1", kind="native-fault") + assert p is not None + assert crash.summarize(p).startswith("Fatal Python error") + + +def test_issue_url_prefills_and_truncates(tmp_path): + p = crash.write_crash_report(tmp_path, "x" * 20000 + "\nOSError: disk", version="2.0") + assert p is not None + url = crash.github_issue_url("owner/repo", p, version="2.0", body_limit=500) + assert url.startswith("https://github.com/owner/repo/issues/new?") + q = urllib.parse.parse_qs(urllib.parse.urlparse(url).query) + assert "OSError: disk" in q["title"][0] and "2.0" in q["title"][0] + assert "truncated" in q["body"][0] + assert len(q["body"][0]) < 2000 # clipped, not the full 20k diff --git a/tests/test_naming.py b/tests/test_naming.py index c97bb2a..226cd70 100644 --- a/tests/test_naming.py +++ b/tests/test_naming.py @@ -52,7 +52,8 @@ def test_text_fields_capture_hyphens(): "ID": 80230, "Day": 2, "Section": "War-Treaty", "Cue": "MusicH", "Screen": "TC-MASTER", "Version": 1, } - assert p.parse("50800_D1_Day-1-Pre_Matte-TransA_DJBS_v001")["Section"] == "Day-1-Pre" + parsed = p.parse("50800_D1_Day-1-Pre_Matte-TransA_DJBS_v001") + assert parsed is not None and parsed["Section"] == "Day-1-Pre" def test_hyphen_as_separator_still_resolves(): diff --git a/tests/test_smoke_ui.py b/tests/test_smoke_ui.py index bb46833..a02cb63 100644 --- a/tests/test_smoke_ui.py +++ b/tests/test_smoke_ui.py @@ -49,7 +49,7 @@ def test_watch_folder_scan_actually_runs(tmp_path, monkeypatch): wf = tmp_path / "watch" wf.mkdir() (wf / "Screen_v1.mp4").write_bytes(b"x") - got = {} + got: dict[str, list] = {} sp._watch_scanned.connect(lambda listing: got.setdefault("listing", listing)) sp._watch_folder = str(wf) sp.set_watch_ignore_dir(str(tmp_path / "out")) # must not raise NameError @@ -103,8 +103,9 @@ def test_first_run_welcome_suppressed_when_headless(tmp_path, monkeypatch): platform. A blocking .exec() there has no one to dismiss it and hung the headless CI smoke job for ~10 min (then failed) on most releases.""" from PySide6.QtWidgets import QApplication, QMessageBox - app = QApplication.instance() or QApplication([]) - assert app.platformName() == "offscreen" + QApplication.instance() or QApplication([]) + # (class access: .instance() is typed QCoreApplication, no platformName) + assert QApplication.platformName() == "offscreen" import app_qt monkeypatch.setattr(app_qt, "PROFILE_PATH", tmp_path / "p.json") monkeypatch.setattr(app_qt, "HISTORY_PATH", tmp_path / "h.json") @@ -115,6 +116,11 @@ def test_first_run_welcome_suppressed_when_headless(tmp_path, monkeypatch): # weren't gated, _maybe_first_run would call QMessageBox.exec() (and block). w._is_first_run = True opened = {"exec": False} - monkeypatch.setattr(QMessageBox, "exec", lambda self: opened.__setitem__("exec", True) or 0) + + def _spy_exec(self) -> int: + opened["exec"] = True + return 0 + + monkeypatch.setattr(QMessageBox, "exec", _spy_exec) w._maybe_first_run() assert opened["exec"] is False, "first-run welcome modal not suppressed under offscreen" diff --git a/tests/test_ui_dialogs.py b/tests/test_ui_dialogs.py new file mode 100644 index 0000000..2709c1f --- /dev/null +++ b/tests/test_ui_dialogs.py @@ -0,0 +1,93 @@ +"""MessageDialog behaviour: value semantics, Esc, keyboard navigation, focus, +and the accessibility surface (window title, accessible names). Offscreen Qt; +no exec() — everything is driven through the widget API and key events.""" +from __future__ import annotations + +import os + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PySide6.QtCore import Qt +from PySide6.QtGui import QKeyEvent +from PySide6.QtWidgets import QApplication + +from ui_dialogs import MessageDialog + +app = QApplication.instance() or QApplication([]) + + +def _key(dlg, key): + dlg.keyPressEvent(QKeyEvent(QKeyEvent.Type.KeyPress, key, + Qt.KeyboardModifier.NoModifier)) + + +@pytest.fixture +def three_btn(): + dlg = MessageDialog( + None, "Remove clips?", "This can't be undone.", + kind="danger", + buttons=[("Cancel", "cancel", "neutral"), + ("Keep", "keep", "neutral"), + ("Remove", "remove", "danger")], + default="cancel") + yield dlg + dlg.deleteLater() + + +def test_choose_sets_value_and_accepts(three_btn): + three_btn._choose("remove") + assert three_btn.value == "remove" + + +def test_escape_means_dismissed(three_btn): + _key(three_btn, Qt.Key.Key_Escape) + assert three_btn.value is None + + +def test_accessibility_surface(three_btn): + assert three_btn.windowTitle() == "Remove clips?" + assert three_btn.accessibleName() == "Remove clips?" + assert three_btn.accessibleDescription() == "This can't be undone." + assert [b.accessibleName() for b in three_btn._buttons] == ["Cancel", "Keep", "Remove"] + + +def test_initial_focus_is_the_default_button(three_btn): + three_btn.show() + app.processEvents() + assert three_btn.focusWidget() is three_btn._buttons[0] # "Cancel" = default + three_btn.hide() + + +def test_arrow_keys_cycle_buttons(three_btn): + three_btn.show() + app.processEvents() + _key(three_btn, Qt.Key.Key_Right) + assert three_btn.focusWidget().text() == "Keep" + _key(three_btn, Qt.Key.Key_Right) + assert three_btn.focusWidget().text() == "Remove" + _key(three_btn, Qt.Key.Key_Right) # wraps + assert three_btn.focusWidget().text() == "Cancel" + _key(three_btn, Qt.Key.Key_Left) # and back + assert three_btn.focusWidget().text() == "Remove" + three_btn.hide() + + +def test_return_activates_focused_not_hidden_default(three_btn): + # All buttons are autoDefault: with focus on "Keep", Return must never fire + # the danger default under the user's fingers. + three_btn.show() + app.processEvents() + _key(three_btn, Qt.Key.Key_Right) # focus "Keep" + focused = three_btn.focusWidget() + assert focused.text() == "Keep" and focused.autoDefault() + focused.click() # what Return triggers + assert three_btn.value == "keep" + + +def test_default_falls_back_to_role_when_not_given(): + dlg = MessageDialog(None, "T", buttons=[("No", False, "neutral"), + ("Yes", True, "primary")]) + assert dlg._default_btn is not None and dlg._default_btn.text() == "Yes" + dlg.deleteLater() diff --git a/tests/test_utils.py b/tests/test_utils.py index 9e3a046..43669d0 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -2,6 +2,7 @@ import re import subprocess import sys +from typing import cast from core.utils import ( atomic_write_text, @@ -78,26 +79,32 @@ def __init__(self, size, blocks=None, attrs=None): self.st_file_attributes = attrs +def _fake_stat(**kw) -> os.stat_result: + # Duck-typed stand-in: is_cloud_placeholder only reads st_size/st_blocks/ + # st_file_attributes, so a cast keeps the call sites honestly typed. + return cast(os.stat_result, _FakeStat(**kw)) + + def test_cloud_placeholder_local_file_is_not_flagged(): # Fully-allocated file: blocks cover the size → local, ingest it. - st = _FakeStat(size=1_000_000, blocks=2048) # 2048*512 = ~1MB + st = _fake_stat(size=1_000_000, blocks=2048) # 2048*512 = ~1MB assert is_cloud_placeholder("x.mp4", st) is False def test_cloud_placeholder_dataless_unix_is_flagged(): # Logical size present but almost no blocks on disk → online-only placeholder. - st = _FakeStat(size=1_000_000, blocks=0) + st = _fake_stat(size=1_000_000, blocks=0) assert is_cloud_placeholder("x.mp4", st) is True def test_cloud_placeholder_windows_offline_attr_is_flagged(): # Windows OneDrive/Dropbox OFFLINE attribute (0x1000), blocks irrelevant. - st = _FakeStat(size=1_000_000, blocks=2048, attrs=0x1000) + st = _fake_stat(size=1_000_000, blocks=2048, attrs=0x1000) assert is_cloud_placeholder("x.mp4", st) is True def test_cloud_placeholder_empty_file_not_flagged(): - st = _FakeStat(size=0, blocks=0) + st = _fake_stat(size=0, blocks=0) assert is_cloud_placeholder("x.mp4", st) is False diff --git a/tests/test_watch_wiring.py b/tests/test_watch_wiring.py new file mode 100644 index 0000000..b096ca8 --- /dev/null +++ b/tests/test_watch_wiring.py @@ -0,0 +1,220 @@ +"""Window ↔ WatchPanel wiring (WatchMixin): load/apply round-trips, first-run +acknowledgement, profile persistence across a window rebuild, resilience of the +ingest path to a broken pattern, and the crash-report offer gating. These are +the seams the headless smoke test constructs but never exercises.""" +from __future__ import annotations + + +def _make_window(tmp_path, monkeypatch): + from PySide6.QtWidgets import QApplication + QApplication.instance() or QApplication([]) + import app_qt + monkeypatch.setattr(app_qt, "PROFILE_PATH", tmp_path / "p.json") + monkeypatch.setattr(app_qt, "HISTORY_PATH", tmp_path / "h.json") + monkeypatch.setattr(app_qt, "LOG_PATH", tmp_path / "l.txt") + monkeypatch.setattr(app_qt, "CRASH_DIR", tmp_path / "crashes") + w = app_qt.BlenderVideoMapperQt() + w._blender_path = "" + w._check_updates_on_launch = False + return w + + +def test_apply_watch_panel_writes_config_back(tmp_path, monkeypatch): + w = _make_window(tmp_path, monkeypatch) + wp = w.watch_panel + wp.previz_radio.setChecked(True) + wp.pattern_edit.setText("{ID#}_D{Day#}_{Sec}_{Cue}_{Screen}_v{Ver#}") + wp.content_edit.setText("") + wp.screen_table.set_pairs({"TC-MASTER": "TC-MASTER", "DJBS": "DJBS"}) + wp.setup_table.set_pairs({1: "/x/D1.c4d", 2: "/x/D2.c4d"}) + wp.deliver_edit.setText("/deliver") + w._apply_watch_panel() + ag = w._asset_grouping + assert ag.enabled is True + assert ag.pattern == "{ID#}_D{Day#}_{Sec}_{Cue}_{Screen}_v{Ver#}" + assert ag.content_type == "" + assert ag.screen_to_material == {"TC-MASTER": "TC-MASTER", "DJBS": "DJBS"} + assert ag.setup_to_scene == {1: "/x/D1.c4d", 2: "/x/D2.c4d"} + assert w._deliver_dir == "/deliver" + + +def test_load_watch_panel_mirrors_config(tmp_path, monkeypatch): + w = _make_window(tmp_path, monkeypatch) + ag = w._asset_grouping + ag.enabled = True + ag.pattern = "{P}_S{Setup#}_{Screen}_V{Version#}" + ag.screen_to_material = {"LEFT": "Wall_L"} + ag.setup_to_scene = {3: "/scenes/three.c4d"} + w._deliver_dir = "/out/deliver" + w._load_watch_panel() + wp = w.watch_panel + assert wp.previz_radio.isChecked() + assert wp.pattern_edit.text() == ag.pattern + assert wp.screen_table.get_pairs() == {"LEFT": "Wall_L"} + assert wp.setup_table.get_pairs() == {3: "/scenes/three.c4d"} + assert wp.deliver_edit.text() == "/out/deliver" + + +def test_first_run_dismiss_persists_and_hides_banner(tmp_path, monkeypatch): + w = _make_window(tmp_path, monkeypatch) + assert w._watch_first_run_seen is False + w._on_watch_first_run_dismissed() + assert w._watch_first_run_seen is True + # A rebuilt window (fresh profile load) must not resurrect the banner. + w2 = _make_window(tmp_path, monkeypatch) + assert w2._watch_first_run_seen is True + + +def test_watch_settings_survive_window_rebuild(tmp_path, monkeypatch): + w = _make_window(tmp_path, monkeypatch) + wp = w.watch_panel + wp.previz_radio.setChecked(True) + wp.pattern_edit.setText("{ID#}_D{Day#}_{Screen}_v{Ver#}") + wp.screen_table.set_pairs({"TC-MASTER": "TC-MASTER"}) + wp.setup_table.set_pairs({2: "/x/D2.c4d"}) + wp.deliver_edit.setText("/deliver") + w._apply_watch_panel() + w._save_profile() + + w2 = _make_window(tmp_path, monkeypatch) + ag2 = w2._asset_grouping + assert ag2.enabled is True + assert ag2.pattern == "{ID#}_D{Day#}_{Screen}_v{Ver#}" + assert ag2.screen_to_material == {"TC-MASTER": "TC-MASTER"} + assert ag2.setup_to_scene == {2: "/x/D2.c4d"} + assert w2._deliver_dir == "/deliver" + + +def test_broken_pattern_never_crashes_ingest(tmp_path, monkeypatch): + w = _make_window(tmp_path, monkeypatch) + w._asset_grouping.enabled = True + w._asset_grouping.pattern = "(?P= 1 + assert w2.watch_panel.activity.item(0, 1).text() == "Previz" + # Clearing the visible feed must keep the on-disk audit trail. + w2.watch_panel.clear_activity() + assert hist.exists() and "Previz" in hist.read_text() diff --git a/tests/test_web_driver.py b/tests/test_web_driver.py index e87ceda..29fca0b 100644 --- a/tests/test_web_driver.py +++ b/tests/test_web_driver.py @@ -23,7 +23,8 @@ def test_driver_url_shape(): assert url is not None assert url.startswith("https://") assert re.search(r"/playwright-\d+\.\d+\.\d+-[\w-]+\.zip$", url), url - assert w._web_driver_platform() in url + platform = w._web_driver_platform() + assert platform is not None and platform in url def test_node_present_in_dev_tree(): diff --git a/tests/test_web_vendor.py b/tests/test_web_vendor.py index e716ee1..63a9c6f 100644 --- a/tests/test_web_vendor.py +++ b/tests/test_web_vendor.py @@ -58,6 +58,8 @@ def test_launch_enables_local_module_access() -> None: from core import web_render as wr for cfg in (wr._GPU_LAUNCH, wr._SOFTWARE_LAUNCH): - assert "--allow-file-access-from-files" in cfg["args"], ( + args = cfg["args"] + assert isinstance(args, list) + assert "--allow-file-access-from-files" in args, ( "Chromium blocks file://→file:// ES-module imports without this flag" ) diff --git a/ui_dialogs.py b/ui_dialogs.py index b3c6fdb..f954f50 100644 --- a/ui_dialogs.py +++ b/ui_dialogs.py @@ -44,11 +44,19 @@ def __init__(self, parent, title, message="", *, kind="question", self.setObjectName("MessageDialog") self._value = None self._drag_offset = None + self._buttons: list[QPushButton] = [] + self._default_btn: QPushButton | None = None pal = active_palette() self.setModal(True) self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint) self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True) + # Frameless = no visible title bar, but screen readers and window + # switchers still announce these. + self.setWindowTitle(title) + self.setAccessibleName(title) + if message: + self.setAccessibleDescription(message) outer = QVBoxLayout(self) outer.setContentsMargins(18, 18, 18, 18) # breathing room for the shadow @@ -88,6 +96,8 @@ def __init__(self, parent, title, message="", *, kind="question", body = QLabel(message) body.setObjectName("DialogBody") body.setWordWrap(True) + # Error/report text should be copyable, like native message boxes. + body.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) col.addWidget(body) col.addStretch(1) top.addLayout(col, 1) @@ -104,10 +114,17 @@ def __init__(self, parent, title, message="", *, kind="question", elif role == "danger": btn.setObjectName("DangerButton") btn.setMinimumWidth(84) + btn.setAccessibleName(label) btn.clicked.connect(lambda _=False, v=value: self._choose(v)) is_default = (value == default) if default is not None else (role in ("primary", "danger")) btn.setDefault(is_default) - btn.setAutoDefault(is_default) + # autoDefault on ALL buttons: Return activates the FOCUSED button + # (falling back to the default) — never a hidden destructive default + # while focus sits on Cancel. + btn.setAutoDefault(True) + self._buttons.append(btn) + if is_default: + self._default_btn = btn row.addWidget(btn) lay.addLayout(row) @@ -124,10 +141,23 @@ def value(self): return self._value def keyPressEvent(self, event): - if event.key() == Qt.Key.Key_Escape: + key = event.key() + if key == Qt.Key.Key_Escape: self._value = None self.reject() return + # Arrow keys walk the button row, like native message boxes. + if key in (Qt.Key.Key_Left, Qt.Key.Key_Up, Qt.Key.Key_Right, Qt.Key.Key_Down) \ + and self._buttons: + step = -1 if key in (Qt.Key.Key_Left, Qt.Key.Key_Up) else 1 + focused = self.focusWidget() + try: + i = self._buttons.index(focused) # type: ignore[arg-type] + except ValueError: + i = -step if step > 0 else 0 # enter the row from either end + self._buttons[(i + step) % len(self._buttons)].setFocus( + Qt.FocusReason.TabFocusReason) + return super().keyPressEvent(event) def showEvent(self, event): @@ -137,6 +167,11 @@ def showEvent(self, event): if par is not None: center = par.window().frameGeometry().center() self.move(center - self.rect().center()) + # Keyboard users start on the safe/default choice, not wherever tab + # order happens to land. + target = self._default_btn or (self._buttons[-1] if self._buttons else None) + if target is not None: + target.setFocus(Qt.FocusReason.ActiveWindowFocusReason) # Drag the frameless card around by pressing anywhere on it. def mousePressEvent(self, event):