diff --git a/README.md b/README.md
index 0b4fe33d0..508393905 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@
A free, open-source desktop app for recording your screen and turning the result into polished product demos and walkthroughs.
-
+
diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md
index 6bfe88fb4..d53cfc019 100644
--- a/THIRD-PARTY-NOTICES.md
+++ b/THIRD-PARTY-NOTICES.md
@@ -71,7 +71,7 @@ distributed by their own registries, not redistributed inside our binaries.
- **Components**: `selfie_segmentation.tflite`,
`selfie_segmentation_landscape.tflite` and the `selfie_segmentation_landscape.onnx`
- derived from them, shipped inside `app.asar` under `dist/mediapipe/`.
+ derived from them, shipped under `resources/mediapipe/`.
- **License**: Apache-2.0 — .
Copyright The MediaPipe Authors.
- The `.onnx` is a **derived work**, generated from the vendored `.tflite` by
diff --git a/build/com.getopenscreen.OpenScreen.metainfo.xml b/build/com.getopenscreen.OpenScreen.metainfo.xml
index 4281aafa4..dec91da2b 100644
--- a/build/com.getopenscreen.OpenScreen.metainfo.xml
+++ b/build/com.getopenscreen.OpenScreen.metainfo.xml
@@ -87,11 +87,11 @@
-->
- https://raw.githubusercontent.com/getopenscreen/openscreen/main/public/preview4.png
+ https://raw.githubusercontent.com/getopenscreen/openscreen/main/docs/assets/preview4.png
Editing a recording: zoom regions, video effects and export settings
- https://raw.githubusercontent.com/getopenscreen/openscreen/main/public/preview3.png
+ https://raw.githubusercontent.com/getopenscreen/openscreen/main/docs/assets/preview3.png
The timeline, with trim, speed and zoom regions on separate tracks
diff --git a/public/demo.gif b/docs/assets/demo.gif
similarity index 100%
rename from public/demo.gif
rename to docs/assets/demo.gif
diff --git a/public/preview3.png b/docs/assets/preview3.png
similarity index 100%
rename from public/preview3.png
rename to docs/assets/preview3.png
diff --git a/public/preview4.png b/docs/assets/preview4.png
similarity index 100%
rename from public/preview4.png
rename to docs/assets/preview4.png
diff --git a/electron-builder.json5 b/electron-builder.json5
index dafe8c690..9127f0a9a 100644
--- a/electron-builder.json5
+++ b/electron-builder.json5
@@ -3,12 +3,6 @@
"$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json",
"appId": "com.etiennelescot.openscreen",
"asar": true,
- // .node binaries can't be dlopen'd from inside an asar — must live unpacked.
- // onnxruntime-node distributes as a `.node` shared object that whisper.cpp
- // helpers run from process spawn, so they all unpack via the same glob.
- "asarUnpack": [
- "**/*.node"
- ],
"productName": "Openscreen",
// There is deliberately no `electronVersion` here. electron-builder needs an EXACT
// version because it downloads the binaries for one release, and it takes that from
@@ -59,6 +53,28 @@
// let electron-builder use the prebuilt instead of recompiling.
"buildDependenciesFromSource": false,
"compression": "normal",
+ // Strip unused Chromium locale pak files (saves ~20 MB). Only package locales supported by OpenScreen.
+ // Note: macOS uses underscore variants (e.g. pt_BR.lproj, zh_CN.lproj, zh_TW.lproj), while Windows
+ // and Linux use hyphenated pak files (e.g. pt-BR.pak, zh-CN.pak, zh-TW.pak). Both forms are listed
+ // so app-builder-lib retains the correct locale assets across all platforms.
+ "electronLanguages": [
+ "en-US",
+ "fr",
+ "ar",
+ "es",
+ "it",
+ "ja",
+ "ko",
+ "pt-BR",
+ "pt_BR",
+ "ru",
+ "tr",
+ "vi",
+ "zh-CN",
+ "zh_CN",
+ "zh-TW",
+ "zh_TW"
+ ],
"directories": {
"output": "release/${version}",
// Electron-builder's default, spelled out because the appx target depends on it:
@@ -72,6 +88,12 @@
"files": [
"dist",
"dist-electron",
+ // Exclude assets already bundled into extraResources (saves ~17 MB duplicate in asar)
+ "!dist/wallpapers/**",
+ "!dist/cursors/**",
+ "!dist/mediapipe/**",
+ // Exclude dead-weight node_modules: all frontend and electron code is pre-bundled by Vite (saves ~238 MB)
+ "!node_modules/**",
"!*.png",
"!preview*.png",
"!*.md",
diff --git a/electron/media/audioPeaks.test.ts b/electron/media/audioPeaks.test.ts
index 2a455bfc3..485fbb034 100644
--- a/electron/media/audioPeaks.test.ts
+++ b/electron/media/audioPeaks.test.ts
@@ -1,5 +1,5 @@
// @vitest-environment node
-import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
@@ -7,6 +7,48 @@ import { ffmpegCandidates, peakBlockCount, resolveFfmpeg } from "./audioPeaks";
const ROOT = path.resolve(__dirname, "..", "..");
+function stripJson5Comments(source: string): string {
+ let out = "";
+ let inString = false;
+ for (let i = 0; i < source.length; i++) {
+ const ch = source[i];
+ if (inString) {
+ out += ch;
+ if (ch === "\\") {
+ out += source[++i] ?? "";
+ } else if (ch === '"') {
+ inString = false;
+ }
+ continue;
+ }
+ if (ch === '"') {
+ inString = true;
+ out += ch;
+ continue;
+ }
+ if (ch === "/" && source[i + 1] === "/") {
+ while (i < source.length && source[i] !== "\n") i++;
+ out += "\n";
+ continue;
+ }
+ out += ch;
+ }
+ return out;
+}
+
+function objectBody(source: string, key: string): string | null {
+ const opener = new RegExp(`"${key}"\\s*:\\s*{`).exec(source);
+ if (!opener) return null;
+ let depth = 0;
+ for (let i = opener.index + opener[0].length - 1; i < source.length; i++) {
+ if (source[i] === "{") depth++;
+ else if (source[i] === "}" && --depth === 0) {
+ return source.slice(opener.index + opener[0].length, i);
+ }
+ }
+ return null;
+}
+
describe("peakBlockCount", () => {
it("matches the browser pipelines' block maths", () => {
// Same formula as audioPeaksWorker.ts / streamingAudioPeaks.ts: a clip must
@@ -41,6 +83,33 @@ describe("ffmpeg resolution", () => {
}
});
+ it("ensures the Windows installer filter packages ffmpeg-shared.exe", () => {
+ const configSource = readFileSync(path.join(ROOT, "electron-builder.json5"), "utf8");
+ const stripped = stripJson5Comments(configSource);
+ const winBlock = objectBody(stripped, "win");
+ expect(winBlock, "electron-builder.json5 must declare a win block").toBeTruthy();
+ const winFilterMatch = winBlock!.match(/"filter"\s*:\s*\[([^\]]+)\]/);
+ expect(winFilterMatch, "win.extraResources must declare a filter").toBeTruthy();
+ const filters = winFilterMatch![1]
+ .split(",")
+ .map((f: string) => f.trim().replace(/^["']|["']$/g, ""));
+ // Static ffmpeg.exe must be excluded
+ expect(filters).toContain("!win32-*/ffmpeg.exe");
+ // ffmpeg-shared.exe must NOT be excluded
+ expect(filters).not.toContain("!win32-*/ffmpeg-shared.exe");
+ // Verify win32 candidate survives the filter rules
+ const relativePath = "win32-x64/ffmpeg-shared.exe";
+ const isIncluded = filters.some(
+ (f: string) =>
+ !f.startsWith("!") && new RegExp(`^${f.replace(/\*/g, ".*")}$`).test(relativePath),
+ );
+ const isExcluded = filters.some(
+ (f: string) =>
+ f.startsWith("!") && new RegExp(`^${f.slice(1).replace(/\*/g, ".*")}$`).test(relativePath),
+ );
+ expect(isIncluded && !isExcluded).toBe(true);
+ });
+
it("honours the env override first", () => {
process.env.OPENSCREEN_FFMPEG_PATH = "/custom/ffmpeg";
try {
diff --git a/scripts/i18n-check.mjs b/scripts/i18n-check.mjs
index b37af68c9..cbe2ad5f7 100644
--- a/scripts/i18n-check.mjs
+++ b/scripts/i18n-check.mjs
@@ -7,6 +7,7 @@
*/
import fs from "node:fs";
import path from "node:path";
+import { objectBody, stripJson5Comments } from "./macos-floor.mjs";
const LOCALES_DIR = path.resolve("src/i18n/locales");
const BASE_LOCALE = "en";
@@ -77,11 +78,117 @@ for (const namespace of namespaces) {
}
}
+// Validate that SUPPORTED_LOCALES (src/i18n/config.ts), appx.languages, and
+// electronLanguages (electron-builder.json5) all agree on the supported locales.
+function readSupportedLocales() {
+ const configContent = fs.readFileSync(path.resolve("src/i18n/config.ts"), "utf-8");
+ const match = configContent.match(/SUPPORTED_LOCALES\s*=\s*\[([\s\S]*?)\]\s*as\s*const/);
+ if (!match) throw new Error("Could not find SUPPORTED_LOCALES in src/i18n/config.ts");
+ return match[1]
+ .split(",")
+ .map((s) => s.trim().replace(/^["']|["']$/g, ""))
+ .filter(Boolean);
+}
+
+function readBuilderLanguages() {
+ const content = fs.readFileSync(path.resolve("electron-builder.json5"), "utf-8");
+ const stripped = stripJson5Comments(content);
+
+ const electronLanguagesMatch = stripped.match(/"electronLanguages"\s*:\s*\[([\s\S]*?)\]/);
+ if (!electronLanguagesMatch)
+ throw new Error("Could not find electronLanguages in electron-builder.json5");
+ const electronLanguages = electronLanguagesMatch[1]
+ .split(",")
+ .map((s) => s.trim().replace(/^["']|["']$/g, ""))
+ .filter(Boolean);
+
+ const appxBlock = objectBody(stripped, "appx");
+ if (!appxBlock) throw new Error("Could not find appx block in electron-builder.json5");
+ const appxLanguagesMatch = appxBlock.match(/"languages"\s*:\s*\[([\s\S]*?)\]/);
+ if (!appxLanguagesMatch)
+ throw new Error("Could not find appx.languages in electron-builder.json5");
+ const appxLanguages = appxLanguagesMatch[1]
+ .split(",")
+ .map((s) => s.trim().replace(/^["']|["']$/g, ""))
+ .filter(Boolean);
+
+ return { electronLanguages, appxLanguages };
+}
+
+const supportedLocales = readSupportedLocales();
+const { electronLanguages, appxLanguages } = readBuilderLanguages();
+
+// 1. Check all disk locales in src/i18n/locales match SUPPORTED_LOCALES
+const diskLocales = [BASE_LOCALE, ...compareLocales].sort();
+const sortedSupported = [...supportedLocales].sort();
+
+const missingOnDisk = sortedSupported.filter((l) => !diskLocales.includes(l));
+const extraOnDisk = diskLocales.filter((l) => !sortedSupported.includes(l));
+
+if (missingOnDisk.length > 0) {
+ console.error(
+ `MISSING on disk (declared in SUPPORTED_LOCALES but no folder in src/i18n/locales): ${missingOnDisk.join(", ")}`,
+ );
+ hasErrors = true;
+}
+if (extraOnDisk.length > 0) {
+ console.error(
+ `EXTRA on disk (folder in src/i18n/locales but missing in SUPPORTED_LOCALES): ${extraOnDisk.join(", ")}`,
+ );
+ hasErrors = true;
+}
+
+// Packaging requirements differ across distribution channels:
+// - Microsoft Store (AppX) requires BCP-47 tags (en-US, fr-FR)
+// - Chromium pak files use bare tags for ja/ko (ja.pak, ko.pak) and hyphens (zh-CN, pt-BR)
+// - macOS .lproj folders require underscore variants (zh_CN, pt_BR, zh_TW)
+// Any locale not in this table defaults to its bare tag for both channels.
+const LOCALE_PACKAGING_OVERRIDES = {
+ en: { appx: "en-US", electron: ["en-US"] },
+ fr: { appx: "fr-FR", electron: ["fr"] },
+ "ja-JP": { appx: "ja-JP", electron: ["ja"] },
+ "ko-KR": { appx: "ko-KR", electron: ["ko"] },
+ "pt-BR": { appx: "pt-BR", electron: ["pt-BR", "pt_BR"] },
+ "zh-CN": { appx: "zh-CN", electron: ["zh-CN", "zh_CN"] },
+ "zh-TW": { appx: "zh-TW", electron: ["zh-TW", "zh_TW"] },
+};
+
+function getPackagingTags(locale) {
+ return LOCALE_PACKAGING_OVERRIDES[locale] ?? { appx: locale, electron: [locale] };
+}
+
+function assertListsMatch(actual, expected, label) {
+ for (const tag of expected) {
+ if (!actual.includes(tag)) {
+ console.error(`MISSING in electron-builder.json5 ${label}: "${tag}"`);
+ hasErrors = true;
+ }
+ }
+ for (const tag of actual) {
+ if (!expected.includes(tag)) {
+ console.error(
+ `EXTRA in electron-builder.json5 ${label}: "${tag}" (not in SUPPORTED_LOCALES)`,
+ );
+ hasErrors = true;
+ }
+ }
+}
+
+// 2. Check appx.languages matches SUPPORTED_LOCALES
+const expectedAppxLanguages = supportedLocales.map((l) => getPackagingTags(l).appx);
+assertListsMatch(appxLanguages, expectedAppxLanguages, "appx.languages");
+
+// 3. Check electronLanguages matches SUPPORTED_LOCALES
+const expectedElectronLanguages = supportedLocales.flatMap((l) => getPackagingTags(l).electron);
+assertListsMatch(electronLanguages, expectedElectronLanguages, "electronLanguages");
+
if (hasErrors) {
- console.error("\ni18n check FAILED — translation files are out of sync.");
+ console.error(
+ "\ni18n check FAILED — translation files or packaging locale lists are out of sync.",
+ );
process.exit(1);
} else {
console.log(
- `i18n check PASSED — all ${compareLocales.length} locales match ${BASE_LOCALE} across ${namespaces.length} namespaces.`,
+ `i18n check PASSED — all ${compareLocales.length} locales match ${BASE_LOCALE} across ${namespaces.length} namespaces, and all ${supportedLocales.length} SUPPORTED_LOCALES align with appx.languages and electronLanguages.`,
);
}
diff --git a/scripts/macos-floor.mjs b/scripts/macos-floor.mjs
index 347685bc6..4edcb6e0a 100644
--- a/scripts/macos-floor.mjs
+++ b/scripts/macos-floor.mjs
@@ -17,7 +17,7 @@
* String-aware rather than a plain `s.replace(/\/\/.*$/gm, "")` because the config also
* carries URLs, whose `//` a naive strip would eat.
*/
-function stripJson5Comments(source) {
+export function stripJson5Comments(source) {
let out = "";
let inString = false;
for (let i = 0; i < source.length; i++) {
@@ -47,7 +47,7 @@ function stripJson5Comments(source) {
}
/** The body of a top-level `"": { ... }` object, brace-matched. */
-function objectBody(source, key) {
+export function objectBody(source, key) {
const opener = new RegExp(`"${key}"\\s*:\\s*{`).exec(source);
if (!opener) {
return null;
diff --git a/technical-documentation/engineering/build-and-packaging.md b/technical-documentation/engineering/build-and-packaging.md
index 65a437712..81007212e 100644
--- a/technical-documentation/engineering/build-and-packaging.md
+++ b/technical-documentation/engineering/build-and-packaging.md
@@ -223,6 +223,54 @@ The check compares modification times, so `git checkout` (which restamps source
Diagnosing a suspected stale addon: serde embeds its field-name literals in the compiled binary, so `grep -c compositor_view.node` returning 0 means the binary predates that contract.
+## ASAR layout and packaging optimization
+
+`electron-builder.json5` configures package contents, file exclusions, and resource distribution. Several deliberate optimizations reduce installer payload size and eliminate redundant file copies across platforms.
+
+### Node modules excluded from ASAR
+
+All renderer application code and Electron main/preload entry points are pre-bundled by Vite into `dist/` and `dist-electron/`. The bundled main process relies solely on Node built-ins and `electron`. As a result, `node_modules/**` is excluded from `files` in `electron-builder.json5`, eliminating ~238 MB of redundant dependencies from `app.asar`.
+
+### Asset deduplication and extraResources
+
+Assets requiring direct filesystem access are distributed via `extraResources` rather than bundled inside `app.asar`:
+
+- Dynamic scene assets (`wallpapers/`, `cursors/`) resolve via `ASSET_BASE_DIR` (`process.resourcesPath`) in the renderer and `sceneAssetBaseDirs()` in the main process.
+- Background segmentation models (`dist/mediapipe/`) are placed in `resources/mediapipe/` for access by the segmentation worker.
+
+These directories are excluded from `files` (`!dist/wallpapers/**`, `!dist/cursors/**`, `!dist/mediapipe/**`), avoiding ~17 MB of duplicate files inside the ASAR archive.
+
+### Native addons ship outside ASAR
+
+Native `.node` addons cannot be loaded directly from an ASAR archive. The native compositor addon (`compositor_view.node`) and ONNX Runtime libraries are colocated with their linked FFmpeg/shared libraries under `electron/native/bin/-/` and distributed via `extraResources`. Because no `.node` binary travels through `files`, `asarUnpack: ["**/*.node"]` is unnecessary.
+
+### Windows FFmpeg runtime binaries
+
+On Windows, `scripts/fetch-ffmpeg.mjs` stages two artifacts: the shared `av*.dll` set required by the D3D11 compositor addon, and `ffmpeg-shared.exe` (~1 MB), which links against those same DLLs.
+
+`electron-builder.json5` explicitly excludes the standalone static `ffmpeg.exe` (`!win32-*/ffmpeg.exe`, ~109 MB), while retaining `ffmpeg-shared.exe` via `win32-*/*`. This shared executable is spawned at runtime by:
+- `electron/media/audioPeaks.ts` (`getAudioPeaks`): waveform peak extraction for timeline audio.
+- `electron/stt/extractAudio.ts`: 16 kHz mono WAV audio extraction for Whisper speech-to-text.
+- `electron/media/extensionClip.ts`: silence padding generation for AI word extension clips.
+
+`electron/media/audioPeaks.test.ts` validates that the `win.extraResources` filter in `electron-builder.json5` continues to package `ffmpeg-shared.exe`.
+
+### Locale pruning
+
+Chromium packages ~55 locale `.pak` files and macOS `.lproj` directories, totaling over 20 MB of unneeded translations. `electronLanguages` restricts the packaged locales to OpenScreen's 13 supported languages.
+
+Because macOS `ElectronFramework` directory matching uses underscores (`pt_BR.lproj`, `zh_CN.lproj`, `zh_TW.lproj`) while Windows and Linux `.pak` files use hyphens (`pt-BR.pak`, `zh-CN.pak`, `zh-TW.pak`), both forms are declared in `electronLanguages` to prevent `removeUnusedLanguagesIfNeeded` from deleting supported locales on macOS.
+
+`scripts/i18n-check.mjs` (`npm run i18n:check`) verifies that `SUPPORTED_LOCALES` in `src/i18n/config.ts`, `appx.languages`, and `electronLanguages` in `electron-builder.json5` remain synchronized.
+
+### Documentation assets outside `public/`
+
+Marketing and documentation assets (`demo.gif`, `preview*.png`) reside in `docs/assets/` rather than Vite's `public/` directory. This prevents Vite's dev server and build step from copying ~8 MB of documentation media into `dist/`.
+
+### Compression configuration
+
+`electron-builder.json5` declares `compression: "normal"`. For Windows NSIS installers, electron-builder's differential packaging options enforce normal, non-solid compression with a 1 MB dictionary (`dictSize = 1`, `solid = false`). Setting `"maximum"` has no effect on Windows installers and only increases build times for Linux AppImage targets without meaningful size reductions.
+
## Platform packaging
### Windows