Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
742d3d8
feat(wgc): read a list of cameras from the helper config
christian-wr Oct 3, 2026
d09a1ac
feat(wgc): record every camera in the config on the shared clock
christian-wr Oct 4, 2026
e86b382
fix(wgc): drop a camera whose encoder cannot start
christian-wr Oct 4, 2026
bce2d6d
feat(recording): read per-camera helper events
christian-wr Oct 4, 2026
c0b235a
feat(recording): keep additional cameras in the session and media links
christian-wr Oct 4, 2026
1dead65
feat(recording): start and stop several cameras on Windows
christian-wr Oct 4, 2026
9665e7b
fix(wgc): never open the same camera twice in one take
christian-wr Oct 4, 2026
e45dad0
fix(recording): tell cameras with the same name apart
christian-wr Oct 4, 2026
1a41993
feat(project): keep a recording's additional cameras
christian-wr Oct 4, 2026
aa84135
feat(hud): pick additional cameras for a Windows recording
christian-wr Oct 4, 2026
66bb3b5
fix(hud): note why the capture probe failed
christian-wr Oct 4, 2026
a8fea4b
docs(e2e): several cameras on Windows
christian-wr Oct 4, 2026
1607b29
fix(project): no fixed limit on additional camera tracks
christian-wr Oct 4, 2026
3f3eb2c
fix(recording): never match identical cameras by name across an id
christian-wr Oct 4, 2026
21c848e
fix(wgc): report a camera file that could not be removed
christian-wr Oct 4, 2026
31e8d31
feat(recording): name a camera that stopped early
christian-wr Oct 4, 2026
20f7044
docs(e2e): identical cameras and screen pacing with several cameras
christian-wr Oct 4, 2026
38d838b
docs(e2e): log the first real two-camera run on Windows on ARM
christian-wr Oct 5, 2026
b4d10be
fix(wgc): stop and report a camera that is unplugged mid-take
christian-wr Oct 5, 2026
0aeb914
docs(e2e): log the on-site camera runs and the unplug fix
christian-wr Oct 5, 2026
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
55 changes: 55 additions & 0 deletions electron/app-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,4 +165,59 @@ describe("app settings store", () => {

expect(new AppSettingsStore(dir).getSnapshot().recording.camQuality).toBe("2160p");
});

it("reads a missing additional camera list as empty", () => {
const dir = temp();
writeFileSync(
path.join(dir, "recording-settings.json"),
JSON.stringify({ camEnabled: true }),
"utf8",
);

expect(new AppSettingsStore(dir).getSnapshot().recording.camAdditionalDevices).toEqual([]);
});

it("drops additional camera entries without a name and keeps at most three", () => {
const store = new AppSettingsStore(temp());
store.setRecordingPreferences({
camAdditionalDevices: [
{ id: "a", name: "A" },
{ id: null, name: "B" },
{ id: "x", name: "" },
{ id: "c", name: "C" },
{ id: "d", name: "D" },
{ id: "e", name: "E" },
],
});

expect(store.getSnapshot().recording.camAdditionalDevices).toEqual([
{ id: "a", name: "A" },
{ id: null, name: "B" },
{ id: "c", name: "C" },
]);
});

it("ignores junk in a stored additional camera list", () => {
const dir = temp();
writeFileSync(
path.join(dir, "recording-settings.json"),
JSON.stringify({
camAdditionalDevices: [null, 3, "x", { name: 5 }, { id: 7, name: "Kept" }, { name: "Ok" }],
}),
"utf8",
);

expect(new AppSettingsStore(dir).getSnapshot().recording.camAdditionalDevices).toEqual([
{ id: null, name: "Kept" },
{ id: null, name: "Ok" },
]);
});

it("rejects an additional camera list that is not a list", () => {
const store = new AppSettingsStore(temp());

expect(() => store.setRecordingPreferences({ camAdditionalDevices: "x" as never })).toThrow(
TypeError,
);
});
});
27 changes: 27 additions & 0 deletions electron/app-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export interface RecordingPreferences {
camEnabled: boolean;
camDeviceId: string | null;
camDeviceName: string | null;
/** Cameras 2-4 of a native Windows recording, in the order they were picked. At most three. */
camAdditionalDevices: Array<{ id: string | null; name: string }>;
/** Capture resolution for the camera. See WEBCAM_QUALITY_PRESETS. */
camQuality: WebcamQualityId;
systemAudioEnabled: boolean;
Expand All @@ -38,6 +40,7 @@ export const DEFAULT_RECORDING_PREFERENCES: RecordingPreferences = {
camEnabled: false,
camDeviceId: null,
camDeviceName: null,
camAdditionalDevices: [],
camQuality: DEFAULT_WEBCAM_QUALITY,
systemAudioEnabled: false,
cursorCaptureMode: "editable-overlay",
Expand Down Expand Up @@ -101,6 +104,22 @@ const bool = (value: unknown, fallback: boolean) => (typeof value === "boolean"
const nullableString = (value: unknown, fallback: string | null) =>
value === null || typeof value === "string" ? value : fallback;

const MAX_ADDITIONAL_CAMERAS = 3;

/** Keeps the entries that name a camera, in order, up to the cap; everything else is junk. */
function additionalCameras(value: unknown): RecordingPreferences["camAdditionalDevices"] {
if (!Array.isArray(value)) return [];
const kept: RecordingPreferences["camAdditionalDevices"] = [];
for (const entry of value) {
if (kept.length >= MAX_ADDITIONAL_CAMERAS) break;
if (!entry || typeof entry !== "object") continue;
const { id, name } = entry as Record<string, unknown>;
if (typeof name !== "string" || name.length === 0) continue;
kept.push({ id: typeof id === "string" ? id : null, name });
}
return kept;
}

function parseRecording(raw: RawSettings): RecordingPreferences {
return {
micEnabled: bool(raw.micEnabled, DEFAULT_RECORDING_PREFERENCES.micEnabled),
Expand All @@ -109,6 +128,7 @@ function parseRecording(raw: RawSettings): RecordingPreferences {
camEnabled: bool(raw.camEnabled, DEFAULT_RECORDING_PREFERENCES.camEnabled),
camDeviceId: nullableString(raw.camDeviceId, DEFAULT_RECORDING_PREFERENCES.camDeviceId),
camDeviceName: nullableString(raw.camDeviceName, DEFAULT_RECORDING_PREFERENCES.camDeviceName),
camAdditionalDevices: additionalCameras(raw.camAdditionalDevices),
// Unset in every settings file written before the camera had a quality
// setting, and `webcamQualityFrom` answers those with the default.
camQuality: webcamQualityFrom(raw.camQuality),
Expand Down Expand Up @@ -169,6 +189,10 @@ function validateRecordingPatch(patch: Partial<RecordingPreferences>): void {
if ((key.endsWith("Enabled") || key === "hideDesktopIcons") && typeof value !== "boolean") {
throw new TypeError(`${key} must be a boolean`);
}
if (key === "camAdditionalDevices") {
if (!Array.isArray(value)) throw new TypeError("camAdditionalDevices must be a list");
continue;
}
if (
(key.endsWith("DeviceId") || key.endsWith("DeviceName")) &&
value !== null &&
Expand Down Expand Up @@ -207,6 +231,9 @@ export class AppSettingsStore {
const next = Object.fromEntries(
Object.entries(patch).filter(([, value]) => value !== undefined),
) as Partial<RecordingPreferences>;
if (next.camAdditionalDevices) {
next.camAdditionalDevices = additionalCameras(next.camAdditionalDevices);
}
atomicWrite(this.userData, { ...raw, ...current, ...next });
return this.getSnapshot();
}
Expand Down
20 changes: 14 additions & 6 deletions electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,17 @@ interface Window {
* saved without it. Still a success — the screen video is intact.
*/
webcamDropped?: boolean;
/**
* Labels (device name, else `Camera <n>`) of additional cameras (2-4) that
* produced nothing usable and were left out of the session. Camera 1 is
* `webcamDropped`.
*/
droppedWebcams?: string[];
/**
* Labels of cameras (camera 1 included) that the helper disabled mid-take.
* Their partial files are kept in the session; the user is told which.
*/
webcamsStoppedEarly?: string[];
}>;
pauseNativeWindowsRecording: () => Promise<{
success: boolean;
Expand Down Expand Up @@ -353,12 +364,9 @@ interface Window {
/** Why this recording ended before it was stopped, when it did. */
warning?: string;
}>;
findRecordingCamera: (videoPath: string) => Promise<{
success: boolean;
webcamVideoPath?: string;
offsetMs?: number;
error?: string;
}>;
findRecordingCamera: (
videoPath: string,
) => Promise<import("../src/lib/recordingSession").FindRecordingCameraResult>;
readBinaryFile: (filePath: string) => Promise<{
success: boolean;
data?: ArrayBuffer;
Expand Down
Loading
Loading