Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<p align="center"><strong>A free, open-source desktop app for recording your screen and turning the result into polished product demos and walkthroughs.</strong></p>

<p align="center">
<img src="public/demo.gif" alt="Editing a recording in OpenScreen: wallpaper and video effects, an AI-assisted cut driven from the chat, then export" width="100%" />
<img src="docs/assets/demo.gif" alt="Editing a recording in OpenScreen: wallpaper and video effects, an AI-assisted cut driven from the chat, then export" width="100%" />
</p>

<p align="center">
Expand Down
2 changes: 1 addition & 1 deletion THIRD-PARTY-NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 — <https://google.github.io/mediapipe/solutions/selfie_segmentation>.
Copyright The MediaPipe Authors.
- The `.onnx` is a **derived work**, generated from the vendored `.tflite` by
Expand Down
4 changes: 2 additions & 2 deletions build/com.getopenscreen.OpenScreen.metainfo.xml
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,11 @@
-->
<screenshots>
<screenshot type="default">
<image type="source">https://raw.githubusercontent.com/getopenscreen/openscreen/main/public/preview4.png</image>
<image type="source">https://raw.githubusercontent.com/getopenscreen/openscreen/main/docs/assets/preview4.png</image>
<caption>Editing a recording: zoom regions, video effects and export settings</caption>
</screenshot>
<screenshot>
<image type="source">https://raw.githubusercontent.com/getopenscreen/openscreen/main/public/preview3.png</image>
<image type="source">https://raw.githubusercontent.com/getopenscreen/openscreen/main/docs/assets/preview3.png</image>
<caption>The timeline, with trim, speed and zoom regions on separate tracks</caption>
</screenshot>
</screenshots>
Expand Down
File renamed without changes
File renamed without changes
File renamed without changes
34 changes: 28 additions & 6 deletions electron-builder.json5
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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": [
Comment thread
atharva9167j marked this conversation as resolved.
"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:
Expand All @@ -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/**",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Exclude dead-weight node_modules: all frontend and electron code is pre-bundled by Vite (saves ~238 MB)
"!node_modules/**",
"!*.png",
"!preview*.png",
"!*.md",
Expand Down
71 changes: 70 additions & 1 deletion electron/media/audioPeaks.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,54 @@
// @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";
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
Expand Down Expand Up @@ -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 {
Expand Down
111 changes: 109 additions & 2 deletions scripts/i18n-check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.`,
);
}
4 changes: 2 additions & 2 deletions scripts/macos-floor.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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++) {
Expand Down Expand Up @@ -47,7 +47,7 @@ function stripJson5Comments(source) {
}

/** The body of a top-level `"<key>": { ... }` 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;
Expand Down
48 changes: 48 additions & 0 deletions technical-documentation/engineering/build-and-packaging.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <newCamelCaseField> 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/<platform>-<arch>/` 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
Expand Down
Loading