From 07d7c3bad6460d9826ef5db521b8b8c3ac0ee3c8 Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Wed, 16 Sep 2026 09:43:57 +0800 Subject: [PATCH] official 0.1.0 version --- .gitignore | 1 + build/README.md | 165 ++++++++++++---- build/studio/build.ps1 | 293 +++++++++++++++++++++++------ build/studio/constraints-build.txt | 27 +++ build/studio/orisys-studio.spec | 55 +++++- build/studio/setup.bat | 21 +++ build/studio/setup.ps1 | 292 ++++++++++++++++++++++++++++ 7 files changed, 762 insertions(+), 92 deletions(-) create mode 100644 build/studio/constraints-build.txt create mode 100644 build/studio/setup.bat create mode 100644 build/studio/setup.ps1 diff --git a/.gitignore b/.gitignore index 1bb7297..14cf82f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ __pycache__/ .eggs/ dist/ build/_pyinstaller/ +build/_smoke/ *.egg .venv/ .venv-build/ diff --git a/build/README.md b/build/README.md index 07bc54f..dce2bfc 100644 --- a/build/README.md +++ b/build/README.md @@ -1,40 +1,79 @@ # Build -Windows packaging for the studio desktop app. +Windows packaging for the studio desktop app: **one provisioning step per machine, then +repeatable builds.** ## Layout ```text build/ studio/ - entry.py # frozen entry (no source chdir) - orisys-studio.spec # PyInstaller onedir spec - build.ps1 # create/reuse .venv-build and build - _pyinstaller/ # PyInstaller workpath (gitignored) + setup.bat / setup.ps1 # provision the build venv (run once per machine) + constraints-build.txt # pinned build-time versions + build.bat / build.ps1 # build the EXE and verify it + entry.py # frozen entry (no source chdir) + orisys-studio.spec # PyInstaller onedir spec + _pyinstaller/ # PyInstaller workpath (gitignored) + _smoke/ # log of the last build's start-up smoke test dist/ OrisysStudio/ OrisysStudio.exe - config/ # lean locales + sensors (no finger visualization/) - _internal/ # Python, PyQt5, opencv-headless, orisys, … + config/ # lean locales + sensors (no finger visualization/) + _internal/ # Python, PyQt5, opencv-headless, orisys, OpenGL, … ``` -## Prerequisites +## Why there are two steps -- Windows + Python 3.10+ on PATH -- `orisys` installable into the build venv (`pip install orisys>=0.5`) -- Do **not** install `orisys[gui]` / Open3D for this build +A PyInstaller bundle depends on the *machine* as much as on the sources: which interpreter, +whether it is Anaconda (whose `libexpat`/`libffi`/OpenSSL DLLs live outside the venv and are +therefore **not** bundled), and where the `orisys` SDK checkout is. Those facts are decided +once, by `setup.ps1`, and recorded in `.venv-build\orisys-build-env.json`. + +`build.ps1` then installs nothing and guesses nothing: it verifies the environment, builds, +and verifies the artefact. That split is what keeps a build reproducible instead of +"works on my machine". + +## First time on a machine + +```bat +cd build\studio +setup.bat +setup.bat -Python C:\Python310\python.exe +setup.bat -AllowConda +``` + +`setup.bat` finds an interpreter, creates or repairs `.venv-build`, installs the pinned +toolchain plus the app's extras (including the studio's `three-d` extra — without it the 3D +pane silently reports "PyOpenGL is not installed" on the customer's machine), writes the +stamp, and then verifies the result by importing what the frozen app imports. + +Interpreter resolution, in order: `-Python `; `py -3.10`; `python`; the interpreter +already behind `.venv-build`. The Microsoft Store `python.exe` alias is refused (it is not an +interpreter), and an Anaconda interpreter is refused unless `-AllowConda` is passed — in that +case its `Library\bin` is recorded and the spec adds the runtime DLLs, because PyInstaller +does not search there and the EXE would otherwise die at start-up with +`ImportError: DLL load failed while importing pyexpat`. Building with python.org CPython is +still the preferred answer. + +The SDK is installed from `..\..\..\orisys-sdk` when that checkout exists (`-SdkPath` to point +elsewhere, `-FromPypi` to install the published wheel instead). Installing the published SDK +is how a build once shipped with no `orisys.finger` at all. + +Re-running `setup.bat` is safe: with a valid stamp for this checkout it reports and exits; +without one it repairs the existing venv rather than deleting it. `-Force` recreates it. ## Build -From `build\studio` (recommended): - ```bat build.bat -build.bat -Clean -build.bat -SkipInstall +build.bat -Clean # after dependency/spec changes +build.bat -RebuildVenv # setup.bat -Force, then build +build.bat -SkipInstall # never provision: fail instead +build.bat -SkipSmokeTest # do not launch the produced EXE +build.bat -StrictSmokeTest # treat a 3D-context warning as a failure ``` -Or from repo root: +Or from the repo root: ```powershell powershell -ExecutionPolicy Bypass -File .\build\studio\build.ps1 @@ -42,35 +81,97 @@ powershell -ExecutionPolicy Bypass -File .\build\studio\build.ps1 `build.bat` always resolves `build.ps1` next to itself (cwd-independent) and forwards flags. -Flags: +## What the build verifies, and why -| Flag | Meaning | -|------|---------| -| `-Clean` | Pass `--clean` to PyInstaller (use after dep/spec changes) | -| `-SkipInstall` | Reuse packages already in `.venv-build` (faster iteration) | +Every silent failure this packaging has produced looked like a *successful* build, so the +script checks the things that only show up when the EXE runs: -Output: `dist\OrisysStudio\OrisysStudio.exe` +| check | failure it prevents | +|---|---| +| the venv imports `orisys_app_shared`, `orisys_studio`, `orisys`, `OpenGL`, `cv2`, `PyInstaller` | a stale editable install still builds (the spec's `pathex` finds the modules) and produces an EXE that cannot start | +| `collect_data_files('orisys_app_shared', includes=['theme/*.json'])` is non-empty | the theme colours are package data; without them the app dies in `build_qss()` before logging anything | +| every `config\sensors\**\*.oryscene` is present, and the finger manifest/`sensor.json`/unroll map survive the config strip | the 3D pane refuses the scene when the device description cannot be derived | +| no `open3d*` under `dist` | the offline tool leaking into the shipped bundle | +| **smoke test**: start the produced EXE, wait for its own log to say `Studio shell started` (and `3D scene loaded` when a finger scene shipped), then stop it | a bundle that cannot start at all — missing runtime DLLs, an excluded module, missing data. The log is kept under `build\_smoke\` | + +The smoke test seeds a `preference.json` beside the EXE to select the finger sensor with the +3D pane on, and restores (or removes) it afterwards. A *GL context* failure is reported as a +warning, because it is environment-dependent; `-StrictSmokeTest` turns it into a failure. + +## Where the app's own code lives in the bundle + +`_internal\` contains **native code and data files only**. Every pure-Python module — +`orisys_studio.*`, `orisys_app_shared.*`, `orisys.*` — is compiled into the `PYZ` archive that +sits *inside* `OrisysStudio.exe` (that is most of the EXE's ~13 MB). So there is deliberately +no `_internal\orisys_studio\` folder, and no `.py` files anywhere: PyInstaller ships bytecode, +never sources. + +`_internal` therefore shows only these orisys-related paths: + +```text +_internal\orisys_app_shared\theme\colors_dark.json <- package data, bundled (qss.py reads it) +_internal\orisys_app_shared\theme\colors_light.json +_internal\orisys\.gitignore <- stray non-.py file of the SDK package +_internal\branding\... <- logos +``` + +(`collect_data_files("orisys")` exists for the case where the SDK is installed from a wheel that +bundles `sensors/` assets; with a local editable checkout the only non-`.py` file is that +`.gitignore`. It is harmless — the app prefers `config\sensors` — but it does look odd.) + +To list what is really in the archive: + +```bat +.venv-build\Scripts\python.exe -m PyInstaller.utils.cliutils.archive_viewer ^ + -l -r -b dist\OrisysStudio\OrisysStudio.exe +``` + +(The `PYZ-00.toc` under `build\_pyinstaller\orisys-studio\` is the same information at build +time, and `build\_smoke\*.log` is the runtime proof: "Studio shell started" and "3D scene +loaded" can only be printed if those modules imported.) ## Dependency notes -- App shared dep uses **`opencv-python-headless`** (PyQt owns the GUI). -- Upstream `orisys` may still declare `opencv-python`. `build.ps1` uninstalls that wheel and installs **`opencv-python-headless>=4.10,<5`** (5.x has shipped broken wheels without a `cv2` module). The script verifies `import cv2` before packaging. -- **Open3D is excluded** (spec + build env). Finger 3D remains a placeholder in the UI. +- App shared dep uses **`opencv-python-headless`** (PyQt owns the GUI). `setup.ps1` uninstalls + any `opencv-python` and pins `opencv-python-headless>=4.10,<5` (5.x has shipped wheels + without a `cv2` module) and verifies `import cv2`. +- **PyOpenGL** is the GL binding for the 3D pane and comes from the studio's `three-d` extra. + Add the `protected-assets` extra (`cryptography`) if sealed `.oryscene` packages are shipped. +- **Open3D stays excluded** (spec + build env): it is an offline geometry tool. The 3D pane is + Qt `QOpenGLWidget` + PyOpenGL. +- `constraints-build.txt` pins the toolchain. Move a pin only together with `setup.bat -Force` + and a passing `build.bat` — the smoke test is the acceptance evidence, not the file. + +## GL note + +The pane asks for the profile in `gl_profile` (`desktop33` by default, `gles30` otherwise). A +`desktop33` request needs a desktop GL 3.3 core context; on some sessions Qt falls back to its +ANGLE (GLES) backend, which cannot satisfy it, and the widget then reports no context on a +machine whose GPU is fine. `QT_OPENGL=desktop` is the workaround; the durable fix is to pin +the backend in `orisys_studio.main` before `QApplication` is constructed +(`Qt.AA_UseDesktopOpenGL` / `Qt.AA_UseOpenGLES`). `build.bat -StrictSmokeTest` makes this a +build failure. ## Size / startup - **onedir** (not onefile): faster cold start; ship the whole `OrisysStudio` folder. -- Finger `assets/visualization/` is stripped from the shipped `config/` (~11 MB saved). +- Finger `assets/visualization/` is stripped from the shipped `config/` (~11 MB saved). Nothing + in the app reads it (`manifest.json`'s `viz_pose` is used only by the SDK examples); the 3D + pane needs `scene/*.oryscene` plus `assets/30fps-0703`, and the build checks both. - Studio shell defers OpenCV / acquisition imports until Preview or Run. -Expect hundreds of MB total (Qt + numpy/scipy/numba + OpenCV). Without Open3D this stays smaller than a full `orisys[gui]` bundle. +Expect ~450 MB total (Qt + numpy/scipy/numba + OpenCV + PyOpenGL). -## Verification +## Verification on a new machine -1. Launch `OrisysStudio.exe` on the build machine (and ideally a clean PC without Python). -2. Theme / locale / prefs work; `preference.json` and `outputs\` appear next to the EXE. -3. Camera preview + box/mini/finger Run (video) with image modes and force plots. -4. Confirm no `open3d*` files under `dist\OrisysStudio`. +1. `setup.bat` (see its report: interpreter, conda or not, SDK source). +2. `build.bat` — its smoke test must print "the application started" and "the packaged 3D + scene loaded". +3. Launch `OrisysStudio.exe` and check theme/locale/prefs; `preference.json` and `outputs\` + appear next to the EXE. +4. Camera preview + box/mini/finger Run (video) with image modes and force plots. +5. Open the 3D pane on a finger source: the surface draws, the legend shows the field. +6. Confirm no `open3d*` files under `dist\OrisysStudio`. ## Editable run (unchanged) diff --git a/build/studio/build.ps1 b/build/studio/build.ps1 index c48fb02..f64b245 100644 --- a/build/studio/build.ps1 +++ b/build/studio/build.ps1 @@ -1,90 +1,134 @@ #Requires -Version 5.1 <# .SYNOPSIS - Build Orisys Studio Windows onedir EXE with PyInstaller. + Build the Orisys Studio Windows onedir EXE with PyInstaller. + +.DESCRIPTION + This script installs nothing. Provisioning belongs to `setup.ps1`, which decides the + machine-specific facts once (interpreter, Anaconda or not, where the SDK checkout is) and + records them in `.venv-build\orisys-build-env.json`. Here we only: + + 1. make sure that provisioning happened (and run `setup.ps1` if it plainly has not), + 2. verify the environment can import what the frozen app imports, + 3. build, + 4. verify the artefact — config, excludes, and a real start-up of the produced EXE. + + Step 2 and step 4 exist because the interesting failures are silent ones: a stale venv + still builds (the spec's pathex finds the modules) yet loses the theme data; an excluded + module still builds yet makes `import orisys.finger` fail; a missing runtime DLL still + builds yet the EXE never writes a log. Each of those looked like a successful build. .PARAMETER Clean - Pass --clean to PyInstaller (slower; use when deps/spec change). + Pass --clean to PyInstaller (slower; use after dependency or spec changes). .PARAMETER SkipInstall - Skip pip install steps (reuse existing .venv-build packages). + Do not run `setup.ps1`, even if the environment looks unprovisioned. Fails instead of + provisioning silently. + +.PARAMETER RebuildVenv + Run `setup.ps1 -Force` first (recreate the venv from scratch), then build. + +.PARAMETER SkipSmokeTest + Do not launch the produced EXE. The smoke test is the only step that proves the bundle + starts; skip it only where a desktop session is genuinely unavailable. + +.PARAMETER StrictSmokeTest + Treat a 3D-context warning from the smoke test as a failure. + +.PARAMETER GlProfile + GL profile the smoke test starts the app with (default: the shipped `desktop33`). #> param( [switch]$Clean, - [switch]$SkipInstall + [switch]$SkipInstall, + [switch]$RebuildVenv, + [switch]$SkipSmokeTest, + [switch]$StrictSmokeTest, + [string]$GlProfile = "desktop33" ) $ErrorActionPreference = "Stop" -$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path +$StudioDir = $PSScriptRoot +$RepoRoot = (Resolve-Path (Join-Path $StudioDir "..\..")).Path $VenvDir = Join-Path $RepoRoot ".venv-build" $Python = Join-Path $VenvDir "Scripts\python.exe" -$Spec = Join-Path $PSScriptRoot "orisys-studio.spec" +$StampPath = Join-Path $VenvDir "orisys-build-env.json" +$Spec = Join-Path $StudioDir "orisys-studio.spec" +$Setup = Join-Path $StudioDir "setup.ps1" $WorkPath = Join-Path $RepoRoot "build\_pyinstaller" $DistPath = Join-Path $RepoRoot "dist" +$OutDir = Join-Path $DistPath "OrisysStudio" +$Exe = Join-Path $OutDir "OrisysStudio.exe" +$SmokeKeep = Join-Path $RepoRoot "build\_smoke" + +function Assert-Ok([string]$what) { + if ($LASTEXITCODE -ne 0) { throw "$what failed (exit $LASTEXITCODE)" } +} + +# PowerShell 5.1's `Set-Content -Encoding UTF8` writes a BOM, and a BOM makes Python's +# `json.loads` raise ("Unexpected UTF-8 BOM") — the app then silently falls back to its +# defaults, which is exactly the kind of quiet failure this script exists to avoid. +function Write-JsonFile([string]$path, [string]$json) { + [System.IO.File]::WriteAllText($path, $json, (New-Object System.Text.UTF8Encoding($false))) +} Write-Host "Repo: $RepoRoot" -if (-not (Test-Path $Python)) { - Write-Host "Creating build venv at $VenvDir" - python -m venv $VenvDir +# --------------------------------------------------------------- 1. provisioning +if ($RebuildVenv) { + Write-Host "Recreating the build environment ..." + & $Setup -Force + Assert-Ok "setup.ps1 -Force" } -if (-not $SkipInstall) { - Write-Host "Installing build dependencies (editable shared + studio + orisys + pyinstaller)…" - & $Python -m pip install --upgrade pip - & $Python -m pip install "pyinstaller>=6.0" - & $Python -m pip install -e (Join-Path $RepoRoot "shared") - & $Python -m pip install -e (Join-Path $RepoRoot "apps\studio") - # orisys may already be installed; ensure present without [gui]/open3d - & $Python -m pip install "orisys>=0.5" - - Write-Host "Forcing opencv-python-headless 4.x (orisys may have pulled opencv-python; 5.x can be broken)…" - & $Python -m pip uninstall -y opencv-python opencv-python-headless 2>$null - & $Python -m pip install "opencv-python-headless>=4.10.0.84,<5" - & $Python -c "import cv2; print('cv2 OK', cv2.__version__)" - if ($LASTEXITCODE -ne 0) { - Write-Error "cv2 import failed after installing opencv-python-headless" - exit $LASTEXITCODE - } - - # Guard: Open3D must not be present in the build env - $hasOpen3d = & $Python -c "import importlib.util; print(importlib.util.find_spec('open3d') is not None)" - if ($hasOpen3d -eq "True") { - Write-Host "Uninstalling open3d from build venv…" - & $Python -m pip uninstall -y open3d - } +$stampRoot = "" +if (Test-Path $StampPath) { + $stampRoot = (Get-Content $StampPath -Raw | ConvertFrom-Json).checkout_root } +$needsSetup = (-not (Test-Path $Python)) -or ($stampRoot -ne $RepoRoot) +if ($needsSetup) { + if ($SkipInstall) { + throw "the build environment is missing or was provisioned for another checkout. Run setup.bat (or drop -SkipInstall)." + } + Write-Host "No usable build environment for this checkout: running setup.ps1 ..." + & $Setup + Assert-Ok "setup.ps1" +} +if (-not (Test-Path $Spec)) { throw "spec not found: $Spec" } +# ---------------------------------------------------- 2. environment verification +# Cheap (about a second) and it catches the two provisioning mistakes that build silently: +# an editable install pointing at another checkout, and a missing GL binding. +Write-Host "Verifying the build environment ..." +& $Python -c "import orisys_app_shared, orisys_studio, orisys, OpenGL, cv2, PyInstaller; print('imports OK')" +Assert-Ok "the build venv cannot import what the app imports" +& $Python -c "from PyInstaller.utils.hooks import collect_data_files as c; d=c('orisys_app_shared', includes=['theme/*.json']); assert d, 'theme data not collectable'; print('theme data OK:', len(d), 'files')" +Assert-Ok "theme data would not be bundled" + +# ------------------------------------------------------------------ 3. PyInstaller $pyiArgs = @( "--noconfirm", "--distpath", $DistPath, "--workpath", $WorkPath, $Spec ) -if ($Clean) { - $pyiArgs = @("--clean") + $pyiArgs -} +if ($Clean) { $pyiArgs = @("--clean") + $pyiArgs } -Write-Host "Running PyInstaller…" +Write-Host "Running PyInstaller ..." & $Python -m PyInstaller @pyiArgs -if ($LASTEXITCODE -ne 0) { - Write-Error "PyInstaller failed with exit code $LASTEXITCODE" - exit $LASTEXITCODE -} +Assert-Ok "PyInstaller" -$OutDir = Join-Path $DistPath "OrisysStudio" Write-Host "" Write-Host "Build complete: $OutDir" -# Lean config beside EXE (exclude finger visualization assets) +# -------------------------------------------------------------- 4. shipped config +# Lean config beside the EXE (the finger visualization bundle is a development artefact). $ConfigSrc = Join-Path $RepoRoot "apps\studio\config" $ConfigDst = Join-Path $OutDir "config" if (Test-Path $ConfigSrc) { - Write-Host "Copying lean config to $ConfigDst …" - if (Test-Path $ConfigDst) { - Remove-Item $ConfigDst -Recurse -Force - } + Write-Host "Copying lean config to $ConfigDst ..." + if (Test-Path $ConfigDst) { Remove-Item $ConfigDst -Recurse -Force } New-Item -ItemType Directory -Path $ConfigDst -Force | Out-Null Get-ChildItem $ConfigSrc -Force | ForEach-Object { Copy-Item $_.FullName -Destination $ConfigDst -Recurse -Force @@ -96,21 +140,154 @@ if (Test-Path $ConfigSrc) { } } -Write-Host "Launch: $(Join-Path $OutDir 'OrisysStudio.exe')" +# The 3D pane refuses the whole scene when the device description cannot be derived, and that +# description needs the sensor manifest, the unroll map and the compiled package. A strip +# step that removed one of them would leave a pane that explains itself instead of drawing. +Write-Host "Checking the shipped 3D prerequisites ..." +$scenePackages = @(Get-ChildItem (Join-Path $ConfigDst "sensors") -Recurse -Filter "*.oryscene" -ErrorAction SilentlyContinue) +if ($scenePackages.Count -eq 0) { + Write-Warning "No *.oryscene package under config\sensors: the 3D pane will report 'no compiled scene package'." +} else { + foreach ($pkg in $scenePackages) { Write-Host " scene: $($pkg.FullName.Substring($ConfigDst.Length + 1))" } +} +$fingerCfg = Join-Path $ConfigDst "sensors\finger" +if (Test-Path $fingerCfg) { + $required = @("manifest.json", "sensor.json") + foreach ($name in $required) { + if (-not (Test-Path (Join-Path $fingerCfg $name))) { throw "the shipped finger config is missing $name" } + } + $manifest = Get-Content (Join-Path $fingerCfg "manifest.json") -Raw | ConvertFrom-Json + $assets = Join-Path $fingerCfg ([string]$manifest.assets_dir) + $sensor = Get-Content (Join-Path $fingerCfg "sensor.json") -Raw | ConvertFrom-Json + $scale = $sensor.camera.source_scale + $mapName = if ($null -eq $scale -or [double]$scale -eq 1.0) { "unroll_map_uv.npz" } else { "unroll_map_uv_s$([double]$scale).npz" } + if (-not (Test-Path (Join-Path $assets $mapName))) { + throw "the shipped finger assets have no $mapName (the device geometry description needs it; the pane would refuse the scene)" + } + Write-Host " finger assets: $($manifest.assets_dir) ($mapName present)" +} -# Size summary +# ------------------------------------------------------------------- 5. no Open3D +$o3dHits = Get-ChildItem $OutDir -Recurse -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match 'open3d' } +if ($o3dHits) { + Write-Warning "Open3D-related files found under dist (should be empty):" + $o3dHits | ForEach-Object { Write-Warning $_.FullName } +} else { + Write-Host "Open3D check: none found under dist." +} + +# ----------------------------------------------------------------- 6. smoke test +# Start the EXE the way a user would and require the application's own log to say it came up. +# This is the step that would have caught every silent failure found in this build. +$smokeFailed = $false +$smokeWarned = $false +if (-not $SkipSmokeTest -and (Test-Path $Exe)) { + Write-Host "" + Write-Host "Smoke test: starting $Exe ..." + $prefPath = Join-Path $OutDir "preference.json" + $hadPref = Test-Path $prefPath + $prefBackup = $null + if ($hadPref) { $prefBackup = Get-Content $prefPath -Raw } + $logsDir = Join-Path $OutDir "outputs\logs" + $before = @() + $outputsExisted = Test-Path (Join-Path $OutDir "outputs") + if (Test-Path $logsDir) { $before = @(Get-ChildItem $logsDir -Filter *.log | ForEach-Object { $_.Name }) } + + # The pane is shown for the finger sensor only; seeding the preference is how the build + # proves the *packaged* asset loads, not just that the window opened. + $sensorFor3D = "" + if (Test-Path $fingerCfg) { $sensorFor3D = "finger" } + $seed = [ordered]@{ language = "en_US"; theme = "dark"; view_preset = "live"; show_finger_3d = $true } + if ($sensorFor3D) { $seed["sensor_type"] = $sensorFor3D } + ($seed | ConvertTo-Json) | ForEach-Object { Write-JsonFile $prefPath $_ } + + $oldQpa = $env:QT_QPA_PLATFORM + $env:QT_QPA_PLATFORM = "windows" + + $proc = $null + try { + $proc = Start-Process -FilePath $Exe -WorkingDirectory $OutDir -PassThru + # The scene is read on a worker thread *after* the shell is up, so the two lines + # arrive separately: waiting for both (rather than reading the log once) is what + # keeps this test from being a race. + $wanted = @("Studio shell started") + if ($sensorFor3D) { $wanted += "3D scene loaded" } + $deadline = (Get-Date).AddSeconds(75) + $logText = "" + $logPath = "" + while ((Get-Date) -lt $deadline) { + Start-Sleep -Milliseconds 1000 + $fresh = Get-ChildItem $logsDir -Filter *.log -ErrorAction SilentlyContinue | + Where-Object { $before -notcontains $_.Name } + if ($fresh) { + $newest = $fresh | Sort-Object LastWriteTime | Select-Object -Last 1 + $logPath = $newest.FullName + $logText = Get-Content $logPath -Raw -ErrorAction SilentlyContinue + $missing = @($wanted | Where-Object { $logText -notmatch [regex]::Escape($_) }) + if ($missing.Count -eq 0) { break } + } + if ($proc.HasExited) { break } + } + if (-not $logText -or $logText -notmatch "Studio shell started") { + $smokeFailed = $true + Write-Warning "The produced EXE did not reach 'Studio shell started'." + if ($logPath) { Write-Warning "log: $logPath" } else { Write-Warning "no log was written at all" } + } else { + Write-Host " the application started" + if ($sensorFor3D -and $logText -notmatch "3D scene loaded") { + $smokeFailed = $true + Write-Warning "The 3D scene did not load from the shipped package." + # The loader logs the diagnostic it refused with; quoting it here is the + # difference between "the build failed" and "this asset was rejected". + $rejection = @($logText -split "`r?`n" | Where-Object { $_ -match "rejected|no compiled scene|asset_" }) + if ($rejection) { Write-Warning (" " + $rejection[-1]) } + } elseif ($sensorFor3D) { + Write-Host " the packaged 3D scene loaded" + } + # A fallback to Qt's ANGLE backend cannot satisfy a desktop 3.3 core request, and + # the pane then reports itself unavailable on a machine whose GPU is fine. It is + # environment-dependent, so it is a warning unless -StrictSmokeTest is asked for. + if ($logText -match "QWindowsEGLContext|Failed to create context|context creation failed") { + $smokeWarned = $true + Write-Warning "The 3D widget could not create a GL context in this session." + Write-Warning " desktop33: set QT_OPENGL=desktop (Qt otherwise falls back to ANGLE, which is GLES-only)" + Write-Warning " or pin it in the app (Qt.AA_UseDesktopOpenGL / AA_UseOpenGLES) before QApplication." + } + } + if ($logPath) { + New-Item -ItemType Directory -Path $SmokeKeep -Force | Out-Null + $keepName = "smoke-{0}.log" -f (Get-Date).ToString("yyyyMMdd_HHmmss") + Copy-Item $logPath (Join-Path $SmokeKeep $keepName) -Force + Write-Host " smoke log kept: build\_smoke\$keepName" + } + } finally { + if ($proc -and -not $proc.HasExited) { + Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue + } + # The app is not given the chance to save preferences; restore what was there. + if ($hadPref) { Write-JsonFile $prefPath $prefBackup } + elseif (Test-Path $prefPath) { Remove-Item $prefPath -Force -ErrorAction SilentlyContinue } + if (-not $outputsExisted -and (Test-Path (Join-Path $OutDir "outputs"))) { + Remove-Item (Join-Path $OutDir "outputs") -Recurse -Force -ErrorAction SilentlyContinue + } + if ($null -eq $oldQpa) { Remove-Item Env:\QT_QPA_PLATFORM -ErrorAction SilentlyContinue } + else { $env:QT_QPA_PLATFORM = $oldQpa } + } +} + +# ------------------------------------------------------------------- 7. summary if (Test-Path $OutDir) { $bytes = (Get-ChildItem $OutDir -Recurse -File -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum).Sum $mb = [math]::Round($bytes / 1MB, 1) Write-Host ("Output size: {0} MB" -f $mb) - - $o3dHits = Get-ChildItem $OutDir -Recurse -ErrorAction SilentlyContinue | - Where-Object { $_.Name -match 'open3d' } - if ($o3dHits) { - Write-Warning "Open3D-related files found under dist (should be empty):" - $o3dHits | ForEach-Object { Write-Warning $_.FullName } - } else { - Write-Host "Open3D check: none found under dist." - } + Write-Host "Launch: $Exe" } + +if ($smokeFailed -or ($StrictSmokeTest -and $smokeWarned)) { + Write-Host "" + Write-Host "FAILED: the build produced an EXE that did not pass the smoke test." -ForegroundColor Red + exit 1 +} +exit 0 diff --git a/build/studio/constraints-build.txt b/build/studio/constraints-build.txt new file mode 100644 index 0000000..5b0c926 --- /dev/null +++ b/build/studio/constraints-build.txt @@ -0,0 +1,27 @@ +# Pinned build-time versions, applied with `pip install -c` by setup.ps1. +# +# The point is not tidiness: PyInstaller's output depends on the *environment* as much as +# on the spec (which Qt, which numpy, which hooks-contrib), so a build that "works" on one +# machine and produces a non-starting EXE on another is usually a version difference that +# nobody wrote down. `-c` constrains whatever else asks for these packages, so the app's +# own loose requirements (PyQt5>=5.15, scipy>=1.10.1, ...) resolve to exactly this set. +# +# `orisys` is deliberately absent: it is installed from a local checkout (or deliberately +# from PyPI with -FromPypi), and pinning it by name would fight the editable install. +# +# To move a pin: change it here, run `setup.bat -Force`, and run the build's smoke test. +# That test is the acceptance evidence, not this file. + +PyInstaller==6.22.2 +pyinstaller-hooks-contrib==2026.7 +PyOpenGL==3.1.10 +PyQt5==5.15.11 +PyQt5-Qt5==5.15.2 +PyQt5-sip==12.19.0 +numpy==2.2.6 +scipy==1.15.3 +numba==0.67.0 +llvmlite==0.49.0 +opencv-python-headless==4.14.0.94 +pyqtgraph==0.14.0 +cryptography==49.0.0 diff --git a/build/studio/orisys-studio.spec b/build/studio/orisys-studio.spec index 49f5ec9..a555a2f 100644 --- a/build/studio/orisys-studio.spec +++ b/build/studio/orisys-studio.spec @@ -3,6 +3,8 @@ from __future__ import annotations +import json +import os from pathlib import Path from PyInstaller.building.api import COLLECT, EXE, PYZ @@ -13,6 +15,50 @@ SPEC_DIR = Path(SPECPATH).resolve() REPO_ROOT = SPEC_DIR.parents[1] BRANDING_DIR = REPO_ROOT / "shared" / "resources" / "branding" ENTRY = SPEC_DIR / "entry.py" +#: What ``setup.ps1`` recorded about this machine (interpreter, and where its runtime DLLs +#: live when that interpreter is not a python.org one). +BUILD_ENV_STAMP = REPO_ROOT / ".venv-build" / "orisys-build-env.json" + +#: The runtime DLLs an Anaconda interpreter keeps in ``\Library\bin`` or +#: ``\DLLs``. PyInstaller does not search those directories, so a frozen app built +#: without them dies before it can log anything: +#: ``ImportError: DLL load failed while importing pyexpat`` (libexpat) +#: ``ImportError: DLL load failed while importing _ctypes`` (ffi-8) +#: Building with python.org CPython avoids the whole list; when ``setup.ps1`` accepts a conda +#: interpreter it records the directory, and this is where that decision is honoured. +_RUNTIME_DLLS = ( + "libexpat.dll", + "expat.dll", + "ffi-8.dll", + "ffi-7.dll", + "ffi.dll", + "libssl-3-x64.dll", + "libcrypto-3-x64.dll", + "sqlite3.dll", + "liblzma.dll", + "libbz2.dll", + "zlib.dll", +) + + +def _extra_runtime_binaries() -> list[tuple[str, str]]: + """Runtime DLLs from a non-python.org interpreter, as ``(source, dest)`` pairs.""" + folders: list[str] = [] + try: + stamp = json.loads(BUILD_ENV_STAMP.read_text(encoding="utf-8")) + folders.extend(str(item) for item in (stamp.get("extra_dll_dirs") or ())) + except Exception: + # No stamp (a spec run by hand): fall back to the environment, if it says it is conda. + prefix = os.environ.get("CONDA_PREFIX") + if prefix: + folders.append(os.path.join(prefix, "Library", "bin")) + out: list[tuple[str, str]] = [] + for folder in folders: + for name in _RUNTIME_DLLS: + candidate = Path(folder) / name + if candidate.is_file(): + out.append((str(candidate), ".")) + return out def _branding_datas() -> list[tuple[str, str]]: @@ -30,7 +76,7 @@ block_cipher = None # Config is copied beside the EXE by build.ps1 (writable / editable). # Branding + package data go into _internal via Analysis datas. datas = _branding_datas() -binaries: list = [] +binaries: list = _extra_runtime_binaries() hiddenimports = [ "cv2", "PyQt5.sip", @@ -68,7 +114,12 @@ a = Analysis( runtime_hooks=[], excludes=[ "open3d", - "orisys.finger.viewer", + # NOTE: `orisys.finger` imports `.viewer` at module level, and `finger/viewer.py` + # imports open3d *inside* its functions — so bundling it costs a few KB of Python and + # pulls in no Open3D. Excluding it made `import orisys.finger` raise + # ModuleNotFoundError in the frozen app, which took the whole window down with it + # (the app cannot even reach logging). The `open3d` entry above is the one that + # matters. "tkinter", "matplotlib", "IPython", diff --git a/build/studio/setup.bat b/build/studio/setup.bat new file mode 100644 index 0000000..047e0ec --- /dev/null +++ b/build/studio/setup.bat @@ -0,0 +1,21 @@ +@echo off +setlocal EnableExtensions +REM Provision the Orisys Studio build environment (run once per machine). +REM Works from any cwd; forwards args to setup.ps1 +REM Examples: +REM setup.bat +REM setup.bat -Force +REM setup.bat -Python C:\Python310\python.exe +REM setup.bat -AllowConda + +set "SCRIPT_DIR=%~dp0" +set "PS1=%SCRIPT_DIR%setup.ps1" + +if not exist "%PS1%" ( + echo ERROR: setup.ps1 not found at "%PS1%" + exit /b 1 +) + +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%PS1%" %* +set "ERR=%ERRORLEVEL%" +exit /b %ERR% diff --git a/build/studio/setup.ps1 b/build/studio/setup.ps1 new file mode 100644 index 0000000..0bb6a66 --- /dev/null +++ b/build/studio/setup.ps1 @@ -0,0 +1,292 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Provision the Windows packaging environment for Orisys Studio. Run once per machine. + +.DESCRIPTION + Building the EXE has two kinds of requirement, and mixing them is what makes a build + "work on my machine": + + * things that are true of *this machine* — which interpreter exists, whether it is + Anaconda (whose libexpat/libffi DLLs live outside the venv and are therefore not + bundled), where the orisys SDK checkout is. Those are decided **here**, once, and + recorded in `.venv-build\orisys-build-env.json`. + * things that are true of the *sources* — the specs, the excludes, the assets. Those + belong to `build.ps1`, which then installs nothing and guesses nothing. + + So this script is the only place that installs anything. It creates `.venv-build` (or + repairs it), installs the pinned toolchain plus the app's own extras, writes the stamp that + `build.ps1` and the spec read, and verifies the result by importing what the frozen app + imports before anything is packaged. + + Re-running is safe: with a valid stamp and no -Force it reports and exits; without a stamp + it repairs the existing venv instead of deleting it. + +.PARAMETER Python + Interpreter used to create the venv. Default: `py -3.10` when available, else `python`, + else the interpreter that already backs `.venv-build`. + +.PARAMETER SdkPath + Local `orisys` SDK checkout to install editable. Default: `..\..\..\orisys-sdk` when that + exists. Without it (or with -FromPypi) the SDK comes from PyPI, which is *not* the code + under test — that is how a build once shipped with no `orisys.finger` at all. + +.PARAMETER Force + Recreate the venv from scratch. + +.PARAMETER AllowConda + Accept an Anaconda interpreter. Anaconda keeps libexpat/libffi/OpenSSL/SQLite in + `\Library\bin`, where PyInstaller does not look; the stamp records that directory + and the spec adds those DLLs. Building with a python.org CPython is still preferred. + +.PARAMETER FromPypi + Install `orisys` from PyPI instead of a local checkout. + +.PARAMETER NoVerify + Skip the post-install verification (not recommended; that verification is what catches a + provisioning which would produce an EXE that cannot start). + +.EXAMPLE + setup.bat + setup.bat -Force + setup.bat -Python C:\Python310\python.exe + setup.bat -AllowConda +#> +param( + [string]$Python = "", + [string]$SdkPath = "", + [switch]$Force, + [switch]$AllowConda, + [switch]$FromPypi, + [switch]$NoVerify +) + +$ErrorActionPreference = "Stop" + +$StudioDir = $PSScriptRoot +$RepoRoot = (Resolve-Path (Join-Path $StudioDir "..\..")).Path +$VenvDir = Join-Path $RepoRoot ".venv-build" +$VenvPy = Join-Path $VenvDir "Scripts\python.exe" +$VenvCfg = Join-Path $VenvDir "pyvenv.cfg" +$StampPath = Join-Path $VenvDir "orisys-build-env.json" +$Constraints = Join-Path $StudioDir "constraints-build.txt" +#: Bumped when the stamp's meaning changes: an old stamp must not be read as valid. +$StampVersion = 1 +#: `pyinstaller` is pinned here as well as in the constraints file: it is the tool, not a +#: dependency of the app, and its version decides what the bundle contains. +$PyInstallerPin = "pyinstaller==6.22.2" +#: opencv-python 5.x has shipped wheels without a `cv2` module; the app is tested on 4.x. +$CvPin = "opencv-python-headless>=4.10.0.84,<5" + +# A condition the operator has to fix, with the instructions in the message itself. +function Stop-With([string]$message) { + Write-Host "" + Write-Host $message -ForegroundColor Red + exit 1 +} + +# PowerShell 5.1's `Set-Content -Encoding UTF8` writes a BOM, and a BOM makes Python's +# `json.loads` raise ("Unexpected UTF-8 BOM"). The spec reads this stamp, so a BOM here would +# quietly drop the extra runtime DLLs and the EXE would not start. +function Write-JsonFile([string]$path, [string]$json) { + [System.IO.File]::WriteAllText($path, $json, (New-Object System.Text.UTF8Encoding($false))) +} + +# A path that is really a Python: the Microsoft Store alias on PATH answers `python` but is +# not an interpreter, and it would otherwise be silently chosen. +function Test-BasePython([string]$exe) { + if (-not $exe) { return $null } + if ($exe -match '\\WindowsApps\\') { return $null } + if (-not (Test-Path $exe)) { return $null } + try { + $out = & $exe -c "import sys; print(sys.executable)" 2>$null + if ($LASTEXITCODE -ne 0 -or -not $out) { return $null } + return ($out | Select-Object -Last 1).Trim() + } catch { + return $null + } +} + +function Resolve-BasePython([string]$explicit) { + if ($explicit) { + $ok = Test-BasePython $explicit + if (-not $ok) { Stop-With "-Python is not a usable interpreter: $explicit" } + return $ok + } + $tried = @() + if (Get-Command py.exe -ErrorAction SilentlyContinue) { + $out = & py -3.10 -c "import sys; print(sys.executable)" 2>$null + $tried += "py -3.10" + if ($LASTEXITCODE -eq 0 -and $out) { + $ok = Test-BasePython (($out | Select-Object -Last 1).Trim()) + if ($ok) { return $ok } + } + } + foreach ($name in @("python3.10.exe", "python.exe")) { + $found = Get-Command $name -ErrorAction SilentlyContinue + if (-not $found) { continue } + $tried += $found.Source + $ok = Test-BasePython $found.Source + if ($ok) { return $ok } + } + # Last resort: whatever already backs this venv. An existing build environment is + # evidence of an interpreter that works on this machine. + if (Test-Path $VenvCfg) { + $match = Select-String -Path $VenvCfg -Pattern '^home\s*=\s*(.+)$' + if ($match) { + $baseHome = $match.Matches[0].Groups[1].Value.Trim() + $tried += "$baseHome (from pyvenv.cfg)" + $ok = Test-BasePython (Join-Path $baseHome "python.exe") + if ($ok) { return $ok } + } + } + Stop-With "no usable Python found (tried: $($tried -join ', ')). Install CPython 3.10+ and pass -Python ." +} + +function Get-PythonFacts([string]$exe) { + $json = & $exe -c "import json,os,sys;print(json.dumps({'exe':sys.executable,'version':sys.version.split()[0],'prefix':sys.prefix,'conda':os.path.isdir(os.path.join(sys.prefix,'conda-meta')) or 'conda' in sys.prefix.lower()}))" + if ($LASTEXITCODE -ne 0) { throw "could not query $exe" } + return ($json | Select-Object -Last 1 | ConvertFrom-Json) +} + +Write-Host "Orisys Studio build environment" +Write-Host " repo : $RepoRoot" +Write-Host " venv : $VenvDir" + +# --------------------------------------------------------------------- stamp check +if ((Test-Path $VenvPy) -and (Test-Path $StampPath) -and -not $Force) { + $stamp = Get-Content $StampPath -Raw | ConvertFrom-Json + if ($stamp.stamp_version -eq $StampVersion -and $stamp.checkout_root -eq $RepoRoot) { + Write-Host "" + Write-Host "Already provisioned ($($stamp.created)); nothing to do." + Write-Host " base python : $($stamp.base_python) ($($stamp.python_version))" + Write-Host " sdk : $($stamp.sdk)" + Write-Host "Use -Force to rebuild the venv, or go straight to ..\studio\build.bat" + exit 0 + } + Write-Host "The existing venv was provisioned for another checkout or an older stamp: repairing it." +} + +# ------------------------------------------------------------------- interpreter +$BasePy = Resolve-BasePython $Python +$facts = Get-PythonFacts $BasePy +Write-Host " base python : $($facts.exe) ($($facts.version))" + +$extraDllDirs = @() +if ($facts.conda) { + $libraryBin = Join-Path $facts.prefix "Library\bin" + if (-not $AllowConda) { + Stop-With @" +$($facts.exe) is an Anaconda interpreter. + +PyInstaller does not search Anaconda's Library\bin, where libexpat.dll, ffi-8.dll, +libssl/libcrypto and sqlite3.dll live, so the EXE it produces fails at start-up with +"ImportError: DLL load failed while importing pyexpat". Either: + + * build with python.org CPython (recommended): setup.bat -Python C:\Python310\python.exe + * or accept the risk: setup.bat -AllowConda + +-AllowConda records $libraryBin in the build stamp and the spec adds those DLLs. +"@ + } + if (Test-Path $libraryBin) { + $extraDllDirs += $libraryBin + Write-Host " conda : accepted; runtime DLLs will be taken from $libraryBin" + } else { + Write-Warning "conda prefix $($facts.prefix) has no Library\bin; the EXE may not start" + } +} + +# ----------------------------------------------------------------------- the venv +if ($Force -and (Test-Path $VenvDir)) { + Write-Host "Removing $VenvDir" + Remove-Item $VenvDir -Recurse -Force +} +if (-not (Test-Path $VenvPy)) { + Write-Host "Creating venv with $BasePy" + & $BasePy -m venv $VenvDir + if ($LASTEXITCODE -ne 0) { throw "python -m venv failed" } +} else { + Write-Host "Reusing the existing venv (use -Force to recreate it)" +} + +# ------------------------------------------------------------------------ the SDK +$sdkSource = "" +if (-not $FromPypi) { + $candidate = if ($SdkPath) { $SdkPath } else { Join-Path (Split-Path $RepoRoot -Parent) "orisys-sdk" } + if ($candidate -and (Test-Path (Join-Path $candidate "pyproject.toml"))) { + $sdkSource = (Resolve-Path $candidate).Path + } elseif ($SdkPath) { + throw "-SdkPath has no pyproject.toml: $SdkPath" + } +} +if (-not $sdkSource) { + Write-Warning "No local orisys SDK checkout found; installing 'orisys>=0.5' from PyPI. The EXE will not contain the SDK under test unless that is what you intend." +} + +# ----------------------------------------------------------------------- install +Write-Host "Installing the pinned toolchain and the app's own packages ..." +$install = @("-m", "pip", "install", "--disable-pip-version-check") +if (Test-Path $Constraints) { $install += @("-c", $Constraints) } +if ($sdkSource) { $install += @("-e", $sdkSource) } else { $install += "orisys>=0.5" } +$install += @("-e", (Join-Path $RepoRoot "shared")) +# The `three-d` extra is the GL binding. Without it the app still builds, and the 3D pane +# silently reports "PyOpenGL is not installed" on the customer's machine. +$install += @("-e", "$(Join-Path $RepoRoot 'apps\studio')[three-d]") +$install += @($PyInstallerPin, $CvPin) +& $VenvPy @install +if ($LASTEXITCODE -ne 0) { throw "pip install failed" } + +# Upstream `orisys` may declare opencv-python; the app needs the headless wheel (PyQt owns +# the GUI), and 5.x can arrive without a `cv2` module. +& $VenvPy -m pip uninstall -y opencv-python opencv-python-headless 2>$null | Out-Null +& $VenvPy -m pip install --disable-pip-version-check -c $Constraints $CvPin +if ($LASTEXITCODE -ne 0) { throw "opencv-python-headless install failed" } + +# Open3D must not be in the build env: it is an offline tool, and the spec excludes it. +$hasOpen3d = & $VenvPy -c "import importlib.util; print(importlib.util.find_spec('open3d') is not None)" +if ($hasOpen3d -eq "True") { + Write-Host "Uninstalling open3d from the build venv ..." + & $VenvPy -m pip uninstall -y open3d +} + +# ------------------------------------------------------------------- verification +if (-not $NoVerify) { + Write-Host "" + Write-Host "Verifying the provisioned environment ..." + # These are the imports the frozen app makes before it can draw anything. Checking them + # here is what turns "the build succeeded" into "the build can start". + & $VenvPy -c "import orisys_app_shared, orisys_studio, orisys, OpenGL, cv2, PyQt5.QtWidgets, PyInstaller; print('imports OK')" + if ($LASTEXITCODE -ne 0) { throw "the venv cannot import what the app imports (see above)" } + # Theme colours are package data: PyInstaller only bundles them if the package is + # importable at build time, so a stale editable install loses them silently. + & $VenvPy -c "from PyInstaller.utils.hooks import collect_data_files as c; d=c('orisys_app_shared', includes=['theme/*.json']); assert d, 'theme data not collectable'; print('theme data OK:', len(d), 'files')" + if ($LASTEXITCODE -ne 0) { throw "theme data would not be bundled" } + # When a local checkout was asked for, the installed SDK must be that checkout. + if ($sdkSource) { + & $VenvPy -c "import orisys, os; p=os.path.dirname(orisys.__file__); assert os.path.isfile(os.path.join(p,'finger','__init__.py')), 'orisys.finger missing from '+p; print('SDK OK:', p)" + if ($LASTEXITCODE -ne 0) { throw "the installed SDK has no finger package" } + } +} + +# ------------------------------------------------------------------------ stamp +$stampData = [ordered]@{ + stamp_version = $StampVersion + checkout_root = $RepoRoot + created = (Get-Date).ToString("o") + base_python = $facts.exe + python_version = $facts.version + is_conda = [bool]$facts.conda + conda_prefix = $facts.prefix + extra_dll_dirs = @($extraDllDirs) + sdk = $sdkSource + venv = $VenvDir +} +Write-JsonFile $StampPath ($stampData | ConvertTo-Json -Depth 4) +Write-Host "Wrote $StampPath" + +Write-Host "" +Write-Host "Environment ready. Next:" +Write-Host " build.bat # build the EXE" +Write-Host " build.bat -Clean # after dependency/spec changes" +Write-Host " build.bat -SkipSmokeTest # skip launching the produced EXE"