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
100 changes: 100 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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" <args>`

## 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.
20 changes: 20 additions & 0 deletions docs/superpowers/plans/2026-08-25-webcam-background-blur.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Webcam background blur implementation plan

1. Add failing tests for setting normalization, project/editor persistence, dirty state,
recording preferences, recording-session snapshots, and manifests. Implement the
shared settings type and optional compatible fields.
2. Add the pinned MediaPipe/TFJS dependencies, build-time asset plugin, asset smoke check,
dynamic segmentation chunk, and packaged Apache notices. Verify dev and production
asset URLs without committing generated binaries.
3. Add a tested lazy blur engine with serialized initialization/inference, 15 FPS preview
throttling, timestamp caching, stale-result suppression, retry/dispose, and raw fallback.
4. Integrate the launch popover and recording HUD through the existing preview stream.
Add localized switch/slider/status controls and preserve the raw MediaRecorder stream.
5. Integrate editor playback using the synchronized webcam element. Refresh on playback,
seeks, source changes, toggles, and strength changes.
6. Feed processed canvases into Canvas2D/GIF and Pixi/WebCodecs webcam composition before
existing presentation effects and hooks. Preflight exports and add the explicit native
skip reason.
7. Run targeted tests after each slice, then full TypeScript, lint, Vitest, i18n, touched
formatting, Vite build, asset smoke, packaged Windows smoke, and interactive camera and
export checks. Commit only after fresh verification.
51 changes: 51 additions & 0 deletions docs/superpowers/specs/2026-08-25-webcam-background-blur.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Webcam background blur specification

## Goal

Add a non-destructive, local-only webcam background blur to Recordly's setup preview,
recording HUD, editor preview, MP4 export, and GIF export. The raw webcam sidecar remains
unchanged and editable.

## Product behavior

- A fresh install starts with blur disabled and strength 12.
- The webcam controls expose a `Blur background` switch and an integer strength slider
from 1 through 20.
- The launch choice is remembered and snapshotted into a new recording session. Editor
changes also become the preference for the next recording.
- Old projects, imported webcam media, and old recording manifests default to blur off.
- While the local model loads, Recordly shows raw webcam video. Model or inference failures
leave the webcam visible and warn once rather than aborting recording or export.
- The effect runs locally from packaged assets. It never uploads frames or downloads a
model at runtime.

## Technical design

- Add `WebcamBackgroundBlurSettings { enabled, amount }` under
`WebcamOverlaySettings.backgroundBlur`, recording preferences, and optional version-2
recording-session manifest data. Project wire versions do not change.
- Use `@tensorflow-models/body-segmentation` with MediaPipe Selfie Segmentation's
landscape model. Lazy-load one serialized segmenter per renderer and composite with
threshold 0.5, edge blur 3, and the selected strength.
- A Vite plugin serves a fixed whitelist of MediaPipe package assets in development and
emits them into `dist` for packaged builds. Third-party notices ship beside them.
- Interactive previews infer at most 15 times per second and reuse the latest processed
frame. Exports process each distinct webcam source timestamp and cache repeated frames.
- Processing happens before existing webcam crop, mirror, shape, shadow, and
`post-webcam` extension hooks.
- Blurred jobs are ineligible for the native static-layout compositor and use the existing
software frame renderer. Native encoders that consume software-rendered frames remain
available.

## Acceptance

- Setup preview, HUD, editor, MP4, and GIF visibly agree at the same strength.
- Mirroring, cropping, seeks, speed changes, and webcam time offsets remain correct.
- Saving and reopening a project preserves blur; opening legacy data remains unblurred.
- A packaged Windows build loads the model with networking disabled.
- Missing/corrupt assets and inference errors fall back to raw webcam with one warning.

## Exclusions

No virtual camera, transparent background, replacement image, native C++/CUDA model, or
destructive capture-time processing is included.
4 changes: 4 additions & 0 deletions electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -718,6 +718,7 @@ interface Window {
webcamPath?: string | null;
timeOffsetMs?: number;
hideOverlayCursorByDefault?: boolean;
webcamBackgroundBlur?: { enabled: boolean; amount: number };
},
options?: { preserveProjectPath?: boolean },
) => Promise<{ success: boolean }>;
Expand All @@ -728,6 +729,7 @@ interface Window {
webcamPath?: string | null;
timeOffsetMs?: number;
hideOverlayCursorByDefault?: boolean;
webcamBackgroundBlur?: { enabled: boolean; amount: number };
};
}>;
getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>;
Expand Down Expand Up @@ -881,6 +883,7 @@ interface Window {
microphoneEnabled: boolean;
microphoneDeviceId?: string;
systemAudioEnabled: boolean;
webcamBackgroundBlur: { enabled: boolean; amount: number };
}>;
getRecordingAudioLabConfig: () => Promise<{
browserMicrophoneProfile: string;
Expand All @@ -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 }>;
Expand Down
66 changes: 66 additions & 0 deletions electron/ipc/project/session.test.ts
Original file line number Diff line number Diff line change
@@ -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 },
});
});
});
18 changes: 13 additions & 5 deletions electron/ipc/project/session.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<void> {
export async function persistRecordingSessionManifest(
session: RecordingSessionData,
): Promise<void> {
const normalizedVideoPath = normalizeVideoSourcePath(session.videoPath);
if (!normalizedVideoPath) {
return;
Expand All @@ -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");
Expand All @@ -51,8 +55,7 @@ export async function resolveRecordingSessionManifest(

try {
const content = await fs.readFile(manifestPath, "utf-8");
const parsed =
parseJsonWithByteOrderMark<Partial<RecordingSessionManifest>>(content);
const parsed = parseJsonWithByteOrderMark<Partial<RecordingSessionManifest>>(content);
if (parsed.version !== 1 && parsed.version !== 2) {
return null;
}
Expand All @@ -67,6 +70,9 @@ export async function resolveRecordingSessionManifest(
videoPath: normalizedVideoPath,
webcamPath: null,
timeOffsetMs: normalizeRecordingTimeOffsetMs(parsed.timeOffsetMs),
webcamBackgroundBlur: normalizeWebcamBackgroundBlurSettings(
parsed.webcamBackgroundBlur,
),
};
}

Expand All @@ -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;
Expand Down Expand Up @@ -136,7 +145,6 @@ export async function resolveRecordingSession(
return {
videoPath: normalizedVideoPath,
webcamPath: linkedWebcamPath,
webcamBackgroundBlur: normalizeWebcamBackgroundBlurSettings(undefined),
};
}


28 changes: 28 additions & 0 deletions electron/ipc/recordingPreferences.test.ts
Original file line number Diff line number Diff line change
@@ -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 },
});
});
});
27 changes: 27 additions & 0 deletions electron/ipc/recordingPreferences.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) : {};
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,
),
};
}
Loading