From 0d4dd3af8acaa669d0f683794aedbacc517a9f44 Mon Sep 17 00:00:00 2001 From: Christian Schmittel Date: Thu, 2 Jul 2026 11:48:23 +0200 Subject: [PATCH 1/9] feat(build): add pure Windows helper arch-resolution module --- scripts/windows-helper-arch.mjs | 62 ++++++++++++++++++++++++ scripts/windows-helper-arch.test.mjs | 71 ++++++++++++++++++++++++++++ vitest.config.ts | 2 +- 3 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 scripts/windows-helper-arch.mjs create mode 100644 scripts/windows-helper-arch.test.mjs diff --git a/scripts/windows-helper-arch.mjs b/scripts/windows-helper-arch.mjs new file mode 100644 index 000000000..e9f0503a6 --- /dev/null +++ b/scripts/windows-helper-arch.mjs @@ -0,0 +1,62 @@ +// Pure helpers that map a target CPU architecture to the parameters the Windows +// native-helper build needs. Kept side-effect free so it is unit-testable and +// importable from both the build script and Vitest. + +export const SUPPORTED_ARCHES = ["x64", "arm64"]; + +const ALIASES = new Map([ + ["x64", "x64"], + ["amd64", "x64"], + ["x86_64", "x64"], + ["arm64", "arm64"], + ["aarch64", "arm64"], +]); + +export function normalizeArch(value) { + if (value === undefined || value === null || value === "") { + return undefined; + } + return ALIASES.get(String(value).toLowerCase()); +} + +export function resolveTargetArch({ cliArch, envArch, hostArch } = {}) { + for (const [label, raw] of [ + ["--arch", cliArch], + ["OPENSCREEN_WIN_HELPER_ARCH", envArch], + ]) { + if (raw !== undefined && raw !== null && raw !== "") { + const normalized = normalizeArch(raw); + if (!normalized) { + throw new Error( + `Invalid ${label} value "${raw}". Expected one of: ${SUPPORTED_ARCHES.join(", ")}.`, + ); + } + return normalized; + } + } + + const host = normalizeArch(hostArch); + if (!host) { + throw new Error( + `Unsupported host architecture "${hostArch}". Pass --arch with one of: ${SUPPORTED_ARCHES.join(", ")}.`, + ); + } + return host; +} + +export function resolveVcvarsArch(hostArch, targetArch) { + const host = normalizeArch(hostArch); + const target = normalizeArch(targetArch); + if (!host || !target) { + throw new Error(`Unsupported architecture pair host="${hostArch}" target="${targetArch}".`); + } + return host === target ? target : `${host}_${target}`; +} + +export function winBinDirName(targetArch) { + const target = normalizeArch(targetArch); + if (!target) { + throw new Error(`Unsupported target architecture "${targetArch}".`); + } + return `win32-${target}`; +} diff --git a/scripts/windows-helper-arch.test.mjs b/scripts/windows-helper-arch.test.mjs new file mode 100644 index 000000000..078620347 --- /dev/null +++ b/scripts/windows-helper-arch.test.mjs @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { + SUPPORTED_ARCHES, + normalizeArch, + resolveTargetArch, + resolveVcvarsArch, + winBinDirName, +} from "./windows-helper-arch.mjs"; + +describe("normalizeArch", () => { + it("maps known aliases to canonical arches", () => { + expect(normalizeArch("x64")).toBe("x64"); + expect(normalizeArch("amd64")).toBe("x64"); + expect(normalizeArch("x86_64")).toBe("x64"); + expect(normalizeArch("arm64")).toBe("arm64"); + expect(normalizeArch("aarch64")).toBe("arm64"); + expect(normalizeArch("ARM64")).toBe("arm64"); + }); + + it("returns undefined for empty or unknown values", () => { + expect(normalizeArch(undefined)).toBeUndefined(); + expect(normalizeArch("")).toBeUndefined(); + expect(normalizeArch("mips")).toBeUndefined(); + }); +}); + +describe("resolveTargetArch", () => { + it("prefers the CLI arch over env and host", () => { + expect(resolveTargetArch({ cliArch: "arm64", envArch: "x64", hostArch: "x64" })).toBe("arm64"); + }); + + it("falls back to env when no CLI arch", () => { + expect(resolveTargetArch({ envArch: "arm64", hostArch: "x64" })).toBe("arm64"); + }); + + it("defaults to the host arch when nothing explicit is given", () => { + expect(resolveTargetArch({ hostArch: "arm64" })).toBe("arm64"); + expect(resolveTargetArch({ hostArch: "x64" })).toBe("x64"); + }); + + it("throws on an invalid explicit arch", () => { + expect(() => resolveTargetArch({ cliArch: "mips", hostArch: "x64" })).toThrow(/Invalid/); + }); + + it("throws on an unsupported host with no explicit arch", () => { + expect(() => resolveTargetArch({ hostArch: "ppc64" })).toThrow(/host/i); + }); +}); + +describe("resolveVcvarsArch", () => { + it("returns native tokens when host equals target", () => { + expect(resolveVcvarsArch("x64", "x64")).toBe("x64"); + expect(resolveVcvarsArch("arm64", "arm64")).toBe("arm64"); + }); + + it("returns cross tokens as _", () => { + expect(resolveVcvarsArch("x64", "arm64")).toBe("x64_arm64"); + expect(resolveVcvarsArch("arm64", "x64")).toBe("arm64_x64"); + }); +}); + +describe("winBinDirName", () => { + it("builds the packaged bin folder name", () => { + expect(winBinDirName("x64")).toBe("win32-x64"); + expect(winBinDirName("arm64")).toBe("win32-arm64"); + }); +}); + +it("exposes the supported arch list", () => { + expect(SUPPORTED_ARCHES).toEqual(["x64", "arm64"]); +}); diff --git a/vitest.config.ts b/vitest.config.ts index e6b1497f9..a50a0570b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ test: { globals: true, environment: "jsdom", - include: ["{src,electron,.github}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], + include: ["{src,electron,.github,scripts}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], exclude: ["src/**/*.browser.test.{ts,tsx}"], }, resolve: { From 42083e734d68713e76c0d631854a5508f438195e Mon Sep 17 00:00:00 2001 From: Christian Schmittel Date: Thu, 2 Jul 2026 11:53:17 +0200 Subject: [PATCH 2/9] feat(build): make Windows WGC helper build arch-aware (x64/arm64, native+cross) --- scripts/build-windows-wgc-helper.mjs | 35 ++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/scripts/build-windows-wgc-helper.mjs b/scripts/build-windows-wgc-helper.mjs index e0e3219bb..1179bc943 100644 --- a/scripts/build-windows-wgc-helper.mjs +++ b/scripts/build-windows-wgc-helper.mjs @@ -4,12 +4,37 @@ import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { + resolveTargetArch, + resolveVcvarsArch, + winBinDirName, +} from "./windows-helper-arch.mjs"; + +function parseArchFlag(argv) { + const eq = argv.find((a) => a.startsWith("--arch=")); + if (eq) { + return eq.slice("--arch=".length); + } + const idx = argv.indexOf("--arch"); + if (idx !== -1) { + return argv[idx + 1]; + } + return undefined; +} + +const TARGET_ARCH = resolveTargetArch({ + cliArch: parseArchFlag(process.argv.slice(2)), + envArch: process.env.OPENSCREEN_WIN_HELPER_ARCH, + hostArch: process.arch, +}); +const VCVARS_ARCH = resolveVcvarsArch(process.arch, TARGET_ARCH); + const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.join(__dirname, ".."); const SOURCE_DIR = path.join(ROOT, "electron", "native", "wgc-capture"); const BUILD_DIR = path.join(SOURCE_DIR, "build"); const COMPAT_LIB_DIR = path.join(BUILD_DIR, "compat-libs"); -const BIN_DIR = path.join(ROOT, "electron", "native", "bin", "win32-x64"); +const BIN_DIR = path.join(ROOT, "electron", "native", "bin", winBinDirName(TARGET_ARCH)); const CMAKE = process.env.CMAKE_EXE ?? "cmake"; function findVcVarsAll() { @@ -75,7 +100,7 @@ function findWindowsSdkUmLibDir() { return fs .readdirSync(sdkLibRoot, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) - .map((entry) => path.join(sdkLibRoot, entry.name, "um", "x64")) + .map((entry) => path.join(sdkLibRoot, entry.name, "um", TARGET_ARCH)) .filter((candidate) => fs.existsSync(path.join(candidate, "kernel32.lib"))) .sort() .at(-1); @@ -115,10 +140,10 @@ async function runInVsEnv(command) { cmdPath, [ "@echo off", - `call "${vcvarsAll}" x64`, + `call "${vcvarsAll}" ${VCVARS_ARCH}`, "if errorlevel 1 exit /b %errorlevel%", `if not exist "${COMPAT_LIB_DIR}" mkdir "${COMPAT_LIB_DIR}"`, - `for %%L in (gdi32.lib gdiplus.lib winspool.lib shell32.lib oleaut32.lib uuid.lib comdlg32.lib advapi32.lib) do if not exist "%WindowsSdkDir%Lib\\%WindowsSDKLibVersion%um\\x64\\%%L" copy /Y "%WindowsSdkDir%Lib\\%WindowsSDKLibVersion%um\\x64\\kernel32.Lib" "${COMPAT_LIB_DIR}\\%%L" >nul`, + `for %%L in (gdi32.lib gdiplus.lib winspool.lib shell32.lib oleaut32.lib uuid.lib comdlg32.lib advapi32.lib) do if not exist "%WindowsSdkDir%Lib\\%WindowsSDKLibVersion%um\\${TARGET_ARCH}\\%%L" copy /Y "%WindowsSdkDir%Lib\\%WindowsSDKLibVersion%um\\${TARGET_ARCH}\\kernel32.Lib" "${COMPAT_LIB_DIR}\\%%L" >nul`, "if errorlevel 1 exit /b %errorlevel%", `set "LIB=${sdkUmLibDir ? `${sdkUmLibDir};` : ""}%LIB%;${COMPAT_LIB_DIR}"`, command, @@ -138,6 +163,8 @@ if (process.platform !== "win32") { process.exit(0); } +console.log(`Building Windows WGC helper for target arch: ${TARGET_ARCH} (vcvars: ${VCVARS_ARCH})`); + fs.mkdirSync(BUILD_DIR, { recursive: true }); await runInVsEnv( From cb115d31ad2149c1ef2ad7d69833b8ddd930bf86 Mon Sep 17 00:00:00 2001 From: Christian Schmittel Date: Thu, 2 Jul 2026 11:57:02 +0200 Subject: [PATCH 3/9] feat(build): add build:win:arm64 and build:native:win:arm64 scripts --- package.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/package.json b/package.json index 04ca1b839..43480d8f8 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,8 @@ "build:mac": "npm run build:native:mac && tsc && vite build && electron-builder --mac", "build:native:win": "node scripts/build-windows-wgc-helper.mjs", "build:win": "npm run build:native:win && tsc && vite build && electron-builder --win --config.npmRebuild=false", + "build:native:win:arm64": "node scripts/build-windows-wgc-helper.mjs --arch arm64", + "build:win:arm64": "npm run build:native:win:arm64 && tsc && vite build && electron-builder --win --arm64 --config.npmRebuild=false", "build:linux": "tsc && vite build && electron-builder --linux AppImage deb pacman --config.npmRebuild=false", "test": "vitest --run", "test:watch": "vitest", From e8f895e27103eecc9a428ff4143a2e956e3af5af Mon Sep 17 00:00:00 2001 From: Christian Schmittel Date: Thu, 2 Jul 2026 11:58:51 +0200 Subject: [PATCH 4/9] build: include arch in Windows installer filename to avoid x64/arm64 collision --- electron-builder.json5 | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/electron-builder.json5 b/electron-builder.json5 index 0ff145383..5810481a8 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -1,7 +1,7 @@ // @see - https://www.electron.build/configuration/configuration { "$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json", - "appId": "com.etiennelescot.openscreen", + "appId": "com.etiennelescot.openscreen", "asar": true, // .node binaries can't be dlopen'd from inside an asar — must live unpacked. "asarUnpack": [ @@ -74,26 +74,26 @@ "NSCameraUseContinuityCameraDeviceType": true } }, - "linux": { - "target": [ - "AppImage", - "deb", - "pacman" - ], - "icon": "icons/icons/png", - "artifactName": "${productName}-Linux-${version}.${ext}", - "maintainer": "Etienne Lescot ", - "category": "AudioVideo" - }, - "win": { - "target": [ - "nsis" - ], - "icon": "icons/icons/win/icon.ico", - "artifactName": "${productName}.Setup.${version}.${ext}", - "extraResources": [ - { - "from": "electron/native/bin", + "linux": { + "target": [ + "AppImage", + "deb", + "pacman" + ], + "icon": "icons/icons/png", + "artifactName": "${productName}-Linux-${version}.${ext}", + "maintainer": "Etienne Lescot ", + "category": "AudioVideo" + }, + "win": { + "target": [ + "nsis" + ], + "icon": "icons/icons/win/icon.ico", + "artifactName": "${productName}.Setup.${version}-${arch}.${ext}", + "extraResources": [ + { + "from": "electron/native/bin", "to": "electron/native/bin", "filter": ["win32-*/*"] } From f17a26338464bb8400ed4b4edcbf6ef994cd591d Mon Sep 17 00:00:00 2001 From: Christian Schmittel Date: Thu, 2 Jul 2026 12:01:01 +0200 Subject: [PATCH 5/9] ci: build Windows x64 and arm64 installers via matrix --- .github/workflows/build.yml | 41 +++++++++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6487cbb17..5d9b4e89f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -29,8 +29,12 @@ concurrency: jobs: build-windows: - name: Windows installer + name: Windows ${{ matrix.arch }} installer runs-on: windows-latest + strategy: + fail-fast: false + matrix: + arch: [x64, arm64] steps: - name: Checkout code uses: actions/checkout@v4 @@ -38,20 +42,44 @@ jobs: - name: Setup Node.js uses: ./.github/actions/setup + - name: Ensure MSVC ARM64 build tools + if: matrix.arch == 'arm64' + shell: pwsh + run: | + $vswhere = "C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" + $installPath = & $vswhere -latest -products * -property installationPath + $hasArm64 = & $vswhere -latest -products * ` + -requires Microsoft.VisualStudio.Component.VC.Tools.ARM64 ` + -property installationPath + if (-not $hasArm64) { + Write-Host "Installing MSVC ARM64 build tools component..." + $installer = "C:\Program Files (x86)\Microsoft Visual Studio\Installer\vs_installer.exe" + & $installer modify --installPath "$installPath" ` + --add Microsoft.VisualStudio.Component.VC.Tools.ARM64 ` + --quiet --norestart --wait + } else { + Write-Host "MSVC ARM64 build tools already present." + } + - name: Cache caption assets uses: actions/cache@v4 with: path: caption-assets key: caption-assets-${{ runner.os }}-${{ hashFiles('scripts/fetch-caption-model.mjs') }} - - name: Build Windows app + - name: Build Windows app (x64) + if: matrix.arch == 'x64' run: npm run build:win -- --publish never + - name: Build Windows app (arm64) + if: matrix.arch == 'arm64' + run: npm run build:win:arm64 -- --publish never + - name: Upload Windows installer uses: actions/upload-artifact@v4 with: - name: openscreen-windows - path: release/**/Openscreen.Setup.*.exe + name: openscreen-windows-${{ matrix.arch }} + path: release/**/Openscreen.Setup.*-${{ matrix.arch }}.exe if-no-files-found: error retention-days: 30 @@ -330,10 +358,11 @@ jobs: echo "prerelease_flag=$PRERELEASE_FLAG" >> "$GITHUB_OUTPUT" echo "notes_start_tag=$NOTES_START_TAG" >> "$GITHUB_OUTPUT" - - name: Download Windows installer + - name: Download Windows installers uses: actions/download-artifact@v4 with: - name: openscreen-windows + pattern: openscreen-windows-* + merge-multiple: true path: artifacts/windows - name: Download macOS arm64 DMG From 365652c6cb6b691d53f06af3666e52e9c772c08a Mon Sep 17 00:00:00 2001 From: Christian Schmittel Date: Thu, 2 Jul 2026 12:05:05 +0200 Subject: [PATCH 6/9] docs: document Windows arm64 native helper build and installer --- README.md | 2 +- electron/native/README.md | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2a58e132d..20ac6baf1 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ After running this command, proceed to **System Preferences > Security & Privacy ### Windows -Download the `.exe` installer directly from the [Releases page](https://github.com/EtienneLescot/openscreen/releases). +Download the `.exe` installer directly from the [Releases page](https://github.com/EtienneLescot/openscreen/releases). Windows builds are provided for both x64 (`Openscreen.Setup.-x64.exe`) and ARM64 (`Openscreen.Setup.-arm64.exe`). ### Linux diff --git a/electron/native/README.md b/electron/native/README.md index cda6c9f55..dd8bf50f2 100644 --- a/electron/native/README.md +++ b/electron/native/README.md @@ -43,10 +43,14 @@ Windows native recording is resolved from one of these locations: Build the Windows helper with: ```powershell +# Builds for the host architecture by default (x64 on x64 hosts, arm64 on arm64 hosts). npm run build:native:win + +# Explicit target architecture (native on a matching host, or cross-compiled otherwise): +node scripts/build-windows-wgc-helper.mjs --arch arm64 ``` -The build writes the CMake output to `electron/native/wgc-capture/build/wgc-capture.exe` and copies the redistributable binary to `electron/native/bin/win32-x64/wgc-capture.exe`. +The target architecture is resolved from `--arch` (or the `OPENSCREEN_WIN_HELPER_ARCH` env var), falling back to the host arch. Cross-compiling requires the matching MSVC component — "VS C++ ARM64/ARM64EC build tools" for `arm64`. The build writes the CMake output to `electron/native/wgc-capture/build/wgc-capture.exe` and copies the redistributable binary to `electron/native/bin/win32-/wgc-capture.exe` (e.g. `win32-arm64`). The helper contract is process-based: the app starts the process with one JSON argument and sends commands on stdin. `stop\n` finalizes the recording. During migration the helper prints both newline-delimited JSON events and the legacy text messages `Recording started` / `Recording stopped. Output path: `. From edc73fd220226dba163dd5e992a50f20dcc8eeed Mon Sep 17 00:00:00 2001 From: Christian Schmittel Date: Thu, 2 Jul 2026 12:14:57 +0200 Subject: [PATCH 7/9] fix(build): wipe WGC build dir on target-arch change to prevent stale-compiler wrong-arch binaries --- scripts/build-windows-wgc-helper.mjs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/scripts/build-windows-wgc-helper.mjs b/scripts/build-windows-wgc-helper.mjs index 1179bc943..4ec472472 100644 --- a/scripts/build-windows-wgc-helper.mjs +++ b/scripts/build-windows-wgc-helper.mjs @@ -165,7 +165,21 @@ if (process.platform !== "win32") { console.log(`Building Windows WGC helper for target arch: ${TARGET_ARCH} (vcvars: ${VCVARS_ARCH})`); +// CMake caches the detected compiler in the build directory. Reusing a build +// dir that was configured for a different target arch silently produces +// wrong-arch binaries (the cached compiler wins over the new vcvars env). Wipe +// the build dir when the target arch changes; same-arch reruns stay incremental. +const ARCH_STAMP = path.join(BUILD_DIR, ".target-arch"); +if (fs.existsSync(BUILD_DIR)) { + const previousArch = fs.existsSync(ARCH_STAMP) + ? fs.readFileSync(ARCH_STAMP, "utf8").trim() + : ""; + if (previousArch !== TARGET_ARCH) { + fs.rmSync(BUILD_DIR, { recursive: true, force: true }); + } +} fs.mkdirSync(BUILD_DIR, { recursive: true }); +fs.writeFileSync(ARCH_STAMP, TARGET_ARCH); await runInVsEnv( `"${CMAKE}" -S "${SOURCE_DIR}" -B "${BUILD_DIR}" -G Ninja -DCMAKE_BUILD_TYPE=Release`, From fc933b3edb895889788aeb45239fb751aa311559 Mon Sep 17 00:00:00 2001 From: Christian Schmittel Date: Thu, 2 Jul 2026 12:30:33 +0200 Subject: [PATCH 8/9] build: normalize electron-builder.json5 to keep diff to the artifactName line only --- electron-builder.json5 | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/electron-builder.json5 b/electron-builder.json5 index 5810481a8..564e72bbf 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -1,7 +1,7 @@ // @see - https://www.electron.build/configuration/configuration { "$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json", - "appId": "com.etiennelescot.openscreen", + "appId": "com.etiennelescot.openscreen", "asar": true, // .node binaries can't be dlopen'd from inside an asar — must live unpacked. "asarUnpack": [ @@ -74,26 +74,26 @@ "NSCameraUseContinuityCameraDeviceType": true } }, - "linux": { - "target": [ - "AppImage", - "deb", - "pacman" - ], - "icon": "icons/icons/png", - "artifactName": "${productName}-Linux-${version}.${ext}", - "maintainer": "Etienne Lescot ", - "category": "AudioVideo" - }, - "win": { - "target": [ - "nsis" - ], - "icon": "icons/icons/win/icon.ico", - "artifactName": "${productName}.Setup.${version}-${arch}.${ext}", - "extraResources": [ - { - "from": "electron/native/bin", + "linux": { + "target": [ + "AppImage", + "deb", + "pacman" + ], + "icon": "icons/icons/png", + "artifactName": "${productName}-Linux-${version}.${ext}", + "maintainer": "Etienne Lescot ", + "category": "AudioVideo" + }, + "win": { + "target": [ + "nsis" + ], + "icon": "icons/icons/win/icon.ico", + "artifactName": "${productName}.Setup.${version}-${arch}.${ext}", + "extraResources": [ + { + "from": "electron/native/bin", "to": "electron/native/bin", "filter": ["win32-*/*"] } From ec629d5a38c76ff2bf8a87cb068a846cf4d76651 Mon Sep 17 00:00:00 2001 From: Christian Schmittel Date: Thu, 2 Jul 2026 12:35:30 +0200 Subject: [PATCH 9/9] ci: fetch win32-arm64 sharp prebuilt for arm64 packaging (non-fatal) --- .github/workflows/build.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5d9b4e89f..e898260d7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -61,6 +61,17 @@ jobs: Write-Host "MSVC ARM64 build tools already present." } + - name: Ensure win32-arm64 sharp binary + # sharp (transitive via @xenova/transformers) only fetches a host-arch + # prebuilt at install time, so on the x64 runner the packaged arm64 app + # would otherwise ship an x64 sharp.node. Best-effort fetch of the + # win32-arm64 prebuilt; non-fatal so it never blocks the build. Verify + # the first arm64 release loads sharp if auto-captions use it. + if: matrix.arch == 'arm64' + continue-on-error: true + shell: bash + run: npm_config_platform=win32 npm_config_arch=arm64 npm rebuild sharp + - name: Cache caption assets uses: actions/cache@v4 with: