From 2178e5cb6675381eefb71ebd3b7c870440e7e5d1 Mon Sep 17 00:00:00 2001 From: Dodzi Agbenorku Date: Tue, 25 Aug 2026 17:32:22 +0100 Subject: [PATCH 1/6] Add repository agent guidance Document the verified quality baseline, platform setup gotchas, and preview/export invariants so future work does not miss native or GIF paths. --- AGENTS.md | 100 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..e5fa7a095 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,100 @@ +# Recordly working agreement + +## Scope + +Recordly is an Electron desktop screen recorder/editor. The renderer is React + TypeScript; +PixiJS and canvas compose previews and exported frames. Capture and fast export also have +platform-native helpers. Changes that look renderer-only can therefore affect Windows, +macOS, Linux, MP4, and GIF differently. + +## Toolchain and setup + +- Use Node.js 22, matching `.github/workflows/quality.yml` and build/release CI. +- For quality-only work, install exactly as CI does: `npm ci --ignore-scripts`. +- A normal `npm install` runs `scripts/postinstall.mjs`, which rebuilds `uiohook-napi` and + all platform helpers. Do not use it merely to run TypeScript tests; on Windows it can + invoke Visual Studio/CMake and overwrite tracked helper binaries/manifests. +- `npm run dev` starts the Vite/Electron development app (not verified in this checkout; + camera and runtime smoke require an interactive desktop session). +- `npm run build` builds native helpers, typechecks, bundles, smokes the Electron main + entry, and packages the current platform (not verified locally; this is a heavy + platform build rather than the normal inner-loop check). + +On this machine, the PowerShell `npm` shim may fail with: + +`Cannot find module 'C:\Users\dodzi\AppData\Roaming\npm\node_modules\npm\bin\npm-cli.js'` + +That is a broken npm-prefix/shim resolution, not a Recordly failure. Use a repaired Node +22 installation. As a temporary diagnostic workaround, invoke the installed CLI directly: + +`node "C:\Program Files\nodejs\node_modules\npm\bin\npm-cli.js" ` + +## Verified quality commands + +Run these from the repository root: + +- `npx tsc --noEmit` — verified passing on 2026-08-25. +- `npm run lint` — verified exit 0 on 2026-08-25; it currently reports existing hook + dependency warnings, so inspect new warnings in touched files. +- `npm test` — verified on 2026-08-25: 111 files passed, 1020 tests passed, 1 skipped. +- `npm run i18n:check` — verified passing on 2026-08-25. +- `npm run format:check` — verified failing on 2026-08-25 with 122 pre-existing format + errors. CI deliberately treats this check as advisory. Do not bulk-format unrelated + files to make a focused change pass; format touched code and report the baseline debt. + +If Vitest/esbuild fails under an agent sandbox with `Cannot read directory "../../..": +Access is denied` and `Could not resolve ... vitest.config.ts`, rerun the same test outside +the filesystem sandbox. Otherwise a sandbox boundary looks like a code/test failure. + +Windows Application Control can block freshly built executables or test DLLs with +`0x800711C7` / `Application Control policy has blocked this file`. Rebuild and rerun; +changing product code in response can hide an environmental failure. + +## Rendering and export invariants + +- Preview/export parity is a product requirement. Editor webcam preview is a DOM video + layer in `VideoPlayback.tsx`; MP4/WebCodecs uses `modernFrameRenderer.ts`; GIF uses + `frameRenderer.ts`. A webcam visual effect is incomplete until all three agree. +- `ModernVideoExporter` can bypass JavaScript frame composition through native static- + layout exporters. When adding an effect unsupported by the native compositors, add an + explicit native skip reason and test it; otherwise preview is correct but some MP4 + exports silently omit the effect. +- Webcam media is recorded as a separate sidecar with `timeOffsetMs`. Preserve raw media + and the synchronization logic in `videoPlayback/webcamSync.ts`; destructive processing + at capture time removes editability and risks screen/webcam drift. +- Extensions have ordered render hooks, including `post-webcam`. Preserve hook ordering + when moving webcam composition, or extensions will render on the wrong layer. +- Preview and export intentionally have fallback paths for decoder/media-element and + Pixi WebGPU/WebGL failures. New effects must fail soft (unprocessed webcam plus warning) + instead of dropping the webcam or aborting an export. + +## Project and settings compatibility + +- `.recordly` projects persist `WebcamOverlaySettings`. Add new webcam fields with safe + defaults in `types.ts` and normalization in `projectPersistence.ts`; old projects must + continue to load without a version bump unless the wire shape truly becomes incompatible. +- Keep settings non-destructive: raw webcam footage remains the source of truth and editor + controls determine rendering at preview/export time. +- User-visible settings keys must exist in every locale. Run `npm run i18n:check`; missing + parity means some localized settings panels show fallback/missing text. +- `LICENSE.md` and the README identify Recordly as AGPL-3.0; `CONTRIBUTING.md` currently + and incorrectly points contributors to an MIT `LICENSE` path. Treat the canonical license + file as authoritative, and verify the license and redistribution terms of bundled ML + runtimes or model assets before committing them. + +## Native helper rules + +- Windows helper sources and the tracked binaries under `electron/native/bin/win32-x64` + are tied to `helpers-manifest.json` fingerprints. Use the corresponding + `scripts/build-*.mjs` command after source changes; hand-copying a binary without the + manifest makes builds reject or unknowingly reuse stale helpers. +- Full package validation is platform-specific. Follow `.github/workflows/build.yml` for + FFmpeg installation, Electron native dependency rebuilds, packaging targets, and + `npm run smoke:packaged-binaries` rather than inventing a local release sequence. + +## Onboarding provenance + +This guide was derived from repository files and freshly run commands on 2026-08-25. +No Recordly-specific auto-memory entries existed, and the repository owner had just forked +the project, so there is no human-supplied tribal-knowledge tier yet. Add only recurring, +expensive-to-rediscover lessons here as they emerge. From e2579e06955080f490889c5f97e8e1f3769a764b Mon Sep 17 00:00:00 2001 From: Dodzi Agbenorku Date: Tue, 25 Aug 2026 19:03:39 +0100 Subject: [PATCH 2/6] Add webcam blur state and offline assets Persist normalized blur settings across preferences, projects, and recording manifests. Bundle the lazy MediaPipe runtime assets and complete Apache notice for offline packaged use. --- electron/electron-env.d.ts | 4 + electron/ipc/project/session.test.ts | 66 +++ electron/ipc/project/session.ts | 18 +- electron/ipc/recordingPreferences.test.ts | 28 + electron/ipc/recordingPreferences.ts | 27 + electron/ipc/register/project.ts | 4 +- electron/ipc/register/settings.ts | 19 +- electron/ipc/types.ts | 4 + electron/preload.ts | 1 + electron/windows.ts | 15 +- package-lock.json | 483 +++++++----------- package.json | 6 + public/THIRD_PARTY_NOTICES.txt | 212 ++++++++ scripts/smoke-webcam-segmentation-assets.mjs | 30 ++ .../video-editor/editorPreferences.test.ts | 26 + .../video-editor/projectDirtyState.test.ts | 27 + .../video-editor/projectPersistence.test.ts | 16 + .../video-editor/projectPersistence.ts | 2 + src/components/video-editor/types.ts | 7 + src/hooks/useScreenRecorder.ts | 26 +- src/lib/webcamBackgroundBlur.test.ts | 27 + src/lib/webcamBackgroundBlur.ts | 32 ++ src/lib/webcamSegmentationAssets.test.ts | 18 + tsconfig.node.json | 2 +- vite.config.ts | 8 + webcamSegmentationAssets.ts | 77 +++ 26 files changed, 860 insertions(+), 325 deletions(-) create mode 100644 electron/ipc/project/session.test.ts create mode 100644 electron/ipc/recordingPreferences.test.ts create mode 100644 electron/ipc/recordingPreferences.ts create mode 100644 public/THIRD_PARTY_NOTICES.txt create mode 100644 scripts/smoke-webcam-segmentation-assets.mjs create mode 100644 src/lib/webcamBackgroundBlur.test.ts create mode 100644 src/lib/webcamBackgroundBlur.ts create mode 100644 src/lib/webcamSegmentationAssets.test.ts create mode 100644 webcamSegmentationAssets.ts diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 8a4eedd63..a2808ee91 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -718,6 +718,7 @@ interface Window { webcamPath?: string | null; timeOffsetMs?: number; hideOverlayCursorByDefault?: boolean; + webcamBackgroundBlur?: { enabled: boolean; amount: number }; }, options?: { preserveProjectPath?: boolean }, ) => Promise<{ success: boolean }>; @@ -728,6 +729,7 @@ interface Window { webcamPath?: string | null; timeOffsetMs?: number; hideOverlayCursorByDefault?: boolean; + webcamBackgroundBlur?: { enabled: boolean; amount: number }; }; }>; getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>; @@ -881,6 +883,7 @@ interface Window { microphoneEnabled: boolean; microphoneDeviceId?: string; systemAudioEnabled: boolean; + webcamBackgroundBlur: { enabled: boolean; amount: number }; }>; getRecordingAudioLabConfig: () => Promise<{ browserMicrophoneProfile: string; @@ -890,6 +893,7 @@ interface Window { microphoneEnabled?: boolean; microphoneDeviceId?: string; systemAudioEnabled?: boolean; + webcamBackgroundBlur?: { enabled: boolean; amount: number }; }) => Promise<{ success: boolean; error?: string }>; /** Countdown timer before recording */ getCountdownDelay: () => Promise<{ success: boolean; delay: number }>; diff --git a/electron/ipc/project/session.test.ts b/electron/ipc/project/session.test.ts new file mode 100644 index 000000000..e7268519c --- /dev/null +++ b/electron/ipc/project/session.test.ts @@ -0,0 +1,66 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("electron", () => ({ app: { getPath: () => "C:\\RecordlyTest" } })); +import { + getRecordingSessionManifestPath, + persistRecordingSessionManifest, + resolveRecordingSessionManifest, +} from "./session"; + +const temporaryDirectories: string[] = []; + +async function createRecordingFixture() { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-blur-session-")); + temporaryDirectories.push(directory); + const videoPath = path.join(directory, "recording.mp4"); + const webcamPath = path.join(directory, "recording-webcam.webm"); + await Promise.all([fs.writeFile(videoPath, "video"), fs.writeFile(webcamPath, "webcam")]); + return { videoPath, webcamPath }; +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => fs.rm(directory, { recursive: true, force: true })), + ); +}); + +describe("recording session background blur", () => { + it("round-trips the optional blur snapshot in a version-2 manifest", async () => { + const { videoPath, webcamPath } = await createRecordingFixture(); + await persistRecordingSessionManifest({ + videoPath, + webcamPath, + timeOffsetMs: 25, + webcamBackgroundBlur: { enabled: true, amount: 17 }, + }); + + await expect(resolveRecordingSessionManifest(videoPath)).resolves.toMatchObject({ + videoPath, + webcamPath, + timeOffsetMs: 25, + webcamBackgroundBlur: { enabled: true, amount: 17 }, + }); + }); + + it("defaults old manifests to blur off", async () => { + const { videoPath, webcamPath } = await createRecordingFixture(); + await fs.writeFile( + getRecordingSessionManifestPath(videoPath), + JSON.stringify({ + version: 2, + videoFileName: path.basename(videoPath), + webcamFileName: path.basename(webcamPath), + }), + "utf-8", + ); + + await expect(resolveRecordingSessionManifest(videoPath)).resolves.toMatchObject({ + webcamBackgroundBlur: { enabled: false, amount: 12 }, + }); + }); +}); diff --git a/electron/ipc/project/session.ts b/electron/ipc/project/session.ts index 3c126e6d6..b7df4203c 100644 --- a/electron/ipc/project/session.ts +++ b/electron/ipc/project/session.ts @@ -1,6 +1,7 @@ import { constants as fsConstants } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; +import { normalizeWebcamBackgroundBlurSettings } from "../../../src/lib/webcamBackgroundBlur"; import { RECORDING_SESSION_MANIFEST_SUFFIX } from "../constants"; import type { RecordingSessionData, RecordingSessionManifest } from "../types"; import { normalizeVideoSourcePath, parseJsonWithByteOrderMark } from "../utils"; @@ -15,7 +16,9 @@ export function getRecordingSessionManifestPath(videoPath: string) { return path.join(path.dirname(videoPath), `${baseName}${RECORDING_SESSION_MANIFEST_SUFFIX}`); } -export async function persistRecordingSessionManifest(session: RecordingSessionData): Promise { +export async function persistRecordingSessionManifest( + session: RecordingSessionData, +): Promise { const normalizedVideoPath = normalizeVideoSourcePath(session.videoPath); if (!normalizedVideoPath) { return; @@ -34,6 +37,7 @@ export async function persistRecordingSessionManifest(session: RecordingSessionD videoFileName: path.basename(normalizedVideoPath), webcamFileName: path.basename(normalizedWebcamPath), timeOffsetMs: normalizeRecordingTimeOffsetMs(session.timeOffsetMs), + webcamBackgroundBlur: normalizeWebcamBackgroundBlurSettings(session.webcamBackgroundBlur), }; await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2), "utf-8"); @@ -51,8 +55,7 @@ export async function resolveRecordingSessionManifest( try { const content = await fs.readFile(manifestPath, "utf-8"); - const parsed = - parseJsonWithByteOrderMark>(content); + const parsed = parseJsonWithByteOrderMark>(content); if (parsed.version !== 1 && parsed.version !== 2) { return null; } @@ -67,6 +70,9 @@ export async function resolveRecordingSessionManifest( videoPath: normalizedVideoPath, webcamPath: null, timeOffsetMs: normalizeRecordingTimeOffsetMs(parsed.timeOffsetMs), + webcamBackgroundBlur: normalizeWebcamBackgroundBlurSettings( + parsed.webcamBackgroundBlur, + ), }; } @@ -80,6 +86,9 @@ export async function resolveRecordingSessionManifest( videoPath: normalizedVideoPath, webcamPath: webcamExists ? webcamPath : null, timeOffsetMs: normalizeRecordingTimeOffsetMs(parsed.timeOffsetMs), + webcamBackgroundBlur: normalizeWebcamBackgroundBlurSettings( + parsed.webcamBackgroundBlur, + ), }; } catch { return null; @@ -136,7 +145,6 @@ export async function resolveRecordingSession( return { videoPath: normalizedVideoPath, webcamPath: linkedWebcamPath, + webcamBackgroundBlur: normalizeWebcamBackgroundBlurSettings(undefined), }; } - - diff --git a/electron/ipc/recordingPreferences.test.ts b/electron/ipc/recordingPreferences.test.ts new file mode 100644 index 000000000..d1f4a2014 --- /dev/null +++ b/electron/ipc/recordingPreferences.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { normalizeRecordingPreferences } from "./recordingPreferences"; + +describe("normalizeRecordingPreferences", () => { + it("defaults legacy files to webcam blur off", () => { + expect(normalizeRecordingPreferences({ microphoneEnabled: true })).toEqual({ + microphoneEnabled: true, + microphoneDeviceId: undefined, + systemAudioEnabled: false, + webcamBackgroundBlur: { enabled: false, amount: 12 }, + }); + }); + + it("normalizes webcam blur without losing audio preferences", () => { + expect( + normalizeRecordingPreferences({ + microphoneDeviceId: "mic-1", + systemAudioEnabled: true, + webcamBackgroundBlur: { enabled: true, amount: 1_000 }, + }), + ).toEqual({ + microphoneEnabled: false, + microphoneDeviceId: "mic-1", + systemAudioEnabled: true, + webcamBackgroundBlur: { enabled: true, amount: 20 }, + }); + }); +}); diff --git a/electron/ipc/recordingPreferences.ts b/electron/ipc/recordingPreferences.ts new file mode 100644 index 000000000..901209158 --- /dev/null +++ b/electron/ipc/recordingPreferences.ts @@ -0,0 +1,27 @@ +import { + DEFAULT_WEBCAM_BACKGROUND_BLUR, + normalizeWebcamBackgroundBlurSettings, + type WebcamBackgroundBlurSettings, +} from "../../src/lib/webcamBackgroundBlur"; + +export interface RecordingPreferences { + microphoneEnabled: boolean; + microphoneDeviceId?: string; + systemAudioEnabled: boolean; + webcamBackgroundBlur: WebcamBackgroundBlurSettings; +} + +export function normalizeRecordingPreferences(value: unknown): RecordingPreferences { + const candidate = value && typeof value === "object" ? (value as Record) : {}; + return { + microphoneEnabled: candidate.microphoneEnabled === true, + microphoneDeviceId: + typeof candidate.microphoneDeviceId === "string" + ? candidate.microphoneDeviceId + : undefined, + systemAudioEnabled: candidate.systemAudioEnabled === true, + webcamBackgroundBlur: normalizeWebcamBackgroundBlurSettings( + candidate.webcamBackgroundBlur ?? DEFAULT_WEBCAM_BACKGROUND_BLUR, + ), + }; +} diff --git a/electron/ipc/register/project.ts b/electron/ipc/register/project.ts index f1fa43e26..a009c85d0 100644 --- a/electron/ipc/register/project.ts +++ b/electron/ipc/register/project.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import { constants as fsConstants } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; +import { normalizeWebcamBackgroundBlurSettings } from "../../../src/lib/webcamBackgroundBlur"; import { BrowserWindow, dialog, ipcMain, shell } from "electron"; import { RECORDINGS_DIR } from "../../appPaths"; import { buildMediaUrl, getMediaServerBaseUrl } from "../../mediaServer"; @@ -608,7 +609,7 @@ export function registerProjectHandlers() { return { success: true, webcamPath: nextSession.webcamPath ?? null } }) - ipcMain.handle('set-current-recording-session', async (_, session: { videoPath: string; webcamPath?: string | null; timeOffsetMs?: number; hideOverlayCursorByDefault?: boolean }, options?: { preserveProjectPath?: boolean }) => { + ipcMain.handle('set-current-recording-session', async (_, session: { videoPath: string; webcamPath?: string | null; timeOffsetMs?: number; hideOverlayCursorByDefault?: boolean; webcamBackgroundBlur?: unknown }, options?: { preserveProjectPath?: boolean }) => { const normalizedVideoPath = normalizeVideoSourcePath(session.videoPath) ?? session.videoPath setCurrentVideoPath(normalizedVideoPath) setCurrentRecordingSession({ @@ -616,6 +617,7 @@ export function registerProjectHandlers() { webcamPath: normalizeVideoSourcePath(session.webcamPath ?? null), timeOffsetMs: normalizeRecordingTimeOffsetMs(session.timeOffsetMs), hideOverlayCursorByDefault: normalizeBoolean(session.hideOverlayCursorByDefault), + webcamBackgroundBlur: normalizeWebcamBackgroundBlurSettings(session.webcamBackgroundBlur), }); await rememberApprovedLocalReadPath(currentRecordingSession!.videoPath) await rememberApprovedLocalReadPath(currentRecordingSession!.webcamPath) diff --git a/electron/ipc/register/settings.ts b/electron/ipc/register/settings.ts index e84f63171..73907edd3 100644 --- a/electron/ipc/register/settings.ts +++ b/electron/ipc/register/settings.ts @@ -19,6 +19,7 @@ import { setCountdownRemaining, setCountdownTimer, } from "../state"; +import { normalizeRecordingPreferences } from "../recordingPreferences"; import { parseJsonWithByteOrderMark } from "../utils"; const BROWSER_MICROPHONE_PROFILE_ENV = "RECORDLY_BROWSER_MIC_PROFILE"; @@ -146,22 +147,9 @@ export function registerSettingsHandlers() { try { const content = await fs.readFile(RECORDINGS_SETTINGS_FILE, "utf-8"); const parsed = parseJsonWithByteOrderMark>(content); - return { - success: true, - microphoneEnabled: parsed.microphoneEnabled === true, - microphoneDeviceId: - typeof parsed.microphoneDeviceId === "string" - ? parsed.microphoneDeviceId - : undefined, - systemAudioEnabled: parsed.systemAudioEnabled === true, - }; + return { success: true, ...normalizeRecordingPreferences(parsed) }; } catch { - return { - success: true, - microphoneEnabled: false, - microphoneDeviceId: undefined, - systemAudioEnabled: false, - }; + return { success: true, ...normalizeRecordingPreferences(undefined) }; } }); @@ -177,6 +165,7 @@ export function registerSettingsHandlers() { microphoneEnabled?: boolean; microphoneDeviceId?: string; systemAudioEnabled?: boolean; + webcamBackgroundBlur?: unknown; }, ) => { try { diff --git a/electron/ipc/types.ts b/electron/ipc/types.ts index 58f5425bd..307040c23 100644 --- a/electron/ipc/types.ts +++ b/electron/ipc/types.ts @@ -1,3 +1,5 @@ +import type { WebcamBackgroundBlurSettings } from "../../src/lib/webcamBackgroundBlur"; + export type SelectedSource = { id?: string; name: string; @@ -48,6 +50,7 @@ export type RecordingSessionData = { webcamPath?: string | null; timeOffsetMs?: number; hideOverlayCursorByDefault?: boolean; + webcamBackgroundBlur?: WebcamBackgroundBlurSettings; }; export type PauseSegment = { @@ -60,6 +63,7 @@ export type RecordingSessionManifest = { videoFileName: string; webcamFileName?: string | null; timeOffsetMs?: number; + webcamBackgroundBlur?: WebcamBackgroundBlurSettings; }; export type ProjectLibraryEntry = { diff --git a/electron/preload.ts b/electron/preload.ts index e55d42cbd..50e930fd2 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -972,6 +972,7 @@ contextBridge.exposeInMainWorld("electronAPI", { microphoneEnabled?: boolean; microphoneDeviceId?: string; systemAudioEnabled?: boolean; + webcamBackgroundBlur?: { enabled: boolean; amount: number }; }) => ipcRenderer.invoke("set-recording-preferences", prefs), getCountdownDelay: () => ipcRenderer.invoke("get-countdown-delay"), setCountdownDelay: (delay: number) => ipcRenderer.invoke("set-countdown-delay", delay), diff --git a/electron/windows.ts b/electron/windows.ts index cccb9e243..5b74bf838 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -289,9 +289,7 @@ function setHudOverlayFallbackExpanded(expanded: boolean) { function setHudOverlayMousePassthrough(ignore: boolean) { hudOverlayIgnoringMouse = - hudOverlaySourceSelectionActive && !hudOverlayRecordingActive - ? true - : ignore; + hudOverlaySourceSelectionActive && !hudOverlayRecordingActive ? true : ignore; if (hudOverlayMouseReassertTimer) { clearTimeout(hudOverlayMouseReassertTimer); @@ -610,9 +608,14 @@ export function createHudOverlayWindow(): BrowserWindow { if (VITE_DEV_SERVER_URL) { win.loadURL(VITE_DEV_SERVER_URL + "?windowType=hud-overlay"); } else { - win.loadFile(path.join(RENDERER_DIST, "index.html"), { - query: { windowType: "hud-overlay" }, - }); + const packagedRendererBaseUrl = getPackagedRendererBaseUrl(); + if (packagedRendererBaseUrl) { + win.loadURL(`${packagedRendererBaseUrl}/?windowType=hud-overlay`); + } else { + win.loadFile(path.join(RENDERER_DIST, "index.html"), { + query: { windowType: "hud-overlay" }, + }); + } } return win; diff --git a/package-lock.json b/package-lock.json index 89ab3d499..d99d4cf45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,12 @@ "version": "1.3.5-beta.2", "hasInstallScript": true, "dependencies": { + "@mediapipe/selfie_segmentation": "0.1.1675465747", "@phosphor-icons/react": "^2.1.10", + "@tensorflow-models/body-segmentation": "1.0.2", + "@tensorflow/tfjs-backend-webgl": "4.22.0", + "@tensorflow/tfjs-converter": "4.22.0", + "@tensorflow/tfjs-core": "4.22.0", "capturekit": "^1.0.13", "electron-updater": "^6.8.3", "ffmpeg-static": "^5.3.0", @@ -1780,6 +1785,12 @@ "node": ">= 10.0.0" } }, + "node_modules/@mediapipe/selfie_segmentation": { + "version": "0.1.1675465747", + "resolved": "https://registry.npmjs.org/@mediapipe/selfie_segmentation/-/selfie_segmentation-0.1.1675465747.tgz", + "integrity": "sha512-IxYxNhwE5VwOm52L1yoFWYLP7q9Pd+NJjzOC5tlepfvEGaY3o9hslhUrx9BgseqdfZtKSDtd/4NfCSMjNzQalA==", + "license": "Apache-2.0" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3129,6 +3140,94 @@ "node": ">=10" } }, + "node_modules/@tensorflow-models/body-segmentation": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@tensorflow-models/body-segmentation/-/body-segmentation-1.0.2.tgz", + "integrity": "sha512-sbPiL8wpqfKqh01of6qguZzU9yLWOQDTwiPEIHFPA/EAjz5T51LKHKIySll+mmteRo/TxNED8pxd9VJ6q2r7kg==", + "license": "Apache-2.0", + "dependencies": { + "rimraf": "^3.0.2" + }, + "peerDependencies": { + "@mediapipe/selfie_segmentation": "~0.1.0", + "@tensorflow/tfjs-backend-webgl": "^4.9.0", + "@tensorflow/tfjs-converter": "^4.9.0", + "@tensorflow/tfjs-core": "^4.9.0" + } + }, + "node_modules/@tensorflow/tfjs-backend-cpu": { + "version": "4.22.0", + "resolved": "https://registry.npmjs.org/@tensorflow/tfjs-backend-cpu/-/tfjs-backend-cpu-4.22.0.tgz", + "integrity": "sha512-1u0FmuLGuRAi8D2c3cocHTASGXOmHc/4OvoVDENJayjYkS119fcTcQf4iHrtLthWyDIPy3JiPhRrZQC9EwnhLw==", + "license": "Apache-2.0", + "dependencies": { + "@types/seedrandom": "^2.4.28", + "seedrandom": "^3.0.5" + }, + "engines": { + "yarn": ">= 1.3.2" + }, + "peerDependencies": { + "@tensorflow/tfjs-core": "4.22.0" + } + }, + "node_modules/@tensorflow/tfjs-backend-webgl": { + "version": "4.22.0", + "resolved": "https://registry.npmjs.org/@tensorflow/tfjs-backend-webgl/-/tfjs-backend-webgl-4.22.0.tgz", + "integrity": "sha512-H535XtZWnWgNwSzv538czjVlbJebDl5QTMOth4RXr2p/kJ1qSIXE0vZvEtO+5EC9b00SvhplECny2yDewQb/Yg==", + "license": "Apache-2.0", + "dependencies": { + "@tensorflow/tfjs-backend-cpu": "4.22.0", + "@types/offscreencanvas": "~2019.3.0", + "@types/seedrandom": "^2.4.28", + "seedrandom": "^3.0.5" + }, + "engines": { + "yarn": ">= 1.3.2" + }, + "peerDependencies": { + "@tensorflow/tfjs-core": "4.22.0" + } + }, + "node_modules/@tensorflow/tfjs-converter": { + "version": "4.22.0", + "resolved": "https://registry.npmjs.org/@tensorflow/tfjs-converter/-/tfjs-converter-4.22.0.tgz", + "integrity": "sha512-PT43MGlnzIo+YfbsjM79Lxk9lOq6uUwZuCc8rrp0hfpLjF6Jv8jS84u2jFb+WpUeuF4K33ZDNx8CjiYrGQ2trQ==", + "license": "Apache-2.0", + "peerDependencies": { + "@tensorflow/tfjs-core": "4.22.0" + } + }, + "node_modules/@tensorflow/tfjs-core": { + "version": "4.22.0", + "resolved": "https://registry.npmjs.org/@tensorflow/tfjs-core/-/tfjs-core-4.22.0.tgz", + "integrity": "sha512-LEkOyzbknKFoWUwfkr59vSB68DMJ4cjwwHgicXN0DUi3a0Vh1Er3JQqCI1Hl86GGZQvY8ezVrtDIvqR1ZFW55A==", + "license": "Apache-2.0", + "dependencies": { + "@types/long": "^4.0.1", + "@types/offscreencanvas": "~2019.7.0", + "@types/seedrandom": "^2.4.28", + "@webgpu/types": "0.1.38", + "long": "4.0.0", + "node-fetch": "~2.6.1", + "seedrandom": "^3.0.5" + }, + "engines": { + "yarn": ">= 1.3.2" + } + }, + "node_modules/@tensorflow/tfjs-core/node_modules/@types/offscreencanvas": { + "version": "2019.7.3", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", + "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", + "license": "MIT" + }, + "node_modules/@tensorflow/tfjs-core/node_modules/@webgpu/types": { + "version": "0.1.38", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.38.tgz", + "integrity": "sha512-7LrhVKz2PRh+DD7+S+PVaFd5HxaWQvoMqBbsV9fNJO1pjUs1P8bM2vQVNfk+3URTqbuTI7gkXi0rfsN0IadoBA==", + "license": "BSD-3-Clause" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -3286,6 +3385,12 @@ "@types/node": "*" } }, + "node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", + "license": "MIT" + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -3303,6 +3408,12 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/offscreencanvas": { + "version": "2019.3.0", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.3.0.tgz", + "integrity": "sha512-esIJx9bQg+QYF0ra8GnvfianIY8qWB0GBx54PK5Eps6m+xTj86KLavHv6qDhzKcu5UUOgNfJ2pWaIIV7TRUd9Q==", + "license": "MIT" + }, "node_modules/@types/plist": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz", @@ -3353,6 +3464,12 @@ "@types/node": "*" } }, + "node_modules/@types/seedrandom": { + "version": "2.4.34", + "resolved": "https://registry.npmjs.org/@types/seedrandom/-/seedrandom-2.4.34.tgz", + "integrity": "sha512-ytDiArvrn/3Xk6/vtylys5tlY6eo7Ane0hvcx++TKo6RxQXuVfW0AF/oeWqAj9dN29SyhtawuXstgmPlwNcv/A==", + "license": "MIT" + }, "node_modules/@types/verror": { "version": "1.10.11", "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", @@ -4092,7 +4209,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, "license": "MIT" }, "node_modules/base64-js": { @@ -4694,7 +4810,6 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, "license": "MIT" }, "node_modules/convert-source-map": { @@ -4880,18 +4995,6 @@ "node": ">=0.4.0" } }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, "node_modules/detect-node": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", @@ -5884,7 +5987,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, "license": "ISC" }, "node_modules/fsevents": { @@ -6019,7 +6121,6 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -6053,7 +6154,6 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -6064,7 +6164,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -6370,7 +6469,6 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, "license": "ISC", "dependencies": { "once": "^1.3.0", @@ -6624,280 +6722,6 @@ "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", "license": "MIT" }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, - "license": "MPL-2.0", - "optional": true, - "peer": true, - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -6938,6 +6762,12 @@ "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", "license": "MIT" }, + "node_modules/long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", + "license": "Apache-2.0" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -7282,6 +7112,26 @@ "license": "MIT", "optional": true }, + "node_modules/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-StxNAxh15zr77QvvkmveSQ8uCQ4+v5FkvNTj0OESmiHu+VRi/gXArXtkWMElOsOUNLtUEvI4yS+rdtOHZTwlQA==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-gyp": { "version": "12.4.0", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", @@ -7457,7 +7307,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -7527,7 +7376,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8367,6 +8215,22 @@ "node": ">=0.10.0" } }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/roarr": { "version": "2.15.4", "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", @@ -8504,6 +8368,12 @@ "loose-envify": "^1.1.0" } }, + "node_modules/seedrandom": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", + "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==", + "license": "MIT" + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -9269,6 +9139,12 @@ "node": ">=8.0" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/truncate-utf8-bytes": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", @@ -9650,6 +9526,22 @@ "pnpm": ">=8" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -9723,7 +9615,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/xmlbuilder": { diff --git a/package.json b/package.json index f930a7007..315616df3 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "normalize:electron-main-cjs": "node scripts/normalize-electron-main-cjs.mjs", "smoke:electron-main-cjs": "node scripts/smoke-electron-main-cjs.mjs", "smoke:packaged-binaries": "node scripts/smoke-packaged-binaries.mjs", + "smoke:webcam-segmentation-assets": "node scripts/smoke-webcam-segmentation-assets.mjs", "verify:macos-distribution": "node scripts/verify-macos-distribution.mjs", "checksums:release": "node scripts/write-release-checksums.mjs", "release:create": "node scripts/create-release.mjs", @@ -46,7 +47,12 @@ "test:watch": "vitest" }, "dependencies": { + "@mediapipe/selfie_segmentation": "0.1.1675465747", "@phosphor-icons/react": "^2.1.10", + "@tensorflow-models/body-segmentation": "1.0.2", + "@tensorflow/tfjs-backend-webgl": "4.22.0", + "@tensorflow/tfjs-converter": "4.22.0", + "@tensorflow/tfjs-core": "4.22.0", "capturekit": "^1.0.13", "electron-updater": "^6.8.3", "ffmpeg-static": "^5.3.0", diff --git a/public/THIRD_PARTY_NOTICES.txt b/public/THIRD_PARTY_NOTICES.txt new file mode 100644 index 000000000..2ad1fe972 --- /dev/null +++ b/public/THIRD_PARTY_NOTICES.txt @@ -0,0 +1,212 @@ +Recordly webcam background blur includes the following Apache-2.0 components: + +- MediaPipe Selfie Segmentation, Copyright 2020 The MediaPipe Authors + https://github.com/google-ai-edge/mediapipe +- TensorFlow.js Models, Copyright 2018 The TensorFlow Authors + https://github.com/tensorflow/tfjs-models +- TensorFlow.js, Copyright 2018 The TensorFlow Authors + https://github.com/tensorflow/tfjs + +These components are licensed under the following terms: + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/scripts/smoke-webcam-segmentation-assets.mjs b/scripts/smoke-webcam-segmentation-assets.mjs new file mode 100644 index 000000000..5715332e8 --- /dev/null +++ b/scripts/smoke-webcam-segmentation-assets.mjs @@ -0,0 +1,30 @@ +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +const assetDirectory = path.resolve("dist", "webcam-segmentation"); +const expectedFiles = [ + "selfie_segmentation.js", + "selfie_segmentation.binarypb", + "selfie_segmentation.tflite", + "selfie_segmentation_landscape.tflite", + "selfie_segmentation_solution_simd_wasm_bin.data", + "selfie_segmentation_solution_simd_wasm_bin.js", + "selfie_segmentation_solution_simd_wasm_bin.wasm", + "selfie_segmentation_solution_wasm_bin.js", + "selfie_segmentation_solution_wasm_bin.wasm", +]; + +const missing = expectedFiles.filter( + (fileName) => !fs.existsSync(path.join(assetDirectory, fileName)), +); +if (!fs.existsSync(path.resolve("dist", "THIRD_PARTY_NOTICES.txt"))) { + missing.push("../THIRD_PARTY_NOTICES.txt"); +} + +if (missing.length > 0) { + console.error(`Missing packaged webcam segmentation assets: ${missing.join(", ")}`); + process.exit(1); +} + +console.log(`Verified ${expectedFiles.length} webcam segmentation runtime assets and notices.`); diff --git a/src/components/video-editor/editorPreferences.test.ts b/src/components/video-editor/editorPreferences.test.ts index 59566e62d..22b8270b5 100644 --- a/src/components/video-editor/editorPreferences.test.ts +++ b/src/components/video-editor/editorPreferences.test.ts @@ -87,6 +87,32 @@ describe("editorPreferences", () => { expect(DEFAULT_EDITOR_PREFERENCES.exportQuality).toBe("source"); }); + it("normalizes and persists webcam background blur preferences", () => { + const localStorage = createStorageMock(); + vi.stubGlobal("localStorage", localStorage); + + expect( + normalizeEditorPreferences({ + webcam: { + ...DEFAULT_EDITOR_PREFERENCES.webcam, + backgroundBlur: { enabled: true, amount: 99 }, + }, + }).webcam.backgroundBlur, + ).toEqual({ enabled: true, amount: 20 }); + + saveEditorPreferences({ + webcam: { + ...DEFAULT_EDITOR_PREFERENCES.webcam, + backgroundBlur: { enabled: true, amount: 7 }, + }, + }); + + expect(loadEditorPreferences().webcam.backgroundBlur).toEqual({ + enabled: true, + amount: 7, + }); + }); + it("defaults cursor preferences to Tahoe at 2.5x with gentler sway", () => { expect(DEFAULT_EDITOR_PREFERENCES.cursorStyle).toBe("tahoe"); expect(DEFAULT_EDITOR_PREFERENCES.cursorSize).toBe(2.5); diff --git a/src/components/video-editor/projectDirtyState.test.ts b/src/components/video-editor/projectDirtyState.test.ts index 68c5650f4..6e039263b 100644 --- a/src/components/video-editor/projectDirtyState.test.ts +++ b/src/components/video-editor/projectDirtyState.test.ts @@ -118,4 +118,31 @@ describe("hasUnsavedProjectChanges", () => { expect(hasUnsavedProjectChanges(current, saved)).toBe(true); }); + + it("detects webcam background blur changes", () => { + const saved = createProjectData({ + editor: { + ...createProjectData().editor, + webcam: { + enabled: true, + sourcePath: "/Users/test/webcam.mp4", + timeOffsetMs: 0, + backgroundBlur: { enabled: false, amount: 12 }, + }, + }, + }); + const current = createProjectData({ + editor: { + ...createProjectData().editor, + webcam: { + enabled: true, + sourcePath: "/Users/test/webcam.mp4", + timeOffsetMs: 0, + backgroundBlur: { enabled: true, amount: 12 }, + }, + }, + }); + + expect(hasUnsavedProjectChanges(current, saved)).toBe(true); + }); }); diff --git a/src/components/video-editor/projectPersistence.test.ts b/src/components/video-editor/projectPersistence.test.ts index 575c3b676..77cd83863 100644 --- a/src/components/video-editor/projectPersistence.test.ts +++ b/src/components/video-editor/projectPersistence.test.ts @@ -4,6 +4,22 @@ import { normalizeProjectEditor } from "./projectPersistence"; import { ADVANCED_VERTICAL_PADDING_MAX } from "./types"; describe("normalizeProjectEditor", () => { + it("defaults legacy webcam settings to background blur off", () => { + const editor = normalizeProjectEditor({ webcam: { enabled: true } }); + + expect(editor.webcam.backgroundBlur).toEqual({ enabled: false, amount: 12 }); + }); + + it("normalizes persisted webcam background blur settings", () => { + const editor = normalizeProjectEditor({ + webcam: { + backgroundBlur: { enabled: true, amount: 99 }, + }, + }); + + expect(editor.webcam.backgroundBlur).toEqual({ enabled: true, amount: 20 }); + }); + it("preserves the extended advanced vertical padding range", () => { const editor = normalizeProjectEditor({ padding: { diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index 9810d5fb3..b79e7f1b8 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -19,6 +19,7 @@ import { TEMPORAL_MOTION_BLUR_MIN_SHUTTER_FRACTION, } from "@/lib/exporter/temporalMotionBlur"; import { DEFAULT_WALLPAPER_PATH } from "@/lib/wallpapers"; +import { normalizeWebcamBackgroundBlurSettings } from "@/lib/webcamBackgroundBlur"; import { ASPECT_RATIOS, type AspectRatio, isCustomAspectRatio } from "@/utils/aspectRatioUtils"; import { CURSOR_MOTION_PRESETS, resolveCursorMotionPresetId } from "./cursorMotionPresets"; import { @@ -1064,6 +1065,7 @@ export function normalizeProjectEditor(editor: Partial): Pro margin: isFiniteNumber(webcam.margin) ? clamp(webcam.margin, 0, 96) : DEFAULT_WEBCAM_MARGIN, + backgroundBlur: normalizeWebcamBackgroundBlurSettings(webcam.backgroundBlur), }, sourceAudioTrackSettingsByClip: editor.sourceAudioTrackSettingsByClip && diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index 98eefb3fc..122b55831 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -1,3 +1,8 @@ +import { + DEFAULT_WEBCAM_BACKGROUND_BLUR, + type WebcamBackgroundBlurSettings, +} from "@/lib/webcamBackgroundBlur"; + export type ZoomDepth = 1 | 2 | 3 | 4 | 5 | 6; export interface ZoomFocus { @@ -143,6 +148,7 @@ export interface WebcamOverlaySettings { cornerRadius: number; shadow: number; margin: number; + backgroundBlur: WebcamBackgroundBlurSettings; } export const DEFAULT_CURSOR_SIZE = 3.0; @@ -207,6 +213,7 @@ export const DEFAULT_WEBCAM_OVERLAY: WebcamOverlaySettings = { cornerRadius: DEFAULT_WEBCAM_CORNER_RADIUS, shadow: DEFAULT_WEBCAM_SHADOW, margin: DEFAULT_WEBCAM_MARGIN, + backgroundBlur: { ...DEFAULT_WEBCAM_BACKGROUND_BLUR }, }; export interface TrimRegion { diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index ed065f664..4c934ca4d 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -1,6 +1,11 @@ import { fixWebmDuration } from "@fix-webm-duration/fix"; import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; +import { + DEFAULT_WEBCAM_BACKGROUND_BLUR, + normalizeWebcamBackgroundBlurSettings, + type WebcamBackgroundBlurSettings, +} from "@/lib/webcamBackgroundBlur"; import { getEffectiveRecordingDurationMs } from "@/lib/mediaTiming"; import { getVideoExtensionForMimeType, @@ -147,6 +152,8 @@ type UseScreenRecorderReturn = { setWebcamEnabled: (enabled: boolean) => void; webcamDeviceId: string | undefined; setWebcamDeviceId: (deviceId: string | undefined) => void; + webcamBackgroundBlur: WebcamBackgroundBlurSettings; + setWebcamBackgroundBlur: (settings: WebcamBackgroundBlurSettings) => void; countdownDelay: number; setCountdownDelay: (delay: number) => void; }; @@ -385,6 +392,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const [systemAudioEnabled, setSystemAudioEnabled] = useState(false); const [webcamEnabled, setWebcamEnabled] = useState(false); const [webcamDeviceId, setWebcamDeviceId] = useState(undefined); + const [webcamBackgroundBlur, setWebcamBackgroundBlur] = useState({ + ...DEFAULT_WEBCAM_BACKGROUND_BLUR, + }); const [countdownDelay, setCountdownDelayState] = useState(3); const mediaRecorder = useRef(null); const webcamRecorder = useRef(null); @@ -748,6 +758,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { webcamPath, timeOffsetMs: webcamTimeOffsetMs.current, hideOverlayCursorByDefault: shouldHideOverlayCursor, + webcamBackgroundBlur, }); } else { await window.electronAPI.setCurrentVideoPath(videoPath, { @@ -772,7 +783,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { `[PERF:RENDERER] Finalize Session & Switch to Editor: COMPLETED in ${(performance.now() - start).toFixed(2)}ms`, ); }, - [], + [webcamBackgroundBlur], ); const closeMicFallbackPauseInterval = useCallback((now = performance.now()) => { @@ -1397,6 +1408,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { webcamPath, timeOffsetMs: webcamTimeOffsetMs.current, hideOverlayCursorByDefault: hideEditorOverlayCursorByDefault.current, + webcamBackgroundBlur, }); console.log( @@ -1495,6 +1507,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { setMicrophoneDeviceId(result.microphoneDeviceId); } setSystemAudioEnabled(result.systemAudioEnabled); + setWebcamBackgroundBlur( + normalizeWebcamBackgroundBlurSettings(result.webcamBackgroundBlur), + ); } })(); }, []); @@ -1514,6 +1529,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn { void window.electronAPI.setRecordingPreferences({ systemAudioEnabled: enabled }); }, []); + const persistWebcamBackgroundBlur = useCallback((settings: WebcamBackgroundBlurSettings) => { + const normalized = normalizeWebcamBackgroundBlurSettings(settings); + setWebcamBackgroundBlur(normalized); + void window.electronAPI.setRecordingPreferences({ webcamBackgroundBlur: normalized }); + }, []); + useEffect(() => { let cleanup: (() => void) | undefined; @@ -2162,6 +2183,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { timeOffsetMs: webcamTimeOffsetMs.current, hideOverlayCursorByDefault: hideEditorOverlayCursorByDefault.current, + webcamBackgroundBlur, }); } } finally { @@ -2392,6 +2414,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn { setWebcamEnabled, webcamDeviceId, setWebcamDeviceId, + webcamBackgroundBlur, + setWebcamBackgroundBlur: persistWebcamBackgroundBlur, countdownDelay, setCountdownDelay, }; diff --git a/src/lib/webcamBackgroundBlur.test.ts b/src/lib/webcamBackgroundBlur.test.ts new file mode 100644 index 000000000..bcf8a2353 --- /dev/null +++ b/src/lib/webcamBackgroundBlur.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_WEBCAM_BACKGROUND_BLUR, + normalizeWebcamBackgroundBlurSettings, +} from "./webcamBackgroundBlur"; + +describe("normalizeWebcamBackgroundBlurSettings", () => { + it("defaults legacy and invalid values to blur off at the medium strength", () => { + expect(normalizeWebcamBackgroundBlurSettings(undefined)).toEqual( + DEFAULT_WEBCAM_BACKGROUND_BLUR, + ); + expect(normalizeWebcamBackgroundBlurSettings({ enabled: "yes", amount: NaN })).toEqual( + DEFAULT_WEBCAM_BACKGROUND_BLUR, + ); + }); + + it("rounds and clamps blur strength while preserving an explicit toggle", () => { + expect(normalizeWebcamBackgroundBlurSettings({ enabled: true, amount: 0 })).toEqual({ + enabled: true, + amount: 1, + }); + expect(normalizeWebcamBackgroundBlurSettings({ enabled: false, amount: 20.6 })).toEqual({ + enabled: false, + amount: 20, + }); + }); +}); diff --git a/src/lib/webcamBackgroundBlur.ts b/src/lib/webcamBackgroundBlur.ts new file mode 100644 index 000000000..064a251a1 --- /dev/null +++ b/src/lib/webcamBackgroundBlur.ts @@ -0,0 +1,32 @@ +export interface WebcamBackgroundBlurSettings { + enabled: boolean; + amount: number; +} + +export const WEBCAM_BACKGROUND_BLUR_MIN = 1; +export const WEBCAM_BACKGROUND_BLUR_MAX = 20; +export const DEFAULT_WEBCAM_BACKGROUND_BLUR: WebcamBackgroundBlurSettings = Object.freeze({ + enabled: false, + amount: 12, +}); + +export function normalizeWebcamBackgroundBlurSettings( + value: unknown, +): WebcamBackgroundBlurSettings { + const candidate = value && typeof value === "object" ? (value as Record) : {}; + const amount = + typeof candidate.amount === "number" && Number.isFinite(candidate.amount) + ? Math.min( + WEBCAM_BACKGROUND_BLUR_MAX, + Math.max(WEBCAM_BACKGROUND_BLUR_MIN, Math.round(candidate.amount)), + ) + : DEFAULT_WEBCAM_BACKGROUND_BLUR.amount; + + return { + enabled: + typeof candidate.enabled === "boolean" + ? candidate.enabled + : DEFAULT_WEBCAM_BACKGROUND_BLUR.enabled, + amount, + }; +} diff --git a/src/lib/webcamSegmentationAssets.test.ts b/src/lib/webcamSegmentationAssets.test.ts new file mode 100644 index 000000000..571bbf825 --- /dev/null +++ b/src/lib/webcamSegmentationAssets.test.ts @@ -0,0 +1,18 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + assertWebcamSegmentationAssets, + getWebcamSegmentationPackageDirectory, + WEBCAM_SEGMENTATION_ASSET_FILES, +} from "../../webcamSegmentationAssets"; + +describe("webcam segmentation package assets", () => { + it("contains every runtime file emitted by the build", () => { + const packageDirectory = getWebcamSegmentationPackageDirectory(); + expect(() => assertWebcamSegmentationAssets(packageDirectory)).not.toThrow(); + for (const fileName of WEBCAM_SEGMENTATION_ASSET_FILES) { + expect(fs.statSync(path.join(packageDirectory, fileName)).isFile()).toBe(true); + } + }); +}); diff --git a/tsconfig.node.json b/tsconfig.node.json index 1caabefce..c2b663a8b 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -7,5 +7,5 @@ "allowSyntheticDefaultImports": true, "strict": true }, - "include": ["vite.config.ts"] + "include": ["vite.config.ts", "webcamSegmentationAssets.ts"] } diff --git a/vite.config.ts b/vite.config.ts index 3dd36633d..63f7cbf0c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,6 +3,7 @@ import path from "node:path"; import react from "@vitejs/plugin-react"; import { defineConfig, type Plugin } from "vite"; import electron from "vite-plugin-electron/simple"; +import { webcamSegmentationAssetsPlugin } from "./webcamSegmentationAssets"; function electronMainCjsOutputPlugin(): Plugin { return { @@ -58,6 +59,7 @@ function electronMainCjsGuardPlugin(): Plugin { export default defineConfig({ plugins: [ react(), + webcamSegmentationAssetsPlugin(), electron({ main: { // Shortcut of `build.lib.entry`. @@ -129,6 +131,12 @@ export default defineConfig({ pixi: ["pixi.js"], "react-vendor": ["react", "react-dom"], "video-processing": ["mediabunny", "mp4box", "@fix-webm-duration/fix"], + "webcam-segmentation": [ + "@tensorflow-models/body-segmentation", + "@tensorflow/tfjs-core", + "@tensorflow/tfjs-converter", + "@tensorflow/tfjs-backend-webgl", + ], }, }, }, diff --git a/webcamSegmentationAssets.ts b/webcamSegmentationAssets.ts new file mode 100644 index 000000000..e241e910b --- /dev/null +++ b/webcamSegmentationAssets.ts @@ -0,0 +1,77 @@ +import fs from "node:fs"; +import path from "node:path"; +import { createRequire } from "node:module"; +import type { Plugin } from "vite"; + +export const WEBCAM_SEGMENTATION_ASSET_PATH = "webcam-segmentation"; +export const WEBCAM_SEGMENTATION_ASSET_FILES = [ + "selfie_segmentation.js", + "selfie_segmentation.binarypb", + "selfie_segmentation.tflite", + "selfie_segmentation_landscape.tflite", + "selfie_segmentation_solution_simd_wasm_bin.data", + "selfie_segmentation_solution_simd_wasm_bin.js", + "selfie_segmentation_solution_simd_wasm_bin.wasm", + "selfie_segmentation_solution_wasm_bin.js", + "selfie_segmentation_solution_wasm_bin.wasm", +] as const; + +const require = createRequire(import.meta.url); + +export function getWebcamSegmentationPackageDirectory(): string { + return path.dirname(require.resolve("@mediapipe/selfie_segmentation/package.json")); +} + +export function assertWebcamSegmentationAssets(packageDirectory: string): void { + const missing = WEBCAM_SEGMENTATION_ASSET_FILES.filter( + (fileName) => !fs.existsSync(path.join(packageDirectory, fileName)), + ); + if (missing.length > 0) { + throw new Error(`Missing MediaPipe webcam segmentation assets: ${missing.join(", ")}`); + } +} + +function contentTypeForAsset(fileName: string): string { + if (fileName.endsWith(".js")) return "text/javascript; charset=utf-8"; + if (fileName.endsWith(".wasm")) return "application/wasm"; + return "application/octet-stream"; +} + +export function webcamSegmentationAssetsPlugin(): Plugin { + const packageDirectory = getWebcamSegmentationPackageDirectory(); + assertWebcamSegmentationAssets(packageDirectory); + + return { + name: "recordly-webcam-segmentation-assets", + configureServer(server) { + const prefix = `/${WEBCAM_SEGMENTATION_ASSET_PATH}/`; + server.middlewares.use((request, response, next) => { + const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1"); + if (!requestUrl.pathname.startsWith(prefix)) { + next(); + return; + } + + const fileName = decodeURIComponent(requestUrl.pathname.slice(prefix.length)); + if (!(WEBCAM_SEGMENTATION_ASSET_FILES as readonly string[]).includes(fileName)) { + response.statusCode = 404; + response.end("Not Found"); + return; + } + + response.statusCode = 200; + response.setHeader("Content-Type", contentTypeForAsset(fileName)); + fs.createReadStream(path.join(packageDirectory, fileName)).pipe(response); + }); + }, + generateBundle() { + for (const fileName of WEBCAM_SEGMENTATION_ASSET_FILES) { + this.emitFile({ + type: "asset", + fileName: `${WEBCAM_SEGMENTATION_ASSET_PATH}/${fileName}`, + source: fs.readFileSync(path.join(packageDirectory, fileName)), + }); + } + }, + }; +} From 6c8a78a58d51f41117ec900bc03a09f279d36c23 Mon Sep 17 00:00:00 2001 From: Dodzi Agbenorku Date: Tue, 25 Aug 2026 19:03:55 +0100 Subject: [PATCH 3/6] Build the lazy webcam blur engine Load MediaPipe segmentation on first use, serialize inference, cache frames, reject stale work, and support retry and fail-soft cleanup. --- src/hooks/useWebcamBackgroundBlurStatus.ts | 11 + src/lib/webcamBackgroundBlurEngine.test.ts | 110 ++++++++++ src/lib/webcamBackgroundBlurEngine.ts | 244 +++++++++++++++++++++ 3 files changed, 365 insertions(+) create mode 100644 src/hooks/useWebcamBackgroundBlurStatus.ts create mode 100644 src/lib/webcamBackgroundBlurEngine.test.ts create mode 100644 src/lib/webcamBackgroundBlurEngine.ts diff --git a/src/hooks/useWebcamBackgroundBlurStatus.ts b/src/hooks/useWebcamBackgroundBlurStatus.ts new file mode 100644 index 000000000..0ce4f00bd --- /dev/null +++ b/src/hooks/useWebcamBackgroundBlurStatus.ts @@ -0,0 +1,11 @@ +import { useSyncExternalStore } from "react"; +import { getSharedWebcamBackgroundBlurEngine } from "@/lib/webcamBackgroundBlurEngine"; + +export function useWebcamBackgroundBlurStatus() { + const engine = getSharedWebcamBackgroundBlurEngine(); + const snapshot = useSyncExternalStore(engine.subscribe, engine.getSnapshot, engine.getSnapshot); + return { + ...snapshot, + retry: () => engine.retry(), + }; +} diff --git a/src/lib/webcamBackgroundBlurEngine.test.ts b/src/lib/webcamBackgroundBlurEngine.test.ts new file mode 100644 index 000000000..5f3268df6 --- /dev/null +++ b/src/lib/webcamBackgroundBlurEngine.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it, vi } from "vitest"; +import { WebcamBackgroundBlurEngine, type WebcamBlurRuntime } from "./webcamBackgroundBlurEngine"; + +function createRuntime(): WebcamBlurRuntime { + return { + segmenter: { + segmentPeople: vi.fn(async () => [{ mask: "person" }]), + dispose: vi.fn(), + }, + drawBokehEffect: vi.fn(async () => undefined), + }; +} + +describe("WebcamBackgroundBlurEngine", () => { + it("lazy-loads once and caches repeated frame timestamps", async () => { + const runtime = createRuntime(); + const loader = vi.fn(async () => runtime); + const canvas = {} as HTMLCanvasElement; + const engine = new WebcamBackgroundBlurEngine({ loader, canvasFactory: () => canvas }); + const source = {} as HTMLVideoElement; + + await expect(engine.processFrame(source, { amount: 12, frameKey: 100 })).resolves.toBe( + canvas, + ); + await expect(engine.processFrame(source, { amount: 12, frameKey: 100 })).resolves.toBe( + canvas, + ); + + expect(loader).toHaveBeenCalledTimes(1); + expect(runtime.segmenter.segmentPeople).toHaveBeenCalledTimes(1); + expect(runtime.drawBokehEffect).toHaveBeenCalledWith( + canvas, + source, + [{ mask: "person" }], + 0.5, + 12, + 3, + false, + ); + }); + + it("serializes concurrent inference calls", async () => { + let active = 0; + let maximumActive = 0; + const runtime = createRuntime(); + runtime.segmenter.segmentPeople = vi.fn(async () => { + active += 1; + maximumActive = Math.max(maximumActive, active); + await Promise.resolve(); + active -= 1; + return []; + }); + const engine = new WebcamBackgroundBlurEngine({ + loader: async () => runtime, + canvasFactory: () => ({}) as HTMLCanvasElement, + }); + + await Promise.all([ + engine.processFrame({} as HTMLVideoElement, { amount: 8, frameKey: 1 }), + engine.processFrame({} as HTMLVideoElement, { amount: 8, frameKey: 2 }), + ]); + + expect(maximumActive).toBe(1); + }); + + it("discards a stale result after invalidation", async () => { + let finish: (() => void) | undefined; + const runtime = createRuntime(); + runtime.segmenter.segmentPeople = vi.fn( + () => + new Promise((resolve) => { + finish = () => resolve([]); + }), + ); + const engine = new WebcamBackgroundBlurEngine({ + loader: async () => runtime, + canvasFactory: () => ({}) as HTMLCanvasElement, + }); + const pending = engine.processFrame({} as HTMLVideoElement, { amount: 10, frameKey: 1 }); + await vi.waitFor(() => expect(finish).toBeTypeOf("function")); + engine.invalidate(); + finish?.(); + + await expect(pending).resolves.toBeNull(); + expect(runtime.drawBokehEffect).not.toHaveBeenCalled(); + }); + + it("enters an error state, falls back, and can retry", async () => { + const runtime = createRuntime(); + const loader = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error("model missing")) + .mockResolvedValue(runtime); + const engine = new WebcamBackgroundBlurEngine({ + loader, + canvasFactory: () => ({}) as HTMLCanvasElement, + }); + + await expect( + engine.processFrame({} as HTMLVideoElement, { amount: 12, frameKey: 1 }), + ).resolves.toBeNull(); + expect(engine.getSnapshot().status).toBe("error"); + + engine.retry(); + await expect( + engine.processFrame({} as HTMLVideoElement, { amount: 12, frameKey: 2 }), + ).resolves.not.toBeNull(); + expect(loader).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/lib/webcamBackgroundBlurEngine.ts b/src/lib/webcamBackgroundBlurEngine.ts new file mode 100644 index 000000000..0204bc2a1 --- /dev/null +++ b/src/lib/webcamBackgroundBlurEngine.ts @@ -0,0 +1,244 @@ +import type { BodySegmenterInput } from "@tensorflow-models/body-segmentation"; +import { + normalizeWebcamBackgroundBlurSettings, + type WebcamBackgroundBlurSettings, +} from "./webcamBackgroundBlur"; + +export type WebcamBackgroundBlurStatus = "idle" | "loading" | "ready" | "error"; + +export interface WebcamBackgroundBlurSnapshot { + status: WebcamBackgroundBlurStatus; + error: string | null; +} + +export interface WebcamBlurRuntime { + segmenter: { + segmentPeople: ( + source: BodySegmenterInput, + config?: { flipHorizontal?: boolean }, + ) => Promise; + dispose: () => void; + }; + drawBokehEffect: ( + canvas: HTMLCanvasElement, + source: BodySegmenterInput, + segmentations: unknown[], + foregroundThreshold: number, + backgroundBlurAmount: number, + edgeBlurAmount: number, + flipHorizontal: boolean, + ) => Promise; +} + +export interface WebcamBlurFrameOptions { + amount: number; + frameKey?: string | number; +} + +type WebcamBlurRuntimeLoader = () => Promise; + +export interface WebcamBackgroundBlurEngineOptions { + loader?: WebcamBlurRuntimeLoader; + canvasFactory?: () => HTMLCanvasElement; +} + +const FOREGROUND_THRESHOLD = 0.5; +const EDGE_BLUR_AMOUNT = 3; + +function getErrorMessage(error: unknown): string { + return error instanceof Error && error.message ? error.message : String(error); +} + +function getSegmentationAssetBaseUrl(): string { + return new URL("./webcam-segmentation", window.location.href).href; +} + +async function loadDefaultRuntime(): Promise { + const [tf, bodySegmentation] = await Promise.all([ + import("@tensorflow/tfjs-core"), + Promise.all([ + import("@tensorflow/tfjs-converter"), + import("@tensorflow/tfjs-backend-webgl"), + import("@mediapipe/selfie_segmentation"), + ]).then(() => import("@tensorflow-models/body-segmentation")), + ]); + await tf.setBackend("webgl"); + await tf.ready(); + const segmenter = await bodySegmentation.createSegmenter( + bodySegmentation.SupportedModels.MediaPipeSelfieSegmentation, + { + runtime: "mediapipe", + modelType: "landscape", + solutionPath: getSegmentationAssetBaseUrl(), + }, + ); + + return { + segmenter: segmenter as WebcamBlurRuntime["segmenter"], + drawBokehEffect: async (...args) => { + await bodySegmentation.drawBokehEffect( + args[0], + args[1], + args[2] as never, + args[3], + args[4], + args[5], + args[6], + ); + }, + }; +} + +export class WebcamBackgroundBlurEngine { + private readonly loader: WebcamBlurRuntimeLoader; + private readonly canvasFactory: () => HTMLCanvasElement; + private readonly listeners = new Set<() => void>(); + private runtime: WebcamBlurRuntime | null = null; + private loadPromise: Promise | null = null; + private outputCanvas: HTMLCanvasElement | null = null; + private queue: Promise = Promise.resolve(); + private generation = 0; + private disposed = false; + private lastFrameKey: string | number | undefined; + private lastAmount: number | undefined; + private snapshot: WebcamBackgroundBlurSnapshot = { status: "idle", error: null }; + + constructor(options: WebcamBackgroundBlurEngineOptions = {}) { + this.loader = options.loader ?? loadDefaultRuntime; + this.canvasFactory = options.canvasFactory ?? (() => document.createElement("canvas")); + } + + getSnapshot = (): WebcamBackgroundBlurSnapshot => this.snapshot; + + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + private setSnapshot(snapshot: WebcamBackgroundBlurSnapshot): void { + this.snapshot = snapshot; + for (const listener of this.listeners) listener(); + } + + private async ensureRuntime(): Promise { + if (this.runtime) return this.runtime; + if (this.loadPromise) return this.loadPromise; + if (this.disposed) throw new Error("Webcam background blur engine is disposed"); + + this.setSnapshot({ status: "loading", error: null }); + this.loadPromise = this.loader() + .then((runtime) => { + if (this.disposed) { + runtime.segmenter.dispose(); + throw new Error("Webcam background blur engine was disposed while loading"); + } + this.runtime = runtime; + this.setSnapshot({ status: "ready", error: null }); + return runtime; + }) + .catch((error) => { + this.setSnapshot({ status: "error", error: getErrorMessage(error) }); + throw error; + }) + .finally(() => { + this.loadPromise = null; + }); + return this.loadPromise; + } + + private enqueue(work: () => Promise): Promise { + const result = this.queue.then(work, work); + this.queue = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + async processFrame( + source: BodySegmenterInput, + options: WebcamBlurFrameOptions, + ): Promise { + if (this.disposed || this.snapshot.status === "error") return null; + const normalized: WebcamBackgroundBlurSettings = normalizeWebcamBackgroundBlurSettings({ + enabled: true, + amount: options.amount, + }); + if ( + options.frameKey !== undefined && + options.frameKey === this.lastFrameKey && + normalized.amount === this.lastAmount && + this.outputCanvas + ) { + return this.outputCanvas; + } + + const requestGeneration = this.generation; + return this.enqueue(async () => { + if (requestGeneration !== this.generation || this.disposed) return null; + if ( + options.frameKey !== undefined && + options.frameKey === this.lastFrameKey && + normalized.amount === this.lastAmount && + this.outputCanvas + ) { + return this.outputCanvas; + } + + try { + const runtime = await this.ensureRuntime(); + if (requestGeneration !== this.generation || this.disposed) return null; + const segmentations = await runtime.segmenter.segmentPeople(source, { + flipHorizontal: false, + }); + if (requestGeneration !== this.generation || this.disposed) return null; + this.outputCanvas ??= this.canvasFactory(); + await runtime.drawBokehEffect( + this.outputCanvas, + source, + segmentations, + FOREGROUND_THRESHOLD, + normalized.amount, + EDGE_BLUR_AMOUNT, + false, + ); + if (requestGeneration !== this.generation || this.disposed) return null; + this.lastFrameKey = options.frameKey; + this.lastAmount = normalized.amount; + return this.outputCanvas; + } catch (error) { + this.setSnapshot({ status: "error", error: getErrorMessage(error) }); + return null; + } + }); + } + + invalidate(): void { + this.generation += 1; + this.lastFrameKey = undefined; + this.lastAmount = undefined; + } + + retry(): void { + this.invalidate(); + this.runtime?.segmenter.dispose(); + this.runtime = null; + this.loadPromise = null; + this.setSnapshot({ status: "idle", error: null }); + } + + dispose(): void { + this.disposed = true; + this.invalidate(); + this.runtime?.segmenter.dispose(); + this.runtime = null; + this.listeners.clear(); + } +} + +let sharedWebcamBackgroundBlurEngine: WebcamBackgroundBlurEngine | null = null; + +export function getSharedWebcamBackgroundBlurEngine(): WebcamBackgroundBlurEngine { + sharedWebcamBackgroundBlurEngine ??= new WebcamBackgroundBlurEngine(); + return sharedWebcamBackgroundBlurEngine; +} From 3abd843791b274ae3d60cafd82056baf9aaa70f8 Mon Sep 17 00:00:00 2001 From: Dodzi Agbenorku Date: Tue, 25 Aug 2026 19:04:23 +0100 Subject: [PATCH 4/6] Add blur controls and live previews Use the shared processed-canvas preview in setup, the recording HUD, and synchronized editor playback while retaining raw video during loading or failure. --- src/components/launch/LaunchWindow.tsx | 11 +- .../launch/popovers/WebcamPopover.tsx | 96 ++++++++-- src/components/video-editor/SettingsPanel.tsx | 79 +++++++++ src/components/video-editor/VideoEditor.tsx | 18 ++ src/components/video-editor/VideoPlayback.tsx | 8 +- .../WebcamBackgroundBlurPreview.test.ts | 16 ++ .../webcam/WebcamBackgroundBlurPreview.tsx | 166 ++++++++++++++++++ src/i18n/locales/de/launch.json | 5 + src/i18n/locales/de/settings.json | 6 + src/i18n/locales/en/launch.json | 5 + src/i18n/locales/en/settings.json | 6 + src/i18n/locales/es/launch.json | 5 + src/i18n/locales/es/settings.json | 6 + src/i18n/locales/fr/launch.json | 5 + src/i18n/locales/fr/settings.json | 6 + src/i18n/locales/it/launch.json | 5 + src/i18n/locales/it/settings.json | 6 + src/i18n/locales/ko/launch.json | 5 + src/i18n/locales/ko/settings.json | 6 + src/i18n/locales/nl/launch.json | 5 + src/i18n/locales/nl/settings.json | 6 + src/i18n/locales/pt-BR/launch.json | 5 + src/i18n/locales/pt-BR/settings.json | 6 + src/i18n/locales/ru/launch.json | 7 +- src/i18n/locales/ru/settings.json | 6 + src/i18n/locales/zh-CN/launch.json | 5 + src/i18n/locales/zh-CN/settings.json | 6 + src/i18n/locales/zh-TW/launch.json | 5 + src/i18n/locales/zh-TW/settings.json | 6 + 29 files changed, 501 insertions(+), 16 deletions(-) create mode 100644 src/components/webcam/WebcamBackgroundBlurPreview.test.ts create mode 100644 src/components/webcam/WebcamBackgroundBlurPreview.tsx diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index cb43ead1b..fd261e77c 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -39,6 +39,7 @@ import { MorePopover } from "./popovers/MorePopover"; import { ProjectPopover } from "./popovers/ProjectPopover"; import { SourcePopover } from "./popovers/SourcePopover"; import { WebcamPopover } from "./popovers/WebcamPopover"; +import { WebcamBackgroundBlurPreview } from "@/components/webcam/WebcamBackgroundBlurPreview"; import { RecordingControls } from "./RecordingControls"; import { MarqueeText } from "./SourceSelector"; @@ -75,6 +76,8 @@ function LaunchWindowContent() { setWebcamEnabled, webcamDeviceId, setWebcamDeviceId, + webcamBackgroundBlur, + setWebcamBackgroundBlur, countdownDelay, setCountdownDelay, preparePermissions, @@ -302,6 +305,8 @@ function LaunchWindowContent() { videoDevices={videoDevices} webcamDeviceId={webcamDeviceId} selectedVideoDeviceId={selectedVideoDeviceId} + backgroundBlur={webcamBackgroundBlur} + onBackgroundBlurChange={setWebcamBackgroundBlur} onSelectVideoDevice={(deviceId) => { setWebcamEnabled(true); setSelectedVideoDeviceId(deviceId); @@ -534,8 +539,10 @@ function LaunchWindowContent() { onPointerUp={handleWebcamPreviewPointerUp} onPointerCancel={handleWebcamPreviewPointerUp} > -