From e050104cc8956cafb367197d6140143e3f33f81c Mon Sep 17 00:00:00 2001 From: MMArephinAbid <71713365+MMArephinAbid@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:25:59 +0600 Subject: [PATCH 1/5] feat(recording): add camera-only recording mode Adds a Camera only toggle to the recording bar. While it is on, Record starts a take of the camera alone: no screen or window is picked, no native capture helper starts, and no Linux portal is raised. The camera is recorded through the browser pipeline as the take's main video, so the editor's background, padding, roundness and shadow frame it like a screen recording. - The choice is persisted as the `cameraOnly` recording preference. - Turning it on turns the camera on, and is refused when the camera check refuses; turning the camera off leaves camera only. - Source, system audio and cursor controls are hidden while it is on, since all three belong to screen capture. - The take records a clone of the camera track, so stopping it never fires the preview track's `onended` (camera disconnected). - The take stores cursor mode "system" and writes no cursor telemetry. Closes #1276 Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- electron/app-settings.test.ts | 18 ++ electron/app-settings.ts | 12 +- electron/ipc/handlers.ts | 3 + electron/ipc/recordingPrefs.test.ts | 1 + .../ai-edition/v4/RecStage.test.tsx | 2 + src/components/launch/HudControls.tsx | 32 +++ src/components/launch/HudIcons.tsx | 11 + src/components/launch/LaunchWindow.test.tsx | 84 +++++++ src/components/launch/LaunchWindow.tsx | 98 ++++++-- .../useScreenRecorder.cameraOnly.test.tsx | 194 ++++++++++++++ src/hooks/useScreenRecorder.noCamera.test.tsx | 1 + .../useScreenRecorder.prefsRace.test.tsx | 1 + src/hooks/useScreenRecorder.ts | 236 ++++++++++++------ src/i18n/locales/ar/editor.json | 1 + src/i18n/locales/ar/launch.json | 2 + src/i18n/locales/cs/editor.json | 1 + src/i18n/locales/cs/launch.json | 2 + src/i18n/locales/de/editor.json | 1 + src/i18n/locales/de/launch.json | 2 + src/i18n/locales/en/editor.json | 1 + src/i18n/locales/en/launch.json | 2 + src/i18n/locales/es/editor.json | 1 + src/i18n/locales/es/launch.json | 2 + src/i18n/locales/fr/editor.json | 1 + src/i18n/locales/fr/launch.json | 2 + src/i18n/locales/it/editor.json | 1 + src/i18n/locales/it/launch.json | 2 + src/i18n/locales/ja-JP/editor.json | 1 + src/i18n/locales/ja-JP/launch.json | 2 + src/i18n/locales/ko-KR/editor.json | 1 + src/i18n/locales/ko-KR/launch.json | 2 + src/i18n/locales/pt-BR/editor.json | 1 + src/i18n/locales/pt-BR/launch.json | 2 + src/i18n/locales/ru/editor.json | 1 + src/i18n/locales/ru/launch.json | 2 + src/i18n/locales/tr/editor.json | 1 + src/i18n/locales/tr/launch.json | 2 + src/i18n/locales/vi/editor.json | 1 + src/i18n/locales/vi/launch.json | 2 + src/i18n/locales/zh-CN/editor.json | 1 + src/i18n/locales/zh-CN/launch.json | 2 + src/i18n/locales/zh-TW/editor.json | 1 + src/i18n/locales/zh-TW/launch.json | 2 + .../architecture/recording.md | 8 + 45 files changed, 656 insertions(+), 92 deletions(-) create mode 100644 src/hooks/useScreenRecorder.cameraOnly.test.tsx diff --git a/AGENTS.md b/AGENTS.md index ceea69bb61..84b7537391 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,7 +126,7 @@ This section is the *mechanics*. **What to actually run is [`technical-documenta - **The HUD and the editor never share the screen.** Opening the editor closes the HUD, and closing the editor, the last window, quits the app. The way back is the editor's Record mode (see the flow below). - Frameless, transparent, always-on-top, `skipTaskbar`, centered at the **bottom of the primary display** (`createHudOverlayWindow`, 820×560 at construction, then resized to fit its content — measured 904×698 with the bar at the bottom and mostly empty reserve above it). It is **click-through** (`setIgnoreMouseEvents(ignore)`): moving the real cursor over an interactive control makes that region clickable and shows its tooltip, so `mouse_move` → screenshot → `left_click` works; a blind click on empty HUD area passes through to the desktop. - **Only a real OS mouse move reaches the HUD — on macOS as much as on Windows.** While the window is input-transparent Chromium delivers it no pointer events at all, so the main process samples the OS cursor instead: the `hud-overlay-cursor` poll in `electron/windows.ts` reads `screen.getCursorScreenPoint()` while the HUD is click-through and pushes the window-relative point to the renderer, which hit-tests it with `elementFromPoint(…).closest("[data-hud-interactive='true']")`. One path, both platforms — there is no platform branch. **Linux is the exception** (`!enabled && !isLinuxHud` in `LaunchWindow.tsx`, where the call is a no-op), so it is the one platform where a blind click on the HUD simply lands. What the poll keys off is the OS cursor's position *relative to the window*, so a resize or re-anchor that slides the bar under a motionless pointer produces a fresh sample too. What it can never key off is synthesised input: Playwright's `.click()`, `javascript_tool`-dispatched pointer events and everything like them move no pointer at all, so they never put one on a control. They arrive *below* the OS hit-test, fire the DOM handler, and look like they worked — while the click-through path was never exercised at all. `tests/e2e/windows-native-checklist.spec.ts` does click HUD test IDs and stays green for exactly that reason; it proves renderer wiring, not reachability, and a macOS spec written the same way would prove no more. Use computer-use (`mouse_move` → `left_click`), and never conclude from a passing injected click that a user could have clicked it. (Until #385 the lift was Electron's `{ forward: true }` — a global `WH_MOUSE_LL` hook on Windows — which Windows can revoke without telling the app, leaving the HUD painted and permanently dead. The poll replaced it. The rule for you is unchanged, because both mechanisms key off the real cursor.) -- Control row (left→right): drag handle; horizontal/vertical bar toggle; **source** button (`Screen` until a source is picked, then its name); system audio; microphone; **camera**; a gear, *Device settings*, which picks the microphone and the camera and shows a level meter and a preview; cursor mode (editable or system cursor); **record**; *Open Studio* while idle, or pause, restart and cancel while recording; notes; language (the locale code, e.g. `EN`); *Hide recording bar*; *Quit OpenScreen*. Per platform: on macOS 15.2+ the source button opens Apple's system picker and the HUD hides until it closes; on macOS 13 and 14 there is no microphone; on Linux there is no source button (the portal asks when recording starts) and no notes button. +- Control row (left→right): drag handle; horizontal/vertical bar toggle; **camera only** (records the camera alone; while on, the source button, system audio and the cursor toggle are hidden); **source** button (`Screen` until a source is picked, then its name); system audio; microphone; **camera**; a gear, *Device settings*, which picks the microphone and the camera and shows a level meter and a preview; cursor mode (editable or system cursor); **record**; *Open Studio* while idle, or pause, restart and cancel while recording; notes; language (the locale code, e.g. `EN`); *Hide recording bar*; *Quit OpenScreen*. Per platform: on macOS 15.2+ the source button opens Apple's system picker and the HUD hides until it closes; on macOS 13 and 14 there is no microphone; on Linux there is no source button (the portal asks when recording starts) and no notes button. - Record is never disabled for want of a source. With none picked, its tooltip reads "Choose a screen or window to record", and a click opens the picker, then starts recording once a source is chosen. **The tray icon** (notification area on Windows, menu bar on macOS, the desktop's indicator area on Linux) diff --git a/electron/app-settings.test.ts b/electron/app-settings.test.ts index f3781734f8..21736b63fb 100644 --- a/electron/app-settings.test.ts +++ b/electron/app-settings.test.ts @@ -62,6 +62,24 @@ describe("app settings store", () => { ); }); + // getopenscreen/openscreen#1276 — camera only is a choice that has to survive a restart, and + // every settings file written before it existed records a screen. + it("persists camera only and reads a missing or invalid key as off", () => { + const dir = temp(); + const file = path.join(dir, "recording-settings.json"); + const store = new AppSettingsStore(dir); + expect(store.getSnapshot().recording.cameraOnly).toBe(false); + expect(store.setRecordingPreferences({ cameraOnly: true }).recording.cameraOnly).toBe(true); + expect(JSON.parse(readFileSync(file, "utf8"))).toMatchObject({ cameraOnly: true }); + expect(new AppSettingsStore(dir).getSnapshot().recording.cameraOnly).toBe(true); + + writeFileSync(file, JSON.stringify({ micEnabled: true }), "utf8"); + expect(store.getSnapshot().recording.cameraOnly).toBe(false); + writeFileSync(file, JSON.stringify({ cameraOnly: "true" }), "utf8"); + expect(store.getSnapshot().recording.cameraOnly).toBe(false); + expect(() => store.setRecordingPreferences({ cameraOnly: "yes" as never })).toThrow(TypeError); + }); + it("stores the last source beside the recording preferences", () => { const dir = temp(); const store = new AppSettingsStore(dir); diff --git a/electron/app-settings.ts b/electron/app-settings.ts index 81d334260c..2602bb49e8 100644 --- a/electron/app-settings.ts +++ b/electron/app-settings.ts @@ -30,6 +30,11 @@ export interface RecordingPreferences { * behaviour it gates has been on for every such installation. */ autoZoomEnabled: boolean; + /** + * Record the camera alone, with no screen or window. The camera's footage becomes the + * take's main video, so the editor's background and padding frame it like a screen. + */ + cameraOnly: boolean; } export const DEFAULT_RECORDING_PREFERENCES: RecordingPreferences = { @@ -44,6 +49,7 @@ export const DEFAULT_RECORDING_PREFERENCES: RecordingPreferences = { cursorCaptureMode: "editable-overlay", hideDesktopIcons: false, autoZoomEnabled: true, + cameraOnly: false, }; export interface RecordingSourceDescriptor { @@ -123,6 +129,7 @@ function parseRecording(raw: RawSettings): RecordingPreferences { : DEFAULT_RECORDING_PREFERENCES.cursorCaptureMode, hideDesktopIcons: bool(raw.hideDesktopIcons, DEFAULT_RECORDING_PREFERENCES.hideDesktopIcons), autoZoomEnabled: bool(raw.autoZoomEnabled, DEFAULT_RECORDING_PREFERENCES.autoZoomEnabled), + cameraOnly: bool(raw.cameraOnly, DEFAULT_RECORDING_PREFERENCES.cameraOnly), }; } @@ -167,7 +174,10 @@ function validateRecordingPatch(patch: Partial): void { for (const [key, value] of Object.entries(patch)) { if (!allowed.has(key)) throw new TypeError(`unknown recording preference: ${key}`); if (value === undefined) continue; - if ((key.endsWith("Enabled") || key === "hideDesktopIcons") && typeof value !== "boolean") { + if ( + (key.endsWith("Enabled") || key === "hideDesktopIcons" || key === "cameraOnly") && + typeof value !== "boolean" + ) { throw new TypeError(`${key} must be a boolean`); } if ( diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 0fcd435b1b..85716ec540 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -699,6 +699,8 @@ export interface RecordingPrefs { hideDesktopIcons: boolean; /** Whether a fresh take gets automatic zooms on import. Persisted; defaults on. */ autoZoomEnabled: boolean; + /** Record the camera alone, as the take's main video. Persisted; defaults off. */ + cameraOnly: boolean; } const defaultRecordingPrefs: RecordingPrefs = { micEnabled: false, @@ -712,6 +714,7 @@ const defaultRecordingPrefs: RecordingPrefs = { cursorCaptureMode: "editable-overlay", hideDesktopIcons: false, autoZoomEnabled: true, + cameraOnly: false, }; // Cached source from the user's pick. Used by setDisplayMediaRequestHandler in main.ts for cursor-free capture. diff --git a/electron/ipc/recordingPrefs.test.ts b/electron/ipc/recordingPrefs.test.ts index 2c3317af93..858c95c9dc 100644 --- a/electron/ipc/recordingPrefs.test.ts +++ b/electron/ipc/recordingPrefs.test.ts @@ -24,6 +24,7 @@ const defaults: RecordingPrefs = { cursorCaptureMode: "editable-overlay", hideDesktopIcons: false, autoZoomEnabled: true, + cameraOnly: false, }; let dir: string; beforeEach(() => { diff --git a/src/components/ai-edition/v4/RecStage.test.tsx b/src/components/ai-edition/v4/RecStage.test.tsx index 49230e2784..c7aee45c81 100644 --- a/src/components/ai-edition/v4/RecStage.test.tsx +++ b/src/components/ai-edition/v4/RecStage.test.tsx @@ -423,6 +423,7 @@ describe("RecStage controls", () => { cursorCaptureMode: "editable-overlay", hideDesktopIcons: false, autoZoomEnabled: true, + cameraOnly: false, }; stubRecordingPrefs(); let answerReread: ((prefs: RecordingPrefs) => void) | undefined; @@ -635,6 +636,7 @@ describe("RecStage controls", () => { cursorCaptureMode: "editable-overlay", hideDesktopIcons: false, autoZoomEnabled: true, + cameraOnly: false, }; act(() => { recordingPrefsListeners.forEach((listener) => listener(resetPrefs)); diff --git a/src/components/launch/HudControls.tsx b/src/components/launch/HudControls.tsx index 2c5fa7df1f..b02a85df3a 100644 --- a/src/components/launch/HudControls.tsx +++ b/src/components/launch/HudControls.tsx @@ -14,6 +14,7 @@ import { Button } from "../ui/button"; import { TOOLTIP_GAP_PX, Tooltip } from "../ui/tooltip"; import { CameraIcon, + CameraOnlyIcon, CursorIcon, getIcon, ICON_SIZE, @@ -327,6 +328,37 @@ export const HudCameraButton = memo(function HudCameraButton({ ); }); +/** Switches the take between a screen recording and the camera alone. Same pattern as the toggles above. */ +export const HudCameraOnlyButton = memo(function HudCameraOnlyButton({ + enabled, + locked, + name, + tooltip, + onClick, +}: { + enabled: boolean; + locked: boolean; + name: string; + tooltip: string; + onClick: () => void; +}) { + return ( + + + + ); +}); + export const HudSettingsButton = memo(function HudSettingsButton({ locked, expanded, diff --git a/src/components/launch/HudIcons.tsx b/src/components/launch/HudIcons.tsx index f01a293ef6..28c968b0f9 100644 --- a/src/components/launch/HudIcons.tsx +++ b/src/components/launch/HudIcons.tsx @@ -117,6 +117,17 @@ export function CameraIcon({ off, className }: { off: boolean; className?: strin ); } +/** A person inside a frame: the take is the camera's picture alone, with no screen behind it. */ +export function CameraOnlyIcon({ className }: { className?: string }) { + return ( + + ); +} + /** `off` slashes the arrow, like the mic and camera, so the state is not carried by colour alone. */ export function CursorIcon({ off = false, className }: { off?: boolean; className?: string }) { return ( diff --git a/src/components/launch/LaunchWindow.test.tsx b/src/components/launch/LaunchWindow.test.tsx index 8cd92de957..7d8b2bf562 100644 --- a/src/components/launch/LaunchWindow.test.tsx +++ b/src/components/launch/LaunchWindow.test.tsx @@ -74,6 +74,8 @@ const recorderState = vi.hoisted(() => ({ setSystemAudioEnabled: vi.fn(), cursorCaptureMode: "editable-overlay", setCursorCaptureMode: vi.fn(), + cameraOnly: false, + setCameraOnly: vi.fn(), softwareEncoderFallbackNoticeVisible: false, dismissSoftwareEncoderFallbackNotice: vi.fn(), recordingPrefsLoaded: true, @@ -368,6 +370,8 @@ function resetLaunchMocks() { recorderState.value.setMicrophoneDeviceId.mockClear(); recorderState.value.webcamEnabled = false; recorderState.value.setWebcamEnabled.mockClear(); + recorderState.value.cameraOnly = false; + recorderState.value.setCameraOnly.mockClear(); recorderState.value.recordingPrefsLoaded = true; cameraDevicesState.isReady = true; cameraDevicesState.devices = []; @@ -1905,6 +1909,86 @@ describe("LaunchWindow device buttons", () => { }); }); +// getopenscreen/openscreen#1276 +describe("LaunchWindow camera only", () => { + beforeEach(() => { + platformState.value = "darwin"; + resetLaunchMocks(); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("turns the camera on with camera only, and stores both", async () => { + renderLaunchWindow(); + const toggle = await screen.findByTestId("launch-camera-only-button"); + expect(toggle).toHaveAttribute("aria-pressed", "false"); + + fireEvent.click(toggle); + + await waitFor(() => { + expect(window.electronAPI.setRecordingPrefs).toHaveBeenCalledWith({ + camEnabled: true, + cameraOnly: true, + }); + }); + expect(recorderState.value.setWebcamEnabled).toHaveBeenCalledWith(true); + expect(recorderState.value.setCameraOnly).toHaveBeenCalledWith(true); + }); + + it("stays off when the camera cannot be turned on", async () => { + recorderState.value.setWebcamEnabled.mockResolvedValueOnce(false); + renderLaunchWindow(); + + fireEvent.click(await screen.findByTestId("launch-camera-only-button")); + + await waitFor(() => { + expect(recorderState.value.setWebcamEnabled).toHaveBeenCalledWith(true); + }); + await act(async () => undefined); + expect(recorderState.value.setCameraOnly).not.toHaveBeenCalled(); + expect(window.electronAPI.setRecordingPrefs).not.toHaveBeenCalled(); + }); + + it("records without a source: no source button, and Record never opens the picker", async () => { + recorderState.value.cameraOnly = true; + recorderState.value.webcamEnabled = true; + renderLaunchWindow(); + + const recordButton = await screen.findByTestId("launch-record-button"); + expect(recordButton).toHaveAttribute("aria-label", "Start recording"); + expect(screen.queryByTestId("launch-source-selector-button")).toBeNull(); + // Both come from screen capture, which a camera-only take does not run. + expect(screen.queryByTestId("launch-system-audio-button")).toBeNull(); + expect(screen.queryByTestId("launch-cursor-mode-button")).toBeNull(); + + fireEvent.click(recordButton); + + await waitFor(() => { + expect(recorderState.value.toggleRecording).toHaveBeenCalledTimes(1); + }); + expect(window.electronAPI.openSourceSelector).not.toHaveBeenCalled(); + }); + + it("leaves camera only when the camera is switched off", async () => { + recorderState.value.cameraOnly = true; + recorderState.value.webcamEnabled = true; + renderLaunchWindow(); + + fireEvent.click(await screen.findByTestId("launch-webcam-button")); + + await waitFor(() => { + expect(window.electronAPI.setRecordingPrefs).toHaveBeenCalledWith({ + camEnabled: false, + cameraOnly: false, + }); + }); + expect(recorderState.value.setCameraOnly).toHaveBeenCalledWith(false); + }); +}); + describe("LaunchWindow device settings", () => { beforeEach(() => { platformState.value = "darwin"; diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index d4b402b841..86d38445d0 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -21,6 +21,7 @@ import { requestCameraAccess } from "../../lib/requestCameraAccess"; import { Toaster } from "../ui/sonner"; import { HudCameraButton, + HudCameraOnlyButton, HudCursorButton, HudDivider, HudDragHandle, @@ -131,6 +132,8 @@ export function LaunchWindow() { setWebcamDeviceName, cursorCaptureMode, setCursorCaptureMode, + cameraOnly, + setCameraOnly, softwareEncoderFallbackNoticeVisible, dismissSoftwareEncoderFallbackNotice, recordingPrefsLoaded, @@ -808,8 +811,10 @@ export function LaunchWindow() { } // Linux never detours through the in-app picker: there is nothing for // it to select, and waiting for a selection that can never arrive left - // the record button opening a modal instead of recording. - const sourceSelected = portalOwnsSource || (sourceSelectedOverride ?? hasSelectedSource); + // the record button opening a modal instead of recording. Nor does a + // camera-only take, which records no screen to choose. + const sourceSelected = + cameraOnly || portalOwnsSource || (sourceSelectedOverride ?? hasSelectedSource); if (!sourceSelected && !recording) { recordAfterSourceSelectionRef.current = true; void openSourceSelector() @@ -838,6 +843,7 @@ export function LaunchWindow() { void startWhenDevicesReady(); }, [ + cameraOnly, hasSelectedSource, portalOwnsSource, openSourceSelector, @@ -920,6 +926,7 @@ export function LaunchWindow() { micDeviceName?: string; systemAudioEnabled?: boolean; cursorCaptureMode?: "editable-overlay" | "system"; + cameraOnly?: boolean; }) => { void window.electronAPI?.setRecordingPrefs?.(patch).catch((error) => { console.warn("Failed to persist the device preference:", error); @@ -974,9 +981,46 @@ export function LaunchWindow() { if (controlsLocked) return; const next = !webcamEnabled; void setWebcamEnabled(next).then((ok) => { - if (ok) persistRecordingPrefs({ camEnabled: next }); + if (!ok) return; + // Camera only with the camera off would record nothing, so it goes with it. + if (!next && cameraOnly) { + setCameraOnly(false); + persistRecordingPrefs({ camEnabled: false, cameraOnly: false }); + return; + } + persistRecordingPrefs({ camEnabled: next }); }); - }, [controlsLocked, persistRecordingPrefs, setWebcamEnabled, webcamEnabled]); + }, [ + cameraOnly, + controlsLocked, + persistRecordingPrefs, + setCameraOnly, + setWebcamEnabled, + webcamEnabled, + ]); + + // Turning camera only on switches the camera on with it: that is the whole take. The + // camera check runs first, so a camera the OS does not list refuses both (#995). + const toggleCameraOnly = useCallback(() => { + if (controlsLocked) return; + if (cameraOnly) { + setCameraOnly(false); + persistRecordingPrefs({ cameraOnly: false }); + return; + } + void (webcamEnabled ? Promise.resolve(true) : setWebcamEnabled(true)).then((ok) => { + if (!ok) return; + setCameraOnly(true); + persistRecordingPrefs({ camEnabled: true, cameraOnly: true }); + }); + }, [ + cameraOnly, + controlsLocked, + persistRecordingPrefs, + setCameraOnly, + setWebcamEnabled, + webcamEnabled, + ]); // Selecting a device never switches it on. If the device is already live the // recorder re-acquires on the id change; if it isn't, this just records which @@ -1135,11 +1179,13 @@ export function LaunchWindow() { ? t("recording.saving") : recording ? t("recording.stop") - : portalOwnsSource - ? t("recording.systemPicker") - : hasSelectedSource - ? t("recording.start") - : t("recording.selectSource"); + : cameraOnly + ? t("recording.start") + : portalOwnsSource + ? t("recording.systemPicker") + : hasSelectedSource + ? t("recording.start") + : t("recording.selectSource"); // Stable identity, or the panel's memo boundary would break on every parent // render — including the once-a-second one during a recording. @@ -1227,7 +1273,18 @@ export function LaunchWindow() { discarded, which is why picking a window here changed nothing. The compositor's picker is the only one that decides, and it appears when recording starts. */} - {!portalOwnsSource && ( + {/* Beside the source because it is the other answer to "what am I + recording": a screen, or the camera alone. With it on, there is no + source to pick, so the source button gives way. */} + + + {!portalOwnsSource && !cameraOnly && ( + {/* System audio and the cursor come from screen capture, which a + camera-only take does not run: offering them would promise a track + the take cannot record. */} + {!cameraOnly && ( + + )} {/* The gear configures the two toggles beside it, so the three sit closer together than the bar's normal control spacing — proximity is the design's own grouping device, no extra furniture needed. */} @@ -1287,7 +1349,7 @@ export function LaunchWindow() { onClick={toggleDeviceSettings} /> - {supportsCursorModeToggle && ( + {supportsCursorModeToggle && !cameraOnly && ( { + const t = (key: string) => key; + return { useScopedT: () => t }; +}); + +vi.mock("sonner", () => ({ + toast: { error: vi.fn(), success: vi.fn(), info: vi.fn(), warning: vi.fn() }, +})); + +vi.mock("@/lib/requestCameraAccess", () => ({ + requestCameraAccess: vi.fn(async () => ({ success: true, granted: true, status: "granted" })), +})); + +// A recorder that "streamed to disk" and stops the way a real one does: by firing `stop`. +vi.mock("./recorderHandle", () => ({ + createRecorderHandle: vi.fn(() => { + const recorder = Object.assign(new EventTarget(), { + state: "recording", + stop: vi.fn(() => { + recorder.state = "inactive"; + recorder.dispatchEvent(new Event("stop")); + }), + }); + return { + recorder, + recordedBlobPromise: Promise.resolve(new Blob()), + isStreaming: () => true, + discard: vi.fn(async () => undefined), + }; + }), +})); + +import { toast } from "sonner"; +import { createRecorderHandle } from "./recorderHandle"; +import { useScreenRecorder } from "./useScreenRecorder"; + +type ElectronAPI = Window["electronAPI"]; + +let api: Record>; +let cameraTrack: { stop: ReturnType; clone: ReturnType }; +let clonedTrack: { stop: ReturnType; getSettings: () => MediaTrackSettings }; + +class FakeMediaStream { + private readonly tracks: unknown[]; + constructor(tracks: unknown[] = []) { + this.tracks = [...tracks]; + } + getTracks = () => this.tracks; + getVideoTracks = () => this.tracks; + getAudioTracks = () => []; + addTrack = (track: unknown) => this.tracks.push(track); +} + +function stubElectronAPI(camEnabled: boolean) { + api = { + getRecordingPrefs: vi.fn(async () => ({ + micEnabled: false, + micDeviceId: null, + micDeviceName: null, + camEnabled, + camDeviceId: null, + camDeviceName: null, + camQuality: "1080p", + systemAudioEnabled: true, + cursorCaptureMode: "editable-overlay", + hideDesktopIcons: false, + autoZoomEnabled: true, + cameraOnly: true, + })), + setRecordingPrefs: vi.fn(async () => undefined), + onRecordingPrefsChanged: vi.fn(() => () => undefined), + // Windows, with no source picked: a screen take would go native, or refuse. + getPlatform: vi.fn(() => "win32"), + getSelectedSource: vi.fn(async () => null), + isNativeWindowsCaptureAvailable: vi.fn(async () => ({ success: true, available: true })), + startNativeWindowsRecording: vi.fn(), + showCountdownOverlay: vi.fn(async () => true), + setCountdownOverlayValue: vi.fn(async () => true), + hideCountdownOverlay: vi.fn(async () => true), + setRecordingState: vi.fn(async () => undefined), + storeRecordedSession: vi.fn(async () => ({ + success: true, + session: { screenVideoPath: "/rec/recording-1.webm", createdAt: 1 }, + })), + discardCursorTelemetry: vi.fn(), + setCurrentRecordingSession: vi.fn(async () => undefined), + setCurrentVideoPath: vi.fn(async () => undefined), + switchToEditor: vi.fn(async () => undefined), + }; + window.electronAPI = api as unknown as ElectronAPI; + + clonedTrack = { stop: vi.fn(), getSettings: () => ({ width: 1920, height: 1080 }) }; + cameraTrack = { stop: vi.fn(), clone: vi.fn(() => clonedTrack) }; + const cameraStream = { getTracks: () => [cameraTrack], getVideoTracks: () => [cameraTrack] }; + Object.defineProperty(navigator, "mediaDevices", { + configurable: true, + value: { + enumerateDevices: vi.fn(async () => [ + { kind: "videoinput", deviceId: "cam-1", label: "Cam", groupId: "" }, + ]), + getUserMedia: vi.fn(async () => cameraStream), + getDisplayMedia: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }, + }); + vi.stubGlobal("MediaRecorder", { isTypeSupported: () => true }); + vi.stubGlobal("MediaStream", FakeMediaStream); +} + +async function settle(ms = 0) { + await act(async () => { + await vi.advanceTimersByTimeAsync(ms); + }); +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.mocked(toast.error).mockClear(); + vi.mocked(createRecorderHandle).mockClear(); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("useScreenRecorder camera only (#1276)", () => { + it("records the camera as the take's main video, with no source and no screen capture", async () => { + stubElectronAPI(true); + const view = renderHook(() => useScreenRecorder()); + await settle(); + expect(view.result.current.cameraOnly).toBe(true); + + await act(async () => { + view.result.current.toggleRecording(); + }); + await settle(3_500); + + expect(view.result.current.recording).toBe(true); + expect(api.isNativeWindowsCaptureAvailable).not.toHaveBeenCalled(); + expect(api.startNativeWindowsRecording).not.toHaveBeenCalled(); + expect(navigator.mediaDevices.getDisplayMedia).not.toHaveBeenCalled(); + // One recorder, fed a clone of the camera's track and named as a screen take. + expect(createRecorderHandle).toHaveBeenCalledTimes(1); + const [recordedStream, , fileName] = vi.mocked(createRecorderHandle).mock.calls[0]; + expect(recordedStream.getVideoTracks()).toEqual([clonedTrack]); + expect(fileName).toMatch(/^recording-\d+\.webm$/); + // No cursor telemetry: a camera take has nothing for a cursor to point at. + expect(api.setRecordingState).toHaveBeenCalledWith(true, expect.any(Number), "system"); + + await act(async () => { + view.result.current.toggleRecording(); + }); + await settle(); + + expect(api.storeRecordedSession).toHaveBeenCalledTimes(1); + expect(api.storeRecordedSession).toHaveBeenCalledWith( + expect.objectContaining({ + screen: expect.objectContaining({ fileName }), + webcam: undefined, + cursorCaptureMode: "system", + }), + ); + expect(api.switchToEditor).toHaveBeenCalled(); + // The take stopped its own track; the camera preview's track is left running, so + // its `onended` never reports the camera as disconnected. + expect(clonedTrack.stop).toHaveBeenCalled(); + expect(cameraTrack.stop).not.toHaveBeenCalled(); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it("says a camera is needed instead of recording nothing", async () => { + stubElectronAPI(false); + const view = renderHook(() => useScreenRecorder()); + await settle(); + + await act(async () => { + view.result.current.toggleRecording(); + }); + await settle(3_500); + + expect(view.result.current.recording).toBe(false); + expect(createRecorderHandle).not.toHaveBeenCalled(); + expect(toast.error).toHaveBeenCalledWith("recording.cameraOnlyNeedsCamera"); + }); +}); diff --git a/src/hooks/useScreenRecorder.noCamera.test.tsx b/src/hooks/useScreenRecorder.noCamera.test.tsx index cd75f16b8d..65b5d28a72 100644 --- a/src/hooks/useScreenRecorder.noCamera.test.tsx +++ b/src/hooks/useScreenRecorder.noCamera.test.tsx @@ -35,6 +35,7 @@ function prefs(camEnabled: boolean): RecordingPrefs { cursorCaptureMode: "editable-overlay", hideDesktopIcons: false, autoZoomEnabled: true, + cameraOnly: false, }; } diff --git a/src/hooks/useScreenRecorder.prefsRace.test.tsx b/src/hooks/useScreenRecorder.prefsRace.test.tsx index ae84c2cd71..1794fceaf4 100644 --- a/src/hooks/useScreenRecorder.prefsRace.test.tsx +++ b/src/hooks/useScreenRecorder.prefsRace.test.tsx @@ -30,6 +30,7 @@ function prefs(micEnabled: boolean): RecordingPrefs { cursorCaptureMode: "editable-overlay", hideDesktopIcons: false, autoZoomEnabled: true, + cameraOnly: false, }; } diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 8c22aa9b76..567bf5f6cb 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -117,6 +117,9 @@ type UseScreenRecorderReturn = { setWebcamEnabled: (enabled: boolean) => Promise; cursorCaptureMode: CursorCaptureMode; setCursorCaptureMode: (mode: CursorCaptureMode) => void; + /** Record the camera alone: no source is picked and the camera is the take's main video. */ + cameraOnly: boolean; + setCameraOnly: (enabled: boolean) => void; softwareEncoderFallbackNoticeVisible: boolean; dismissSoftwareEncoderFallbackNotice: (dontShowAgain?: boolean) => void; recordingPrefsLoaded: boolean; @@ -272,6 +275,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const [systemAudioEnabled, setSystemAudioEnabled] = useState(false); const [webcamEnabled, setWebcamEnabledState] = useState(false); const [cursorCaptureMode, setCursorCaptureMode] = useState("editable-overlay"); + const [cameraOnly, setCameraOnly] = useState(false); const [softwareEncoderFallbackNoticeVisible, setSoftwareEncoderFallbackNoticeVisible] = useState(false); const [recordingPrefsLoaded, setRecordingPrefsLoaded] = useState(false); @@ -295,6 +299,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { camQuality?: WebcamQualityId | null; systemAudioEnabled: boolean; cursorCaptureMode: CursorCaptureMode; + cameraOnly?: boolean; }) => { if (cancelled) return; // Not applied where a take cannot record the microphone (#700): it would ask @@ -309,6 +314,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { setWebcamEnabledState(prefs.camEnabled); setWebcamDeviceId(prefs.camDeviceId ?? undefined); setWebcamDeviceName(prefs.camDeviceName ?? undefined); + setCameraOnly(prefs.cameraOnly ?? false); } setWebcamQuality(webcamQualityFrom(prefs.camQuality)); setSystemAudioEnabled(prefs.systemAudioEnabled); @@ -353,6 +359,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const webcamStream = useRef(null); const mixingContext = useRef(null); const recordingId = useRef(0); + // The id of the take, if any, that recorded the camera alone. Its finalize has to + // describe a take with no cursor in it, whatever cursor mode is selected. + const cameraOnlyRecordingId = useRef(null); const accumulatedDurationMs = useRef(0); const segmentStartedAt = useRef(null); const finalizingRecordingId = useRef(null); @@ -652,11 +661,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn { // three native paths), and off win32 it cannot exclude the system cursor // — so the mode reported to the main process was forced to "system" and // the stored metadata has to agree. It is user-visible: `openscreen - // project show` prints it. - cursorCaptureMode: effectiveBrowserCursorMode( - window.electronAPI.getPlatform(), - cursorCaptureMode, - ), + // project show` prints it. A camera-only take captured no cursor at + // all, and "system" is the mode that asks the editor to draw none. + cursorCaptureMode: + cameraOnlyRecordingId.current === activeRecordingId + ? "system" + : effectiveBrowserCursorMode(window.electronAPI.getPlatform(), cursorCaptureMode), durationMs: duration, }); @@ -692,6 +702,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (discardRecordingId.current === activeRecordingId) { discardRecordingId.current = null; } + if (cameraOnlyRecordingId.current === activeRecordingId) { + cameraOnlyRecordingId.current = null; + } setSaving(false); } })(); @@ -1634,6 +1647,136 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } }; + const captureMicrophone = async (): Promise => { + try { + return await navigator.mediaDevices.getUserMedia({ + audio: microphoneDeviceId + ? { + deviceId: { exact: microphoneDeviceId }, + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + } + : { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + }, + video: false, + }); + } catch (audioError) { + console.warn("Failed to get microphone access:", audioError); + toast.error(t("recording.microphoneDenied")); + setMicrophoneEnabled(false); + return null; + } + }; + + /** + * Puts a browser-pipeline take on the clock once its recorders exist, and arranges + * for it to be saved when the main recorder stops. Shared by the screen take and the + * camera-only one, which differ only in what they feed the recorders. + */ + const beginBrowserTake = ( + activeScreenRecorder: RecorderHandle, + activeRecordingId: number, + takeCursorCaptureMode: CursorCaptureMode, + ) => { + activeScreenRecorder.recorder.addEventListener( + "error", + () => { + setRecording(false); + // The main process was told a take started (below), so it has to hear + // that it ended too, or the tray and the display-sleep blocker stay on. + window.electronAPI?.setRecordingState(false); + }, + { once: true }, + ); + + accumulatedDurationMs.current = 0; + segmentStartedAt.current = Date.now(); + allowAutoFinalize.current = true; + setRecording(true); + setPaused(false); + setElapsedSeconds(0); + window.electronAPI?.setRecordingState(true, activeRecordingId, takeCursorCaptureMode); + + const activeWebcamRecorder = webcamRecorder.current; + activeScreenRecorder.recorder.addEventListener( + "stop", + () => { + if (!allowAutoFinalize.current) { + return; + } + finalizeRecording( + activeScreenRecorder, + activeWebcamRecorder ?? null, + Math.max(0, getRecordingDurationMs()), + activeRecordingId, + ); + }, + { once: true }, + ); + }; + + /** + * Records the camera alone (#1276), through the browser pipeline on every platform: + * each native helper exists to capture a screen, and there is none here. The camera + * is recorded as the take's MAIN video rather than as a webcam sidecar, so the editor + * treats it the way it treats a screen recording — the background, padding, corner + * radius and shadow all frame it — instead of looking for a screen that never existed. + */ + const startCameraOnlyRecording = async ( + recordMicrophone: boolean, + countdownRunToken?: number, + ) => { + if (webcamEnabled) { + await waitForWebcamReady(); + } + if (!isCountdownRunActive(countdownRunToken)) { + return; + } + const cameraTrack = webcamStream.current?.getVideoTracks()[0]; + if (!cameraTrack) { + throw new Error(t("recording.cameraOnlyNeedsCamera")); + } + + const micMediaStream = recordMicrophone ? await captureMicrophone() : null; + microphoneStream.current = micMediaStream; + if (!isCountdownRunActive(countdownRunToken)) { + teardownMedia(); + return; + } + + // A clone, so the take owns a track of its own. teardownMedia() stops what was + // recorded, and stopping the preview's track instead would fire its `onended`, + // which reports the camera as disconnected and switches it off. + stream.current = new MediaStream([cameraTrack.clone()]); + const { context: mixingCtx, track: micTrack } = mixAudioTracks({ + micAudioTrack: micMediaStream?.getAudioTracks()[0], + }); + if (mixingCtx) { + mixingContext.current = mixingCtx; + } + if (micTrack) { + stream.current.addTrack(micTrack); + } + + recordingId.current = Date.now(); + const activeRecordingId = recordingId.current; + cameraOnlyRecordingId.current = activeRecordingId; + screenRecorder.current = createRecorderHandle( + stream.current, + { + ...webcamRecorderOptions(stream.current), + ...(micTrack ? { audioBitsPerSecond: AUDIO_BITRATE_VOICE } : {}), + }, + `${RECORDING_FILE_PREFIX}${activeRecordingId}${VIDEO_FILE_EXTENSION}`, + ); + // No cursor telemetry: nothing on screen was recorded for a cursor to point at. + beginBrowserTake(screenRecorder.current, activeRecordingId, "system"); + }; + const startRecordCountdown = async () => { if (countdownActive || recording) { return; @@ -1649,8 +1792,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn { console.warn("Failed to read selected source before countdown:", error); } - // Resolved before the liveness check below so every await stays ahead of it. - const portalOwnsSource = await portalOwnsSourceSelection(window.electronAPI); + // Resolved before the liveness check below so every await stays ahead of it. A + // camera-only take records no screen, so there is no portal to raise for it. + const portalOwnsSource = cameraOnly + ? false + : await portalOwnsSourceSelection(window.electronAPI); if (!isCountdownRunActive(runId)) { return; @@ -1661,7 +1807,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { // its picker is raised when capture starts, several steps after this — so // there is nothing to have selected, and refusing here blocked recording // outright once the in-app picker was removed. - if (!selectedSource && !portalOwnsSource) { + if (!selectedSource && !portalOwnsSource && !cameraOnly) { if (countdownRunId.current === runId) { setCountdownActive(false); } @@ -1671,7 +1817,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { try { const platform = window.electronAPI.getPlatform(); - if (platform === "darwin" && cursorCaptureMode === "editable-overlay") { + if (platform === "darwin" && !cameraOnly && cursorCaptureMode === "editable-overlay") { // Accessibility only improves cursor shape hints. Keep recording when // the grant is pending or the helper is unavailable; the cursor session // falls back to position-only telemetry when needed. @@ -1813,6 +1959,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn { toast.error(t("rec.noMicrophoneFound")); } + if (cameraOnly) { + await startCameraOnlyRecording(recordMicrophone, countdownRunToken); + return; + } + // BEFORE THE SOURCE GATE, on purpose. On Wayland the portal raises its // own picker and is the only thing that can choose a source, so there // is nothing for the app to have selected — and the helper needs no @@ -1912,32 +2063,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } as unknown as MediaStreamConstraints); })(); - const micCapture: Promise = recordMicrophone - ? (async () => { - try { - return await navigator.mediaDevices.getUserMedia({ - audio: microphoneDeviceId - ? { - deviceId: { exact: microphoneDeviceId }, - echoCancellation: true, - noiseSuppression: true, - autoGainControl: true, - } - : { - echoCancellation: true, - noiseSuppression: true, - autoGainControl: true, - }, - video: false, - }); - } catch (audioError) { - console.warn("Failed to get microphone access:", audioError); - toast.error(t("recording.microphoneDenied")); - setMicrophoneEnabled(false); - return null; - } - })() - : Promise.resolve(null); + const micCapture = recordMicrophone ? captureMicrophone() : Promise.resolve(null); // Await both in-flight captures. If the screen capture rejects it would // otherwise orphan a mic stream that resolved in parallel (leaving the @@ -2067,16 +2193,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn { }, `${RECORDING_FILE_PREFIX}${activeRecordingId}${VIDEO_FILE_EXTENSION}`, ); - screenRecorder.current.recorder.addEventListener( - "error", - () => { - setRecording(false); - // The main process was told a take started (below), so it has to hear - // that it ended too, or the tray and the display-sleep blocker stay on. - window.electronAPI?.setRecordingState(false); - }, - { once: true }, - ); if (webcamStream.current) { webcamRecorder.current = createRecorderHandle( @@ -2086,33 +2202,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { ); } - accumulatedDurationMs.current = 0; - segmentStartedAt.current = Date.now(); - allowAutoFinalize.current = true; - setRecording(true); - setPaused(false); - setElapsedSeconds(0); - window.electronAPI?.setRecordingState(true, recordingId.current, browserCursorCaptureMode); - - const activeScreenRecorder = screenRecorder.current; - const activeWebcamRecorder = webcamRecorder.current; - if (activeScreenRecorder) { - activeScreenRecorder.recorder.addEventListener( - "stop", - () => { - if (!allowAutoFinalize.current) { - return; - } - finalizeRecording( - activeScreenRecorder, - activeWebcamRecorder ?? null, - Math.max(0, getRecordingDurationMs()), - activeRecordingId, - ); - }, - { once: true }, - ); - } + beginBrowserTake(screenRecorder.current, activeRecordingId, browserCursorCaptureMode); } catch (error) { console.error("Failed to start recording:", error); const errorMsg = error instanceof Error ? error.message : "Failed to start recording"; @@ -2491,6 +2581,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn { setWebcamEnabled, cursorCaptureMode, setCursorCaptureMode, + cameraOnly, + setCameraOnly, softwareEncoderFallbackNoticeVisible, dismissSoftwareEncoderFallbackNotice, recordingPrefsLoaded, diff --git a/src/i18n/locales/ar/editor.json b/src/i18n/locales/ar/editor.json index 293429b036..7a24d70843 100644 --- a/src/i18n/locales/ar/editor.json +++ b/src/i18n/locales/ar/editor.json @@ -46,6 +46,7 @@ "cameraDenied": "تم رفض الوصول إلى الكاميرا. سيستمر التسجيل بدون كاميرا الويب.", "cameraDisconnected": "تم فصل كاميرا الويب.", "cameraNotFound": "لم يتم العثور على كاميرا.", + "cameraOnlyNeedsCamera": "وضع الكاميرا فقط يحتاج إلى كاميرا. شغّل الكاميرا أو اختر واحدة من إعدادات الأجهزة.", "cameraCaptureUnavailable": "تعذّر فتح الكاميرا. يجري التسجيل بدون كاميرا.", "microphoneDefaulted": "تعذّر تحديد الميكروفون المحدَّد؛ يجري التسجيل من الإدخال الافتراضي.", "microphoneUnavailable": "لا يمكن لهذا الإصدار من macOS تسجيل الميكروفون. يجري التسجيل بدون ميكروفون.", diff --git a/src/i18n/locales/ar/launch.json b/src/i18n/locales/ar/launch.json index ef613d15d4..f21d9f8333 100644 --- a/src/i18n/locales/ar/launch.json +++ b/src/i18n/locales/ar/launch.json @@ -13,6 +13,7 @@ "systemAudio": "صوت النظام: يسجّل الصوت الذي يشغّله الكمبيوتر", "microphone": "الميكروفون: يسجّل صوتك", "camera": "الكاميرا: تسجّل صورتك كطبقة منفصلة يمكنك تحريكها في المحرر", + "cameraOnly": "الكاميرا فقط: تسجّلك أنت فقط، دون الشاشة. أضف خلفية لها في المحرر", "openStudio": "فتح الاستوديو: محرر تسجيلاتك", "openNotes": "فتح الملاحظات: نص تقرؤه أثناء التسجيل", "openNotesPlaceholder": "اكتب ملاحظاتك هنا...", @@ -46,6 +47,7 @@ "noneFound": "لم يتم العثور على كاميرا", "unavailable": "الكاميرا غير متوفرة", "camera": "الكاميرا", + "cameraOnly": "الكاميرا فقط", "cameraDevice": "جهاز الكاميرا", "quality": "جودة الكاميرا", "quality1080p": "1080p", diff --git a/src/i18n/locales/cs/editor.json b/src/i18n/locales/cs/editor.json index c4a15bc103..565c08bfe6 100644 --- a/src/i18n/locales/cs/editor.json +++ b/src/i18n/locales/cs/editor.json @@ -46,6 +46,7 @@ "cameraDenied": "Přístup ke kameře odepřen. Nahrávání bude pokračovat bez webkamery.", "cameraDisconnected": "Webkamera odpojena.", "cameraNotFound": "Kamera nenalezena.", + "cameraOnlyNeedsCamera": "Režim Pouze kamera potřebuje kameru. Zapněte kameru nebo ji vyberte v nastavení zařízení.", "cameraCaptureUnavailable": "Kameru se nepodařilo otevřít. Nahrávání probíhá bez ní.", "microphoneDefaulted": "Vybraný mikrofon se nepodařilo identifikovat. Nahrávání bude pokračovat s výchozím vstupem.", "microphoneUnavailable": "Tato verze macOS neumí nahrávat mikrofon. Nahrávání probíhá bez něj.", diff --git a/src/i18n/locales/cs/launch.json b/src/i18n/locales/cs/launch.json index d9912da515..b54fed6835 100644 --- a/src/i18n/locales/cs/launch.json +++ b/src/i18n/locales/cs/launch.json @@ -13,6 +13,7 @@ "systemAudio": "Systémový zvuk: nahrává zvuk, který přehrává počítač", "microphone": "Mikrofon: nahrává váš hlas", "camera": "Kamera: nahrává vás jako samostatnou vrstvu, kterou můžete v editoru přesouvat", + "cameraOnly": "Pouze kamera: nahraje jen vás, bez obrazovky. Pozadí nastavíte v editoru", "openStudio": "Otevřít Studio: editor vašich nahrávek", "openNotes": "Otevřít poznámky: text ke čtení během nahrávání", "openNotesPlaceholder": "Pište poznámky zde...", @@ -46,6 +47,7 @@ "noneFound": "Kamera nenalezena", "unavailable": "Kamera nedostupná", "camera": "Kamera", + "cameraOnly": "Pouze kamera", "cameraDevice": "Zařízení kamery", "quality": "Kvalita kamery", "quality1080p": "1080p", diff --git a/src/i18n/locales/de/editor.json b/src/i18n/locales/de/editor.json index b2343eae03..33b610d948 100644 --- a/src/i18n/locales/de/editor.json +++ b/src/i18n/locales/de/editor.json @@ -46,6 +46,7 @@ "cameraDenied": "Kamerazugriff verweigert. Die Aufnahme läuft ohne Webcam weiter.", "cameraDisconnected": "Webcam getrennt.", "cameraNotFound": "Kamera nicht gefunden.", + "cameraOnlyNeedsCamera": "„Nur Kamera“ braucht eine Kamera. Schalte die Kamera ein oder wähle eine in den Geräteeinstellungen.", "cameraCaptureUnavailable": "Die Kamera konnte nicht geöffnet werden. Die Aufnahme läuft ohne sie.", "microphoneDefaulted": "Das gewählte Mikrofon konnte nicht identifiziert werden. Es wird der Standardeingang aufgenommen.", "microphoneUnavailable": "Diese macOS-Version kann das Mikrofon nicht aufnehmen. Die Aufnahme läuft ohne Mikrofon.", diff --git a/src/i18n/locales/de/launch.json b/src/i18n/locales/de/launch.json index b4dab82a91..10975ea793 100644 --- a/src/i18n/locales/de/launch.json +++ b/src/i18n/locales/de/launch.json @@ -13,6 +13,7 @@ "systemAudio": "Systemton: nimmt den Ton auf, den dein Computer wiedergibt", "microphone": "Mikrofon: nimmt deine Stimme auf", "camera": "Kamera: nimmt dich als getrennte Ebene auf, die du im Editor verschieben kannst", + "cameraOnly": "Nur Kamera: nimmt nur dich auf, ohne Bildschirm. Den Hintergrund legst du im Editor fest", "openStudio": "Studio öffnen: der Editor für deine Aufnahmen", "openNotes": "Notizen öffnen: Text zum Ablesen während der Aufnahme", "openNotesPlaceholder": "Hier Notizen schreiben…", @@ -46,6 +47,7 @@ "noneFound": "Keine Kamera gefunden", "unavailable": "Kamera nicht verfügbar", "camera": "Kamera", + "cameraOnly": "Nur Kamera", "cameraDevice": "Kameragerät", "quality": "Kameraqualität", "quality1080p": "1080p", diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json index 654c987e6b..9c4b7ccaf1 100644 --- a/src/i18n/locales/en/editor.json +++ b/src/i18n/locales/en/editor.json @@ -46,6 +46,7 @@ "cameraDenied": "Camera access denied. Recording will continue without webcam.", "cameraDisconnected": "Webcam disconnected.", "cameraNotFound": "Camera not found.", + "cameraOnlyNeedsCamera": "Camera only needs a camera. Turn the camera on, or pick one in the device settings.", "cameraCaptureUnavailable": "The camera could not be opened. Recording without it.", "microphoneDefaulted": "The chosen microphone could not be identified. Recording the default input.", "microphoneUnavailable": "This version of macOS can't record the microphone. Recording without it.", diff --git a/src/i18n/locales/en/launch.json b/src/i18n/locales/en/launch.json index 97263f1849..1dbcda85e2 100644 --- a/src/i18n/locales/en/launch.json +++ b/src/i18n/locales/en/launch.json @@ -13,6 +13,7 @@ "systemAudio": "System audio: records the sound your computer plays", "microphone": "Microphone: records your voice", "camera": "Camera: records you as a separate layer you can move in the editor", + "cameraOnly": "Camera only: records just you, with no screen. Frame it with a background in the editor", "openStudio": "Open Studio: the editor for your recordings", "openNotes": "Open Notes: text to read while you record", "openNotesPlaceholder": "Take notes here...", @@ -46,6 +47,7 @@ "noneFound": "No camera found", "unavailable": "Camera unavailable", "camera": "Camera", + "cameraOnly": "Camera only", "cameraDevice": "Camera device", "quality": "Camera quality", "quality1080p": "1080p", diff --git a/src/i18n/locales/es/editor.json b/src/i18n/locales/es/editor.json index 10ef581c67..10668e96e2 100644 --- a/src/i18n/locales/es/editor.json +++ b/src/i18n/locales/es/editor.json @@ -38,6 +38,7 @@ "cameraDenied": "Acceso a la cámara denegado. La grabación continuará sin cámara web.", "cameraDisconnected": "Cámara web desconectada.", "cameraNotFound": "Cámara no encontrada.", + "cameraOnlyNeedsCamera": "El modo Solo cámara necesita una cámara. Activa la cámara o elige una en la configuración de dispositivos.", "cameraCaptureUnavailable": "No se pudo abrir la cámara. Grabando sin ella.", "microphoneDefaulted": "No se pudo identificar el micrófono elegido. Grabando la entrada predeterminada.", "microphoneUnavailable": "Esta versión de macOS no puede grabar el micrófono. Grabando sin él.", diff --git a/src/i18n/locales/es/launch.json b/src/i18n/locales/es/launch.json index 3114921ec5..857add43ea 100644 --- a/src/i18n/locales/es/launch.json +++ b/src/i18n/locales/es/launch.json @@ -13,6 +13,7 @@ "systemAudio": "Audio del sistema: graba el sonido que reproduce tu equipo", "microphone": "Micrófono: graba tu voz", "camera": "Cámara: te graba en una capa aparte que puedes mover en el editor", + "cameraOnly": "Solo cámara: graba solo a ti, sin la pantalla. Añade un fondo en el editor", "openStudio": "Abrir Studio: el editor de tus grabaciones", "openNotes": "Abrir notas: texto para leer mientras grabas", "openNotesPlaceholder": "Escribe tus notas aquí...", @@ -46,6 +47,7 @@ "noneFound": "No se encontró cámara", "unavailable": "Cámara no disponible", "camera": "Cámara", + "cameraOnly": "Solo cámara", "cameraDevice": "Dispositivo de cámara", "quality": "Calidad de la cámara", "quality1080p": "1080p", diff --git a/src/i18n/locales/fr/editor.json b/src/i18n/locales/fr/editor.json index 6c145d57d2..64192dd134 100644 --- a/src/i18n/locales/fr/editor.json +++ b/src/i18n/locales/fr/editor.json @@ -44,6 +44,7 @@ "cameraDenied": "Accès à la caméra refusé. L'enregistrement continuera sans webcam.", "cameraDisconnected": "Webcam déconnectée.", "cameraNotFound": "Caméra introuvable.", + "cameraOnlyNeedsCamera": "Le mode Caméra seule nécessite une caméra. Activez la caméra ou choisissez-en une dans les réglages des appareils.", "cameraCaptureUnavailable": "Impossible d'ouvrir la caméra. Enregistrement sans elle.", "microphoneDefaulted": "Micro choisi non identifié. Enregistrement de l'entrée par défaut.", "microphoneUnavailable": "Cette version de macOS ne permet pas d'enregistrer le micro. Enregistrement sans lui.", diff --git a/src/i18n/locales/fr/launch.json b/src/i18n/locales/fr/launch.json index c956fb37ad..02aadf6c36 100644 --- a/src/i18n/locales/fr/launch.json +++ b/src/i18n/locales/fr/launch.json @@ -13,6 +13,7 @@ "systemAudio": "Audio système : enregistre le son que joue votre ordinateur", "microphone": "Microphone : enregistre votre voix", "camera": "Caméra : vous enregistre sur un calque séparé, déplaçable dans l'éditeur", + "cameraOnly": "Caméra seule : enregistre uniquement vous, sans l’écran. Ajoutez un arrière-plan dans l’éditeur", "openStudio": "Ouvrir le Studio : l'éditeur de vos enregistrements", "openNotes": "Ouvrir les notes : texte à lire pendant l'enregistrement", "openNotesPlaceholder": "Prenez des notes ici...", @@ -46,6 +47,7 @@ "noneFound": "Aucune caméra trouvée", "unavailable": "Caméra non disponible", "camera": "Caméra", + "cameraOnly": "Caméra seule", "cameraDevice": "Périphérique caméra", "quality": "Qualité de la caméra", "quality1080p": "1080p", diff --git a/src/i18n/locales/it/editor.json b/src/i18n/locales/it/editor.json index ef4a46b4c8..febfebc273 100644 --- a/src/i18n/locales/it/editor.json +++ b/src/i18n/locales/it/editor.json @@ -46,6 +46,7 @@ "cameraDenied": "Accesso alla fotocamera negato. La registrazione continuerà senza webcam.", "cameraDisconnected": "Webcam disconnessa.", "cameraNotFound": "Fotocamera non trovata.", + "cameraOnlyNeedsCamera": "La modalità Solo fotocamera richiede una fotocamera. Attiva la fotocamera o scegline una nelle impostazioni dei dispositivi.", "cameraCaptureUnavailable": "Impossibile aprire la fotocamera. Registrazione senza di essa.", "microphoneDefaulted": "Impossibile identificare il microfono scelto. Registrazione dall'ingresso predefinito.", "microphoneUnavailable": "Questa versione di macOS non può registrare il microfono. Registrazione senza microfono.", diff --git a/src/i18n/locales/it/launch.json b/src/i18n/locales/it/launch.json index 9897b92eb5..557eb4cbdc 100644 --- a/src/i18n/locales/it/launch.json +++ b/src/i18n/locales/it/launch.json @@ -13,6 +13,7 @@ "systemAudio": "Audio di sistema: registra il suono riprodotto dal computer", "microphone": "Microfono: registra la tua voce", "camera": "Fotocamera: ti registra come livello separato che puoi spostare nell'editor", + "cameraOnly": "Solo fotocamera: registra solo te, senza lo schermo. Aggiungi uno sfondo nell’editor", "openStudio": "Apri Studio: l'editor per le tue registrazioni", "openNotes": "Apri note: testo da leggere durante la registrazione", "openNotesPlaceholder": "Scrivi le tue note qui...", @@ -46,6 +47,7 @@ "noneFound": "Nessuna fotocamera trovata", "unavailable": "Fotocamera non disponibile", "camera": "Fotocamera", + "cameraOnly": "Solo fotocamera", "cameraDevice": "Dispositivo fotocamera", "quality": "Qualità della fotocamera", "quality1080p": "1080p", diff --git a/src/i18n/locales/ja-JP/editor.json b/src/i18n/locales/ja-JP/editor.json index da28ac3893..d75e445dde 100644 --- a/src/i18n/locales/ja-JP/editor.json +++ b/src/i18n/locales/ja-JP/editor.json @@ -47,6 +47,7 @@ "permissionDenied": "録画の権限が拒否されました。画面録画を許可してください。", "cameraDisconnected": "ウェブカメラが切断されました。", "cameraNotFound": "カメラが見つかりません。", + "cameraOnlyNeedsCamera": "カメラのみモードにはカメラが必要です。カメラをオンにするか、デバイス設定で選択してください。", "cameraCaptureUnavailable": "カメラを開けませんでした。カメラなしで録画します。", "microphoneDefaulted": "選択したマイクを特定できませんでした。既定の入力を録音します。", "microphoneUnavailable": "このバージョンの macOS ではマイクを録音できません。マイクなしで録画します。", diff --git a/src/i18n/locales/ja-JP/launch.json b/src/i18n/locales/ja-JP/launch.json index 345da320dd..bf885f2a3c 100644 --- a/src/i18n/locales/ja-JP/launch.json +++ b/src/i18n/locales/ja-JP/launch.json @@ -13,6 +13,7 @@ "systemAudio": "システム音声:コンピューターが再生する音を録音します", "microphone": "マイク:声を録音します", "camera": "カメラ:自分の映像を別レイヤーとして録画し、エディターで動かせます", + "cameraOnly": "カメラのみ:画面なしで自分だけを録画します。背景はエディターで設定できます", "openStudio": "スタジオを開く:録画を編集するエディター", "openNotes": "ノートを開く:録画中に読むテキスト", "openNotesPlaceholder": "ここにメモを入力...", @@ -46,6 +47,7 @@ "noneFound": "カメラが見つかりません", "unavailable": "カメラが利用できません", "camera": "カメラ", + "cameraOnly": "カメラのみ", "cameraDevice": "カメラデバイス", "quality": "カメラの画質", "quality1080p": "1080p", diff --git a/src/i18n/locales/ko-KR/editor.json b/src/i18n/locales/ko-KR/editor.json index e77b0e817e..f8cc5c8462 100644 --- a/src/i18n/locales/ko-KR/editor.json +++ b/src/i18n/locales/ko-KR/editor.json @@ -47,6 +47,7 @@ "permissionDenied": "녹화 권한이 거부되었습니다. 화면 녹화를 허용해 주세요.", "cameraDisconnected": "웹캠 연결이 끊어졌습니다.", "cameraNotFound": "카메라를 찾을 수 없습니다.", + "cameraOnlyNeedsCamera": "카메라만 모드에는 카메라가 필요합니다. 카메라를 켜거나 장치 설정에서 선택하세요.", "cameraCaptureUnavailable": "카메라를 열 수 없습니다. 카메라 없이 녹화합니다.", "microphoneDefaulted": "선택한 마이크를 확인할 수 없습니다. 기본 입력을 녹음합니다.", "microphoneUnavailable": "이 버전의 macOS에서는 마이크를 녹음할 수 없습니다. 마이크 없이 녹화합니다.", diff --git a/src/i18n/locales/ko-KR/launch.json b/src/i18n/locales/ko-KR/launch.json index 5ce2b8e721..2f56d91b51 100644 --- a/src/i18n/locales/ko-KR/launch.json +++ b/src/i18n/locales/ko-KR/launch.json @@ -13,6 +13,7 @@ "systemAudio": "시스템 오디오: 컴퓨터에서 재생되는 소리를 녹음합니다", "microphone": "마이크: 내 목소리를 녹음합니다", "camera": "카메라: 내 모습을 별도 레이어로 녹화하며 편집기에서 옮길 수 있습니다", + "cameraOnly": "카메라만: 화면 없이 나만 녹화합니다. 배경은 편집기에서 설정하세요", "openStudio": "스튜디오 열기: 녹화를 위한 편집기", "openNotes": "노트 열기: 녹화 중 읽을 텍스트", "openNotesPlaceholder": "여기에 메모를 입력하세요...", @@ -46,6 +47,7 @@ "noneFound": "카메라를 찾을 수 없음", "unavailable": "카메라를 사용할 수 없음", "camera": "카메라", + "cameraOnly": "카메라만", "cameraDevice": "카메라 장치", "quality": "카메라 화질", "quality1080p": "1080p", diff --git a/src/i18n/locales/pt-BR/editor.json b/src/i18n/locales/pt-BR/editor.json index d79f9a2f19..ecb089ad9d 100644 --- a/src/i18n/locales/pt-BR/editor.json +++ b/src/i18n/locales/pt-BR/editor.json @@ -46,6 +46,7 @@ "cameraDenied": "Acesso à câmera negado. A gravação continuará sem webcam.", "cameraDisconnected": "Webcam desconectada.", "cameraNotFound": "Câmera não encontrada.", + "cameraOnlyNeedsCamera": "O modo Somente câmera precisa de uma câmera. Ligue a câmera ou escolha uma nas configurações de dispositivos.", "cameraCaptureUnavailable": "Não foi possível abrir a câmera. Gravando sem ela.", "microphoneDefaulted": "Não foi possível identificar o microfone escolhido. Gravando a entrada padrão.", "microphoneUnavailable": "Esta versão do macOS não consegue gravar o microfone. Gravando sem ele.", diff --git a/src/i18n/locales/pt-BR/launch.json b/src/i18n/locales/pt-BR/launch.json index 8866150881..f74b8c4d14 100644 --- a/src/i18n/locales/pt-BR/launch.json +++ b/src/i18n/locales/pt-BR/launch.json @@ -13,6 +13,7 @@ "systemAudio": "Áudio do sistema: grava o som reproduzido pelo computador", "microphone": "Microfone: grava sua voz", "camera": "Câmera: grava você em uma camada separada que pode ser movida no editor", + "cameraOnly": "Somente câmera: grava só você, sem a tela. Adicione um fundo no editor", "openStudio": "Abrir Studio: o editor das suas gravações", "openNotes": "Abrir notas: texto para ler durante a gravação", "openNotesPlaceholder": "Escreva suas notas aqui...", @@ -46,6 +47,7 @@ "noneFound": "Nenhuma câmera encontrada", "unavailable": "Câmera indisponível", "camera": "Câmera", + "cameraOnly": "Somente câmera", "cameraDevice": "Dispositivo de câmera", "quality": "Qualidade da câmera", "quality1080p": "1080p", diff --git a/src/i18n/locales/ru/editor.json b/src/i18n/locales/ru/editor.json index 4b81e75971..8ac75921b8 100644 --- a/src/i18n/locales/ru/editor.json +++ b/src/i18n/locales/ru/editor.json @@ -46,6 +46,7 @@ "cameraDenied": "Доступ к камере запрещён. Запись продолжится без веб-камеры.", "cameraDisconnected": "Веб-камера отключена.", "cameraNotFound": "Камера не найдена.", + "cameraOnlyNeedsCamera": "Для режима «Только камера» нужна камера. Включите камеру или выберите её в настройках устройств.", "cameraCaptureUnavailable": "Не удалось открыть камеру. Запись идёт без неё.", "microphoneDefaulted": "Не удалось определить выбранный микрофон. Записывается вход по умолчанию.", "microphoneUnavailable": "Эта версия macOS не может записывать микрофон. Запись идёт без него.", diff --git a/src/i18n/locales/ru/launch.json b/src/i18n/locales/ru/launch.json index cdc103c0b4..8a0060d24c 100644 --- a/src/i18n/locales/ru/launch.json +++ b/src/i18n/locales/ru/launch.json @@ -13,6 +13,7 @@ "systemAudio": "Системный звук: записывает звук, который воспроизводит компьютер", "microphone": "Микрофон: записывает ваш голос", "camera": "Камера: записывает вас отдельным слоем, который можно двигать в редакторе", + "cameraOnly": "Только камера: записывает только вас, без экрана. Фон можно задать в редакторе", "openStudio": "Открыть студию: редактор ваших записей", "openNotes": "Открыть заметки: текст для чтения во время записи", "openNotesPlaceholder": "Пишите заметки здесь...", @@ -46,6 +47,7 @@ "noneFound": "Камера не найдена", "unavailable": "Камера недоступна", "camera": "Камера", + "cameraOnly": "Только камера", "cameraDevice": "Устройство камеры", "quality": "Качество камеры", "quality1080p": "1080p", diff --git a/src/i18n/locales/tr/editor.json b/src/i18n/locales/tr/editor.json index bb248a3526..b34724f59d 100644 --- a/src/i18n/locales/tr/editor.json +++ b/src/i18n/locales/tr/editor.json @@ -39,6 +39,7 @@ "permissionDenied": "Kayıt izni reddedildi. Lütfen ekran kaydına izin verin.", "cameraDisconnected": "Webcam bağlantısı kesildi.", "cameraNotFound": "Kamera bulunamadı.", + "cameraOnlyNeedsCamera": "Yalnızca kamera modu bir kamera gerektirir. Kamerayı açın veya cihaz ayarlarından birini seçin.", "cameraCaptureUnavailable": "Kamera açılamadı. Kamerasız kaydediliyor.", "microphoneDefaulted": "Seçilen mikrofon belirlenemedi. Varsayılan giriş kaydediliyor.", "microphoneUnavailable": "Bu macOS sürümü mikrofonu kaydedemiyor. Mikrofonsuz kaydediliyor.", diff --git a/src/i18n/locales/tr/launch.json b/src/i18n/locales/tr/launch.json index 530157dd2c..d860342468 100644 --- a/src/i18n/locales/tr/launch.json +++ b/src/i18n/locales/tr/launch.json @@ -13,6 +13,7 @@ "systemAudio": "Sistem sesi: bilgisayarın çaldığı sesi kaydeder", "microphone": "Mikrofon: sesinizi kaydeder", "camera": "Kamera: sizi düzenleyicide taşıyabileceğiniz ayrı bir katman olarak kaydeder", + "cameraOnly": "Yalnızca kamera: ekran olmadan yalnızca sizi kaydeder. Arka planı düzenleyicide ayarlayın", "openStudio": "Stüdyoyu aç: kayıtlarınızın düzenleyicisi", "openNotes": "Notları aç: kayıt sırasında okuyacağınız metin", "openNotesPlaceholder": "Notlarınızı buraya yazın...", @@ -46,6 +47,7 @@ "noneFound": "Kamera bulunamadı", "unavailable": "Kamera kullanılamıyor", "camera": "Kamera", + "cameraOnly": "Yalnızca kamera", "cameraDevice": "Kamera cihazı", "quality": "Kamera kalitesi", "quality1080p": "1080p", diff --git a/src/i18n/locales/vi/editor.json b/src/i18n/locales/vi/editor.json index c9940ac9b6..775be7bf75 100644 --- a/src/i18n/locales/vi/editor.json +++ b/src/i18n/locales/vi/editor.json @@ -46,6 +46,7 @@ "cameraDenied": "Quyền truy cập máy ảnh bị từ chối. Sẽ tiếp tục ghi hình không có webcam.", "cameraDisconnected": "Webcam bị ngắt kết nối.", "cameraNotFound": "Không tìm thấy máy ảnh.", + "cameraOnlyNeedsCamera": "Chế độ Chỉ camera cần có camera. Hãy bật camera hoặc chọn một camera trong cài đặt thiết bị.", "cameraCaptureUnavailable": "Không thể mở máy ảnh. Đang ghi mà không có máy ảnh.", "microphoneDefaulted": "Không xác định được micrô đã chọn. Đang ghi từ đầu vào mặc định.", "microphoneUnavailable": "Phiên bản macOS này không thể ghi micrô. Đang ghi mà không có micrô.", diff --git a/src/i18n/locales/vi/launch.json b/src/i18n/locales/vi/launch.json index 53f452e27c..fa7f7e1bba 100644 --- a/src/i18n/locales/vi/launch.json +++ b/src/i18n/locales/vi/launch.json @@ -13,6 +13,7 @@ "systemAudio": "Âm thanh hệ thống: ghi âm thanh máy tính phát ra", "microphone": "Micro: ghi giọng nói của bạn", "camera": "Máy ảnh: ghi bạn thành một lớp riêng có thể di chuyển trong trình chỉnh sửa", + "cameraOnly": "Chỉ camera: chỉ ghi hình bạn, không có màn hình. Đặt nền trong trình chỉnh sửa", "openStudio": "Mở Studio: trình chỉnh sửa cho các bản ghi hình của bạn", "openNotes": "Mở ghi chú: văn bản để đọc khi ghi hình", "openNotesPlaceholder": "Ghi chú của bạn tại đây...", @@ -46,6 +47,7 @@ "noneFound": "Không tìm thấy máy ảnh", "unavailable": "Máy ảnh không khả dụng", "camera": "Máy ảnh", + "cameraOnly": "Chỉ camera", "cameraDevice": "Thiết bị máy ảnh", "quality": "Chất lượng camera", "quality1080p": "1080p", diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json index 322a2566c2..941f04bf39 100644 --- a/src/i18n/locales/zh-CN/editor.json +++ b/src/i18n/locales/zh-CN/editor.json @@ -46,6 +46,7 @@ "cameraDenied": "摄像头权限被拒绝。录制将继续,但不包含摄像头画面。", "cameraDisconnected": "摄像头已断开连接。", "cameraNotFound": "未找到摄像头。", + "cameraOnlyNeedsCamera": "仅摄像头模式需要摄像头。请打开摄像头,或在设备设置中选择一个。", "cameraCaptureUnavailable": "无法打开摄像头,正在不使用摄像头录制。", "microphoneDefaulted": "无法识别所选麦克风,正在录制默认输入设备。", "microphoneUnavailable": "此版本的 macOS 无法录制麦克风,正在不使用麦克风录制。", diff --git a/src/i18n/locales/zh-CN/launch.json b/src/i18n/locales/zh-CN/launch.json index a4bbf2c576..1560eb47a3 100644 --- a/src/i18n/locales/zh-CN/launch.json +++ b/src/i18n/locales/zh-CN/launch.json @@ -13,6 +13,7 @@ "systemAudio": "系统音频:录制电脑播放的声音", "microphone": "麦克风:录制语音", "camera": "摄像头:单独录制摄像头画面,可在编辑器中调整位置", + "cameraOnly": "仅摄像头:只录制你本人,不录屏幕。可在编辑器中设置背景", "openStudio": "打开工作室,编辑录制内容", "openNotes": "打开笔记,查看录制时需要阅读的文字", "openNotesPlaceholder": "在此输入笔记...", @@ -46,6 +47,7 @@ "noneFound": "未找到摄像头", "unavailable": "摄像头不可用", "camera": "摄像头", + "cameraOnly": "仅摄像头", "cameraDevice": "摄像头设备", "quality": "摄像头画质", "quality1080p": "1080p", diff --git a/src/i18n/locales/zh-TW/editor.json b/src/i18n/locales/zh-TW/editor.json index 1e3bc73e0f..b239b4c97b 100644 --- a/src/i18n/locales/zh-TW/editor.json +++ b/src/i18n/locales/zh-TW/editor.json @@ -47,6 +47,7 @@ "permissionDenied": "錄影權限被拒絕。請允許螢幕錄製。", "cameraDisconnected": "網路攝影機已中斷連線。", "cameraNotFound": "找不到攝影機。", + "cameraOnlyNeedsCamera": "僅攝影機模式需要攝影機。請開啟攝影機,或在裝置設定中選擇一個。", "cameraCaptureUnavailable": "無法開啟攝影機,將在沒有攝影機的情況下錄製。", "microphoneDefaulted": "無法辨識所選麥克風,將錄製預設輸入裝置。", "microphoneUnavailable": "此版本的 macOS 無法錄製麥克風,將在沒有麥克風的情況下錄製。", diff --git a/src/i18n/locales/zh-TW/launch.json b/src/i18n/locales/zh-TW/launch.json index 2e460592ed..fa331c0f43 100644 --- a/src/i18n/locales/zh-TW/launch.json +++ b/src/i18n/locales/zh-TW/launch.json @@ -13,6 +13,7 @@ "systemAudio": "系統音訊:錄製電腦播放的聲音", "microphone": "麥克風:錄製語音", "camera": "攝影機:錄製為獨立圖層,可在編輯器中移動", + "cameraOnly": "僅攝影機:只錄製你本人,不錄螢幕。可在編輯器中設定背景", "openStudio": "開啟工作室:錄製內容的編輯器", "openNotes": "開啟筆記:錄製時閱讀的文字", "openNotesPlaceholder": "在此輸入筆記...", @@ -46,6 +47,7 @@ "noneFound": "未找到攝影機", "unavailable": "攝影機不可用", "camera": "攝影機", + "cameraOnly": "僅攝影機", "cameraDevice": "攝影機裝置", "quality": "攝影機畫質", "quality1080p": "1080p", diff --git a/technical-documentation/architecture/recording.md b/technical-documentation/architecture/recording.md index 2f643ec222..4d4541ddcb 100644 --- a/technical-documentation/architecture/recording.md +++ b/technical-documentation/architecture/recording.md @@ -32,6 +32,14 @@ Electron applies `setContentProtection(true)` to the HUD window (`applyContentPr The division is an invariant: the native helper owns capture, timing, and encoding; Electron owns session orchestration, output-path selection, persistence, and editor handoff. When the Linux helper binary is absent, the recorder falls back to the Electron `getDisplayMedia` path, which is the one case where Electron still owns the media. +### Camera-only takes + +A camera-only take (#1276) records the camera with no screen or window. It is a HUD toggle beside the source button, persisted as `cameraOnly` in the recording preferences. Turning it on turns the camera on, and it is refused when the camera check refuses the camera. Turning the camera off turns camera only off as well. While the toggle is on, the HUD hides the source button, system audio and the cursor toggle. All three belong to screen capture, which this take does not run. + +The take uses the browser pipeline on all three platforms and starts no native helper, because every helper captures a screen. It asks for no source and raises no Linux portal. It also skips the macOS Accessibility preflight, which exists for cursor telemetry. The recorder gets a clone of the camera track plus the microphone, if it is on. It stores that file as the session's `screenVideoPath`, with no `webcamVideoPath`, and stores the cursor mode as `"system"`, so no cursor telemetry is written. The editor therefore opens the camera as the main video. Background, padding, corner radius and shadow frame it as they frame a screen. The camera-layer tools do not apply to it: background cutout, blur, crop and Full Camera. + +The track is cloned for a practical reason. Stopping the take stops what it recorded, and stopping the preview's own track would fire its `onended`, which reports the camera as disconnected and switches it off. + ## Helper contract A native session is a child process boundary. Electron starts the platform helper with one structured JSON request and sends runtime commands on stdin; `stop` finalizes the output. The helper emits newline-delimited JSON events on stdout. The shared shape contains `schemaVersion`, `recordingId`, a `source` (display or window and its bounds), `video`, `audio`, optional `webcam`, optional cursor mode, and `outputs` paths. The helper reports `ready`, `recording-started`, warnings, errors, and `recording-stopped` events. Windows accepts legacy textual start/stop messages during compatibility handling; the structured events are the reference contract. From eab04b769ea4c6b5268eb32e1b5cb9e4b7b7aacf Mon Sep 17 00:00:00 2001 From: MMArephinAbid <71713365+MMArephinAbid@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:58:05 +0600 Subject: [PATCH 2/5] fix(recording): settle camera only before Record, and clear it with the camera Two review findings on #1398: - Record was clickable while camera only waited on the camera check, so a screen take could start and the answer then switched the HUD to camera only mid-take. Record is now disabled until the check answers, and the answer is dropped if a take started in the meantime. - A failed camera cleared camEnabled but left a stored cameraOnly, which restored a HUD with no source picker and a Record that always failed. Both flags are now cleared together wherever the camera is switched off for the user, and a stored pair with the camera off never restores camera only. Co-Authored-By: Claude Opus 5.5 --- src/components/launch/HudControls.tsx | 5 ++- src/components/launch/LaunchWindow.test.tsx | 44 +++++++++++++++++++ src/components/launch/LaunchWindow.tsx | 32 +++++++++++--- .../useScreenRecorder.cameraOnly.test.tsx | 7 ++- src/hooks/useScreenRecorder.noCamera.test.tsx | 26 +++++++++-- src/hooks/useScreenRecorder.ts | 17 +++++-- src/lib/cameraAvailability.ts | 10 +++-- 7 files changed, 121 insertions(+), 20 deletions(-) diff --git a/src/components/launch/HudControls.tsx b/src/components/launch/HudControls.tsx index b02a85df3a..10300af30c 100644 --- a/src/components/launch/HudControls.tsx +++ b/src/components/launch/HudControls.tsx @@ -432,6 +432,7 @@ export const HudRecordButton = memo(function HudRecordButton({ recording, paused, saving, + pending = false, elapsedSeconds, label, savingLabel, @@ -440,6 +441,8 @@ export const HudRecordButton = memo(function HudRecordButton({ recording: boolean; paused: boolean; saving: boolean; + /** Held off for a moment: what the take will record is still being decided. */ + pending?: boolean; elapsedSeconds: number; label: string; savingLabel: string; @@ -451,7 +454,7 @@ export const HudRecordButton = memo(function HudRecordButton({