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.

- Editing a recording in OpenScreen: wallpaper and video effects, an AI-assisted cut driven from the chat, then export + Editing a recording in OpenScreen: wallpaper and video effects, an AI-assisted cut driven from the chat, then export

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