From 27217f6768df95d764b0c1d5489eec37f0d56bb7 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Mon, 14 Sep 2026 15:01:27 +0200 Subject: [PATCH 01/39] fix(macos): settle the native stop on the helper's exit, not its first error The macOS stop resolved on `recording-stopped` and rejected on the first `"event":"error"` anywhere in the helper's buffered output, replaying the whole take to find one. Since #655 the helper raises non-terminal errors on purpose (`writer-failed-during-capture`, and `capture-stopped-with-error` before it), stops itself, and only then reports a terminal outcome. So the stop rejected before the helper had been told to stop, and three cases went wrong: - Capture stopped on its own but the file was finalized: the take was thrown away with an error although `recording-stopped` followed. - The helper died without a word (killed, crashed): nothing noticed until the user pressed Stop, which then waited the full 30 s for a `close` that had already happened and put the whole helper log in the toast. - Cancel/Restart after a failed stop left the .mp4 behind, because deleting it depended on the stop having worked. The stop now lives in electron/recording/nativeMacCaptureStop.ts, the twin of nativeWindowsCaptureStop.ts, and settles when the helper exits. The helper always exits after `stop` once it has said its last word, so `close` is the one moment the output is complete and the file is no longer being written. It resolves instead of rejecting: - `recording-stopped` wins; an earlier interruption becomes a warning the HUD shows, and the take opens. - A terminal error reports the interruption's message when there was one ("the video file could not be written"), not the raw NSError behind it. - Exit 0 with no word keeps the old meaning, except after an interruption, where it only means the command pipe closed. - An exit the output drain already recorded settles at once. - On timeout a helper that already reported a finalized file still counts as a success; one that has not is not killed, because this helper has no shutdown ceiling and a long finishWriting has not been measured. The live-take watch from #655 now also fires when the helper process closes mid-take, and ignores the exit a stop in flight causes. `stop` is only written to a helper that can still hear it, and the command pipe has an error listener. A discard removes the take's files whatever the stop returned. Salvaging the fragments of a take whose writer died is left to a follow-up. --- electron/electron-env.d.ts | 2 + electron/ipc/handlers.ts | 164 +++++----- .../ipc/nativeMacMidCaptureErrorWatch.test.ts | 33 ++ electron/ipc/nativeMacMidCaptureErrorWatch.ts | 29 +- .../recording/nativeMacCaptureStop.test.ts | 290 ++++++++++++++++++ electron/recording/nativeMacCaptureStop.ts | 289 +++++++++++++++++ src/hooks/useScreenRecorder.ts | 3 + 7 files changed, 719 insertions(+), 91 deletions(-) create mode 100644 electron/recording/nativeMacCaptureStop.test.ts create mode 100644 electron/recording/nativeMacCaptureStop.ts diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 6de7a3cc4..0fce60828 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -179,6 +179,8 @@ interface Window { session?: import("../src/lib/recordingSession").RecordingSession; message?: string; discarded?: boolean; + /** The take ended before it was stopped, but its recording was kept. */ + warning?: string; error?: string; }>; attachNativeMacWebcamRecording: (payload: { diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 4e9ba5429..3cd452afb 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -81,6 +81,11 @@ import { findPipeWireCursorHelperPath } from "../native-bridge/cursor/recording/ import type { CursorRecordingSession } from "../native-bridge/cursor/recording/session"; import { toHelperRect } from "../native-bridge/helperCoordinates"; import { scoreDeviceNameMatch } from "../recording/deviceNameMatching"; +import { + type NativeMacCaptureExit, + sendNativeMacStopCommand, + waitForNativeMacCaptureStop, +} from "../recording/nativeMacCaptureStop"; import { isSalvageableFragmentedCapture, NATIVE_WINDOWS_SALVAGEABLE_OUTPUT_BYTES, @@ -765,6 +770,13 @@ let nativeMacCursorRecordingStartMs = 0; let nativeMacPauseStartedAtMs: number | null = null; let nativeMacPauseRanges: Array<{ startMs: number; endMs: number }> = []; let nativeMacIsPaused = false; +/** + * How each macOS helper exited, recorded by its output drain on `close` — the + * point at which its output has been read in full. + */ +const nativeMacCaptureExits = new WeakMap(); +/** True while stop-native-mac-recording runs: the take is ending on purpose. */ +let nativeMacStopInFlight = false; // Global frame of the region captured by the SCK helper (see getSelectedSourceBounds). let activeMacCaptureBounds: Rectangle | null = null; let linuxNativeCaptureSession: LinuxNativeCaptureSession | null = null; @@ -1488,14 +1500,14 @@ function inspectNativeMacCaptureOutput() { function attachNativeMacCaptureOutputDrain( proc: ChildProcessWithoutNullStreams, - onErrorDuringCapture: () => void, + onTakeEnded: () => void, ) { let lineBuffer = ""; - // Hooked here rather than on `nativeMacCaptureEvents`, which the stop wait + // Hooked here rather than on `nativeMacCaptureEvents`, which the start wait // replays from the buffer: the drain sees each line once, live. - const watchForMidCaptureError = createNativeMacMidCaptureErrorWatch( - () => nativeMacCaptureProcess === proc, - onErrorDuringCapture, + const watchLiveTake = createNativeMacMidCaptureErrorWatch( + () => nativeMacCaptureProcess === proc && !nativeMacStopInFlight, + onTakeEnded, ); const drain = (chunk: Buffer) => { const text = chunk.toString(); @@ -1507,21 +1519,32 @@ function attachNativeMacCaptureOutputDrain( const event = tryParseNativeHelperEvent(line.trim()); if (event) { dispatchNativeMacHelperEvent(event); - watchForMidCaptureError(event); + watchLiveTake(event); } } }; - const cleanup = () => { + // Registered right after spawn, before the stop wait can listen for `close`, so + // the wait always finds the exit recorded when its own listener runs. + const onClose = (code: number | null, signal: NodeJS.Signals | null) => { + nativeMacCaptureExits.set(proc, { code, signal }); proc.stdout.off("data", drain); proc.stderr.off("data", drain); - proc.off("close", cleanup); - proc.off("error", cleanup); + watchLiveTake.exited(); }; proc.stdout.on("data", drain); proc.stderr.on("data", drain); - proc.once("close", cleanup); - proc.once("error", cleanup); + proc.once("close", onClose); + // A ChildProcess `error` with no listener throws in the main process. + proc.on("error", (error) => { + console.warn("[native-sck] helper process error:", error); + }); + // `sendNativeMacStopCommand` checks the pipe first, but the helper can still die + // between that check and the write. The main-process guard would swallow the + // EPIPE; a listener here keeps it from being raised as uncaught at all. + proc.stdin.on("error", (error) => { + console.warn("[native-sck] helper command pipe error:", error); + }); } function waitForNativeMacCaptureStart(proc: ChildProcessWithoutNullStreams) { @@ -1571,64 +1594,6 @@ function waitForNativeMacCaptureStart(proc: ChildProcessWithoutNullStreams) { }); } -function waitForNativeMacCaptureStop(proc: ChildProcessWithoutNullStreams) { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - cleanup(); - reject( - new Error( - `Timed out waiting for native macOS capture to stop. Output path: ${ - nativeMacCaptureTargetPath ?? "unknown" - }. Output: ${nativeMacCaptureOutput.trim()}`, - ), - ); - }, 30_000); - - const inspect = (event: Record) => { - if (event.event === "recording-stopped") { - cleanup(); - resolve(String(event.screenPath ?? nativeMacCaptureTargetPath ?? "")); - return; - } - if (event.event === "error") { - cleanup(); - reject(new Error(String(event.message ?? event.code ?? "Native macOS capture failed"))); - } - }; - - const onOutput = (event: Record) => inspect(event); - const onClose = (code: number | null) => { - if (code === 0 && nativeMacCaptureTargetPath) { - cleanup(); - resolve(nativeMacCaptureTargetPath); - return; - } - cleanup(); - reject( - new Error( - nativeMacCaptureOutput.trim() || - `Native macOS capture exited with code=${code ?? "unknown"}`, - ), - ); - }; - const onError = (error: Error) => { - cleanup(); - reject(error); - }; - const cleanup = () => { - clearTimeout(timer); - nativeMacCaptureEvents.off("helper-event", onOutput); - proc.off("close", onClose); - proc.off("error", onError); - }; - - nativeMacCaptureEvents.on("helper-event", onOutput); - proc.once("close", onClose); - proc.once("error", onError); - inspectNativeMacCaptureOutput(); - }); -} - function setCurrentRecordingSessionState(session: RecordingSession | null) { currentRecordingSession = session; currentVideoPath = session?.screenVideoPath ?? null; @@ -2821,6 +2786,7 @@ export function registerIpcHandlers( nativeMacPauseStartedAtMs = null; nativeMacPauseRanges = []; nativeMacIsPaused = false; + nativeMacStopInFlight = false; activeMacCaptureBounds = null; const cursorStartTimeMs = Date.now(); @@ -2836,8 +2802,9 @@ export function registerIpcHandlers( stdio: ["pipe", "pipe", "pipe"], }); nativeMacCaptureProcess = proc; - // Drives the renderer's own stop, the same one the tray's Stop Recording - // sends: it clears the HUD and surfaces the helper's error as the result. + // When the take ends without the user — the helper reported an error or + // exited — this drives the renderer's own stop, the same one the tray's Stop + // Recording sends: it clears the HUD and surfaces the result. attachNativeMacCaptureOutputDrain(proc, () => { const hudWindow = getMainWindow(); if (hudWindow && !hudWindow.isDestroyed()) { @@ -3200,15 +3167,18 @@ export function registerIpcHandlers( return { success: false, error: "Native macOS capture is not running." }; } + nativeMacStopInFlight = true; try { completeNativeMacCursorPauseRange(); - const stoppedPathPromise = waitForNativeMacCaptureStop(proc); - proc.stdin.write("stop\n"); - const stoppedPath = await stoppedPathPromise; - const screenVideoPath = stoppedPath || preferredPath; - if (!screenVideoPath) { - throw new Error("Native macOS capture did not return an output path."); - } + // Listen before sending, so a helper that stops at once cannot slip past. + const stopResultPromise = waitForNativeMacCaptureStop({ + proc, + targetPath: preferredPath, + readOutput: () => nativeMacCaptureOutput, + readExit: () => nativeMacCaptureExits.get(proc) ?? null, + }); + sendNativeMacStopCommand(proc); + const stopResult = await stopResultPromise; if (cursorCaptureMode === "editable-overlay") { await stopCursorRecording(); @@ -3217,12 +3187,40 @@ export function registerIpcHandlers( } if (discard) { pendingCursorRecordingData = null; - await Promise.all([ - fs.rm(screenVideoPath, { force: true }), - fs.rm(`${screenVideoPath}.cursor.json`, { force: true }), - ]); + // Discarding does not wait on the stop having worked: a take thrown away + // after its writer died used to stay behind as an orphan .mp4. + const discardedPath = stopResult.ok ? stopResult.screenVideoPath : preferredPath; + if (discardedPath) { + await Promise.all([ + fs.rm(discardedPath, { force: true }), + fs.rm(`${discardedPath}.cursor.json`, { force: true }), + ]); + } + if (!stopResult.ok) { + console.warn("[native-sck] discarded a take whose stop did not complete", { + reason: stopResult.reason, + message: stopResult.message, + }); + } return { success: true, discarded: true }; } + if (!stopResult.ok) { + pendingCursorRecordingData = null; + console.error("Failed to stop native macOS recording:", { + reason: stopResult.reason, + message: stopResult.message, + helperExited: stopResult.exited, + output: nativeMacCaptureOutput.trim(), + }); + return { success: false, error: stopResult.message }; + } + const screenVideoPath = stopResult.screenVideoPath; + if (stopResult.warning) { + console.warn("[native-sck] the take ended before it was stopped; its recording was kept", { + warning: stopResult.warning, + path: screenVideoPath, + }); + } if (cursorCaptureMode === "editable-overlay") { compactPendingCursorTelemetryPauseRanges(nativeMacPauseRanges); @@ -3250,12 +3248,14 @@ export function registerIpcHandlers( path: screenVideoPath, session, message: "Native macOS recording session stored successfully", + ...(stopResult.warning ? { warning: stopResult.warning } : {}), }; } catch (error) { console.error("Failed to stop native macOS recording:", error); await stopCursorRecording(); return { success: false, error: error instanceof Error ? error.message : String(error) }; } finally { + nativeMacStopInFlight = false; nativeMacCaptureProcess = null; nativeMacCaptureTargetPath = null; nativeMacCaptureRecordingId = null; diff --git a/electron/ipc/nativeMacMidCaptureErrorWatch.test.ts b/electron/ipc/nativeMacMidCaptureErrorWatch.test.ts index c22b9c0e9..c6958cd4b 100644 --- a/electron/ipc/nativeMacMidCaptureErrorWatch.test.ts +++ b/electron/ipc/nativeMacMidCaptureErrorWatch.test.ts @@ -46,4 +46,37 @@ describe("createNativeMacMidCaptureErrorWatch", () => { expect(onError).not.toHaveBeenCalled(); }); + + /** Killed or crashed: no error line ever comes, only the process closing. */ + it("fires when the helper exits in the middle of a take", () => { + const onTakeEnded = vi.fn(); + const watch = createNativeMacMidCaptureErrorWatch(() => true, onTakeEnded); + + watch({ event: "recording-started" }); + watch.exited(); + + expect(onTakeEnded).toHaveBeenCalledTimes(1); + }); + + it("leaves an exit before recording started to the start wait", () => { + const onTakeEnded = vi.fn(); + const watch = createNativeMacMidCaptureErrorWatch(() => true, onTakeEnded); + + watch.exited(); + + expect(onTakeEnded).not.toHaveBeenCalled(); + }); + + /** The exit a stop causes is the stop working, not the take ending under the user. */ + it("ignores the exit of a take that is already being stopped", () => { + const onTakeEnded = vi.fn(); + let live = true; + const watch = createNativeMacMidCaptureErrorWatch(() => live, onTakeEnded); + + watch({ event: "recording-started" }); + live = false; + watch.exited(); + + expect(onTakeEnded).not.toHaveBeenCalled(); + }); }); diff --git a/electron/ipc/nativeMacMidCaptureErrorWatch.ts b/electron/ipc/nativeMacMidCaptureErrorWatch.ts index 4c62f6b7e..6c786d3f3 100644 --- a/electron/ipc/nativeMacMidCaptureErrorWatch.ts +++ b/electron/ipc/nativeMacMidCaptureErrorWatch.ts @@ -1,24 +1,35 @@ /** - * Decides, one live helper event at a time, when the macOS helper raised an - * error mid-take. + * Decides, as the macOS helper speaks and when it exits, whether a take ended + * without the user stopping it, so the HUD can stop with it. * * The start and stop waits only listen while they are pending, so an error the * helper raises between them (a dead writer, a stream that stopped) used to sit * in the buffer until the user pressed stop, while the HUD counted on for - * minutes (issue #621). Before `recording-started` the start wait owns the - * error, and a helper that is no longer the current process has nobody to stop. + * minutes (issue #621). A helper that dies without a word — killed, crashed — is + * the same take ending, and only its process `close` shows it. + * + * Before `recording-started` the start wait owns both. A take that is no longer + * live — another helper replaced it, or a stop is already running — has nothing + * left to stop. */ export function createNativeMacMidCaptureErrorWatch( - isCurrentProcess: () => boolean, - onError: () => void, + isLiveTake: () => boolean, + onTakeEnded: () => void, ) { let recordingStarted = false; - return (event: Record) => { + const watch = (event: Record) => { if (event.event === "recording-started") { recordingStarted = true; } - if (event.event === "error" && recordingStarted && isCurrentProcess()) { - onError(); + if (event.event === "error" && recordingStarted && isLiveTake()) { + onTakeEnded(); + } + }; + /** The helper process closed. */ + const exited = () => { + if (recordingStarted && isLiveTake()) { + onTakeEnded(); } }; + return Object.assign(watch, { exited }); } diff --git a/electron/recording/nativeMacCaptureStop.test.ts b/electron/recording/nativeMacCaptureStop.test.ts new file mode 100644 index 000000000..83150de1e --- /dev/null +++ b/electron/recording/nativeMacCaptureStop.test.ts @@ -0,0 +1,290 @@ +import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import { EventEmitter } from "node:events"; +import { PassThrough, Writable } from "node:stream"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + type NativeMacCaptureExit, + readNativeMacHelperEvents, + readNativeMacStopOutcome, + sendNativeMacStopCommand, + waitForNativeMacCaptureStop, +} from "./nativeMacCaptureStop"; + +const TARGET = "/rec/recording-1.mp4"; + +/** What the helper prints, one JSON event per line. */ +function line(event: Record) { + return `${JSON.stringify(event)}\n`; +} + +const started = line({ event: "recording-started", width: 3840, height: 2160 }); +const stopped = line({ event: "recording-stopped", screenPath: TARGET }); +const writerDied = line({ + event: "error", + code: "writer-failed-during-capture", + message: "Recording stopped: the video file could not be written (video append: -11800).", +}); +const writerFailed = line({ + event: "error", + code: "writer-failed", + message: "Error Domain=AVFoundationErrorDomain Code=-11800", +}); +const captureStopped = line({ + event: "error", + code: "capture-stopped-with-error", + message: "The display was disconnected.", +}); + +/** + * Stands in for openscreen-screencapturekit-helper. `exitCode`/`signalCode` are + * real `ChildProcess` properties the code under test reads, and `exit()` records + * the exit the way the output drain in handlers.ts does before `close` reaches + * any later listener. + */ +class FakeHelper extends EventEmitter { + stdout = new PassThrough(); + stderr = new PassThrough(); + stdin: Writable; + written: string[] = []; + exitCode: number | null = null; + signalCode: NodeJS.Signals | null = null; + recordedExit: NativeMacCaptureExit | null = null; + + constructor() { + super(); + const written = this.written; + this.stdin = new Writable({ + write(chunk, _encoding, callback) { + written.push(chunk.toString()); + callback(); + }, + }); + } + + exit(code: number | null, signal: NodeJS.Signals | null = null) { + this.exitCode = code; + this.signalCode = signal; + this.recordedExit = { code, signal }; + this.emit("close", code, signal); + } +} + +function asProc(helper: FakeHelper) { + return helper as unknown as ChildProcessWithoutNullStreams; +} + +let helper: FakeHelper; + +beforeEach(() => { + vi.useFakeTimers(); + helper = new FakeHelper(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +function waitForStop(readOutput: () => string, timeoutMs?: number) { + return waitForNativeMacCaptureStop({ + proc: asProc(helper), + targetPath: TARGET, + readOutput, + readExit: () => helper.recordedExit, + timeoutMs, + }); +} + +describe("readNativeMacHelperEvents", () => { + it("reads an event glued behind stderr text", () => { + const events = readNativeMacHelperEvents(`2026-09-14 helper log line${stopped}`); + expect(events).toEqual([{ event: "recording-stopped", screenPath: TARGET }]); + }); + + it("skips plain text and objects that are not events", () => { + expect(readNativeMacHelperEvents('warming up\n{"not":"an event"}\n{broken\n')).toEqual([]); + }); +}); + +describe("readNativeMacStopOutcome", () => { + it("takes the path the helper reported", () => { + expect(readNativeMacStopOutcome(started + stopped, null, "/elsewhere.mp4")).toEqual({ + ok: true, + screenVideoPath: TARGET, + }); + }); + + it("falls back to the requested path when the report names none", () => { + expect( + readNativeMacStopOutcome( + line({ event: "recording-stopped" }), + { code: 0, signal: null }, + TARGET, + ), + ).toEqual({ ok: true, screenVideoPath: TARGET }); + }); + + /** + * The shape that used to lose a finalized take: capture stopped on its own, the + * helper finished the file anyway, and the stop rejected on the earlier error. + */ + it("keeps a take whose capture stopped on its own but whose file was finalized", () => { + expect(readNativeMacStopOutcome(started + captureStopped + stopped, null, TARGET)).toEqual({ + ok: true, + screenVideoPath: TARGET, + warning: + "Recording ended early (The display was disconnected). The part recorded until then was saved.", + }); + }); + + it("says why the take ended rather than quoting the raw writer error", () => { + expect(readNativeMacStopOutcome(started + writerDied + writerFailed, null, TARGET)).toEqual({ + ok: false, + reason: "helper-failed", + message: "Recording stopped: the video file could not be written (video append: -11800).", + exited: false, + }); + }); + + /** The replay that used to reject a stop before the helper had even been told to stop. */ + it("does not settle on an interruption alone while the helper is still running", () => { + expect(readNativeMacStopOutcome(started + writerDied, null, TARGET)).toBeNull(); + }); + + it("keeps the old behaviour for a helper that exits 0 without a word", () => { + expect(readNativeMacStopOutcome(started, { code: 0, signal: null }, TARGET)).toEqual({ + ok: true, + screenVideoPath: TARGET, + }); + }); + + /** Exit 0 after an interruption only means the command pipe closed; nothing was finalized. */ + it("does not call exit 0 after an interruption a finished recording", () => { + expect( + readNativeMacStopOutcome(started + writerDied, { code: 0, signal: null }, TARGET), + ).toEqual({ + ok: false, + reason: "helper-failed", + message: "Recording stopped: the video file could not be written (video append: -11800).", + exited: true, + }); + }); + + it("reports a killed helper by its signal instead of dumping its log", () => { + expect(readNativeMacStopOutcome(started, { code: null, signal: "SIGKILL" }, TARGET)).toEqual({ + ok: false, + reason: "helper-failed", + message: "The recorder stopped unexpectedly (signal SIGKILL).", + exited: true, + }); + }); + + it("refuses a finalized report with no path to point at", () => { + expect( + readNativeMacStopOutcome( + line({ event: "recording-stopped" }), + { code: 0, signal: null }, + null, + ), + ).toMatchObject({ ok: false, reason: "helper-failed" }); + }); +}); + +describe("waitForNativeMacCaptureStop", () => { + it("waits for the helper to exit even when an error is already buffered", async () => { + let output = started + writerDied; + let settled = false; + const pending = waitForStop(() => output).then((result) => { + settled = true; + return result; + }); + + await vi.advanceTimersByTimeAsync(1_000); + expect(settled).toBe(false); + + output += writerFailed; + helper.exit(0); + + await expect(pending).resolves.toMatchObject({ + ok: false, + message: "Recording stopped: the video file could not be written (video append: -11800).", + exited: true, + }); + }); + + it("resolves with the finalized path once the helper exits", async () => { + let output = started; + const pending = waitForStop(() => output); + + output += stopped; + helper.exit(0); + + await expect(pending).resolves.toEqual({ ok: true, screenVideoPath: TARGET }); + }); + + /** Node never re-emits `close`, so a dead helper must not cost the whole timeout. */ + it("settles immediately when the helper has already exited", async () => { + helper.exit(null, "SIGKILL"); + + const result = await waitForStop(() => started); + + expect(result).toEqual({ + ok: false, + reason: "helper-failed", + message: "The recorder stopped unexpectedly (signal SIGKILL).", + exited: true, + }); + expect(vi.getTimerCount()).toBe(0); + }); + + it("keeps a finalized recording when the helper never exits", async () => { + const pending = waitForStop(() => started + stopped, 30_000); + + await vi.advanceTimersByTimeAsync(30_000); + + await expect(pending).resolves.toEqual({ ok: true, screenVideoPath: TARGET }); + }); + + it("reports a timeout, without claiming the helper exited, when it never finishes", async () => { + const pending = waitForStop(() => started, 30_000); + + await vi.advanceTimersByTimeAsync(30_000); + + await expect(pending).resolves.toEqual({ + ok: false, + reason: "stop-timeout", + message: "The recorder did not finish saving in time.", + exited: false, + }); + }); + + it("reports a process error as a helper failure", async () => { + const pending = waitForStop(() => started); + + helper.emit("error", new Error("spawn EACCES")); + + await expect(pending).resolves.toEqual({ + ok: false, + reason: "helper-failed", + message: "spawn EACCES", + exited: false, + }); + }); +}); + +describe("sendNativeMacStopCommand", () => { + it("tells a running helper to stop", () => { + expect(sendNativeMacStopCommand(asProc(helper))).toBe(true); + expect(helper.written).toEqual(["stop\n"]); + }); + + it("does not write to a helper that already exited", () => { + helper.exit(null, "SIGKILL"); + expect(sendNativeMacStopCommand(asProc(helper))).toBe(false); + expect(helper.written).toEqual([]); + }); + + it("does not write to a command pipe that is already closed", () => { + helper.stdin.destroy(); + expect(sendNativeMacStopCommand(asProc(helper))).toBe(false); + }); +}); diff --git a/electron/recording/nativeMacCaptureStop.ts b/electron/recording/nativeMacCaptureStop.ts new file mode 100644 index 000000000..b07e5135d --- /dev/null +++ b/electron/recording/nativeMacCaptureStop.ts @@ -0,0 +1,289 @@ +import type { ChildProcessWithoutNullStreams } from "node:child_process"; + +/** + * Stopping a native macOS (ScreenCaptureKit) recording, as a unit that can be + * tested — the macOS twin of `nativeWindowsCaptureStop.ts`, which exists for + * the same reason: `electron/ipc/handlers.ts` cannot be loaded from a test. + * + * # Why the stop settles on the helper's exit, not on its first error + * + * The helper speaks two kinds of error. `writer-failed-during-capture` and + * `capture-stopped-with-error` say that the take ended on its own; the helper + * then shuts down by itself (issue #621, PR #655) and still reports how that + * went, with `recording-stopped` or `writer-failed`. The stop used to reject on + * the first `"event":"error"` in the whole buffered output, so a take whose + * capture stopped but whose file was finalized was thrown away with an error, + * and a take whose writer died was rejected before the helper had finished + * writing anything down. + * + * The helper always exits once it has received `stop` and finished (its command + * loop runs `await recorder.stop(); exit(0)`), after its last word. So the one + * moment at which the output is complete and the file is no longer being + * written is the process's `close`. That is also where Windows settles. + */ + +/** Outer bound on a stop. Unchanged from the wait this replaced. */ +export const NATIVE_MAC_CAPTURE_STOP_TIMEOUT_MS = 30_000; + +/** How the helper process ended, as Node's `close` reports it. */ +export type NativeMacCaptureExit = { + code: number | null; + signal: NodeJS.Signals | null; +}; + +export type NativeMacCaptureStopResult = + | { + ok: true; + screenVideoPath: string; + /** Why the take ended before the user stopped it, when it did. */ + warning?: string; + } + | { + ok: false; + reason: "helper-failed" | "stop-timeout"; + message: string; + /** False when the helper was still running when the wait gave up. */ + exited: boolean; + }; + +/** + * Errors after which the helper stops itself and still reports a terminal + * outcome. They explain a stop; they never settle one. + */ +const INTERRUPTION_ERROR_CODES = new Set([ + "writer-failed-during-capture", + "capture-stopped-with-error", +]); + +type HelperEvent = Record; + +function parseHelperEvent(line: string): HelperEvent | null { + const candidates = [line]; + // stdout and stderr drain into one buffer chunk by chunk, so a stderr line can + // end up glued onto the front of a JSON event. + const braceIndex = line.indexOf("{"); + if (braceIndex > 0) { + candidates.push(line.slice(braceIndex)); + } + for (const candidate of candidates) { + try { + const parsed = JSON.parse(candidate); + if (parsed && typeof parsed === "object" && typeof parsed.event === "string") { + return parsed as HelperEvent; + } + } catch { + // Not an event; the helper log also carries plain text. + } + } + return null; +} + +export function readNativeMacHelperEvents(output: string): HelperEvent[] { + const events: HelperEvent[] = []; + for (const line of output.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + const event = parseHelperEvent(trimmed); + if (event) { + events.push(event); + } + } + return events; +} + +/** `Array.prototype.findLast`, which the project's ES library target predates. */ +function lastWhere(events: HelperEvent[], matches: (event: HelperEvent) => boolean) { + for (let index = events.length - 1; index >= 0; index -= 1) { + if (matches(events[index])) { + return events[index]; + } + } + return undefined; +} + +function messageOf(event: HelperEvent) { + if (typeof event.message === "string" && event.message.trim()) { + return event.message.trim(); + } + return typeof event.code === "string" ? event.code : "Native macOS capture failed"; +} + +function describeExit(exit: NativeMacCaptureExit) { + if (exit.signal) { + return `The recorder stopped unexpectedly (signal ${exit.signal}).`; + } + if (exit.code === 0) { + return "The recorder exited without reporting a finished recording."; + } + return `The recorder stopped unexpectedly (exit code ${exit.code ?? "unknown"}).`; +} + +/** + * What the helper's output says about a stop, or null while it has not said + * enough yet. `exit` is null while the helper is still running. + */ +export function readNativeMacStopOutcome( + output: string, + exit: NativeMacCaptureExit | null, + targetPath: string | null, +): NativeMacCaptureStopResult | null { + const events = readNativeMacHelperEvents(output); + const isInterruption = (event: HelperEvent) => + typeof event.code === "string" && INTERRUPTION_ERROR_CODES.has(event.code); + const stopped = lastWhere(events, (event) => event.event === "recording-stopped"); + const errors = events.filter((event) => event.event === "error"); + const interruption = errors.find(isInterruption); + const failure = lastWhere(errors, (event) => !isInterruption(event)); + const exited = exit !== null; + + // A finalized file outranks anything said before it: this is the take the + // user would otherwise lose. + if (stopped) { + const reportedPath = + typeof stopped.screenPath === "string" && stopped.screenPath ? stopped.screenPath : null; + const screenVideoPath = reportedPath ?? targetPath; + if (!screenVideoPath) { + return { + ok: false, + reason: "helper-failed", + message: "Native macOS capture did not return an output path.", + exited, + }; + } + if (!interruption) { + return { ok: true, screenVideoPath }; + } + const reason = messageOf(interruption).replace(/[.\s]+$/, ""); + return { + ok: true, + screenVideoPath, + warning: `Recording ended early (${reason}). The part recorded until then was saved.`, + }; + } + + if (failure) { + // The interruption is what the user can act on ("the video file could not be + // written"); the terminal error that follows it is the raw NSError behind it. + return { + ok: false, + reason: "helper-failed", + message: messageOf(interruption ?? failure), + exited, + }; + } + + if (!exit) { + return null; + } + + // A helper that exits 0 without a word finalized the file it was asked for — + // unless it had already said the take ended, in which case exit 0 only means + // its command pipe closed. + if (exit.code === 0 && !exit.signal && targetPath && !interruption) { + return { ok: true, screenVideoPath: targetPath }; + } + return { + ok: false, + reason: "helper-failed", + message: interruption ? messageOf(interruption) : describeExit(exit), + exited: true, + }; +} + +/** + * Waits for the helper to finish a stop and says how it went. + * + * Resolves rather than rejects, like the Windows wait: the caller needs to tell + * a helper failure apart from a timeout, and a discard has to proceed either + * way. + */ +export function waitForNativeMacCaptureStop(options: { + proc: ChildProcessWithoutNullStreams; + /** Path we asked the helper to write, used when it exits 0 without saying so. */ + targetPath: string | null; + /** The accumulated helper output; read lazily so late chunks are included. */ + readOutput: () => string; + /** + * How the helper exited, once its output drain has seen `close`. Read from the + * drain rather than from `proc.exitCode`, because `exit` can fire before the + * last output chunk has been read. + */ + readExit: () => NativeMacCaptureExit | null; + timeoutMs?: number; +}): Promise { + const { proc, targetPath, readOutput, readExit } = options; + const timeoutMs = options.timeoutMs ?? NATIVE_MAC_CAPTURE_STOP_TIMEOUT_MS; + + const settleFromExit = (exit: NativeMacCaptureExit): NativeMacCaptureStopResult => + readNativeMacStopOutcome(readOutput(), exit, targetPath) ?? { + ok: false, + reason: "helper-failed", + message: describeExit(exit), + exited: true, + }; + + // The helper may already be gone: killed, crashed, or exited after stopping + // itself. Node never re-emits `close`, so waiting for one would burn the whole + // timeout on a recorder that has nothing left to say. + const alreadyExited = readExit(); + if (alreadyExited) { + return Promise.resolve(settleFromExit(alreadyExited)); + } + + return new Promise((resolve) => { + const onClose = (code: number | null, signal: NodeJS.Signals | null) => { + cleanup(); + resolve(settleFromExit(readExit() ?? { code, signal })); + }; + const onError = (error: Error) => { + cleanup(); + resolve({ + ok: false, + reason: "helper-failed", + message: error.message, + exited: proc.exitCode !== null || proc.signalCode !== null, + }); + }; + const cleanup = () => { + clearTimeout(timer); + proc.off("close", onClose); + proc.off("error", onError); + }; + + const timer = setTimeout(() => { + cleanup(); + // A helper that already reported a finalized file but has not exited still + // left a complete recording behind. It is not killed here: one that has not + // reported may be inside a slow finishWriting, and this helper has no + // shutdown ceiling that would make killing it safe. + const outcome = readNativeMacStopOutcome(readOutput(), null, targetPath); + resolve( + outcome ?? { + ok: false, + reason: "stop-timeout", + message: "The recorder did not finish saving in time.", + exited: false, + }, + ); + }, timeoutMs); + + proc.once("close", onClose); + proc.once("error", onError); + }); +} + +/** + * Sends `stop` to a helper that can still hear it. Returns whether it was sent. + * + * A helper that already exited has closed its command pipe, and writing to it + * raises EPIPE or ERR_STREAM_DESTROYED on `proc.stdin`. + */ +export function sendNativeMacStopCommand(proc: ChildProcessWithoutNullStreams): boolean { + if (proc.exitCode !== null || proc.signalCode !== null || !proc.stdin.writable) { + return false; + } + proc.stdin.write("stop\n"); + return true; +} diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 8960548b2..68a50d2c1 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -767,6 +767,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { clearNativeRecordingState(); return true; } + if (result.warning) { + toast.warning(result.warning); + } if (webcamResult.asset && result.path) { const attachResult = await window.electronAPI.attachNativeMacWebcamRecording({ From 186dd5a7189b204c23b259ae71ff5965069d056f Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Mon, 14 Sep 2026 15:20:25 +0200 Subject: [PATCH 02/39] fix(macos): keep a timed-out helper out of the next take, and say why a take ended Review of the stop rewrite found three defects. A helper whose stop timed out is deliberately left running, but its drain kept appending to the shared output buffer and dispatching on the shared event bus. If the next take started and then failed or was killed, the old helper's late `recording-stopped` was in that take's buffer and outranked its failure: the stop reported success and opened the previous take's file. The same late output could also reject the next take's start. Each helper's output is now kept on its own, the stop reads only its helper's output, and only the current helper feeds the shared buffer and bus. This predates the rewrite; the rewrite still carried it. The "recording ended early" warning was toasted in the HUD, which the editor switch closes a moment later, so nobody saw it. It now travels with the recording hand-off: `get-current-recording-session` returns it for the file it was kept in, the editor shows it when it imports the recording, and the CLI returns it in `warnings` instead of reporting a cut-short take as a clean run. Also: the discard targets are a tested function instead of inline handler code; the fixtures use the messages the helper really prints; and two comments said things the Swift helper cannot do (a self-stopped helper stays alive until it is sent `stop`, and exit 0 without a word is stdin EOF, which the app never causes). --- electron/electron-env.d.ts | 2 + electron/ipc/handlers.ts | 58 ++++++++++++++----- .../recording/nativeMacCaptureStop.test.ts | 58 ++++++++++++++++--- electron/recording/nativeMacCaptureStop.ts | 27 +++++++-- src/cli/CliRecordRunner.tsx | 3 + src/components/ai-edition/NewEditorShell.tsx | 2 +- .../ai-edition/recordingImport.test.ts | 30 +++++++++- src/components/ai-edition/recordingImport.ts | 13 ++++- src/hooks/useScreenRecorder.ts | 3 - 9 files changed, 161 insertions(+), 35 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 0fce60828..838ef6e77 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -319,6 +319,8 @@ interface Window { success: boolean; session?: RecordingSession | null; canceled?: boolean; + /** Why this recording ended before it was stopped, when it did. */ + warning?: string; }>; findRecordingCamera: (videoPath: string) => Promise<{ success: boolean; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 3cd452afb..d270e8b77 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -83,6 +83,7 @@ import { toHelperRect } from "../native-bridge/helperCoordinates"; import { scoreDeviceNameMatch } from "../recording/deviceNameMatching"; import { type NativeMacCaptureExit, + nativeMacDiscardTargets, sendNativeMacStopCommand, waitForNativeMacCaptureStop, } from "../recording/nativeMacCaptureStop"; @@ -775,6 +776,18 @@ let nativeMacIsPaused = false; * point at which its output has been read in full. */ const nativeMacCaptureExits = new WeakMap(); +/** + * Each macOS helper's own output. The shared `nativeMacCaptureOutput` belongs to + * the current take; a helper whose stop timed out keeps talking after the next + * take has started, and its late `recording-stopped` must not settle that take. + */ +const nativeMacCaptureOutputs = new WeakMap(); +/** + * Why the last macOS take ended before it was stopped, keyed by the file it was + * kept in. Handed to whatever opens that recording next — the editor or the CLI — + * because the HUD that ran the stop closes as the editor opens. + */ +let nativeMacRecordingWarning: { screenVideoPath: string; message: string } | null = null; /** True while stop-native-mac-recording runs: the take is ending on purpose. */ let nativeMacStopInFlight = false; // Global frame of the region captured by the SCK helper (see getSelectedSourceBounds). @@ -1511,14 +1524,22 @@ function attachNativeMacCaptureOutputDrain( ); const drain = (chunk: Buffer) => { const text = chunk.toString(); - nativeMacCaptureOutput += text; + nativeMacCaptureOutputs.set(proc, (nativeMacCaptureOutputs.get(proc) ?? "") + text); + // Only the current take's helper feeds the shared buffer and event bus that the + // start wait, the microphone check and the diagnostics bundle read. + const isCurrent = nativeMacCaptureProcess === proc; + if (isCurrent) { + nativeMacCaptureOutput += text; + } lineBuffer += text; const lines = lineBuffer.split(/\r?\n/); lineBuffer = lines.pop() ?? ""; for (const line of lines) { const event = tryParseNativeHelperEvent(line.trim()); if (event) { - dispatchNativeMacHelperEvent(event); + if (isCurrent) { + dispatchNativeMacHelperEvent(event); + } watchLiveTake(event); } } @@ -2787,6 +2808,7 @@ export function registerIpcHandlers( nativeMacPauseRanges = []; nativeMacIsPaused = false; nativeMacStopInFlight = false; + nativeMacRecordingWarning = null; activeMacCaptureBounds = null; const cursorStartTimeMs = Date.now(); @@ -3174,7 +3196,7 @@ export function registerIpcHandlers( const stopResultPromise = waitForNativeMacCaptureStop({ proc, targetPath: preferredPath, - readOutput: () => nativeMacCaptureOutput, + readOutput: () => nativeMacCaptureOutputs.get(proc) ?? "", readExit: () => nativeMacCaptureExits.get(proc) ?? null, }); sendNativeMacStopCommand(proc); @@ -3187,15 +3209,11 @@ export function registerIpcHandlers( } if (discard) { pendingCursorRecordingData = null; - // Discarding does not wait on the stop having worked: a take thrown away - // after its writer died used to stay behind as an orphan .mp4. - const discardedPath = stopResult.ok ? stopResult.screenVideoPath : preferredPath; - if (discardedPath) { - await Promise.all([ - fs.rm(discardedPath, { force: true }), - fs.rm(`${discardedPath}.cursor.json`, { force: true }), - ]); - } + await Promise.all( + nativeMacDiscardTargets(stopResult, preferredPath).map((target) => + fs.rm(target, { force: true }), + ), + ); if (!stopResult.ok) { console.warn("[native-sck] discarded a take whose stop did not complete", { reason: stopResult.reason, @@ -3210,11 +3228,14 @@ export function registerIpcHandlers( reason: stopResult.reason, message: stopResult.message, helperExited: stopResult.exited, - output: nativeMacCaptureOutput.trim(), + output: (nativeMacCaptureOutputs.get(proc) ?? "").trim(), }); return { success: false, error: stopResult.message }; } const screenVideoPath = stopResult.screenVideoPath; + nativeMacRecordingWarning = stopResult.warning + ? { screenVideoPath, message: stopResult.warning } + : null; if (stopResult.warning) { console.warn("[native-sck] the take ended before it was stopped; its recording was kept", { warning: stopResult.warning, @@ -4254,9 +4275,14 @@ export function registerIpcHandlers( }); ipcMain.handle("get-current-recording-session", () => { - return currentRecordingSession - ? { success: true, session: currentRecordingSession } - : { success: false }; + if (!currentRecordingSession) { + return { success: false }; + } + const warning = + nativeMacRecordingWarning?.screenVideoPath === currentRecordingSession.screenVideoPath + ? nativeMacRecordingWarning.message + : undefined; + return { success: true, session: currentRecordingSession, ...(warning ? { warning } : {}) }; }); // returns the webcam path (if any) for a given screen video by diff --git a/electron/recording/nativeMacCaptureStop.test.ts b/electron/recording/nativeMacCaptureStop.test.ts index 83150de1e..9855b7c2d 100644 --- a/electron/recording/nativeMacCaptureStop.test.ts +++ b/electron/recording/nativeMacCaptureStop.test.ts @@ -4,6 +4,7 @@ import { PassThrough, Writable } from "node:stream"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { type NativeMacCaptureExit, + nativeMacDiscardTargets, readNativeMacHelperEvents, readNativeMacStopOutcome, sendNativeMacStopCommand, @@ -17,12 +18,19 @@ function line(event: Record) { return `${JSON.stringify(event)}\n`; } +// The messages as the Swift helper really prints them: the interruption embeds the +// writer's NSError, and a stream the system stopped reports its NSError alone. +const WRITER_DIED_MESSAGE = + 'Recording stopped: the video file could not be written (video append: Error Domain=AVFoundationErrorDomain Code=-11800 "The operation could not be completed" UserInfo={NSLocalizedFailureReason=An unknown error occurred (-16364)}).'; +const CAPTURE_STOPPED_MESSAGE = + 'Error Domain=com.apple.ScreenCaptureKit.SCStreamErrorDomain Code=-3815 "The stream was stopped by the system." UserInfo={NSLocalizedDescription=The stream was stopped by the system.}'; + const started = line({ event: "recording-started", width: 3840, height: 2160 }); const stopped = line({ event: "recording-stopped", screenPath: TARGET }); const writerDied = line({ event: "error", code: "writer-failed-during-capture", - message: "Recording stopped: the video file could not be written (video append: -11800).", + message: WRITER_DIED_MESSAGE, }); const writerFailed = line({ event: "error", @@ -32,7 +40,7 @@ const writerFailed = line({ const captureStopped = line({ event: "error", code: "capture-stopped-with-error", - message: "The display was disconnected.", + message: CAPTURE_STOPPED_MESSAGE, }); /** @@ -131,8 +139,7 @@ describe("readNativeMacStopOutcome", () => { expect(readNativeMacStopOutcome(started + captureStopped + stopped, null, TARGET)).toEqual({ ok: true, screenVideoPath: TARGET, - warning: - "Recording ended early (The display was disconnected). The part recorded until then was saved.", + warning: `Recording ended early (${CAPTURE_STOPPED_MESSAGE}). The part recorded until then was saved.`, }); }); @@ -140,7 +147,7 @@ describe("readNativeMacStopOutcome", () => { expect(readNativeMacStopOutcome(started + writerDied + writerFailed, null, TARGET)).toEqual({ ok: false, reason: "helper-failed", - message: "Recording stopped: the video file could not be written (video append: -11800).", + message: WRITER_DIED_MESSAGE, exited: false, }); }); @@ -164,7 +171,7 @@ describe("readNativeMacStopOutcome", () => { ).toEqual({ ok: false, reason: "helper-failed", - message: "Recording stopped: the video file could not be written (video append: -11800).", + message: WRITER_DIED_MESSAGE, exited: true, }); }); @@ -206,7 +213,7 @@ describe("waitForNativeMacCaptureStop", () => { await expect(pending).resolves.toMatchObject({ ok: false, - message: "Recording stopped: the video file could not be written (video append: -11800).", + message: WRITER_DIED_MESSAGE, exited: true, }); }); @@ -288,3 +295,40 @@ describe("sendNativeMacStopCommand", () => { expect(sendNativeMacStopCommand(asProc(helper))).toBe(false); }); }); + +describe("nativeMacDiscardTargets", () => { + it("removes the file the helper finalized", () => { + expect(nativeMacDiscardTargets({ ok: true, screenVideoPath: "/rec/a.mp4" }, TARGET)).toEqual([ + "/rec/a.mp4", + "/rec/a.mp4.cursor.json", + ]); + }); + + /** The orphan: a take thrown away after its writer died. */ + it("removes the requested file when the stop failed", () => { + expect( + nativeMacDiscardTargets( + { ok: false, reason: "helper-failed", message: WRITER_DIED_MESSAGE, exited: true }, + TARGET, + ), + ).toEqual([TARGET, `${TARGET}.cursor.json`]); + }); + + it("removes the requested file when the stop timed out", () => { + expect( + nativeMacDiscardTargets( + { ok: false, reason: "stop-timeout", message: "timed out", exited: false }, + TARGET, + ), + ).toEqual([TARGET, `${TARGET}.cursor.json`]); + }); + + it("has nothing to remove when no file was ever requested", () => { + expect( + nativeMacDiscardTargets( + { ok: false, reason: "helper-failed", message: "x", exited: true }, + null, + ), + ).toEqual([]); + }); +}); diff --git a/electron/recording/nativeMacCaptureStop.ts b/electron/recording/nativeMacCaptureStop.ts index b07e5135d..42e665f47 100644 --- a/electron/recording/nativeMacCaptureStop.ts +++ b/electron/recording/nativeMacCaptureStop.ts @@ -178,9 +178,11 @@ export function readNativeMacStopOutcome( return null; } - // A helper that exits 0 without a word finalized the file it was asked for — - // unless it had already said the take ended, in which case exit 0 only means - // its command pipe closed. + // Exit 0 with no terminal word is the command loop reaching stdin EOF without a + // `stop` — nothing in the app closes that pipe, and `stop()` always reports + // before its exit(0). It keeps the meaning the stop wait gave it before this + // module (the requested file), except after an interruption, when nothing + // finalized that file. if (exit.code === 0 && !exit.signal && targetPath && !interruption) { return { ok: true, screenVideoPath: targetPath }; } @@ -224,9 +226,9 @@ export function waitForNativeMacCaptureStop(options: { exited: true, }; - // The helper may already be gone: killed, crashed, or exited after stopping - // itself. Node never re-emits `close`, so waiting for one would burn the whole - // timeout on a recorder that has nothing left to say. + // The helper may already be gone: killed or crashed. (One that stopped itself + // stays alive until it is sent `stop`.) Node never re-emits `close`, so waiting + // for one would burn the whole timeout on a recorder that has nothing left to say. const alreadyExited = readExit(); if (alreadyExited) { return Promise.resolve(settleFromExit(alreadyExited)); @@ -287,3 +289,16 @@ export function sendNativeMacStopCommand(proc: ChildProcessWithoutNullStreams): proc.stdin.write("stop\n"); return true; } + +/** + * The files a discarded take leaves behind, whatever its stop returned: a take + * thrown away after its writer died used to stay on disk as an orphan .mp4, + * because removing it depended on the stop having worked. + */ +export function nativeMacDiscardTargets( + result: NativeMacCaptureStopResult, + targetPath: string | null, +): string[] { + const screenVideoPath = result.ok ? result.screenVideoPath : targetPath; + return screenVideoPath ? [screenVideoPath, `${screenVideoPath}.cursor.json`] : []; +} diff --git a/src/cli/CliRecordRunner.tsx b/src/cli/CliRecordRunner.tsx index e19c451f9..f588caa4c 100644 --- a/src/cli/CliRecordRunner.tsx +++ b/src/cli/CliRecordRunner.tsx @@ -284,6 +284,9 @@ export function CliRecordRunner() { cursorDataPath: `${session.screenVideoPath}.cursor.json`, durationMs, ...(request?.projectOut ? { projectData: buildDefaultProject(session) } : {}), + // The take ended before it was stopped but was kept: a script must be + // able to tell that apart from a clean run. + ...(sessionResult.warning ? { warnings: [sessionResult.warning] } : {}), }); } catch (error) { await fail(error); diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx index 681a88031..fabfc3acb 100644 --- a/src/components/ai-edition/NewEditorShell.tsx +++ b/src/components/ai-edition/NewEditorShell.tsx @@ -380,7 +380,7 @@ export function NewEditorShell() { void (async () => { if (!window.electronAPI) return; try { - if (await importPendingRecording()) { + if (await importPendingRecording((warning) => toast.warning(warning))) { toast.success("Recording added to a new project"); return; } diff --git a/src/components/ai-edition/recordingImport.test.ts b/src/components/ai-edition/recordingImport.test.ts index 123c954af..9bc7c5557 100644 --- a/src/components/ai-edition/recordingImport.test.ts +++ b/src/components/ai-edition/recordingImport.test.ts @@ -55,6 +55,7 @@ const realActions = { function stubElectronApi( screenVideoPath: string | null, cursorCaptureMode: "editable-overlay" | "system" = "editable-overlay", + warning?: string, ) { let session: { screenVideoPath: string; @@ -63,7 +64,7 @@ function stubElectronApi( } | null = screenVideoPath ? { screenVideoPath, createdAt: 0, cursorCaptureMode } : null; const api = { getCurrentRecordingSession: vi.fn(async () => - session ? { success: true, session } : { success: false }, + session ? { success: true, session, ...(warning ? { warning } : {}) } : { success: false }, ), setCurrentRecordingSession: vi.fn(async (next: typeof session) => { session = next; @@ -93,6 +94,33 @@ describe("importPendingRecording", () => { expect(createProject).not.toHaveBeenCalled(); }); + // The HUD that stopped a take which ended on its own closes as the editor opens, + // so the editor has to be the one that says the recording was cut short. + it("hands over why the take ended early once the import succeeded", async () => { + stubElectronApi( + "/recordings/recording-1.mp4", + "editable-overlay", + "Recording ended early (stream stopped). The part recorded until then was saved.", + ); + const onWarning = vi.fn(); + + await expect(importPendingRecording(onWarning)).resolves.toBe(true); + + expect(onWarning).toHaveBeenCalledTimes(1); + expect(onWarning).toHaveBeenCalledWith( + "Recording ended early (stream stopped). The part recorded until then was saved.", + ); + }); + + it("says nothing about a take that was stopped normally", async () => { + stubElectronApi("/recordings/recording-1.mp4"); + const onWarning = vi.fn(); + + await importPendingRecording(onWarning); + + expect(onWarning).not.toHaveBeenCalled(); + }); + it("imports the recording into a new project and consumes the hand-off", async () => { const api = stubElectronApi("C:\\recordings\\recording-1.mp4"); diff --git a/src/components/ai-edition/recordingImport.ts b/src/components/ai-edition/recordingImport.ts index 975a4a144..39b0923bd 100644 --- a/src/components/ai-edition/recordingImport.ts +++ b/src/components/ai-edition/recordingImport.ts @@ -297,7 +297,14 @@ export async function maybeSaveFreshRecordingAutoZooms( * reopening the most recent project. Throws if the import itself fails, leaving * the session in place so a later mount can retry it. */ -export async function importPendingRecording(): Promise { +export async function importPendingRecording( + /** + * Called once the import succeeded, with why the take ended before it was + * stopped when it did. The HUD that ran the stop closes as the editor opens, so + * the editor is the only window left to say it. + */ + onWarning?: (message: string) => void, +): Promise { const api = window.electronAPI; if (!api) return false; @@ -305,6 +312,7 @@ export async function importPendingRecording(): Promise { const screenPath = result.success ? result.session?.screenVideoPath : undefined; if (!screenPath) return false; const cursorCaptureMode = result.success ? result.session?.cursorCaptureMode : undefined; + const warning = result.success ? result.warning : undefined; const label = screenPath.split(/[\\/]/).pop() || "Recording"; await useProjectStore.getState().createProject(`Recording ${new Date().toLocaleString()}`); @@ -348,5 +356,8 @@ export async function importPendingRecording(): Promise { if (latest) { await maybeSaveFreshRecordingAutoZooms(latest); } + if (warning) { + onWarning?.(warning); + } return true; } diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 68a50d2c1..8960548b2 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -767,9 +767,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn { clearNativeRecordingState(); return true; } - if (result.warning) { - toast.warning(result.warning); - } if (webcamResult.asset && result.path) { const attachResult = await window.electronAPI.attachNativeMacWebcamRecording({ From 42115ad7f847909f510aa20dd2a6e2b11fffdfae Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Mon, 14 Sep 2026 16:21:11 +0200 Subject: [PATCH 03/39] fix(macos): listen for errors on the helper's output pipes too The macOS drain listened for errors on the process and on stdin, but not on stdout or stderr. The main-process guard swallows only EPIPE, ECONNRESET and ERR_STREAM_DESTROYED, so any other output-stream error would reach uncaughtException and take the main process down. The Windows drain already guards all three pipes; this mirrors it (CodeRabbit review on #661). --- electron/ipc/handlers.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index d270e8b77..5837502da 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -1566,6 +1566,14 @@ function attachNativeMacCaptureOutputDrain( proc.stdin.on("error", (error) => { console.warn("[native-sck] helper command pipe error:", error); }); + // The output pipes too, as the Windows drain does: the guard only swallows a few + // codes, and any other stream error would take the main process down. + proc.stdout.on("error", (error) => { + console.warn("[native-sck] helper stdout error:", error); + }); + proc.stderr.on("error", (error) => { + console.warn("[native-sck] helper stderr error:", error); + }); } function waitForNativeMacCaptureStart(proc: ChildProcessWithoutNullStreams) { From 4457264409572037ea08ff0397540eb1b7213ea2 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Mon, 14 Sep 2026 16:34:04 +0200 Subject: [PATCH 04/39] fix(macos): recover the take a dead writer or a killed helper left on disk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a macOS stop fails — the writer died, or the helper was killed — the file on disk is a fragmented MP4 that ffmpeg, libavformat, Chromium and the editor all open as it is. The take was still reported as lost. This keeps it. electron/recording/nativeMacCaptureSalvage.ts walks the file's top-level boxes by their own offsets and counts the video frames whose bytes are actually in the file: the flat sample table the moov holds for the first second, then every fragment's trun, with tfhd/trex defaults. It does not use mp4box. PR #571 did, in 1 MiB chunks, and AVAssetWriter's last mdat carries a size field of 0, so mp4box aborted whenever a chunk boundary fell inside it and a take was kept or lost depending on its byte length; it also accepted a file cut inside a moof, which nothing can open. A torn tail — a moof, or a header, cut short — is truncated to where it starts and the file inspected again, so success always means the file opens as it is on disk. A final mdat cut short is the fragment still being written and is left alone. The stop handler only salvages when the helper has exited (a running helper may still be writing), then takes the normal save path: cursor telemetry, session manifest and media links are written, the result carries `recovered`, and the warning names how much was kept ("Recording stopped after 0:35: Disk Full. The part recorded until then was saved."). The warning reaches the editor and the CLI through the session hand-off from #661. Fixtures are five real takes from the helper with every mdat payload zeroed and gzipped (3-44 KB): two writer deaths, two killed helpers, one clean flat take. The frame counts the tests expect are ffmpeg's on the original files, and the torn cuts reproduce layouts ffmpeg refuses (cut inside a moof) and accepts once cut back. --- electron/electron-env.d.ts | 2 + electron/ipc/handlers.ts | 69 ++- .../macos-fmp4/clean-flat-8s.mp4.gz | Bin 0 -> 11658 bytes .../macos-fmp4/helper-killed-29s.mp4.gz | Bin 0 -> 43959 bytes .../macos-fmp4/helper-killed-9s-main.mp4.gz | Bin 0 -> 14760 bytes .../macos-fmp4/writer-died-1s-no-moof.mp4.gz | Bin 0 -> 3028 bytes .../macos-fmp4/writer-died-4s.mp4.gz | Bin 0 -> 7271 bytes .../recording/nativeMacCaptureSalvage.test.ts | 219 ++++++++ electron/recording/nativeMacCaptureSalvage.ts | 505 ++++++++++++++++++ 9 files changed, 776 insertions(+), 19 deletions(-) create mode 100644 electron/recording/__fixtures__/macos-fmp4/clean-flat-8s.mp4.gz create mode 100644 electron/recording/__fixtures__/macos-fmp4/helper-killed-29s.mp4.gz create mode 100644 electron/recording/__fixtures__/macos-fmp4/helper-killed-9s-main.mp4.gz create mode 100644 electron/recording/__fixtures__/macos-fmp4/writer-died-1s-no-moof.mp4.gz create mode 100644 electron/recording/__fixtures__/macos-fmp4/writer-died-4s.mp4.gz create mode 100644 electron/recording/nativeMacCaptureSalvage.test.ts create mode 100644 electron/recording/nativeMacCaptureSalvage.ts diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 838ef6e77..dce4db3b7 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -181,6 +181,8 @@ interface Window { discarded?: boolean; /** The take ended before it was stopped, but its recording was kept. */ warning?: string; + /** The stop failed and the recording was recovered from what was on disk. */ + recovered?: boolean; error?: string; }>; attachNativeMacWebcamRecording: (payload: { diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 5837502da..409c162dd 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -81,6 +81,10 @@ import { findPipeWireCursorHelperPath } from "../native-bridge/cursor/recording/ import type { CursorRecordingSession } from "../native-bridge/cursor/recording/session"; import { toHelperRect } from "../native-bridge/helperCoordinates"; import { scoreDeviceNameMatch } from "../recording/deviceNameMatching"; +import { + describeSalvagedTake, + salvageNativeMacCapture, +} from "../recording/nativeMacCaptureSalvage"; import { type NativeMacCaptureExit, nativeMacDiscardTargets, @@ -3230,26 +3234,50 @@ export function registerIpcHandlers( } return { success: true, discarded: true }; } - if (!stopResult.ok) { - pendingCursorRecordingData = null; - console.error("Failed to stop native macOS recording:", { - reason: stopResult.reason, - message: stopResult.message, - helperExited: stopResult.exited, - output: (nativeMacCaptureOutputs.get(proc) ?? "").trim(), - }); - return { success: false, error: stopResult.message }; - } - const screenVideoPath = stopResult.screenVideoPath; - nativeMacRecordingWarning = stopResult.warning - ? { screenVideoPath, message: stopResult.warning } - : null; - if (stopResult.warning) { - console.warn("[native-sck] the take ended before it was stopped; its recording was kept", { - warning: stopResult.warning, + let screenVideoPath: string; + let warning: string | undefined; + let recovered = false; + if (stopResult.ok) { + screenVideoPath = stopResult.screenVideoPath; + warning = stopResult.warning; + if (warning) { + console.warn( + "[native-sck] the take ended before it was stopped; its recording was kept", + { + warning, + path: screenVideoPath, + }, + ); + } + } else { + // A helper that exited left a file nothing writes to any more, and what its + // writer finished before the failure is usually a playable fragmented take. + // One still running may be mid-write, so it is left alone. + const salvage = + stopResult.exited && preferredPath ? await salvageNativeMacCapture(preferredPath) : null; + if (!salvage || !salvage.ok) { + pendingCursorRecordingData = null; + console.error("Failed to stop native macOS recording:", { + reason: stopResult.reason, + message: stopResult.message, + helperExited: stopResult.exited, + salvage: salvage ? salvage.reason : "not attempted: the helper had not exited", + output: (nativeMacCaptureOutputs.get(proc) ?? "").trim(), + }); + return { success: false, error: stopResult.message }; + } + screenVideoPath = salvage.screenVideoPath; + warning = describeSalvagedTake(stopResult.message, salvage.durationSec); + recovered = true; + console.warn("[native-sck] recovered the part of the take written before its stop failed", { + stopFailure: stopResult.message, path: screenVideoPath, + videoSamples: salvage.videoSamples, + durationSec: salvage.durationSec, + truncatedBytes: salvage.truncatedBytes, }); } + nativeMacRecordingWarning = warning ? { screenVideoPath, message: warning } : null; if (cursorCaptureMode === "editable-overlay") { compactPendingCursorTelemetryPauseRanges(nativeMacPauseRanges); @@ -3276,8 +3304,11 @@ export function registerIpcHandlers( success: true, path: screenVideoPath, session, - message: "Native macOS recording session stored successfully", - ...(stopResult.warning ? { warning: stopResult.warning } : {}), + message: recovered + ? "Native macOS recording recovered from a failed stop" + : "Native macOS recording session stored successfully", + ...(warning ? { warning } : {}), + ...(recovered ? { recovered: true } : {}), }; } catch (error) { console.error("Failed to stop native macOS recording:", error); diff --git a/electron/recording/__fixtures__/macos-fmp4/clean-flat-8s.mp4.gz b/electron/recording/__fixtures__/macos-fmp4/clean-flat-8s.mp4.gz new file mode 100644 index 0000000000000000000000000000000000000000..2cae6a9536aeab66d86acbe19a12449c9a85a41e GIT binary patch literal 11658 zcmeI${a4b59tZGk-Luwh=}w(;PSdRi(dI+irs3mlwes2WiA+P64@^xtg7}0$Ww+v* zmZ>;mKzBY(osyd3Q*ydCQ9ziID4;MAaDwPU0TGb9-T&h5d_O;YetZA$d7aNWpL0IW zl^?wGXT7&)jXAyVAD7>`H!XD{d|LVOxv(%l7RLMjKi{TRA}OO@VZ(3KuFPLR*#e{& zyx7`M+HPDBfBe5J&;S8I01yBK00BS%5C8-K0YCr{00aO5K;W+^P%>Y!=iNUC`_wvj z@p1xCfB+x>2mk_r03ZMe00MvjAOHve0)PM@@K+beW^gOyKdR2&VjLi@Q-q5dkUPho zUFg32fd1?Q*R(*{?QpmI5B5Ia7dFVgQIr%Pc;hhPf!)6hw@Y$M|E=(l$gYH!N@!;sXZBjx`U-zdOLfB+!y|1Qu)%`co;Fq>l& zvsMVcycZ`SDMiy?LQ?dq`sVU)lUL38$@ZUXq#;!v9XEvfnz_Ya^P3}~-2*x~GsDvK zGjmJqXo8W(ME(rRbRXAjE4ohpmirrAa$}hz`-)(?`Tg~)W~P0BN<$4WP7D$~K(I{B z0!NEZE%?f97{bK1+F_@d>W+|kZ~GjzpPR8ms! z7k&e;YL?;NXXfcVj-ZNkF+Wqr>Fmwjt3K4==}z$>=_-#LvCFzx4Ytag<2Ihxu1`#@ z2>dcK$GFu4gdjScXSKwsce;s)U-WGDwnuNdxrN;RrCQ4N8V_v)JSqPn}_stWY z71Mf(Rmx7J<$Vd``TT}l@Cu)xk$l6>6|G(Cf0-rJZ}n&#+{I9+)QQ~>+4x~JVR$}d zv~s7lr~t#aF6-QBIE0<9)95Ug<_%bWmCsfekUm+L`W4V$j~#jxGjjGVhu+tdw4|cQcqM1=AN2dqs?g= zadLxSWTB5MmX9ksF0vqx>SC(uVr<0|WiLOQGu*AA-et2`un^kAqXmU-`)Q{XlQ5SC znyW(BP^Vo8s7G9Vf;Objls{pZetf+?_!C5V(CMj8dUEth$xYc|{P}~eAU*G`b7Ts^ zb7Ieo2`jsg=bfD4mS7K*i6i<}A~& zuR=$#g6qWjxWoKi73{<)A=!M6;1yo}xoMYxp-yy5*=1O{i(qBJQOdq6wv>vd&X=qe ztWqiwT_EY;E*Yl90@O;t!E7_)7y!6#7`f3_d&!Hn&! z9dZrRL&nnJ6ir9ZNW!sYQH0H#w#I1c8{6onn0D%lS)3VUxNLD@la%HntND5L`3Or> zC4aqm<`Tqqym^v_CU5QWU1;i?)z+P{B;J<^M9$L@)E8g|HszS*3eH#T zXTSAzt)3)LtliDwh9@Bk%*{#4qtA@rWJiZG7Co1r`=|5SL4_L>?F)X>2FVNb9<$I?oX23{DU8 zdckU{0~EdUY!E5iU*WCpPZukZJ59qun&8elvEx%L_NvynIr+-Y#p?5ZN37uhLK4p< zl|>8YGKsrX|6?_Gi+DN7#&hhKox{YFOlTJk9VJ`DH^x$&YoXd<(&$Z5H^(A~ zXfBi+6VC~6t{8ul+i#4dJzt!(y}8mb zCOlIw=k4?Lt|4MZbR=BZt|#zLJeB-%;L@b2HGE z(vGgp{_D;@B}df4r9ebxS9;m@NO^!^n!5+iE8D>wv&^!;s{+|EZP{hxh}n)fiZSz8 z`dMPi)6tyA9dVPoHjb5lzh#l5TkS%OaqQ!We9&fjWY!31(_96DpzWCw6F(YzrmXZ{ z8I8Sjd?pL+Y)^$E+JxxsKxdzc4%MCt%_&odUKp7?8x*I_iYSjig1ePoBRj2QUL$-0X6a~1@%TAVs#*ZqO1&jw z=PhP0|0mL_!O-)>{U_CImiwIJV9N=I#FW`b(cy`a)YMFGfnEpQ-%9r83+O0pQPI3L z-SUWmIT8)-w5oy^_*hLy=KMD0IJJ08Tl71t^`hkOkd3oN11a9-HSybf1C8a1&{9~_ z$R}>JODs~2RuXF54mG|GHOfPc?KRrj;|l&*SuZro1C1=Ar}RijAbPflkHuEOEtbZ(3;r_^tN-iUub2DHbR)SA%$e9K51v a3AWnP&apdrFm1CQw|79NqfFkZedoUni*~L6 literal 0 HcmV?d00001 diff --git a/electron/recording/__fixtures__/macos-fmp4/helper-killed-29s.mp4.gz b/electron/recording/__fixtures__/macos-fmp4/helper-killed-29s.mp4.gz new file mode 100644 index 0000000000000000000000000000000000000000..5bf2fbcba5d6766ef0e1f4c4740474411b538503 GIT binary patch literal 43959 zcmeI)c~p~E`Z)0EbXrsd>INzTZj~YeB4T7qS`e(FgiSyoL_|P9K-mH$B-6@LMFAB= z#6Y7gvIb<6C5eIv3MA~2B}oxTA|VN8AtWKm&(3$|cV_;Xb7rR7Oke)UIh^FZ_qm^Y z&vTx<@4Yd|T=Rz?|Ni*#;%M}D502}pElspvknnDo<#i4Hql=)6H&*I6j$^%`i|#RZLy=cdycRVNpe z8lL2*FSaRaSfm-(lU;CLV@QIN^2^Xreo@|YyL|jOK_cbHHp>IHT0Uu~VvA}JlS3|K z(`K8i7J_&rOEdb?s7}c4QP|0cYtmShhH*bgofe3esqLigQZP8=aPR|IC4jL zf`Rf}P$1Pm^;)Tu>XS_Ah*(+wDmfydHZiXW>6_^x-6d(ZF$lP^`gQwAtxl}|y|a_` z(j9#p%nb18*wjLbrd!BnTS-RrD!Y}P$vyCG;;aP1 z$P}^Sz0is=bX5BKsVvK)79AGVu6XqTo|S~m982``jh{PK*&)#|;?jzT%Y%&hHHz0-4^H)8H}x*~Z$nE8~_Tj*Y?*tvjr zgC5hnUcXWjQLomt@?`|#nBw4mp10h4w8jnUWVcz|@YrW!!OpGc5m;9&RcbC?)Q%8M z9$qHS(wZU;44^yOKf-$9Valxi1Z<|bLPV-O`3O9sI$Hx-Raf2oi*`d0D1q;Y!v<}`KlH47!h$$nYO$$TG6u;iVn#! z^9S_?wQQzhW_ME2-g5|TY2qydV*%*;YWZ$VORxMo>cY}$6Du?Hv39I7V9tZp3)PX% zon_iG7&WrdNu%y7uQaM3#QDN%iJV0bfH`= z2I&xm?~}(KmXHdjoCQhn0d8!o!Zj-T75D2XLm77g!dgz722UwI{qgI8%BDlLj|Bgn zOgAu0KmZW@Wwx;rcg7$8Bl~cqDVcGj18d!+zNlCh~C2T-u@vm^Iw6` zyep*O|DwtI9fkU5OaoM9>i!^b$Fop=51k&+@x{q?R#%$B$MX7xQEX?&aZ z{QS(59i45LZ+O|Se*7ns116rIZP0bzu;mlC6EP+0Hg0gd>ZNUe-}7xhnM3wS#O9&E zb+(KoW&t9Uj{W3!#0d-)5cuf@eh?lKJf`vynRQR7uzS#fp@$ed_+qZ2pko2QAAIxq zL?oilAnm2_P{{I$sAM0>PF&*OdxXy~lE>ppsO%a`@9w z373%{JUf5tu9-gMZt$jH%n{g>DaCSoSBL2wo@2XwR1|7oq@FZ;NN97nb-5=n$^>h$ zjZRs#w;LgPvY4Pi#qmIFMb6YNP}eGL5WYbc?SR%1G#ym=^@2PCJmz3HNPWLG#U8CM z=nLktigTh>a<5U0KWZjHjt8?((A^{Uw<bOD$afjvrR^LpgEx8~ z8FGreIfh_%kdr>sPKKc&M{QL}v!-X^3NDON6@)KVO5 z0;_69i9ABZ813tKd;{lq)7Nszi+wEp-oE99_%eZ{3{k3-z{9DTVh>ek)|Jx?;-gx) z%nt@3xKg=%N<0{jtW-{0!>JW!`4mh|gd9FEEEPykRfIazz7Q+j`KIFbQgqEI(%BBS zpmtJmN=kR1f~=4@_ygiR8G33SJe8ftaiTPASPnd%JY|^WvW#n)sAXwme5n)s(Rk)L zLI46P?I6|C%F=0tf&C zfWW^gu(Yocu3uZD5pz#h)9bEtL!q36AJ2DEq*e0x{l_Cv%f=bS{d=GMfn(@8G5rTfpUGT9v`YcX$p%Pi$??FD z=zFb3b9SeBs|V&bhwwC{-{ko1g6j&ha-!8Ez`8Ou2VSb}7AEvAsu7JSv$mp?DGi~} z<&KslXS?>q$BX>5C*+DJFS`X&fja}nKg}A8QQmT|az|v-X)^+MOJzFDEaf@F?%#w1 zE&~Jr0YKoF7I@}&N}k=-)by2RY}fPGF=6C6<*V?Nuu=Hq1tl5P_%f8A6!&>48Jons z+=RYlnVl^&Fr_XZXZhBg0B;n~b3A(0?3EXL<%PD2Q*6xMIZ4{IhEOX*Y2#c(NjlPR zt1nRBWtNWGHOD_46D|B2Dd;e;9_bfB)A)8eF~-%Ka3Benb6A}z#Lol}dnHAiBA(P1!nG*kb5Aqim##oU9aQ%R zF?&UjEio&wwhygx{7_WxE{0yr%IWs(BT3Ax9F?R{|1slvg$o(m$h~>zjvZgIQ1L_} z8H<+>%ugCF*s=8u2qRtdrQxV<-qf-j^BXPd5m!wA_Vu^uKjjN`eh=CFI3V&(e!=52CaW{ih#=i0YKoN2q;zQ z=~Gx2G1cGRO08NOq|(f7)_7KNRz%FjNWY5Y}t>|5I(|Dw!qXmJzia_*g^Pa7-^qqW)M_b z)Q`ZZ3pdT6>Fe!u!PHk@dBatuwuw=oOZ&(D1t=d?($F4gdQ=+GFz%_#eA+>Vfh_Tg zY7Zq%GO0g)AdSAwT3r#thT&RLk%>n{C)rEY`+Mvm`m%z->@@Ul(FHbcaHIkxA(hXc zq|aA3HzK(O-uZm;lXqPNeti1`dzs>k$XBiXCmg_AfWXfzuoRt(xrDH;v3)sJ(;BjU zTp%^1r8z}|$G8+jfp-{}UC;*&KARFmWcnZiuYmiUkV3ugR`Jc!VYr>(>!Cb7M=i`I zrguqEzx?Gv(qYk694M_wgSvXKAC#I!?Xa#^9qH$55V&vcP^QzYQ$8bWBfPp$a)_uV zP;rtC>AXMueN~^u{PPAZ*uAEo(&##}k-_YAiQYQl z<>L6#D`=PEs0&Yu@dCNp&&&rj z4+sDPfWS{Fuma7?ZnnXC>CbY<{Vg;&($#Ya+)yDjUM{@0ddAmAHWUxJaCV-El6?8M zlsxcm+L!NBahPp#wbm2@)2}X|!>cRca8a+`%=A>IKH99mC7pOA&ZHW$iK*)jW!h~V zuTE83i<&P-X*2QZXq)P7>cR$Ge%F26L5Kl!HV}PClvY=!pA7R6qVIWoU?x(2;? zhjgcq=a>4c`WoI%kj5C}0VgP{m)c(P!`de?UV8@OR>_U9;w*fL!XZrVIa$W>b}3i9 zYorx4Yn9JJ6x=q8u&J-73Pw6((~Azk&X(ljbY-nhmMX$zD=cO55Ie;9nWOGBI9#9BGfxs zA9o393|kwJP0Md-OWDsnAxrhcsKkRimNW0^iSqM@FFTl%Y~b(mgab*I#l~4LWfRnv z+TgSDvA6R$RB$}z?&(}%`#n zq;U-$AlkB*gEJP*cuhNR41IiN^mvtO%BPAw@+3+=Gi1TvfhHw0-V)x$f^&JS>v4pF z#(*<5HIPy&9ExW+bBx0X7B$f&;^&R}Cpd@^Sz;R*6_4jOjI44Sm4|DP!`3jgftoV` zr699Z%0|YapII1a9uNQo0D+%MV2LqgYrR!d?%1y>>vIk}3hUQm8WUoOR(NJ?_mB@n zuFOUk2iF*9=9|qKSIHYLJ$w)i<#X?{w%>GvZ|tDa(lSHWF@mg=LvZ-a$tzz zdubfmmOV-XL;OR7o{|f37;Tvjj-)M1WHZv0!E8(#c=Ly}ZI*!sO;5dH#|F=;b)vu` z&qV&DQSGMX#*ogU(5}^U?90V9K{&i~N}Y_6)pVVndzvmS{gAS&Ysx|~u}v;|&XiSp zBhQ7JOpy84kV>Sd!_XW|?W{o=6*-*jYCzy$ije;i^*LzMZ^TC>Q1hSTP=W3tQh_%r(@tpkns) zy(~6cz94 z%(~tV79H)2C=siNam&O#VJaVAgqH+ljt#&-xbow^d?V8RtEB-%v|6DodL{DXU&I&a z1P}lO0D+%hK&3X|aCN2*{$lZaQb%sOZl;v-kD1(0&F@34ee^!-$ErKiloeVkN$88c z`SLMoo9x>`hNdE;&8B$0LOSSyGtf}D)9!E&VnF?vEwX~Pc2-tf>_xxWI8~?#6&ZrB zMs^v;L$SKDCzl7BM%5iLri&ALyvXR4#p4eA^#lTM*`u7Dg6oQng6qph;#;y(rCJUK zYZP||{p=ZMFty8CohWa%p9A0ORlI2p(QLqLXymNqVas@N5^JgcfL}*lqEk|R3wxaS zb|v77XxO|cc(x}q9(kpIvm!=nx#NIXAg&^l624)W{}Wh)dJhop%I0%_NDpns(oR?2 z8y(a>oq2QUh;aN|D-0C)=!00aPmUtHh^ zOiA-7S8SbhBi<%sYX0(p;$k+X>ALg+=AZ!uGyL{k@<;*n&ffMTj0tLSVe#qPPUeh& zA{*Zw&Le?v*zz&DUnlB_sA#aTDj)^iFUcT+MJ9n5qkd`zT|e1+v3Q-ZV$ddCGVRYE zO{ZAK!kQYGFDp5O%L9zg_~mqZlR#Lj-235i>z#Dz#2_OLo1EAZ6vz?|7IrGnv+M5Y zt^Kf5Ce#75sZCGT^W#06JtEvvZD(w-S#HYZCB2&00{s(CPGgnxU7+w7;9H?AdIo@Chm;>tjWfB+x>2>b^ED*MQaDNej# zDhkp*dlzTJXb`y-FWmKT|A_9;x?_2Z#(g~Jbm`Bc{*C%4klh`2qapG zY6si9l#)OqNW{f~QWf}4R1iCao%{w|OVQzk6{f2YT%CH5&ylgbp9D*dbO zL6_Bp?^g{?G|RM7mcnn3GWrk!3zPxMz7vn`G5r5lZdbOMZ%OuGw>V&6@v*RbW@%Cj z97ZGM$-*sKFmz54X9*X&j6G6wz-&uEuT^-l-wpOSKz7s$&pyrg7-z68L8#DZnrWY0 z4rz)_(k=>)nMe4*9~V_AeeS6?w0XrZJF*plEaIRf1DhF0v=GS#X9&SFSgOGq!v4in zIVCF{8*&e`L*~PtSV(HkAm0u9x=-_jKPkHoT)iA`R6vufS!AJB#ar*2oAoGf!ctX<<+-RMz{uc~^i?jRq{C=lVFbw+kV%lfA5gESJm^9#xz+TCe}n|( zQf=m^$&w`5afNumTh@97ZOyOD&35KPIUHzthw*&22bt9Vu~9klG3lN_5eCuqP?hI0 zpjLe8wOsxwDKQu7BE2g@LTtp)uURy?gpu(a?+%WkG>y+43*BX67ni02PQd>BYYFH9 z5C8;zN`YsX7tgHzEEYw?W}%S_fqXZ@OEB5d0=z*!{mv8 z)}`hJX^16!{H@5lYI2oOd9(CP4gPxDfdxUOCR%?s^I`3GZKh0wx4bIb3F9o%>RDNM z?0t;(VQ)kbDst5MVZb(F#(C74(Y{ATIzo8Q)1%37bv`YhH}m5B9KBF3IYLD*n7SNBs_FmV#C@a~1)K4WKW;}e^s;jj_QS{Pg44i)6 zRum&Dz9u^{s`ZFCu2|Q&&(K=k&_4k=#rW)}lmprV1ONd*;MWsaBAG9_SJ8_0cU_DK zPd;?NuTMGtkGUHer$~Re)fZmI#ZI&q974#;Uo;Hx{AIL& zU9(2T1A6Nx=WZY@p(n}zr@HT=x?S&wytFjePQPgl!#FL2znk;tZxrjA|B}zowuQEk0)IVwpl3h;5C8=JHGw~4LeDw>UB>RoSLj+dY@#U> z%NNXyAWggrky9QZZO4?z0y_6E)wXL3$+{>qffe2q5O7PdfYx6`?`(Did5ywuU2q#cZJ=`~Fr?a3r}UGQof`wU!}gxtu=ex#5Y}S9LpANw z_wwg6tCFN*$zl>k?h+s5BkZYjF_{zE>uV|uipvbE;b%84Cj~t&Hi${26CdG-;JL9~ z@NF_l+G=PoCT%sPx2F^RY1jRaaiN&rOa}^b7baK8B- zDUDWQI7>x*5e+;O`_~pD@M%B*5cqWket@`w-)uE7a1MNI-ds_XsqT@2h&|s3gWxAB zim^@=DfJ@e9-HCA{+sjEhvAUJhRsn%*Okjn!BS;KL}>-@{K0t*!ZhO0C^e&F-&~c0 z>Y6KDTktiA>f8RhJ1#}~h)^^kOm1yEJZ-aM90Q(%?P6?{Wx6u-1oS}*&?sbWYyACC z_`O)`Dx%OKCAimNm)RQ5jy2-&URG*{t;j%2tYxn}GFNPmwt6t7VMUruqBQI`qL_Ue zkVHf>o5sah`W8GZ7Sm)QA>F0Exyx!wQV_7L+N?RETp)H#B3e-pOsT*YzuF_t5O(aL zK${_yA9Hq7!zW_zRpLj#EwBGXNk+InK)t(rVmHA|? zSyaM;aQKTRv%C8YCIdE)Q9LLQm@mdn9ETXS@Lay$CAe{3S*&V02d|X`>qW-syCgmF zHb`vFk3eK}7>fLL1P^keIm}63!)qF$Cb^Po(TK}u`49Cqn5~s|=V+y_y7W?)1wSm; zW%M8^XKc14k7JoTyu>kL*ItA44)eKod*b0aM}q%^PRxd6eGO@8PJU{K_1uzvRZ!qS zbPRn+svUC19sFpke@c|(Lp5$=9a?wZwwU^{G%4SOZ%&DxmloNQuqsBc$bu=mWy{EA z845bU6bCM)(aj*zg?mnU>ol76Q#k_d00MvjAn@}FJX67?mxEBK18KOIZ_1KQi?9oK z2CfYyq+Dt6D9-;&@jW(l*!zA(XngE>&fSOXXsQ`mqS=hkJ4r(!d-LaQ{WqtO(q(&g zF~<8+dh8YBIn*@NS8P=bGdVb*cWh7<_Ms4$wmoa_u%c$zGF zSMfSduDznkm0#Ri?yjjo+Jm{>l>D_N^<4@E_B)Q#qphHrW>G7ocKxyky$K^H`QrsU|B)B?8@Hbm?%R zO+RL7MTQjP&u-EDd0GFzVgfcmi+8Il-FT~PF&ig>Kkm(8 zQ%(qWTbi3hm`9oHaf`cly8i%EX zCU>I_AxovtMH$eReJ4_2%|h4cKnic~wpT1_9xH3UXSk8kg*#rQul%ClY#m|0=ye_D zP(NRbVE*CUpDOMiG>vEyXT12~cd_&H?XY5OAhDS-(A^WR;6y5XxHlNS@w4|Y^G0xd z&6MaB&7MwM4Z`ll)s3>2It%j^Ff}4j)$rl$AK80Me8j0!qOx5)kS4tuNZiWQN-bKO z;_*gh)UTDHDZyQ-Th!|oT6$923T~iDZ5T=+|AA2g{%AmlnT=Zqh`*nH+;={dkT?^a zN0o#q*C1@TbSc!=3h^xZe{nv6F#!UA!0%Mx>Uzs|p|IiXskl&<_59M?y%i>6?MW%B z%)8&P!TY|!DV6L(=M|# z+LXN_1P*$}E;*Qe700MeUaUNbf%G#>kk%ZBF_d(oP8dEm3(AmAqCr|19}eS=1|^*` zp-U9DUgl88R#tK{Dmme{7Ar-dqID@pMb5Y`RXzGpzgB8g zM$tOC+&+U-nIVU(c8Zn0;}P>JCPg4$`~ngL zyJD6OWoE~>jPS6K*oDSoU_birblC&L1_b{51eBo;ClF}#k<4qnMgQ_0`jr~aYx zo}4RtzqcZKARP1m3-A2D7T)=ucBmu$c#V{J_CTPj?;u7?A_I`sg6V(q=($IGP)V`|{-M&r~h7Gc(q$&4^jB(lN^g}DiBVK6%N;7KAj5KTu zh3RUCxo~GFwp=FZXnjg}S~h-bkszI`CGU}b6ID(VBLDkl2Z;E$FMw%LQ``GvnBOW% XQOCdkm!ktL0QOM{5gvNTy{Kt)7A+1DT-YuL9CNJw=HL}Z$M zQPyS?5eNhb2m~^M5Y|L?Swh4R!e$}~5R#DOwP$L+%=E{Z(yGq=aI5b9aPD({=ehqn zb*nBQ?eJ&69e+BsH4S^hLznRM`cUXV?VdvtxnaN z=?(QW?4mv%y{zv7Sm~R$f1&uzgOf=U&X=}#)n7b#U{#y(>&fFs9{jq0?E)U}Tz7iK z%n}G+B`?vRD6csQg;^~;G{u-#oIvUJKW?g27MK`R-r2-$J7yY)y;C<882U9fI}NaAsgazSkz-G;ZVGx#TU+ z@>cH$3e2uX$a3L6+r9;C4B(2!_%p@T-eht>#8S8j0fjB?;&v{yT89r!5cgmU@&Y^CZCgr ziLGx|* zPxfNZn!bMTj7-_j+o23JU&!YTL;~|o=v;}O8c}J1&XGL;CcW_3-bd;Xe_@#FY#07y z?t?0ZQ*Q&8zNG+yc*;ngW%TB~t#oAhEcf77^0ew;Wz?jW2H8m2j$kDpmjk0-_RlMx_-T57vJdO>KdN_ zj3v?6SmVKN(cM$u!+fk}2UAPq&iTSnKJ@pd#>$P*I{P1K5ioAL;S<^Rwi`>?G4rJW zQue#Yv+xhqymW5XFo+9FEL(l1G1`Y z6O1SJkSrE?zXA4)F|=2p7se=vbKx~pC7OpyMiubuq;Mi9Q| zlduIvyF>Mf6?0hHA~xfCVzjYX^{^M!#>b(<1ro2Bv{WAgB=$@@ejgJlK|ciQbrvW1 zLAP94=r`*Xlzi?rs06<<&wRKD#|~y7v4dTb;=gf&voK-0oHu}M5TR9A^Tud!62{d4gG6CEm0Un@5&a zSE2o}<3?8e%>KW@7uqnrM?Dw*^FhJP#xw5kxn};qSYK;-k;{6x(cs zSw-I9gjlF~kWn(2hj?0S`I`RDSd|3l4f--$xNcM0$Q#mxWGyaiSBbHj60ISb9Hf6_ zhyaW(St--v+h$)pi?F%8%C$f-J@U9Hy?pk4+XVF01#6LsSoJ@$`q_u9?#}v<)gms0 z9Qn_zM$UR9=6w8RlUDFw6?jA3nzDJ2>#HL39_|Pi*B935vToiyb@=-!$4q;S_%AZm z=&N^wqLqpqb4p4=inZ1=FsD1O&S`BO_o$Lp2z-3ZMRnt}+QZYwuMhqc#DN(3W00_MMz-l{8#Ii4ki`&v;`z&Lid;>tU@`ZNWSY-!LW|<^Vfm1 z+{Bm~TH~5HKfcmX7qTQW$@OK#w+BVf>)W|F4v`#Bl6V6*t2-7C^OsL&L}&cj62-vj z`5WxO%p}2UHLH;^o6SFUaIuiWRUP+l(?La%yOX&xlCO=6;qOy(nPoQ+Yo$#o)MYLu zInnO{-K6{$GqJ;#X)@tj1#57eZ60 z(kfC(P-80AekaObZspNQ6UDxbHH7`>!8$YXcML5e#$A?wV}4{grxq~xh*J?XxGg3s z&m7pTL5_%C`|dT|0SbNkTguu+BZQa{JU^<(pG00i2^EMZwI|f)U6*lNGf6CRK z9c66n*&T|4xj~9nb$|-1Hr2p=*cv=31}i5*oiCsrMLGK$7uRv_tT~ZCqKL(7%leiJ%?N6y&+l+Ny5!`{36? zjX<)3z89#g4@Xx=%X1W#!2oL>kzkA@6ZFtr2Bbycu-2Y@5SgXi-vV(23K0v$B%vI1 zkcXnAmrU{(VquZZN`Q+{a{;fWz&wp@wl&Z^KhXZKC=YWy0Q~8X+9uL@O9i9?pS-|+ zSz&woT#!|CUwD+aQ{_3Cmf$zuH69efOQ>Yona94JpBKhbrGf)N=`XI4w5GE1$~7u`Q@lkSxT?@wa^n3vGev8GX#zJ<%$~@MQib4 zMvL31g~d7pq_o*Av1pw!{#JOb3s=9lmI^df-n9;ko^yam_vW8`M@tL*_yPx1>+Ylh zhvbOQh&kD#w{F4e&s$u1@lfumf}0_@>A2I+PtV9m}1DctzT7~ z!1e#Ctw?=-{0>qF>GZ-}@r%DcJ7*iQeEr&#E&;w;$UWkb`lgqbac>nu zi**7sU8nA(51nzqa;|R9^_1g3$(((%C^3J^OGe%qo|Z3940$9Bd2G%R@XIBSUvWJK z*-0@hkSIb3PmW3S9LA4j>|XGocjBg64mi0m$`f~*>P!b{<*2DxqXfN6wTIBRq1aIj zsB~~6M|qe|IF-)#8>k*D1 z#tJnEk8F`e% zJMUEEXUD@HGLc?>o5*b(wXjH=G+V&d=uVg(t7P!hX9k)DvlPXSxks#Mb0@XZ9TVx7 zpWkgJ=}oDCRN!L^9KiD51-7}iAe2JMp|ITkp#0sEjn1CF_Z z-ee3TU(-|T!Mm}>;{Nr<+RlsaB<*rAfMYu7X4+$t!@5>P0U_)cL0esYcLIZeCC$i-dbv+&HzW4_I&mSf}_Hs#| zFBOmqNCo~~K%DBEP}ndNtd>&LvT({5m+yv2qa4DN8>*umt zLzr4?g|h~uE*U_{>~x3k(F`$j7?{n>sRf4zt|2rS^UJUFPsI1g@f$KBN1}L`j!he4 zqC~%(`Sv#C1P6cKiDV|JQ6~a84eO^i<-J1ncdEGcR;p`m^K~&KTgpT2K74C@$7R4? z-03<`6ue_^JiJ)ZRBXf$eB;&K6nkY6vr$ma?A3!OJcWGA>B%(20F^i(b$~^J9Y}2k za-8gC`4j@!kTL5qUa|7>^zU8lE@!r7I#qQOxt*;%7}^SCmgXCp`{8pBe@k(CDYxJJ zp+EBXHsn6PZOE^;2u8=>nzRXD3>@me`ytV{rCaiU?;q*)CnF%Kg?xT4;|n|6u%^%c E9}39UkpKVy literal 0 HcmV?d00001 diff --git a/electron/recording/__fixtures__/macos-fmp4/writer-died-1s-no-moof.mp4.gz b/electron/recording/__fixtures__/macos-fmp4/writer-died-1s-no-moof.mp4.gz new file mode 100644 index 0000000000000000000000000000000000000000..fb246582d620bf0d58081b362452eee7bb013d3f GIT binary patch literal 3028 zcmb2|=3oE;Cg!(S4`#=>N-%u5nL8_Io9pCBNfTB$c8RU=+RA>hX=>A#3tDn(nu0t& zo8`X9Gk=p8bi3K@Kcn}--EwObw44rh2idGpul}&8LqX$_C%0Tn)6`#kx97i{W!5P7 z<;(Ah>p$!db;)5n{{!vH{Z(Uy8SELe*J9Qu{m?2 zXWrj?uJFjS;)4?X>_ zT4~<9w7ri$p7qN~S$ywLO7gd#3yn`aUbktlRBd{j--C}PMbAQ3>BRWFT(T#{wI?Lr-A1_5ruSB}X766{k9ki%zCHd(b)~+0oywc#zzvYEee|D_iPb2aytXqwe2@;y8KqWjW{*j@|y@5knB zm0#3r_}F;jf3dgLhH6QtFaGeie0pi_V<>wz*_; z(VK14{nrKe`Rk^4|E$w)KUFm6#kbQR#k@8eMNWIP(RAq#r|iu0cX*@MFY8@myyzGl}tH>$3T`s>=Qk0m8Qd$D3IKd!9<8Ifo z^UJZ_G|voKk~zQOx6mQq=FQ0zmHZZp0wp3LDgh}1!si~hpYDhK1Mb25hxZTfpI(pW z`~7&l!KH6*`(JI}u{?<`LnZEWd-ahWlS!JH&Y6+dW$}U=Bi@bM{RI{rfBkUi%bGWy z#&(DEX5@?n1_OWFE!v0%AC0`UKRB-ZEni%_=gI37Pp^P~|CJVdZckZUz^7nOKQH*X zc4y}5i~z#Po-c(lVe7984Tf5xRG?Mj;&BBJ$>JC5FS6JP2c~u%BFn!fvq3#6U4qFT z9jE$@U!u91OTZ=I64)XFo8WSkY&9RT{}3n1JH;_n3L_WS{l4L7zU12}IM8;H_k53! zzafp*_psuQXhJ^fNBx2s9|d8yNge*2UGm3{a|k}Nsw>HRh~uVI40w7x7v;T9K9OzBjcy7j-E zbl?3LA^&LyVgRiS9zEOBYj&@UsXj5DdVdf?(Gqe2_<~v@&>*f2Lo$;VNy0%=|Bqsp z>YWz@5I-ZjyEY08tln*2ZRCs}(@GzmR(d)9QDab6uJg~G4oKqj^$UJ&LOD2G`1$2q zxX%ZT+@J8&4*-Wm(Dx@x`6+4^kex;emS6Mnr)$)$MwCS37J5Pil^0i~2>SFj)hKBJ zCd0HhZ;k8D79O@Z)Vc>~dL9`;Qt!aMex|>WV=ji(<7bcU2AfagzghI_cVzn@x)rmH=v!)zPK()_+#`Hq=4E9K%3fK)p@Has*uW z_zbAQVpxI-U&CzGxU(kR3q;q86UtKkGUhQe8|R1Z@YmFgxtKb|-WA!{ys)N$1tKm1 zCF^D^xu3t0d`##Qwm1cNu4u4%BV)v|+!Aj~k~H@+`@#(C;Karl(ILoc8>V#w+Y1z* zifsF)cIF;gZ$K0#a!6U-8_B7?b`ns&eT&?&E2c}pCEybHb%A#F!wV}3nD_p3m3pP4 zvXt{3*l+`5U(=20Ojip1N^Y62j)^Q-<=;qP z;7F9r!}L9s02nrfPOi5DafcJmTfP*X(sRgUqqDaNTB|p~GP7Hb3Ns2Ge$h%o5gA)Es5INZqWIm0JNRikH z`2H8{&Pd||5BeQiu z;L|9E%4o=EV)Dg-2=t%_!o&==<_7|u1g0#>G%zGctZjB;>(i2(t?AsL5lpb%dXXZ{ zfK{yxHWQpVM)sYUfZVu@wxX;YsTf~S{|;3VJC?hVo|Z*Zs+kMakE`vly?Pm4ljlZs zbk3nuU$bD*sxq~XPn$JcbF76z@tBqXjMjlE$1N7EQ6$j@mEMR6vZ>N}$7TtFQjL!+dcCr*D^X6&U4>l2Cld+0hhp*695?8 z6{B1p|CYPDa+ZZ1t#ax!izwq!GiU6jnh7hkz$tHUTB;{lxyiJfZB4!3IN>e(&J29a zh~nD2N7fuaE1Kk%`W27vSw8I}4XkTUWXr~r03ul)R@~OZwM0NI@Lp-cP{_oCltvmv zW5G$I_E(u@>L*$+oO52uz4eVrRuS?Tii&kG4;UU7Mre`_ { + dir = await fs.mkdtemp(path.join(os.tmpdir(), "native-mac-salvage-")); +}); + +afterAll(async () => { + await fs.rm(dir, { recursive: true, force: true }); +}); + +/** A private copy of a fixture, optionally cut to its first `bytes` bytes. */ +async function take(fixture: string, bytes?: number) { + const data = gunzipSync(await fs.readFile(path.join(FIXTURES, `${fixture}.mp4.gz`))); + const target = path.join( + dir, + `${fixture}-${bytes ?? "whole"}-${Math.random().toString(36).slice(2)}.mp4`, + ); + await fs.writeFile(target, bytes === undefined ? data : data.subarray(0, bytes)); + return target; +} + +async function sizeOf(filePath: string) { + return (await fs.stat(filePath)).size; +} + +describe("inspectNativeMacCapture on real helper output", () => { + it.each([ + ["writer-died-4s", 231, 4], + ["writer-died-1s-no-moof", 58, 1], + ["helper-killed-29s", 1652, 29], + ["helper-killed-9s-main", 513, 9], + ["clean-flat-8s", 461, 8], + ])("finds every frame ffmpeg reads in %s", async (fixture, frames, seconds) => { + const inspection = await inspectNativeMacCapture(await take(fixture)); + + expect(inspection).toMatchObject({ ok: true, videoSamples: frames }); + if (!inspection.ok) throw new Error("unreachable"); + expect(inspection.validBytes).toBe(inspection.fileBytes); + expect(Math.abs(inspection.durationSec - seconds)).toBeLessThan(0.2); + }); + + /** + * #571's check rejected this file at random: its last `mdat` has a size field of 0, + * and mp4box aborted whenever a read-chunk boundary fell inside it. + */ + it("reads a take whose last mdat declares size 0, with no read-size dependence", async () => { + const file = await take("helper-killed-29s", KILLED_29S.tailMdat + 8); + await expect(inspectNativeMacCapture(file)).resolves.toMatchObject({ + ok: true, + videoSamples: 1652, + }); + }); + + /** The fragment still being written when the take died: a final mdat cut short. */ + it("accepts a final mdat cut short, keeping the fragments indexed before it", async () => { + const file = await take("helper-killed-29s", KILLED_29S.lastMoof - 1000); + const inspection = await inspectNativeMacCapture(file); + expect(inspection).toMatchObject({ ok: true, videoSamples: 1596 }); + if (!inspection.ok) throw new Error("unreachable"); + expect(inspection.validBytes).toBe(inspection.fileBytes); + }); + + it("marks where a torn moof starts without changing the file", async () => { + const file = await take("helper-killed-29s", KILLED_29S.lastMoof + 300); + const inspection = await inspectNativeMacCapture(file); + + expect(inspection).toMatchObject({ + ok: true, + validBytes: KILLED_29S.lastMoof, + videoSamples: 1596, + }); + expect(await sizeOf(file)).toBe(KILLED_29S.lastMoof + 300); + }); + + it("rejects a file whose movie header is torn", async () => { + const file = await take("helper-killed-29s", KILLED_29S.moov + 500); + await expect(inspectNativeMacCapture(file)).resolves.toMatchObject({ + ok: false, + reason: "no movie header in the readable part of the file", + }); + }); + + it("rejects a file cut before its movie header", async () => { + const file = await take("helper-killed-29s", 600_000); + await expect(inspectNativeMacCapture(file)).resolves.toMatchObject({ ok: false }); + }); + + it("rejects something that is not an MP4 at all", async () => { + const file = path.join(dir, "notes.txt"); + await fs.writeFile(file, "this is not a recording\n".repeat(10)); + await expect(inspectNativeMacCapture(file)).resolves.toMatchObject({ + ok: false, + reason: "not an MP4 file", + }); + }); + + it("rejects a file that is not there", async () => { + await expect(inspectNativeMacCapture(path.join(dir, "missing.mp4"))).resolves.toMatchObject({ + ok: false, + }); + }); +}); + +describe("salvageNativeMacCapture", () => { + it("leaves a readable take exactly as it is", async () => { + const file = await take("writer-died-4s"); + const before = await sizeOf(file); + + await expect(salvageNativeMacCapture(file)).resolves.toMatchObject({ + ok: true, + screenVideoPath: file, + videoSamples: 231, + truncatedBytes: 0, + }); + expect(await sizeOf(file)).toBe(before); + }); + + /** + * A file cut inside a moof opens nowhere (ffmpeg, libavformat and Chromium all + * refuse it), though mp4box accepts it. Cut back to that moof it opens with every + * earlier fragment: ffmpeg reads 1596 frames from exactly this cut. + */ + it("cuts a torn last moof off and keeps every fragment before it", async () => { + const file = await take("helper-killed-29s", KILLED_29S.lastMoof + 300); + + await expect(salvageNativeMacCapture(file)).resolves.toMatchObject({ + ok: true, + videoSamples: 1596, + truncatedBytes: 300, + }); + expect(await sizeOf(file)).toBe(KILLED_29S.lastMoof); + }); + + it("keeps the first second when the first moof is the torn one", async () => { + const file = await take("helper-killed-29s", KILLED_29S.firstMoof + 300); + + await expect(salvageNativeMacCapture(file)).resolves.toMatchObject({ + ok: true, + videoSamples: 58, + }); + expect(await sizeOf(file)).toBe(KILLED_29S.firstMoof); + }); + + it("cuts off a box header left half-written at the end", async () => { + const file = await take("helper-killed-29s", KILLED_29S.wide + 4); + + await expect(salvageNativeMacCapture(file)).resolves.toMatchObject({ + ok: true, + videoSamples: 1652, + truncatedBytes: 4, + }); + }); + + /** Nothing is cut from a file that cannot be recovered anyway. */ + it("does not touch a file it cannot recover", async () => { + const file = await take("helper-killed-29s", KILLED_29S.moov + 500); + + await expect(salvageNativeMacCapture(file)).resolves.toMatchObject({ ok: false }); + expect(await sizeOf(file)).toBe(KILLED_29S.moov + 500); + }); +}); + +describe("describeSalvagedTake", () => { + it("quotes the readable part of an NSError", () => { + expect( + describeSalvagedTake( + 'Recording stopped: the video file could not be written (video append: Error Domain=AVFoundationErrorDomain Code=-11807 "Disk Full" UserInfo={NSLocalizedDescription=Disk Full, NSUnderlyingError=0x1 {Error Domain=NSPOSIXErrorDomain Code=28}}).', + 35.01, + ), + ).toBe("Recording stopped after 0:35: Disk Full. The part recorded until then was saved."); + }); + + it("uses the whole message when there is no NSError in it", () => { + expect(describeSalvagedTake("The recorder stopped unexpectedly (signal SIGKILL).", 9)).toBe( + "Recording stopped after 0:09: The recorder stopped unexpectedly (signal SIGKILL). The part recorded until then was saved.", + ); + }); + + it("counts hours on a long take", () => { + expect(describeSalvagedTake("Disk Full", 3725)).toBe( + "Recording stopped after 1:02:05: Disk Full. The part recorded until then was saved.", + ); + }); +}); diff --git a/electron/recording/nativeMacCaptureSalvage.ts b/electron/recording/nativeMacCaptureSalvage.ts new file mode 100644 index 000000000..38485f32b --- /dev/null +++ b/electron/recording/nativeMacCaptureSalvage.ts @@ -0,0 +1,505 @@ +import fs from "node:fs/promises"; + +/** + * Recovering what a macOS take left on disk when its stop failed. + * + * # What a failed take leaves behind + * + * The helper's AVAssetWriter writes fragmented MP4 (`movieFragmentInterval` = 1 s). + * A clean stop rewrites it flat — `ftyp mdat moov` — but a writer that died, or a + * helper that was killed, leaves the fragmented shape as it was: + * + * ftyp mdat moov[… mvex] (mdat moof)* wide mdat + * + * The first `mdat` holds the first second, indexed by the `moov` sample table. + * Each later fragment writes its `mdat` BEFORE the `moof` that indexes it, with an + * absolute data offset (`tfhd` flag 0x1). The final `mdat` is the fragment that was + * still open, and no `moof` indexes it. ffmpeg, libavformat, Chromium and the editor + * all open such a file as it is, with the right duration (measured on real takes: + * 4 s, 10 s, 29 s, 73 s). + * + * # Why this is not mp4box + * + * PR #571 fed mp4box in 1 MiB chunks, and that last `mdat` has a size field of 0: + * whenever a multiple of the chunk size fell inside it, mp4box aborted with "Invalid + * box type", so whether a take was kept depended on its byte length. mp4box also + * accepted a file cut inside a `moof`, which nothing can open. This walk reads box + * headers at their own offsets, so the read size never matters. + * + * # The one layout that has to be repaired + * + * A file cut inside a box other than `mdat` (a torn `moof`) does not open anywhere. + * Cut back to the start of that box it opens, and keeps every fragment before it. + * So a torn tail is truncated and the file inspected again — a file is never + * reported recoverable while bytes that break it are still on disk. + */ + +/** Guards against a corrupt size field making a structural box look enormous. */ +const MAX_INDEX_BOX_BYTES = 64 * 1024 * 1024; + +type Box = { type: string; start: number; headerSize: number; size: number }; + +export type NativeMacCaptureInspection = + | { + ok: true; + fileBytes: number; + /** Bytes from the start of the file up to the first box that is torn or unreadable. */ + validBytes: number; + /** Video samples whose bytes lie entirely inside `validBytes`. */ + videoSamples: number; + durationSec: number; + fragments: number; + } + | { ok: false; reason: string; fileBytes: number; validBytes: number }; + +export type NativeMacSalvageResult = + | { + ok: true; + screenVideoPath: string; + videoSamples: number; + durationSec: number; + /** Bytes cut off a torn tail before the file could be opened; 0 when none. */ + truncatedBytes: number; + } + | { ok: false; reason: string }; + +function boxType(buffer: Buffer, offset: number) { + return buffer.toString("latin1", offset, offset + 4); +} + +function isPrintableType(type: string) { + return /^[\x20-\x7e]{4}$/.test(type); +} + +/** Complete child boxes of `[start, end)` inside an in-memory box. */ +function childBoxes(buffer: Buffer, start: number, end: number): Box[] { + const boxes: Box[] = []; + let position = start; + while (position + 8 <= end) { + let size = buffer.readUInt32BE(position); + const type = boxType(buffer, position + 4); + let headerSize = 8; + if (size === 1) { + if (position + 16 > end) { + break; + } + size = Number(buffer.readBigUInt64BE(position + 8)); + headerSize = 16; + } else if (size === 0) { + size = end - position; + } + if (size < headerSize || position + size > end) { + break; + } + boxes.push({ type, start: position, headerSize, size }); + position += size; + } + return boxes; +} + +function childBox(buffer: Buffer, parent: Box, type: string) { + return childBoxes(buffer, parent.start + parent.headerSize, parent.start + parent.size).find( + (box) => box.type === type, + ); +} + +/** Offset of the first byte after a full box's version and flags. */ +function fullBoxBody(box: Box) { + return box.start + box.headerSize + 4; +} + +async function readAt(handle: fs.FileHandle, position: number, length: number) { + const buffer = Buffer.alloc(length); + const { bytesRead } = await handle.read(buffer, 0, length, position); + return buffer.subarray(0, bytesRead); +} + +type TopLevel = { boxes: Box[]; validBytes: number }; + +/** + * Walks the top-level boxes by their own offsets. A final `mdat` may run past the end + * of the file — that is the fragment still being written — but any other box that + * does, a header cut short, or bytes that are not a box header end the readable part + * where they start. + */ +async function walkTopLevel(handle: fs.FileHandle, fileBytes: number): Promise { + const boxes: Box[] = []; + let position = 0; + while (position < fileBytes) { + if (fileBytes - position < 8) { + return { boxes, validBytes: position }; + } + const header = await readAt(handle, position, 16); + const type = boxType(header, 4); + if (!isPrintableType(type)) { + return { boxes, validBytes: position }; + } + let size = header.readUInt32BE(0); + let headerSize = 8; + if (size === 1) { + if (header.length < 16) { + return { boxes, validBytes: position }; + } + size = Number(header.readBigUInt64BE(8)); + headerSize = 16; + } else if (size === 0) { + size = fileBytes - position; + } + if (size < headerSize) { + return { boxes, validBytes: position }; + } + if (position + size > fileBytes) { + if (type !== "mdat") { + return { boxes, validBytes: position }; + } + boxes.push({ type, start: position, headerSize, size: fileBytes - position }); + return { boxes, validBytes: fileBytes }; + } + boxes.push({ type, start: position, headerSize, size }); + position += size; + } + return { boxes, validBytes: position }; +} + +type VideoTrack = { + trackId: number; + timescale: number; + defaultSampleDuration: number; + defaultSampleSize: number; +}; + +type SampleTally = { samples: number; duration: number }; + +/** Reads the video `trak`: its id, timescale and flat sample table, counting samples in the file. */ +function readVideoTrack( + moov: Buffer, + validBytes: number, +): { track: VideoTrack; flat: SampleTally } | null { + const root: Box = { type: "moov", start: 0, headerSize: 8, size: moov.length }; + for (const trak of childBoxes(moov, 8, moov.length).filter((box) => box.type === "trak")) { + const tkhd = childBox(moov, trak, "tkhd"); + const mdia = childBox(moov, trak, "mdia"); + if (!tkhd || !mdia) { + continue; + } + const hdlr = childBox(moov, mdia, "hdlr"); + if (!hdlr || boxType(moov, fullBoxBody(hdlr) + 4) !== "vide") { + continue; + } + const mdhd = childBox(moov, mdia, "mdhd"); + const minf = childBox(moov, mdia, "minf"); + const stbl = minf ? childBox(moov, minf, "stbl") : undefined; + const stsd = stbl ? childBox(moov, stbl, "stsd") : undefined; + if (!mdhd || !stbl || !stsd) { + continue; + } + // A visual sample entry: 8-byte header, 8 reserved/data-reference bytes, 16 + // pre-defined bytes, then width and height. + const entry = fullBoxBody(stsd) + 4; + if (moov.readUInt32BE(fullBoxBody(stsd)) === 0 || entry + 36 > stsd.start + stsd.size) { + continue; + } + const width = moov.readUInt16BE(entry + 32); + const height = moov.readUInt16BE(entry + 34); + if (width === 0 || height === 0) { + continue; + } + + const tkhdVersion = moov[tkhd.start + tkhd.headerSize]; + const trackId = moov.readUInt32BE(fullBoxBody(tkhd) + (tkhdVersion === 1 ? 16 : 8)); + const mdhdVersion = moov[mdhd.start + mdhd.headerSize]; + const timescale = moov.readUInt32BE(fullBoxBody(mdhd) + (mdhdVersion === 1 ? 16 : 8)); + if (timescale === 0) { + continue; + } + + let defaultSampleDuration = 0; + let defaultSampleSize = 0; + const mvex = childBox(moov, root, "mvex"); + if (mvex) { + for (const trex of childBoxes(moov, mvex.start + mvex.headerSize, mvex.start + mvex.size)) { + if (trex.type === "trex" && moov.readUInt32BE(fullBoxBody(trex)) === trackId) { + defaultSampleDuration = moov.readUInt32BE(fullBoxBody(trex) + 8); + defaultSampleSize = moov.readUInt32BE(fullBoxBody(trex) + 12); + } + } + } + + return { + track: { trackId, timescale, defaultSampleDuration, defaultSampleSize }, + flat: tallyFlatSamples(moov, stbl, validBytes), + }; + } + return null; +} + +function tallyFlatSamples(moov: Buffer, stbl: Box, validBytes: number): SampleTally { + const stsz = childBox(moov, stbl, "stsz"); + const stsc = childBox(moov, stbl, "stsc"); + const stco = childBox(moov, stbl, "stco") ?? childBox(moov, stbl, "co64"); + const stts = childBox(moov, stbl, "stts"); + if (!stsz || !stsc || !stco) { + return { samples: 0, duration: 0 }; + } + + const uniformSize = moov.readUInt32BE(fullBoxBody(stsz)); + const sampleCount = moov.readUInt32BE(fullBoxBody(stsz) + 4); + const sizeOf = (index: number) => + uniformSize !== 0 ? uniformSize : moov.readUInt32BE(fullBoxBody(stsz) + 8 + index * 4); + + const chunkCount = moov.readUInt32BE(fullBoxBody(stco)); + const chunkOffset = (index: number) => + stco.type === "co64" + ? Number(moov.readBigUInt64BE(fullBoxBody(stco) + 4 + index * 8)) + : moov.readUInt32BE(fullBoxBody(stco) + 4 + index * 4); + + const deltas: number[] = []; + if (stts) { + const entries = moov.readUInt32BE(fullBoxBody(stts)); + for (let entry = 0; entry < entries && deltas.length < sampleCount; entry += 1) { + const count = moov.readUInt32BE(fullBoxBody(stts) + 4 + entry * 8); + const delta = moov.readUInt32BE(fullBoxBody(stts) + 8 + entry * 8); + for (let index = 0; index < count && deltas.length < sampleCount; index += 1) { + deltas.push(delta); + } + } + } + + const runs = moov.readUInt32BE(fullBoxBody(stsc)); + let sample = 0; + const tally: SampleTally = { samples: 0, duration: 0 }; + for (let run = 0; run < runs && sample < sampleCount; run += 1) { + const firstChunk = moov.readUInt32BE(fullBoxBody(stsc) + 4 + run * 12); + const samplesPerChunk = moov.readUInt32BE(fullBoxBody(stsc) + 8 + run * 12); + const nextFirstChunk = + run + 1 < runs ? moov.readUInt32BE(fullBoxBody(stsc) + 4 + (run + 1) * 12) : chunkCount + 1; + for (let chunk = firstChunk; chunk < nextFirstChunk && chunk <= chunkCount; chunk += 1) { + let position = chunkOffset(chunk - 1); + for (let index = 0; index < samplesPerChunk && sample < sampleCount; index += 1) { + const size = sizeOf(sample); + if (position + size <= validBytes) { + tally.samples += 1; + tally.duration += deltas[sample] ?? 0; + } + position += size; + sample += 1; + } + } + } + return tally; +} + +/** Counts the video samples one `moof` indexes whose bytes are inside the file. */ +function tallyFragmentSamples( + moof: Buffer, + moofStart: number, + track: VideoTrack, + validBytes: number, +): SampleTally { + const tally: SampleTally = { samples: 0, duration: 0 }; + for (const traf of childBoxes(moof, 8, moof.length).filter((box) => box.type === "traf")) { + const children = childBoxes(moof, traf.start + traf.headerSize, traf.start + traf.size); + const tfhd = children.find((box) => box.type === "tfhd"); + if (!tfhd) { + continue; + } + const tfhdFlags = moof.readUInt32BE(tfhd.start + tfhd.headerSize) & 0xffffff; + if (moof.readUInt32BE(fullBoxBody(tfhd)) !== track.trackId) { + continue; + } + let cursor = fullBoxBody(tfhd) + 4; + let base = moofStart; + if (tfhdFlags & 0x1) { + base = Number(moof.readBigUInt64BE(cursor)); + cursor += 8; + } + if (tfhdFlags & 0x2) cursor += 4; + let defaultDuration = track.defaultSampleDuration; + if (tfhdFlags & 0x8) { + defaultDuration = moof.readUInt32BE(cursor); + cursor += 4; + } + let defaultSize = track.defaultSampleSize; + if (tfhdFlags & 0x10) { + defaultSize = moof.readUInt32BE(cursor); + } + + let dataPosition = base; + for (const trun of children.filter((box) => box.type === "trun")) { + const end = trun.start + trun.size; + const flags = moof.readUInt32BE(trun.start + trun.headerSize) & 0xffffff; + const count = moof.readUInt32BE(fullBoxBody(trun)); + let field = fullBoxBody(trun) + 4; + if (flags & 0x1) { + dataPosition = base + moof.readInt32BE(field); + field += 4; + } + if (flags & 0x4) field += 4; + const perSample = + (flags & 0x100 ? 4 : 0) + + (flags & 0x200 ? 4 : 0) + + (flags & 0x400 ? 4 : 0) + + (flags & 0x800 ? 4 : 0); + for (let index = 0; index < count && field + perSample <= end; index += 1) { + let duration = defaultDuration; + let size = defaultSize; + if (flags & 0x100) { + duration = moof.readUInt32BE(field); + field += 4; + } + if (flags & 0x200) { + size = moof.readUInt32BE(field); + field += 4; + } + if (flags & 0x400) field += 4; + if (flags & 0x800) field += 4; + if (dataPosition >= 0 && dataPosition + size <= validBytes) { + tally.samples += 1; + tally.duration += duration; + } + dataPosition += size; + } + } + } + return tally; +} + +/** What a capture file on disk can still give back, without changing it. */ +export async function inspectNativeMacCapture( + filePath: string, +): Promise { + let handle: fs.FileHandle | null = null; + let fileBytes = 0; + try { + handle = await fs.open(filePath, "r"); + fileBytes = (await handle.stat()).size; + const { boxes, validBytes } = await walkTopLevel(handle, fileBytes); + const fail = (reason: string): NativeMacCaptureInspection => ({ + ok: false, + reason, + fileBytes, + validBytes, + }); + + if (boxes[0]?.type !== "ftyp") { + return fail("not an MP4 file"); + } + const moovBox = boxes.find((box) => box.type === "moov"); + if (!moovBox) { + return fail("no movie header in the readable part of the file"); + } + if (moovBox.size > MAX_INDEX_BOX_BYTES) { + return fail("the movie header is implausibly large"); + } + const video = readVideoTrack(await readAt(handle, moovBox.start, moovBox.size), validBytes); + if (!video) { + return fail("no video track"); + } + + let samples = video.flat.samples; + let duration = video.flat.duration; + let fragments = 0; + for (const moof of boxes.filter((box) => box.type === "moof")) { + if (moof.size > MAX_INDEX_BOX_BYTES) { + continue; + } + fragments += 1; + const tally = tallyFragmentSamples( + await readAt(handle, moof.start, moof.size), + moof.start, + video.track, + validBytes, + ); + samples += tally.samples; + duration += tally.duration; + } + if (samples === 0) { + return fail("no complete video frame in the file"); + } + return { + ok: true, + fileBytes, + validBytes, + videoSamples: samples, + durationSec: duration / video.track.timescale, + fragments, + }; + } catch (error) { + return { + ok: false, + reason: error instanceof Error ? error.message : String(error), + fileBytes, + validBytes: 0, + }; + } finally { + await handle?.close().catch(() => undefined); + } +} + +/** + * Makes a failed take's file openable if it can be, and says what it holds. + * + * Only for a file no process is writing to any more: the helper must have exited. + * A torn tail is cut off and the result inspected again, so success always means + * the file on disk opens as it is. + */ +export async function salvageNativeMacCapture(filePath: string): Promise { + const first = await inspectNativeMacCapture(filePath); + if (!first.ok) { + return { ok: false, reason: first.reason }; + } + if (first.validBytes === first.fileBytes) { + return { + ok: true, + screenVideoPath: filePath, + videoSamples: first.videoSamples, + durationSec: first.durationSec, + truncatedBytes: 0, + }; + } + + try { + await fs.truncate(filePath, first.validBytes); + } catch (error) { + return { + ok: false, + reason: `could not cut off the torn end: ${error instanceof Error ? error.message : String(error)}`, + }; + } + const second = await inspectNativeMacCapture(filePath); + if (!second.ok) { + return { ok: false, reason: second.reason }; + } + if (second.validBytes !== second.fileBytes) { + return { ok: false, reason: "the file is still torn after cutting off its end" }; + } + return { + ok: true, + screenVideoPath: filePath, + videoSamples: second.videoSamples, + durationSec: second.durationSec, + truncatedBytes: first.fileBytes - second.fileBytes, + }; +} + +function formatDuration(seconds: number) { + const total = Math.max(0, Math.floor(seconds)); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const rest = String(total % 60).padStart(2, "0"); + return hours > 0 ? `${hours}:${String(minutes).padStart(2, "0")}:${rest}` : `${minutes}:${rest}`; +} + +/** + * The warning for a take whose stop failed but whose file was recovered. + * + * The stop's message usually embeds an NSError; its localized description is the + * part a person can read ("Disk Full"). + */ +export function describeSalvagedTake(failureMessage: string, durationSec: number) { + const description = /NSLocalizedDescription=([^,}]+)/.exec(failureMessage)?.[1]?.trim(); + const reason = (description ?? failureMessage) + .replace(/^Recording stopped:\s*/i, "") + .replace(/[.\s]+$/, ""); + return `Recording stopped after ${formatDuration(durationSec)}: ${reason}. The part recorded until then was saved.`; +} From 527b27eaaa37ed915c2499f833c03288365fbd33 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Mon, 14 Sep 2026 16:52:06 +0200 Subject: [PATCH 05/39] fix(macos): keep a recovered take when the disk is still full, and say what failed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the salvage found two defects. A recovered take most often follows a disk that filled up, and the cursor telemetry and session manifest are written to the same volume right after. Either write throwing ENOSPC reached the stop handler's catch, so a take whose video had just been recovered was reported lost with a raw errno. For a recovered take those writes are now secondary: a failure is logged and its partial file removed, and the stop still succeeds with the warning. The warning replaced the helper's sentence with the NSError's localized description. For the -16364 writer death that description is AVFoundation's generic "The operation could not be completed", so the user was never told the video file could not be written. The sentence is now kept, and a description is added only when it says something: "Recording stopped after 0:35: the video file could not be written (Disk Full)." The tests use the helper's verbatim messages instead of an invented one, and the length is rounded rather than floored (a 35 s take's frame durations sum to 34.98 s, which printed "0:34"). Also: whether to salvage is `nativeMacSalvageTarget`, tested, instead of an inline condition in the handler — salvage truncates, and the helper-exited check is what keeps it off a file still being written. The module doc no longer says every torn box breaks a file: a torn moof does; a half-written trailing header is cut as a precaution. --- electron/ipc/handlers.ts | 30 +++++++- .../recording/nativeMacCaptureSalvage.test.ts | 77 ++++++++++++++++++- electron/recording/nativeMacCaptureSalvage.ts | 60 ++++++++++++--- 3 files changed, 149 insertions(+), 18 deletions(-) diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 409c162dd..7b6d64d2d 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -83,6 +83,7 @@ import { toHelperRect } from "../native-bridge/helperCoordinates"; import { scoreDeviceNameMatch } from "../recording/deviceNameMatching"; import { describeSalvagedTake, + nativeMacSalvageTarget, salvageNativeMacCapture, } from "../recording/nativeMacCaptureSalvage"; import { @@ -3253,8 +3254,8 @@ export function registerIpcHandlers( // A helper that exited left a file nothing writes to any more, and what its // writer finished before the failure is usually a playable fragmented take. // One still running may be mid-write, so it is left alone. - const salvage = - stopResult.exited && preferredPath ? await salvageNativeMacCapture(preferredPath) : null; + const salvageTarget = nativeMacSalvageTarget(stopResult, preferredPath); + const salvage = salvageTarget ? await salvageNativeMacCapture(salvageTarget) : null; if (!salvage || !salvage.ok) { pendingCursorRecordingData = null; console.error("Failed to stop native macOS recording:", { @@ -3279,10 +3280,29 @@ export function registerIpcHandlers( } nativeMacRecordingWarning = warning ? { screenVideoPath, message: warning } : null; + // A recovered take most often follows a disk that filled up, and these writes + // go to the same volume. The video is already safe on disk, so for a recovered + // take a failed side write is logged, and its partial file removed, instead of + // turning the recovery back into a lost take. + const writeAlongside = async (label: string, target: string, write: () => Promise) => { + if (!recovered) { + await write(); + return; + } + try { + await write(); + } catch (error) { + console.warn(`[native-sck] could not write the recovered take's ${label}:`, error); + await fs.rm(target, { force: true }).catch(() => undefined); + } + }; + if (cursorCaptureMode === "editable-overlay") { compactPendingCursorTelemetryPauseRanges(nativeMacPauseRanges); shiftPendingCursorTelemetry(nativeMacCursorOffsetMs); - await writePendingCursorTelemetry(screenVideoPath); + await writeAlongside("cursor telemetry", `${screenVideoPath}.cursor.json`, () => + writePendingCursorTelemetry(screenVideoPath), + ); } const session: RecordingSession = { @@ -3297,7 +3317,9 @@ export function registerIpcHandlers( RECORDINGS_DIR, `${path.parse(screenVideoPath).name}${RECORDING_SESSION_SUFFIX}`, ); - await fs.writeFile(sessionManifestPath, JSON.stringify(session, null, 2), "utf-8"); + await writeAlongside("session manifest", sessionManifestPath, () => + fs.writeFile(sessionManifestPath, JSON.stringify(session, null, 2), "utf-8"), + ); await registerRecordingMediaLinks(screenVideoPath, { cursorCaptureMode }); return { diff --git a/electron/recording/nativeMacCaptureSalvage.test.ts b/electron/recording/nativeMacCaptureSalvage.test.ts index a39454131..1cf39180c 100644 --- a/electron/recording/nativeMacCaptureSalvage.test.ts +++ b/electron/recording/nativeMacCaptureSalvage.test.ts @@ -6,6 +6,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { describeSalvagedTake, inspectNativeMacCapture, + nativeMacSalvageTarget, salvageNativeMacCapture, } from "./nativeMacCaptureSalvage"; @@ -176,6 +177,7 @@ describe("salvageNativeMacCapture", () => { expect(await sizeOf(file)).toBe(KILLED_29S.firstMoof); }); + /** Players skip these 4 bytes anyway; the cut is a precaution, and it loses nothing. */ it("cuts off a box header left half-written at the end", async () => { const file = await take("helper-killed-29s", KILLED_29S.wide + 4); @@ -195,14 +197,76 @@ describe("salvageNativeMacCapture", () => { }); }); +describe("nativeMacSalvageTarget", () => { + const TARGET = "/rec/recording-1.mp4"; + + it("salvages the requested file once the helper has exited", () => { + expect( + nativeMacSalvageTarget( + { ok: false, reason: "helper-failed", message: "x", exited: true }, + TARGET, + ), + ).toBe(TARGET); + }); + + /** Salvage truncates; a helper that did not exit may still be inside finishWriting. */ + it("never salvages a file a helper that did not exit may still be writing", () => { + expect( + nativeMacSalvageTarget( + { ok: false, reason: "stop-timeout", message: "x", exited: false }, + TARGET, + ), + ).toBeNull(); + }); + + it("has nothing to salvage after a stop that worked", () => { + expect(nativeMacSalvageTarget({ ok: true, screenVideoPath: TARGET }, TARGET)).toBeNull(); + }); + + it("has nothing to salvage when no file was requested", () => { + expect( + nativeMacSalvageTarget( + { ok: false, reason: "helper-failed", message: "x", exited: true }, + null, + ), + ).toBeNull(); + }); +}); + describe("describeSalvagedTake", () => { - it("quotes the readable part of an NSError", () => { + /** Verbatim from the helper, in the disk-full end-to-end run. */ + it("keeps the helper's sentence and adds a description that says something", () => { expect( describeSalvagedTake( - 'Recording stopped: the video file could not be written (video append: Error Domain=AVFoundationErrorDomain Code=-11807 "Disk Full" UserInfo={NSLocalizedDescription=Disk Full, NSUnderlyingError=0x1 {Error Domain=NSPOSIXErrorDomain Code=28}}).', + 'Recording stopped: the video file could not be written (writer status: Error Domain=AVFoundationErrorDomain Code=-11807 "Disk Full" UserInfo={NSLocalizedDescription=Disk Full, NSUnderlyingError=0x9e326c930 {Error Domain=NSPOSIXErrorDomain Code=28 "No space left on device"}, NSLocalizedRecoverySuggestion=Make room by deleting existing files and try again., NSLocalizedFailureReason=There is not enough available space to continue the file writing.}).', 35.01, ), - ).toBe("Recording stopped after 0:35: Disk Full. The part recorded until then was saved."); + ).toBe( + "Recording stopped after 0:35: the video file could not be written (Disk Full). The part recorded until then was saved.", + ); + }); + + /** The -16364 writer death this whole path started from; its description is generic. */ + it("does not replace the helper's sentence with a generic NSError description", () => { + expect( + describeSalvagedTake( + 'Recording stopped: the video file could not be written (video append: Error Domain=AVFoundationErrorDomain Code=-11800 "The operation could not be completed" UserInfo={NSLocalizedFailureReason=An unknown error occurred (-16364), NSLocalizedDescription=The operation could not be completed, NSUnderlyingError=0x1 {Error Domain=NSOSStatusErrorDomain Code=-16364 "(null)"}}).', + 73.01, + ), + ).toBe( + "Recording stopped after 1:13: the video file could not be written. The part recorded until then was saved.", + ); + }); + + it("reads a bare NSError by its description", () => { + expect( + describeSalvagedTake( + 'Error Domain=com.apple.ScreenCaptureKit.SCStreamErrorDomain Code=-3815 "The stream was stopped by the system." UserInfo={NSLocalizedDescription=The stream was stopped by the system.}', + 12, + ), + ).toBe( + "Recording stopped after 0:12: The stream was stopped by the system. The part recorded until then was saved.", + ); }); it("uses the whole message when there is no NSError in it", () => { @@ -211,6 +275,13 @@ describe("describeSalvagedTake", () => { ); }); + /** Measured: a 35 s disk-full take whose frame durations sum to 34.98 s. */ + it("rounds the length rather than cutting a second off it", () => { + expect(describeSalvagedTake("Disk Full", 34.983333)).toBe( + "Recording stopped after 0:35: Disk Full. The part recorded until then was saved.", + ); + }); + it("counts hours on a long take", () => { expect(describeSalvagedTake("Disk Full", 3725)).toBe( "Recording stopped after 1:02:05: Disk Full. The part recorded until then was saved.", diff --git a/electron/recording/nativeMacCaptureSalvage.ts b/electron/recording/nativeMacCaptureSalvage.ts index 38485f32b..90f3d6dc6 100644 --- a/electron/recording/nativeMacCaptureSalvage.ts +++ b/electron/recording/nativeMacCaptureSalvage.ts @@ -28,12 +28,16 @@ import fs from "node:fs/promises"; * * # The one layout that has to be repaired * - * A file cut inside a box other than `mdat` (a torn `moof`) does not open anywhere. - * Cut back to the start of that box it opens, and keeps every fragment before it. - * So a torn tail is truncated and the file inspected again — a file is never - * reported recoverable while bytes that break it are still on disk. + * A file cut inside a `moof` opens nowhere: ffmpeg, libavformat and Chromium all + * refuse it. Cut back to the start of that `moof` it opens, and keeps every fragment + * before it. So a torn tail is truncated and the file inspected again. The same cut + * is applied to anything else left unfinished at the end, such as a header of a few + * bytes, which players skip anyway: it costs nothing, and a file is never reported + * recoverable while bytes that could break it are still on disk. */ +import type { NativeMacCaptureStopResult } from "./nativeMacCaptureStop"; + /** Guards against a corrupt size field making a structural box look enormous. */ const MAX_INDEX_BOX_BYTES = 64 * 1024 * 1024; @@ -483,23 +487,57 @@ export async function salvageNativeMacCapture(filePath: string): Promise 0 ? `${hours}:${String(minutes).padStart(2, "0")}:${rest}` : `${minutes}:${rest}`; } +/** + * The file a failed stop may be salvaged from, or null. + * + * Only once the helper has exited: a stop that timed out leaves a helper that may + * still be inside finishWriting, and salvaging truncates. + */ +export function nativeMacSalvageTarget( + result: NativeMacCaptureStopResult, + targetPath: string | null, +): string | null { + return !result.ok && result.exited && targetPath ? targetPath : null; +} + +/** NSError descriptions that say nothing a person can act on. */ +const GENERIC_ERROR_DESCRIPTIONS = new Set(["The operation could not be completed"]); + /** * The warning for a take whose stop failed but whose file was recovered. * - * The stop's message usually embeds an NSError; its localized description is the - * part a person can read ("Disk Full"). + * The helper's messages are a sentence followed by a raw NSError, e.g. "Recording + * stopped: the video file could not be written (video append: Error Domain=… + * NSLocalizedDescription=Disk Full …)". The sentence is kept; the NSError is + * reduced to its localized description, and only when that says something — + * AVFoundation's -11800 says "The operation could not be completed". */ export function describeSalvagedTake(failureMessage: string, durationSec: number) { - const description = /NSLocalizedDescription=([^,}]+)/.exec(failureMessage)?.[1]?.trim(); - const reason = (description ?? failureMessage) - .replace(/^Recording stopped:\s*/i, "") - .replace(/[.\s]+$/, ""); + const text = failureMessage.trim().replace(/^Recording stopped:\s*/i, ""); + const description = /NSLocalizedDescription=([^,}]+)/.exec(text)?.[1]?.trim(); + const usefulDescription = + description && !GENERIC_ERROR_DESCRIPTIONS.has(description) ? description : undefined; + + let reason = text; + const errorStart = text.indexOf("Error Domain="); + if (errorStart !== -1) { + const openParen = text.lastIndexOf("(", errorStart); + const sentence = text.slice(0, openParen === -1 ? errorStart : openParen).trim(); + if (sentence && usefulDescription) { + reason = `${sentence} (${usefulDescription})`; + } else { + reason = sentence || usefulDescription || description || "the recorder failed"; + } + } + reason = reason.replace(/[.\s]+$/, ""); return `Recording stopped after ${formatDuration(durationSec)}: ${reason}. The part recorded until then was saved.`; } From fe28dafe84be3fcc0fcc98fc53a056893cd0a16e Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Mon, 14 Sep 2026 10:58:40 +0200 Subject: [PATCH 06/39] fix(wgc): read the config argument as UTF-16 so non-ASCII output paths resolve The helper took its config JSON from a narrow argv, which the CRT transcodes through the ANSI code page. utf8ToWide then mangled every non-ASCII path component and Media Foundation failed with ERROR_PATH_NOT_FOUND (0x80070003) before choosing an encoder. Refs #483 --- electron/native/wgc-capture/src/main.cpp | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/electron/native/wgc-capture/src/main.cpp b/electron/native/wgc-capture/src/main.cpp index 614e84d00..223211c9f 100644 --- a/electron/native/wgc-capture/src/main.cpp +++ b/electron/native/wgc-capture/src/main.cpp @@ -650,7 +650,13 @@ void readCaptureCommands(CaptureControl& control, const std::function Date: Fri, 28 Aug 2026 07:52:07 +0530 Subject: [PATCH 07/39] fix(timeline): coalesce scrub state updates per frame --- .../v4/V4Timeline.geometry.test.tsx | 72 +++++++++++++++++-- src/components/ai-edition/v4/V4Timeline.tsx | 6 +- 2 files changed, 70 insertions(+), 8 deletions(-) diff --git a/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx b/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx index 5119eedae..3a0e19044 100644 --- a/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx +++ b/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx @@ -1,7 +1,8 @@ // @vitest-environment jsdom import "@testing-library/jest-dom"; -import { fireEvent, render, screen } from "@testing-library/react"; -import { beforeAll, describe, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { Profiler, type ProfilerOnRenderCallback } from "react"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; // The regression under test is geometric, so the environment has to have a size: // jsdom reports 0 for every box, which would leave `pxPerSec` at 0 (the @@ -76,6 +77,7 @@ function renderTimeline( clips = [clip(0, TOTAL_SEC)], annotation = { id: "ann1", startMs: 10_000, endMs: 11_000 }, assets: Array> = [NO_CAMERA_ASSET], + onRender?: ProfilerOnRenderCallback, ) { const tl = { clips, @@ -104,13 +106,14 @@ function renderTimeline( /* the toolbar only awaits it */ }), }; - render( + const setCurrentTime = vi.fn(); + const timeline = ( } - setCurrentTime={vi.fn()} + setCurrentTime={setCurrentTime} playing={false} onTogglePlay={vi.fn()} onPrevClip={vi.fn()} @@ -118,15 +121,29 @@ function renderTimeline( onEditClip={vi.fn()} onAddVoiceover={vi.fn()} /> - , + + ); + render( + onRender ? ( + + {timeline} + + ) : ( + timeline + ), ); return { pill: screen.getByTitle("toolbar.newAnnotation"), clipEls: Array.from(document.querySelectorAll("[data-clip-id]")), tl, + setCurrentTime, }; } +afterEach(() => { + vi.unstubAllGlobals(); +}); + /** Drag a handle by `dxPx`. The move/up listeners live on `window`, so the drag * is driven by pointer deltas alone — the handle may re-mount under it. */ function dragHandle(handle: Element, dxPx: number) { @@ -147,6 +164,51 @@ function zoomIn(notches: number) { wheelZoomOn(document.querySelector("[class*=tlTracks]") as HTMLElement, notches); } +describe("V4Timeline scrubbing", () => { + it("publishes at most one React scrub-state update per animation frame", () => { + const frames = new Map(); + let nextFrameId = 1; + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + const frameId = nextFrameId++; + frames.set(frameId, callback); + return frameId; + }); + vi.stubGlobal("cancelAnimationFrame", (frameId: number) => { + frames.delete(frameId); + }); + const onRender = vi.fn(); + const { setCurrentTime } = renderTimeline( + [clip(0, TOTAL_SEC)], + { id: "ann1", startMs: 10_000, endMs: 11_000 }, + [NO_CAMERA_ASSET], + onRender, + ); + const ruler = document.querySelector("[class*=tlRulerRow]"); + expect(ruler).not.toBeNull(); + + fireEvent.pointerDown(ruler as HTMLElement, { button: 0, clientX: 90 }); + const commitsAfterPointerDown = onRender.mock.calls.length; + setCurrentTime.mockClear(); + + fireEvent.pointerMove(window, { clientX: 180 }); + fireEvent.pointerMove(window, { clientX: 270 }); + fireEvent.pointerMove(window, { clientX: 360 }); + + expect(onRender).toHaveBeenCalledTimes(commitsAfterPointerDown); + expect(setCurrentTime).not.toHaveBeenCalled(); + expect(frames.size).toBe(1); + + const [[frameId, frame]] = frames; + frames.delete(frameId); + act(() => frame(0)); + + expect(onRender).toHaveBeenCalledTimes(commitsAfterPointerDown + 1); + expect(setCurrentTime).toHaveBeenCalledTimes(1); + expect(setCurrentTime).toHaveBeenCalledWith(720); + fireEvent.pointerUp(window); + }); +}); + describe("V4Timeline lane pills", () => { it("draws a pill exactly as wide as its region, at any zoom", () => { // 1 s of 1800 s. The old `Math.max(1.5, …)` floor drew this as 1.5% — 27 diff --git a/src/components/ai-edition/v4/V4Timeline.tsx b/src/components/ai-edition/v4/V4Timeline.tsx index cd2edb07f..502122a84 100644 --- a/src/components/ai-edition/v4/V4Timeline.tsx +++ b/src/components/ai-edition/v4/V4Timeline.tsx @@ -773,8 +773,6 @@ export function V4Timeline({ playheadElRef.current.style.left = `${pct * 100}%`; } - // Optimistic local UI state update - setScrubbingTimeSec(targetTime); pendingSeekTimeRef.current = targetTime; if (isImmediate) { @@ -782,15 +780,17 @@ export function V4Timeline({ cancelAnimationFrame(rafSeekRef.current); rafSeekRef.current = 0; } + setScrubbingTimeSec(targetTime); setCurrentTime(targetTime); return; } - // Throttled store update / D3D seek via rAF to avoid IPC flooding + // Throttled React state + store update / D3D seek via rAF to avoid re-render and IPC floods. if (rafSeekRef.current === 0) { rafSeekRef.current = requestAnimationFrame(() => { rafSeekRef.current = 0; if (pendingSeekTimeRef.current !== null) { + setScrubbingTimeSec(pendingSeekTimeRef.current); setCurrentTime(pendingSeekTimeRef.current); } }); From ea01ad4b8aa3867ad10253b919ad0afc63225f18 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Mon, 14 Sep 2026 18:17:47 +0200 Subject: [PATCH 08/39] test(timeline): measure per-frame scrub commits against one update's cost Main's toolbar tooltips (Radix asChild triggers) re-attach their ref on every commit on React 18, so each V4Timeline commit is followed by a nested one and the hardcoded +1 no longer held. The test now measures what one scrub-state update costs at pointer-down and asserts, over two frames, that pointer moves add no commit and each frame adds exactly that cost. --- .../v4/V4Timeline.geometry.test.tsx | 45 ++++++++++++------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx b/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx index 3a0e19044..6cbc72def 100644 --- a/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx +++ b/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx @@ -186,25 +186,36 @@ describe("V4Timeline scrubbing", () => { const ruler = document.querySelector("[class*=tlRulerRow]"); expect(ruler).not.toBeNull(); + // What ONE scrub-state update costs in commits, measured on the pointer-down seek + // (which sets it once, synchronously) rather than hardcoded: the toolbar's Radix + // tooltip triggers re-attach their ref on every commit and add a nested one. + const commitsBeforePointerDown = onRender.mock.calls.length; fireEvent.pointerDown(ruler as HTMLElement, { button: 0, clientX: 90 }); - const commitsAfterPointerDown = onRender.mock.calls.length; - setCurrentTime.mockClear(); - - fireEvent.pointerMove(window, { clientX: 180 }); - fireEvent.pointerMove(window, { clientX: 270 }); - fireEvent.pointerMove(window, { clientX: 360 }); - - expect(onRender).toHaveBeenCalledTimes(commitsAfterPointerDown); - expect(setCurrentTime).not.toHaveBeenCalled(); - expect(frames.size).toBe(1); - - const [[frameId, frame]] = frames; - frames.delete(frameId); - act(() => frame(0)); + const commitsPerUpdate = onRender.mock.calls.length - commitsBeforePointerDown; + expect(commitsPerUpdate).toBeGreaterThan(0); + + // Over two frames: no pointer move commits, and each frame costs exactly one + // update however many moves it coalesced. + for (const [clientXs, expectedSec] of [ + [[180, 270, 360], 720], + [[450, 540], 1080], + ] as const) { + const commitsBeforeFrame = onRender.mock.calls.length; + setCurrentTime.mockClear(); + for (const clientX of clientXs) fireEvent.pointerMove(window, { clientX }); + + expect(onRender).toHaveBeenCalledTimes(commitsBeforeFrame); + expect(setCurrentTime).not.toHaveBeenCalled(); + expect(frames.size).toBe(1); + + const [[frameId, frame]] = frames; + frames.delete(frameId); + act(() => frame(0)); - expect(onRender).toHaveBeenCalledTimes(commitsAfterPointerDown + 1); - expect(setCurrentTime).toHaveBeenCalledTimes(1); - expect(setCurrentTime).toHaveBeenCalledWith(720); + expect(onRender).toHaveBeenCalledTimes(commitsBeforeFrame + commitsPerUpdate); + expect(setCurrentTime).toHaveBeenCalledTimes(1); + expect(setCurrentTime).toHaveBeenCalledWith(expectedSec); + } fireEvent.pointerUp(window); }); }); From d108bdd5a3f82fd8e6e23acb469bf9db3b36e546 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E8=B6=85=E8=B6=85=E8=B6=85=E8=B6=85=E7=BA=A7?= =?UTF-8?q?=E5=96=9C=E6=AC=A2=E4=BD=A0=E7=9A=84=E8=BE=BE=E5=A6=AE=E5=A8=85?= <176143450+My-Denia@users.noreply.github.com> Date: Fri, 11 Sep 2026 03:38:38 +0800 Subject: [PATCH 09/39] fix(windows): low-pass before decimating so 96/192 kHz sources stop aliasing When the source rate is an integer multiple of the AAC target -- 96, 192 or 384 kHz loopback into a 48 kHz encoder -- convertAudioWithGain averaged each group of source frames. A box average is a low-pass FIR, but its stop-band attenuation is far short of what decimation needs: at 96 kHz -> 48 kHz a 36 kHz tone still passes at about 38% (-8.3 dB) and folds onto 12 kHz in the recording. The existing Nyquist test passed only because a two-tap mean has a zero at exactly 48 kHz, the one frequency it checked. Every frame now runs through a Kaiser-windowed sinc before frames are dropped: 80 * factor + 1 taps, beta 8.6, cutoff at 11/24 of the output rate (22 kHz for a 48 kHz output), unity gain at DC. The filter's history has to outlive a single packet, so it moves out of the byte-remainder buffer into AudioDecimatorState. AudioMixer owns one per stream, so system audio and the microphone keep independent histories, and it resets both where it used to reset the remainder (start, beginTimeline, setPaused). pendingFrames() replaces the leftover-bytes accounting and reports the same thing: how far into the current group the stream has got. Output is still exactly floor(frames / factor) whatever the packet boundaries, so nothing downstream sees a different frame count. Measured through the shipped code in float, for factors 2, 3, 4, 6, 12 and 24: -0.00 dB at 19.2 kHz, -6.02 dB at 22 kHz, and a worst stop-band response of -87.2 dB (factor 2) to -87.7 dB (factor 24), at the first sidelobe just above 24 kHz. Trade-offs: - Content between 20 and 24 kHz is attenuated: -6.02 dB at 22 kHz and below -87 dB from 24 kHz. That transition is the price of a stop band deep enough to drop frames safely. - Delay. The FIR is linear-phase, with a group delay of 40 * factor source frames. Each output frame is computed on the last source frame of its group, which takes back factor - 1 of those, so against the output timeline (output frame k at the time of source frame k * factor) a decimated stream lags by 39 + 1/factor output frames: 0.81 to 0.82 ms at a 48 kHz output. A stream that does not decimate has no such lag, so a mix of the two now carries that skew, where the box average's offset was under half an output frame. The suite asserts the value. - Level. The sharp transition needs negative taps, so a decimated peak can exceed its input, which a box average (all taps positive) never allows. Measured on square waves before the clamp: +1.38 to +1.45 dB for a 1 kHz square at the factors tested (2 to 24), and up to +3.82 dB for fundamentals up to 20 kHz. The ceiling for arbitrary bounded input is the taps' absolute sum, 2.22 to 2.23, about +7 dB -- a bound, not a typical value. Output is clamped, and the suite asserts it saturates rather than wraps, but full-scale material with sharp edges can clip where it did not before. - Non-finite input. Input is not sanitised, before or after this change. One NaN or Inf sample, which the box average confined to one output frame, now reaches 80 or 81 output frames (1.67 to 1.69 ms at 48 kHz) before the filter history clears. - Cost follows the source rate, not the factor: per source frame the filter does 80 + 1/factor multiply-adds per channel. In a bench on one development machine, a 10 ms packet from 192 kHz measured a steady-state p99 of 290 to 353 us, and a first-packet p99 (tap design included) of at most 373 us, across three runs; 192 -> 48 kHz and 192 -> 8 kHz steady-state means were within 4% of each other in those three. One of them also covered higher rates: 550 us steady p99 for a 384 kHz source, and 1.08 ms for 768 kHz, above the 1 ms per-packet budget this change set itself; no 768 kHz device was available. A fourth run, taken under background load, put 384 kHz at 1.07 ms, also above that budget, and the two 192 kHz means 5.5% apart. Tests: 95 assertions (18 before). A float layer measures the filter itself, with float32 in and out as WASAPI delivers, so no quantization floor hides the stop band; a PCM16 layer measures what the encoder receives. Beyond the response: bit-identical output under ragged packetization, frame accounting across all-zero packets, reset() equal to a cold start, the delay above, channel mapping and gain on this branch, the overshoot size, a sample-by-sample check of the clamped PCM16 write at gains 1, 2 and 8, and a case that drives the real AudioMixer, the only one that catches a mixer re-initialising the filter per packet. Real hardware, factors 2, 4 and 8: with a USB DAC as the loopback source, its shared-mode format set to 96, 192 and 384 kHz in turn against a 48 kHz target, the helper built from the base and from this branch recorded the same tones. In the fold bins, for 96 / 192 / 384 kHz, a 36 kHz tone (onto 12 kHz) reads -22.25 / -23.90 / -24.28 dBFS before and at most -115.32 / -138.02 / -111.54 dBFS after; a 42 kHz tone (onto 6 kHz) reads -28.20 / -30.40 / -30.96 dBFS before and at most -137.14 / -135.09 / -117.25 dBFS after. The after readings bound what the recordings hold and sit near each recording's floor; they are not the filter's leakage, which the design puts at -123 to -155 dBFS at these points. The 1 kHz reference reads -13.98 or -13.99 dBFS in every recording, and every before reading matches the box average's response to within 0.08 dB. Closes #582 --- .../wgc-capture/src/audio_sample_utils.cpp | 236 +++- .../wgc-capture/src/audio_sample_utils.h | 36 +- .../src/audio_sample_utils_test.cpp | 1043 ++++++++++++++++- 3 files changed, 1237 insertions(+), 78 deletions(-) diff --git a/electron/native/wgc-capture/src/audio_sample_utils.cpp b/electron/native/wgc-capture/src/audio_sample_utils.cpp index 96847ee7d..4e7fb4ee9 100644 --- a/electron/native/wgc-capture/src/audio_sample_utils.cpp +++ b/electron/native/wgc-capture/src/audio_sample_utils.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -133,6 +134,120 @@ AudioInputFormat makeAacCompatibleAudioFormat(const AudioInputFormat& source) { return format; } +namespace { + +// Zeroth-order modified Bessel of the first kind, for the Kaiser window. The +// series converges fast for the beta this file uses; the loop bails once a term +// stops moving the sum at double precision. +constexpr double kPi = 3.14159265358979323846; + +double besselI0(double x) { + double sum = 1.0; + double term = 1.0; + for (int k = 1; k < 128; k += 1) { + term *= (x * x / 4.0) / (static_cast(k) * k); + sum += term; + if (term < sum * 1e-17) { + break; + } + } + return sum; +} + +// The whole anti-alias filter is these three numbers, and two properties follow +// from them for EVERY decimation factor -- which is what keeps this path general +// instead of tuned for the two rates that happen to be common: +// +// * the cutoff always lands at 11/12 = 0.9167 of the OUTPUT Nyquist, because +// both it and the Nyquist scale as 1/factor. So the passband stays flat to +// roughly 0.8 of the output Nyquist and the stop band is already deep by the +// time anything can fold back; +// * the group delay is always 80*factor/2 source frames. After the decimation +// phase hands back (factor-1) of them, the net is 39 + 1/factor output +// frames -- bounded in [39, 39.5] whatever the factor, about 0.82 ms at +// 48 kHz out. +// +// Depth: a 36 kHz tone leaves 96 kHz -> 48 kHz at about -117 dB, against roughly +// -8 dB for the box average this replaces. 80 taps per step is ~1.5x the Kaiser +// minimum for that transition and depth. The margin is affordable because the +// cost is set by the SOURCE rate rather than the factor: the tap count grows +// with factor exactly as fast as the output rate falls, so 192 kHz -> 48 kHz and +// 192 kHz -> 8 kHz cost the same. +constexpr size_t kTapsPerDecimationStep = 80; +constexpr double kCutoffFractionOfSourceRate = 11.0 / 24.0; +constexpr double kKaiserBeta = 8.6; + +} // namespace + +void AudioDecimatorState::reset() { + std::fill(history_.begin(), history_.end(), 0.0); + position_ = 0; + phase_ = 0; +} + +void AudioDecimatorState::prepare(UINT32 factor, UINT32 channels) { + if (factor_ == factor && channels_ == channels && !taps_.empty()) { + return; + } + factor_ = factor; + channels_ = channels; + // Kaiser-windowed sinc. The three constants and what follows from them are + // documented where they are defined; here it is enough that the cutoff is + // expressed against the SOURCE rate, which is what makes every factor land + // on the same fraction of its own output Nyquist. + const size_t tapCount = kTapsPerDecimationStep * factor + 1; + const double cutoff = kCutoffFractionOfSourceRate / static_cast(factor); + const double middle = static_cast(tapCount - 1) / 2.0; + const double norm = besselI0(kKaiserBeta); + taps_.assign(tapCount, 0.0); + for (size_t k = 0; k < tapCount; k += 1) { + const double offset = static_cast(k) - middle; + const double sinc = std::abs(offset) < 1e-12 + ? 2.0 * cutoff + : std::sin(2.0 * kPi * cutoff * offset) / (kPi * offset); + const double ratio = offset / middle; + taps_[k] = + sinc * besselI0(kKaiserBeta * std::sqrt(std::max(0.0, 1.0 - ratio * ratio))) / norm; + } + // Unity at DC, so the passband keeps the level the caller handed us. + const double dc = std::accumulate(taps_.begin(), taps_.end(), 0.0); + if (dc != 0.0) { + for (double& tap : taps_) { + tap /= dc; + } + } + history_.assign(tapCount * channels, 0.0); + position_ = 0; + phase_ = 0; +} + +bool AudioDecimatorState::consume(const double* frame, double* out) { + if (taps_.empty() || channels_ == 0) { + return false; + } + for (UINT32 channel = 0; channel < channels_; channel += 1) { + history_[position_ * channels_ + channel] = frame[channel]; + } + bool produced = false; + phase_ += 1; + if (phase_ == factor_) { + phase_ = 0; + produced = true; + const size_t tapCount = taps_.size(); + for (UINT32 channel = 0; channel < channels_; channel += 1) { + double sum = 0.0; + size_t read = position_; + for (size_t k = 0; k < tapCount; k += 1) { + sum += taps_[k] * history_[read * channels_ + channel]; + read = read != 0 ? read - 1 : tapCount - 1; + } + out[channel] = sum; + } + } + position_ = (position_ + 1 == taps_.size()) ? 0 : position_ + 1; + return produced; +} + void copyAudioWithGain( const BYTE* source, DWORD byteCount, @@ -190,9 +305,9 @@ void convertAudioWithGain( const AudioInputFormat& targetFormat, double gain, std::vector& destination) { - std::vector discardedRemainder; + AudioDecimatorState discardedDecimator; convertAudioWithGain( - source, byteCount, sourceFormat, targetFormat, gain, destination, discardedRemainder); + source, byteCount, sourceFormat, targetFormat, gain, destination, discardedDecimator); } void convertAudioWithGain( @@ -202,7 +317,7 @@ void convertAudioWithGain( const AudioInputFormat& targetFormat, double gain, std::vector& destination, - std::vector& remainder) { + AudioDecimatorState& decimator) { if (!source || byteCount == 0 || sourceFormat.blockAlign == 0 || targetFormat.blockAlign == 0 || sourceFormat.sampleRate == 0 || targetFormat.sampleRate == 0 || sourceFormat.channels == 0 || targetFormat.channels == 0) { @@ -211,6 +326,20 @@ void convertAudioWithGain( } if (sameAudioFormatForMixing(sourceFormat, targetFormat)) { + // The decimator belongs to the caller, not to this call, so a packet + // that does not decimate has to hand it back empty rather than leave a + // half-finished group waiting for frames that will never arrive. + // + // In this helper that is a contract, not a live path. The mix format is + // read once per session (a single GetMixFormat in + // WasapiLoopbackCapture) and resolveInputFormat passes its subtype + // through unchanged, so what + // arrives here is whatever the audio engine hands out -- which is + // float32, while the encoder target is always PCM16. Subtype alone + // therefore never matches, and this branch is unreachable in + // production. The suite does reach it, by reusing one state across + // formats -- which is the point of writing it. + decimator.reset(); copyAudioWithGain(source, byteCount, targetFormat, gain, destination); return; } @@ -221,58 +350,63 @@ void convertAudioWithGain( return; } - // Integer-factor downsample (96 kHz / 192 kHz -> 48 kHz): average each - // group of source frames instead of picking one. Nearest-neighbour - // decimation aliases content above the new Nyquist into the recording. - // Incomplete groups stay in remainder so the next packet can finish them. + // A note on level, because this filter can do something the box average could + // not: a decimated peak can exceed the largest input sample it came from. A + // transition band this sharp needs negative taps, and negative taps make the + // step response overshoot; the box average never overshoots only because its + // taps are all positive, which is also why it rejects so little. HOW MUCH it + // overshoots is this design's choice, set by the cutoff and the window. For + // a near-full-scale 1 kHz square wave -- the Gibbs case -- it is +1.4 to + // +1.5 dB depending on the factor; a square with a higher fundamental + // overshoots more, up to about +3.8 dB in the cases measured. For arbitrary + // bounded input the ceiling is the sum of |taps|, 2.22 to 2.23 depending on + // the factor, or about +7 dB. writeSampleFromDouble clamps, so overshoot + // saturates rather than wrapping; the suite checks that write sample by + // sample, and pins the 1 kHz figure at every factor it tests. + + // Integer-factor downsample (96 kHz / 192 kHz -> 48 kHz). Dropping every + // Nth frame is only safe once the content above the new Nyquist is gone, so + // every frame runs through an anti-alias low-pass first and that filter's + // history rides across packets inside `decimator`. A group a packet leaves + // unfinished is no longer held back as bytes — its frames are already in the + // filter — and `pendingFrames()` is what reports how many there were. if (sourceFormat.sampleRate > targetFormat.sampleRate && sourceFormat.sampleRate % targetFormat.sampleRate == 0) { const UINT32 factor = sourceFormat.sampleRate / targetFormat.sampleRate; - if (remainder.size() % sourceFormat.blockAlign != 0) { - remainder.clear(); - } - std::vector combined; - combined.reserve(remainder.size() + byteCount); - combined.insert(combined.end(), remainder.begin(), remainder.end()); - combined.insert(combined.end(), source, source + byteCount); - const size_t totalFrames = combined.size() / sourceFormat.blockAlign; - const size_t targetFrames = totalFrames / factor; - const size_t consumedFrames = targetFrames * factor; - const size_t leftoverBytes = (totalFrames - consumedFrames) * sourceFormat.blockAlign; - if (targetFrames == 0) { - destination.clear(); - remainder.swap(combined); - return; - } - destination.assign(targetFrames * targetFormat.blockAlign, 0); - for (size_t targetFrame = 0; targetFrame < targetFrames; ++targetFrame) { + decimator.prepare(factor, targetFormat.channels); + const size_t maxTargetFrames = (packetFrames + decimator.pendingFrames()) / factor; + destination.assign(maxTargetFrames * targetFormat.blockAlign, 0); + std::vector frame(targetFormat.channels, 0.0); + std::vector filtered(targetFormat.channels, 0.0); + size_t targetFrame = 0; + for (size_t sourceFrame = 0; sourceFrame < packetFrames; ++sourceFrame) { + for (UINT32 channel = 0; channel < targetFormat.channels; ++channel) { + frame[channel] = readMappedChannel( + source, sourceFormat, sourceFrame, channel, targetFormat.channels); + } + if (!decimator.consume(frame.data(), filtered.data())) { + continue; + } for (UINT32 channel = 0; channel < targetFormat.channels; ++channel) { - double sum = 0.0; - for (UINT32 tap = 0; tap < factor; ++tap) { - sum += readMappedChannel( - combined.data(), - sourceFormat, - targetFrame * factor + tap, - channel, - targetFormat.channels); - } writeSampleFromDouble( destination.data(), targetFormat, targetFrame, channel, - (sum / static_cast(factor)) * gain); + filtered[channel] * gain); } + targetFrame += 1; } - remainder.assign( - combined.begin() + static_cast(consumedFrames * sourceFormat.blockAlign), - combined.end()); - if (remainder.size() != leftoverBytes) { - remainder.resize(leftoverBytes); - } + destination.resize(targetFrame * targetFormat.blockAlign); return; } + // Same ownership rule as the pass-through above, but unlike that one this + // branch is hot. A 48 kHz microphone against a 48 kHz PCM16 target fails + // sameAudioFormatForMixing on subtype alone -- WASAPI hands out float32 -- + // and `sourceRate > targetRate` is false, so on an ordinary machine every + // microphone packet lands here and resets a decimator it never used. + decimator.reset(); const size_t sourceFrames = packetFrames; const double rateRatio = static_cast(targetFormat.sampleRate) / static_cast(sourceFormat.sampleRate); @@ -371,8 +505,8 @@ bool AudioMixer::start() { emittedFrames_ = 0; timelineStarted_ = false; paused_ = false; - systemResampleRemainder_.clear(); - microphoneResampleRemainder_.clear(); + systemDecimator_.reset(); + microphoneDecimator_.reset(); thread_ = std::thread([this] { mixLoop(); }); @@ -384,8 +518,8 @@ void AudioMixer::beginTimeline() { std::scoped_lock lock(mutex_); systemQueue_.clear(); microphoneQueue_.clear(); - systemResampleRemainder_.clear(); - microphoneResampleRemainder_.clear(); + systemDecimator_.reset(); + microphoneDecimator_.reset(); emittedFrames_ = 0; timelineStarted_ = true; } @@ -399,8 +533,8 @@ void AudioMixer::setPaused(bool paused) { if (paused_) { systemQueue_.clear(); microphoneQueue_.clear(); - systemResampleRemainder_.clear(); - microphoneResampleRemainder_.clear(); + systemDecimator_.reset(); + microphoneDecimator_.reset(); } } cv_.notify_all(); @@ -424,7 +558,7 @@ void AudioMixer::pushSystem(const BYTE* data, DWORD byteCount) { if (paused_) { return; } - append(systemQueue_, data, byteCount, systemFormat_, 1.0, systemResampleRemainder_); + append(systemQueue_, data, byteCount, systemFormat_, 1.0, systemDecimator_); } cv_.notify_all(); } @@ -445,7 +579,7 @@ void AudioMixer::pushMicrophone(const BYTE* data, DWORD byteCount) { byteCount, microphoneFormat_, microphoneGain_, - microphoneResampleRemainder_); + microphoneDecimator_); } cv_.notify_all(); } @@ -456,12 +590,12 @@ void AudioMixer::append( DWORD byteCount, const AudioInputFormat& sourceFormat, double gain, - std::vector& remainder) { + AudioDecimatorState& decimator) { if (!data || byteCount == 0) { return; } - convertAudioWithGain(data, byteCount, sourceFormat, format_, gain, gainBuffer_, remainder); + convertAudioWithGain(data, byteCount, sourceFormat, format_, gain, gainBuffer_, decimator); queue.insert(queue.end(), gainBuffer_.begin(), gainBuffer_.end()); } diff --git a/electron/native/wgc-capture/src/audio_sample_utils.h b/electron/native/wgc-capture/src/audio_sample_utils.h index 0f8e6b69f..d1eaa49a8 100644 --- a/electron/native/wgc-capture/src/audio_sample_utils.h +++ b/electron/native/wgc-capture/src/audio_sample_utils.h @@ -27,6 +27,34 @@ void convertAudioWithGain( const AudioInputFormat& targetFormat, double gain, std::vector& destination); +// Cross-packet state for the integer-factor downsample path (96/192 kHz -> 48). +// Dropping frames is only safe once everything above the new Nyquist is gone, +// and a filter long enough to do that reaches back further than one packet — so +// its history has to outlive the call. `pendingFrames()` is how far into the +// current output group the stream has got, which is the same accounting the +// caller used to read off a leftover-bytes buffer: a group only produces an +// output frame once all `factor` of its frames have arrived. +class AudioDecimatorState { +public: + void reset(); + size_t pendingFrames() const { return phase_; } + + // Used by the decimation path; not part of the caller's contract. `consume` + // takes one source frame and writes `channels` filtered samples into `out` + // on the frame that completes a group, which is the only frame that + // survives the decimation. + void prepare(UINT32 factor, UINT32 channels); + bool consume(const double* frame, double* out); + +private: + std::vector taps_; + std::vector history_; + size_t position_ = 0; + size_t phase_ = 0; + UINT32 factor_ = 0; + UINT32 channels_ = 0; +}; + void convertAudioWithGain( const BYTE* source, DWORD byteCount, @@ -34,7 +62,7 @@ void convertAudioWithGain( const AudioInputFormat& targetFormat, double gain, std::vector& destination, - std::vector& remainder); + AudioDecimatorState& decimator); void mixAudioInPlace( std::vector& destination, const BYTE* source, @@ -72,7 +100,7 @@ class AudioMixer { DWORD byteCount, const AudioInputFormat& sourceFormat, double gain, - std::vector& remainder); + AudioDecimatorState& decimator); bool pop(std::vector& queue, std::vector& chunk, size_t byteCount); void mixLoop(); @@ -87,8 +115,8 @@ class AudioMixer { std::condition_variable cv_; std::vector systemQueue_; std::vector microphoneQueue_; - std::vector systemResampleRemainder_; - std::vector microphoneResampleRemainder_; + AudioDecimatorState systemDecimator_; + AudioDecimatorState microphoneDecimator_; std::vector gainBuffer_; std::thread thread_; std::atomic stopRequested_ = false; diff --git a/electron/native/wgc-capture/src/audio_sample_utils_test.cpp b/electron/native/wgc-capture/src/audio_sample_utils_test.cpp index 8b74b9144..6297cbe0b 100644 --- a/electron/native/wgc-capture/src/audio_sample_utils_test.cpp +++ b/electron/native/wgc-capture/src/audio_sample_utils_test.cpp @@ -6,11 +6,16 @@ #include #include +#include +#include +#include #include #include #include #include +#include #include +#include #include #include @@ -19,6 +24,8 @@ namespace { int g_ran = 0; int g_failed = 0; +constexpr double kTestPi = 3.14159265358979323846; + AudioInputFormat makeFormat( GUID subtype, UINT32 sampleRate, @@ -274,7 +281,438 @@ int main() { for (size_t i = 0; folded && i < downFrames * 2; i += 1) { folded = std::abs(static_cast(down[i])) <= 1; } - expect("resample-96k-nyquist-box", folded, "frames=" + std::to_string(downFrames)); + expect("resample-96k-nyquist-rejected", folded, "frames=" + std::to_string(downFrames)); + + // --- Layer 1: the filter itself, measured in floating point ------------- + // + // These read the anti-alias filter's own response, which is a different + // question from "what reaches the encoder" (layer 2, below) and has to be + // reported separately. Both the source and the target are float32 here, for + // two reasons: WASAPI mix formats ARE float32, so this is the production + // shape; and it removes both quantization floors. Measured through PCM16 the + // same stopband reads -300 dB (the output quantizer) or -98 dB (the input + // quantizer) -- neither of which says anything about the filter. + // + // Tone frequencies are multiples of 10 Hz and the analysis window is exactly + // 4800 output frames = 0.1 s, so every tone is periodic in the window and + // Goertzel scalloping cannot bias the reading. That matters: the cutoff + // assertion below has a +/-0.3 dB window, which scalloping alone could break. + constexpr size_t kResponseWindow = 4800; // 0.1 s at 48 kHz -> 10 Hz bins + constexpr size_t kResponseSkip = 256; // past the cold-filter ramp + const auto responseDb = [&](UINT32 factor, double toneHz) -> double { + const UINT32 outRate = 48000; + const AudioInputFormat src = makeFormat(MFAudioFormat_Float, outRate * factor, 2, 32); + const AudioInputFormat dst = makeFormat(MFAudioFormat_Float, outRate, 2, 32); + const size_t outFrames = kResponseWindow + kResponseSkip; + const size_t srcFrames = outFrames * factor; + const double amplitude = 0.5; + std::vector in(srcFrames * src.blockAlign, 0); + auto* inSamples = reinterpret_cast(in.data()); + for (size_t i = 0; i < srcFrames; i += 1) { + const double v = amplitude * + std::sin(2.0 * kTestPi * toneHz * static_cast(i) / src.sampleRate); + inSamples[i * 2] = static_cast(v); + inSamples[i * 2 + 1] = static_cast(v); + } + std::vector out; + convertAudioWithGain(in.data(), static_cast(in.size()), src, dst, 1.0, out); + const size_t produced = out.size() / dst.blockAlign; + if (produced < kResponseSkip + kResponseWindow) { + return 300.0; // deliberately out of budget: too short to judge + } + // Where the tone lands after decimation. Below the output Nyquist it + // stays put; above it, it folds -- which is the whole point. + double folded = std::fmod(toneHz, static_cast(outRate)); + if (folded > outRate / 2.0) { + folded = outRate - folded; + } + const auto* outSamples = reinterpret_cast(out.data()); + const double w = 2.0 * kTestPi * folded / static_cast(outRate); + const double c = 2.0 * std::cos(w); + double s1 = 0.0; + double s2 = 0.0; + for (size_t i = 0; i < kResponseWindow; i += 1) { + const double v = static_cast(outSamples[(kResponseSkip + i) * 2]); + const double s0 = v + c * s1 - s2; + s2 = s1; + s1 = s0; + } + const double mag = 2.0 * std::sqrt(std::max(0.0, s1 * s1 + s2 * s2 - c * s1 * s2)) / + static_cast(kResponseWindow); + return mag <= 0.0 ? -300.0 : 20.0 * std::log10(mag / amplitude); + }; + + for (UINT32 factor : {2u, 3u, 4u, 6u, 12u, 24u}) { + const double outNyquist = 24000.0; // the target is 48 kHz + const std::string tag = "-f" + std::to_string(factor); + const double passband = responseDb(factor, 0.8 * outNyquist); // 19200 Hz + const double cutoff = responseDb(factor, 11.0 / 12.0 * outNyquist); // 22000 Hz + // Just above the Nyquist rather than exactly on it: a tone at exactly + // 24000 Hz sits on the output's Nyquist bin, where the measured + // magnitude depends on sampling phase rather than on the filter. + const double stopEntry = responseDb(factor, outNyquist + 10.0); + // Sweep the WHOLE band that can fold, starting at the stop band edge. + // Starting further in would miss the worst case entirely: for a Kaiser + // design the largest surviving lobe sits immediately above the edge, and + // an interior-only sweep reads ~6 dB better than the truth. The coarse + // pass covers the band; the dense pass is what actually lands on that + // first lobe (about 24.03 kHz), since the coarse points are hundreds of + // Hz apart and step straight over it, reading up to 0.2 dB optimistic. + double worst = -300.0; + double worstAt = 0.0; + const double top = outNyquist * static_cast(factor); // source Nyquist + const auto consider = [&](double hz) { + const double db = responseDb(factor, hz); + if (db > worst) { + worst = db; + worstAt = hz; + } + }; + for (int step = 0; step <= 32; step += 1) { + consider(std::round( + (outNyquist + 10.0 + (top - outNyquist - 10.0) * step / 32.0) / 10.0) * 10.0); + } + for (double hz = outNyquist + 20.0; hz <= outNyquist + 300.0; hz += 10.0) { + consider(hz); + } + char detail[192]{}; + sprintf_s( + detail, "pb19200=%.3f cutoff22000=%.3f stop24010=%.2f worst=%.2f@%.0fHz", + passband, cutoff, stopEntry, worst, worstAt); + std::cout << "RESP_RAW factor" << factor << " " << detail << std::endl; + expect(("filter-response-passband-flat" + tag).c_str(), passband >= -0.05, detail); + expect( + ("filter-response-cutoff-minus6db" + tag).c_str(), + std::abs(cutoff + 6.03) <= 0.3, detail); + expect(("filter-response-stopband-entry" + tag).c_str(), stopEntry <= -80.0, detail); + expect(("filter-response-alias-band-worst" + tag).c_str(), worst <= -80.0, detail); + } + + // --- Layer 2: what the encoder actually receives, in PCM16 -------------- + // + // A tone between the new Nyquist (24 kHz) and the old one aliases on the way + // down: 36 kHz folds to 12 kHz at 48 kHz, so the decimator's low-pass has to + // remove it BEFORE frames are dropped — a box average leaves it at roughly + // 38%. Goertzel reads the 12 kHz bin out of the output; the leading frames + // are skipped because the filter starts cold. + const auto measureTone = [&](const AudioInputFormat& sourceFormat, + unsigned toneHz, + unsigned readHz) -> double { + const double amplitude = 16384.0; + const size_t toneFrames = static_cast(sourceFormat.sampleRate) / 4; + std::vector toneBytes(toneFrames * sourceFormat.blockAlign, 0); + auto* toneSamples = reinterpret_cast(toneBytes.data()); + for (size_t frame = 0; frame < toneFrames; frame += 1) { + const double phase = 2.0 * 3.14159265358979323846 * static_cast(toneHz) * + static_cast(frame) / static_cast(sourceFormat.sampleRate); + const auto value = static_cast(std::lround(amplitude * std::sin(phase))); + toneSamples[frame * 2] = value; + toneSamples[frame * 2 + 1] = value; + } + std::vector toneOut; + convertAudioWithGain( + toneBytes.data(), static_cast(toneBytes.size()), sourceFormat, target48k, 1.0, toneOut); + const size_t outFrames = toneOut.size() / target48k.blockAlign; + const auto* outSamples = reinterpret_cast(toneOut.data()); + const size_t skip = std::min(2400, outFrames / 4); + double s1 = 0.0; + double s2 = 0.0; + const double omega = 2.0 * 3.14159265358979323846 * static_cast(readHz) / + static_cast(target48k.sampleRate); + const double coeff = 2.0 * std::cos(omega); + size_t counted = 0; + for (size_t frame = skip; frame < outFrames; frame += 1) { + const double sample = static_cast(outSamples[frame * 2]); + const double s0 = sample + coeff * s1 - s2; + s2 = s1; + s1 = s0; + counted += 1; + } + const double magnitude = counted == 0 + ? 0.0 + : 2.0 * std::sqrt(s1 * s1 + s2 * s2 - coeff * s1 * s2) / static_cast(counted); + return magnitude <= 0.0 ? -300.0 : 20.0 * std::log10(magnitude / amplitude); + }; + const auto expectTone = [&](const AudioInputFormat& sourceFormat, + unsigned toneHz, + unsigned readHz, + bool below, + double limitDb, + const char* name) { + const double db = measureTone(sourceFormat, toneHz, readHz); + char detail[112]{}; + sprintf_s(detail, "%u Hz in, %u Hz out = %.2f dB", toneHz, readHz, db); + std::cout << "TONE_RAW " << name << " " << detail << std::endl; + expect(name, below ? db < limitDb : db > limitDb, detail); + }; + const AudioInputFormat source192k = makeFormat(MFAudioFormat_PCM, 192000, 2, 16); + // Everything above the 24 kHz output Nyquist folds somewhere into the band, + // so one example is not coverage. Each of these names where it lands: + // f mod 48000, reflected about 24000. + expectTone(source96k, 25000, 23000, true, -60.0, "resample-96k-25k-alias"); + expectTone(source96k, 30000, 18000, true, -60.0, "resample-96k-30k-alias"); + expectTone(source96k, 36000, 12000, true, -60.0, "resample-96k-36k-alias-below-minus60db"); + // 47 kHz folds to exactly 1 kHz, which is where the passband control sits. + // It therefore does double duty: if the control were ever reading an alias + // rather than the real passband, this case would expose it. + expectTone(source96k, 47000, 1000, true, -60.0, "resample-96k-47k-alias-onto-passband"); + expectTone(source192k, 25000, 23000, true, -60.0, "resample-192k-25k-alias"); + expectTone(source192k, 36000, 12000, true, -60.0, "resample-192k-36k-alias-below-minus60db"); + expectTone(source192k, 50000, 2000, true, -60.0, "resample-192k-50k-alias"); + expectTone(source192k, 70000, 22000, true, -60.0, "resample-192k-70k-alias"); + expectTone(source192k, 90000, 6000, true, -60.0, "resample-192k-90k-alias"); + // The alias assertions above are satisfied by silence, so the passband is + // asserted alongside them: these tones have to come through at their level. + expectTone(source96k, 1000, 1000, false, -0.5, "resample-96k-1k-passband-intact"); + expectTone(source192k, 1000, 1000, false, -0.5, "resample-192k-1k-passband-intact"); + expectTone(source96k, 15000, 15000, false, -0.5, "resample-96k-15k-passband-intact"); + expectTone(source192k, 15000, 15000, false, -0.5, "resample-192k-15k-passband-intact"); + + // The filter reaches back further than one packet, so the same stream cut + // into ragged packets has to come out bit-identical to one long call, with + // the same number of frames still pending. This is what "stateful" has to + // mean at a packet boundary, and it is checked for every factor rather than + // for the one that happens to be commonest: a partition bug that only shows + // up when the group is longer than two frames would hide at factor 2. + const auto expectPartitionIdentical = [&](const AudioInputFormat& sourceFormat, + const char* name) { + const size_t streamFrames = 9600; + std::vector stream(streamFrames * sourceFormat.blockAlign, 0); + auto* streamSamples = reinterpret_cast(stream.data()); + for (size_t frame = 0; frame < streamFrames; frame += 1) { + const double phase = 2.0 * kTestPi * 7000.0 * + static_cast(frame) / static_cast(sourceFormat.sampleRate); + const auto value = static_cast(std::lround(12000.0 * std::sin(phase))); + streamSamples[frame * 2] = value; + streamSamples[frame * 2 + 1] = static_cast(-value); + } + AudioDecimatorState whole; + std::vector wholeOut; + convertAudioWithGain( + stream.data(), static_cast(stream.size()), sourceFormat, target48k, 1.0, + wholeOut, whole); + + AudioDecimatorState split; + std::vector splitOut; + std::vector piece; + const size_t chunks[] = {1, 2, 3, 5, 8, 13, 21, 34, 55, 89}; + size_t cursor = 0; + size_t index = 0; + while (cursor < streamFrames) { + const size_t frames = std::min(chunks[index % 10], streamFrames - cursor); + index += 1; + convertAudioWithGain( + stream.data() + cursor * sourceFormat.blockAlign, + static_cast(frames * sourceFormat.blockAlign), + sourceFormat, + target48k, + 1.0, + piece, + split); + splitOut.insert(splitOut.end(), piece.begin(), piece.end()); + cursor += frames; + } + // Frame accounting is the other half: however the stream was cut, the + // decimator must have produced exactly floor(total/factor) frames and be + // holding exactly total%factor. + const UINT32 factor = sourceFormat.sampleRate / target48k.sampleRate; + const size_t expectedOut = streamFrames / factor; + const size_t expectedPending = streamFrames % factor; + expect( + name, + wholeOut == splitOut && whole.pendingFrames() == split.pendingFrames() && + splitOut.size() / target48k.blockAlign == expectedOut && + split.pendingFrames() == expectedPending, + "whole=" + std::to_string(wholeOut.size()) + " split=" + std::to_string(splitOut.size()) + + " frames=" + std::to_string(splitOut.size() / target48k.blockAlign) + + " want=" + std::to_string(expectedOut) + + " pending=" + std::to_string(split.pendingFrames()) + + " wantPending=" + std::to_string(expectedPending)); + }; + + // 144 kHz and 288 kHz are not rates a sound card is likely to report; they + // are here because the entry point is generic in `factor` and must be shown + // to be, rather than tuned for the two ratios real hardware reaches. + const AudioInputFormat source144k = makeFormat(MFAudioFormat_PCM, 144000, 2, 16); + const AudioInputFormat source288k = makeFormat(MFAudioFormat_PCM, 288000, 2, 16); + expectPartitionIdentical(source96k, "resample-96k-packet-partition-bit-identical"); + expectPartitionIdentical(source144k, "resample-144k-f3-packet-partition-bit-identical"); + expectPartitionIdentical(source192k, "resample-192k-f4-packet-partition-bit-identical"); + expectPartitionIdentical(source288k, "resample-288k-f6-packet-partition-bit-identical"); + + // Alias and passband at the two non-{2,4} factors, for the same reason. + expectTone(source144k, 36000, 12000, true, -60.0, "resample-144k-f3-36k-alias"); + expectTone(source288k, 36000, 12000, true, -60.0, "resample-288k-f6-36k-alias"); + expectTone(source144k, 1000, 1000, false, -0.5, "resample-144k-f3-1k-passband-intact"); + expectTone(source288k, 1000, 1000, false, -0.5, "resample-288k-f6-1k-passband-intact"); + + // --- The state machine, independent of the filter's quality ------------- + + // A group is only complete once all `factor` of its frames have arrived, and + // at factor 4 there are three ways to be part-way through. Factor 2 has only + // one, so exercising it alone leaves the interesting arithmetic untested. + { + AudioDecimatorState state; + std::vector one(source192k.blockAlign, 0); + auto* s = reinterpret_cast(one.data()); + s[0] = 4000; + s[1] = -4000; + std::vector out; + bool ok = true; + std::string detail; + for (size_t pending = 1; pending <= 3; pending += 1) { + convertAudioWithGain( + one.data(), static_cast(one.size()), source192k, target48k, 1.0, out, state); + ok = ok && out.empty() && state.pendingFrames() == pending; + detail += " after" + std::to_string(pending) + "=" + + std::to_string(state.pendingFrames()); + } + convertAudioWithGain( + one.data(), static_cast(one.size()), source192k, target48k, 1.0, out, state); + ok = ok && out.size() == target48k.blockAlign && state.pendingFrames() == 0; + expect( + "resample-192k-f4-pending-counts-1-2-3", ok, + detail + " after4=" + std::to_string(state.pendingFrames()) + " out=" + + std::to_string(out.size())); + } + + // reset() has to mean cold, not nearly cold. start(), beginTimeline() and + // setPaused(true) all call it, so anything it left behind would be audible + // in the first frames after a pause. + { + const size_t warmFrames = 512; + const size_t tailFrames = 2048; + std::vector stream((warmFrames + tailFrames) * source96k.blockAlign, 0); + auto* s = reinterpret_cast(stream.data()); + for (size_t i = 0; i < warmFrames + tailFrames; i += 1) { + const double phase = + 2.0 * kTestPi * 5000.0 * static_cast(i) / source96k.sampleRate; + const auto v = static_cast(std::lround(9000.0 * std::sin(phase))); + s[i * 2] = v; + s[i * 2 + 1] = v; + } + const BYTE* tail = stream.data() + warmFrames * source96k.blockAlign; + const DWORD tailBytes = static_cast(tailFrames * source96k.blockAlign); + + AudioDecimatorState used; + std::vector scratch; + convertAudioWithGain( + stream.data(), static_cast(warmFrames * source96k.blockAlign), source96k, + target48k, 1.0, scratch, used); + used.reset(); + std::vector afterReset; + convertAudioWithGain(tail, tailBytes, source96k, target48k, 1.0, afterReset, used); + + AudioDecimatorState fresh; + std::vector fromCold; + convertAudioWithGain(tail, tailBytes, source96k, target48k, 1.0, fromCold, fresh); + + expect( + "resample-96k-reset-equals-cold-start", + afterReset == fromCold && !afterReset.empty(), + "reset=" + std::to_string(afterReset.size()) + " cold=" + + std::to_string(fromCold.size())); + } + + // WASAPI delivers no packets while a loopback source is silent; the capture + // layer synthesizes zero frames and pushes them through the SAME callback + // (emitSilenceFrames in wasapi_loopback_capture.cpp). Those frames have to be + // CONSUMED rather than skipped: skipping them would slide the decimation + // phase and shorten the take. Ragged packets, two silent stretches, and a + // total that is deliberately not a multiple of the factor. + { + const size_t totalFrames = 7777; + std::vector stream(totalFrames * source96k.blockAlign, 0); + auto* s = reinterpret_cast(stream.data()); + for (size_t i = 0; i < totalFrames; i += 1) { + const bool silent = (i > 1500 && i < 3000) || (i > 5000 && i < 5100); + if (silent) { + continue; + } + const double phase = + 2.0 * kTestPi * 3000.0 * static_cast(i) / source96k.sampleRate; + const auto v = static_cast(std::lround(8000.0 * std::sin(phase))); + s[i * 2] = v; + s[i * 2 + 1] = v; + } + AudioDecimatorState state; + std::vector piece; + size_t produced = 0; + const size_t chunks[] = {480, 1, 960, 2, 4800, 3, 1, 1}; + size_t cursor = 0; + size_t index = 0; + while (cursor < totalFrames) { + const size_t frames = std::min(chunks[index % 8], totalFrames - cursor); + index += 1; + convertAudioWithGain( + stream.data() + cursor * source96k.blockAlign, + static_cast(frames * source96k.blockAlign), + source96k, target48k, 1.0, piece, state); + produced += piece.size() / target48k.blockAlign; + cursor += frames; + } + expect( + "resample-96k-gap-silence-frames-are-counted", + produced == totalFrames / 2 && state.pendingFrames() == totalFrames % 2, + "produced=" + std::to_string(produced) + " want=" + + std::to_string(totalFrames / 2) + " pending=" + + std::to_string(state.pendingFrames())); + } + + // One state handed packets of different shapes. The decimating branch is not + // the only one that touches it: the pass-through and interpolation branches + // reset it, which is the contract that stops a half-finished group bleeding + // across a format boundary. In this helper the format is fixed for the + // session so production never switches, but the entry point is shared, so + // the contract is checked here rather than assumed. + { + const size_t frames = 1024; + std::vector hi(frames * source96k.blockAlign, 0); + auto* h = reinterpret_cast(hi.data()); + for (size_t i = 0; i < frames; i += 1) { + const double phase = + 2.0 * kTestPi * 4000.0 * static_cast(i) / source96k.sampleRate; + const auto v = static_cast(std::lround(7000.0 * std::sin(phase))); + h[i * 2] = v; + h[i * 2 + 1] = v; + } + // An odd frame count leaves exactly one frame pending, so a missing reset + // shows up as a shifted phase instead of needing luck to detect. + const DWORD oddBytes = static_cast((frames - 1) * source96k.blockAlign); + std::vector passthrough(64 * target48k.blockAlign, 0); + const AudioInputFormat source44k = makeFormat(MFAudioFormat_PCM, 44100, 2, 16); + std::vector odd(64 * source44k.blockAlign, 0); + + AudioDecimatorState reused; + std::vector scratch; + convertAudioWithGain(hi.data(), oddBytes, source96k, target48k, 1.0, scratch, reused); + const bool pendingBefore = reused.pendingFrames() == 1; + convertAudioWithGain( + passthrough.data(), static_cast(passthrough.size()), target48k, target48k, 1.0, + scratch, reused); + const bool clearedByPassthrough = reused.pendingFrames() == 0; + convertAudioWithGain(hi.data(), oddBytes, source96k, target48k, 1.0, scratch, reused); + convertAudioWithGain( + odd.data(), static_cast(odd.size()), source44k, target48k, 1.0, scratch, reused); + const bool clearedByInterpolation = reused.pendingFrames() == 0; + + std::vector afterMix; + convertAudioWithGain( + hi.data(), static_cast(hi.size()), source96k, target48k, 1.0, afterMix, reused); + AudioDecimatorState fresh; + std::vector fromCold; + convertAudioWithGain( + hi.data(), static_cast(hi.size()), source96k, target48k, 1.0, fromCold, fresh); + + expect( + "resample-state-reused-across-formats", + pendingBefore && clearedByPassthrough && clearedByInterpolation && + afterMix == fromCold && !afterMix.empty(), + "pendingBefore=" + std::to_string(pendingBefore) + " passthrough=" + + std::to_string(clearedByPassthrough) + " interp=" + + std::to_string(clearedByInterpolation) + " match=" + + std::to_string(afterMix == fromCold)); + } auto fillStereoFrame = [](std::vector& packet, int16_t left, int16_t right) { auto* samples = reinterpret_cast(packet.data()); @@ -284,11 +722,9 @@ int main() { const auto destFrames = [&](const std::vector& out) -> size_t { return target48k.blockAlign == 0 ? 0 : out.size() / target48k.blockAlign; }; - const auto remainderFrames = [&](const std::vector& rem) -> size_t { - return source96k.blockAlign == 0 ? 0 : rem.size() / source96k.blockAlign; - }; - - std::vector remainder; + // The frames a packet leaves in an unfinished group are inside the filter + // now rather than parked as bytes, so the accounting is read off the state. + AudioDecimatorState decimator; std::vector shortPkt(source96k.blockAlign, 0); fillStereoFrame(shortPkt, 12345, -12345); std::vector shortOut; @@ -299,44 +735,44 @@ int main() { target48k, 1.0, shortOut, - remainder); + decimator); expect( "resample-96k-short-packet", - shortOut.empty() && remainderFrames(remainder) == 1, - "dest=" + std::to_string(shortOut.size()) + " rem=" + std::to_string(remainder.size())); + shortOut.empty() && decimator.pendingFrames() == 1, + "dest=" + std::to_string(shortOut.size()) + " pending=" + std::to_string(decimator.pendingFrames())); - remainder.clear(); + decimator.reset(); std::vector oneA(source96k.blockAlign, 0); std::vector oneB(source96k.blockAlign, 0); fillStereoFrame(oneA, 1000, 2000); fillStereoFrame(oneB, 3000, 4000); std::vector outA; std::vector outB; - convertAudioWithGain(oneA.data(), static_cast(oneA.size()), source96k, target48k, 1.0, outA, remainder); - convertAudioWithGain(oneB.data(), static_cast(oneB.size()), source96k, target48k, 1.0, outB, remainder); + convertAudioWithGain(oneA.data(), static_cast(oneA.size()), source96k, target48k, 1.0, outA, decimator); + convertAudioWithGain(oneB.data(), static_cast(oneB.size()), source96k, target48k, 1.0, outB, decimator); expect( "resample-96k-one-frame-packets", - destFrames(outA) == 0 && destFrames(outB) == 1 && remainder.empty(), + destFrames(outA) == 0 && destFrames(outB) == 1 && decimator.pendingFrames() == 0, "a=" + std::to_string(destFrames(outA)) + " b=" + std::to_string(destFrames(outB)) + - " rem=" + std::to_string(remainderFrames(remainder))); + " pending=" + std::to_string(decimator.pendingFrames())); - remainder.clear(); + decimator.reset(); std::vector threePkt(3 * source96k.blockAlign, 0); std::vector onePkt(source96k.blockAlign, 0); fillStereoFrame(onePkt, 5000, 6000); std::vector threeOut; std::vector oneOut; convertAudioWithGain( - threePkt.data(), static_cast(threePkt.size()), source96k, target48k, 1.0, threeOut, remainder); + threePkt.data(), static_cast(threePkt.size()), source96k, target48k, 1.0, threeOut, decimator); convertAudioWithGain( - onePkt.data(), static_cast(onePkt.size()), source96k, target48k, 1.0, oneOut, remainder); + onePkt.data(), static_cast(onePkt.size()), source96k, target48k, 1.0, oneOut, decimator); expect( "resample-96k-remainder-three-then-one", - destFrames(threeOut) == 1 && destFrames(oneOut) == 1 && remainder.empty(), + destFrames(threeOut) == 1 && destFrames(oneOut) == 1 && decimator.pendingFrames() == 0, "three=" + std::to_string(destFrames(threeOut)) + " one=" + std::to_string(destFrames(oneOut)) + - " rem=" + std::to_string(remainderFrames(remainder))); + " pending=" + std::to_string(decimator.pendingFrames())); - remainder.clear(); + decimator.reset(); std::vector remainderFullOut; convertAudioWithGain( source.data(), @@ -345,14 +781,575 @@ int main() { target48k, 1.0, remainderFullOut, - remainder); + decimator); const size_t remainderFullFrames = destFrames(remainderFullOut); const bool remainderFullOk = remainderFullFrames == 48000 || remainderFullFrames == 47999 || remainderFullFrames == 48001; expect( "resample-96k-remainder-full", - remainderFullOk && remainder.empty(), - "frames=" + std::to_string(remainderFullFrames) + " rem=" + std::to_string(remainder.size())); + remainderFullOk && decimator.pendingFrames() == 0, + "frames=" + std::to_string(remainderFullFrames) + " pending=" + std::to_string(decimator.pendingFrames())); + + // --- Channel mapping and gain, ON the decimation branch ------------------ + // + // Everything above this point drives the decimator with both channels + // carrying identical content and a gain of 1.0, so neither the channel + // mapping nor the gain multiply is actually pinned on this branch. Two + // deliberate mutations -- forcing readMappedChannel's target channel to 0, + // and dropping the `* gain` from the write -- passed the entire suite. The + // contract names both as things that must not regress, so they get an + // assertion here rather than an assumption. + // + // Distinct per-channel frequencies are the point: identical channels cannot + // distinguish "mapped correctly" from "left copied into both". + { + constexpr size_t kMapWindow = 4800; // 0.1 s at 48 kHz -> 10 Hz bins + constexpr size_t kMapSkip = 256; // past the filter's startup ramp + constexpr double kLeftHz = 1000.0; // both are multiples of 10 Hz, so + constexpr double kRightHz = 5000.0; // each is periodic in the window + constexpr double kMapAmplitude = 0.3; + const size_t mapSourceFrames = (kMapWindow + kMapSkip) * 2 + 4096; + + std::vector mapped(mapSourceFrames * source96k.blockAlign, 0); + auto* mappedSamples = reinterpret_cast(mapped.data()); + for (size_t frame = 0; frame < mapSourceFrames; frame += 1) { + const double time = static_cast(frame) / source96k.sampleRate; + mappedSamples[frame * 2] = static_cast(std::lround( + kMapAmplitude * 32767.0 * std::sin(2.0 * kTestPi * kLeftHz * time))); + mappedSamples[frame * 2 + 1] = static_cast(std::lround( + kMapAmplitude * 32767.0 * std::sin(2.0 * kTestPi * kRightHz * time))); + } + + // Goertzel over interleaved PCM16, on a NAMED channel, in dBFS. + const auto binDbChannel = [&](const std::vector& pcm, UINT32 channel, + double hz) -> double { + const size_t available = pcm.size() / target48k.blockAlign; + if (available <= kMapSkip) { + return -300.0; + } + const size_t n = std::min(kMapWindow, available - kMapSkip); + const auto* s = reinterpret_cast(pcm.data()); + const double w = 2.0 * kTestPi * hz / target48k.sampleRate; + const double c = 2.0 * std::cos(w); + double s1 = 0.0; + double s2 = 0.0; + for (size_t i = 0; i < n; i += 1) { + const double v = static_cast(s[(kMapSkip + i) * 2 + channel]); + const double s0 = v + c * s1 - s2; + s2 = s1; + s1 = s0; + } + const double mag = 2.0 * std::sqrt(std::max(0.0, s1 * s1 + s2 * s2 - c * s1 * s2)) / + static_cast(n); + return mag <= 0.0 ? -300.0 : 20.0 * std::log10(mag / 32768.0); + }; + + AudioDecimatorState mapDecimator; + std::vector mappedOut; + convertAudioWithGain( + mapped.data(), + static_cast(mapped.size()), + source96k, + target48k, + 1.0, + mappedOut, + mapDecimator); + + const double leftAtLeft = binDbChannel(mappedOut, 0, kLeftHz); + const double leftAtRight = binDbChannel(mappedOut, 0, kRightHz); + const double rightAtRight = binDbChannel(mappedOut, 1, kRightHz); + const double rightAtLeft = binDbChannel(mappedOut, 1, kLeftHz); + std::cout << "CHANMAP_RAW L@1k=" << leftAtLeft << " L@5k=" << leftAtRight + << " R@5k=" << rightAtRight << " R@1k=" << rightAtLeft << " dBFS" << std::endl; + + // 0.3 FS is -10.46 dBFS. Each channel must carry its OWN tone at full + // level and the other channel's tone at least 60 dB down -- the leak + // floor is set by PCM16 quantization, not by the filter. + expect( + "resample-96k-channel-mapping-preserved", + leftAtLeft > -11.0 && rightAtRight > -11.0 && + leftAtRight < leftAtLeft - 60.0 && rightAtLeft < rightAtRight - 60.0, + "L@1k=" + std::to_string(leftAtLeft) + " L@5k=" + std::to_string(leftAtRight) + + " R@5k=" + std::to_string(rightAtRight) + " R@1k=" + std::to_string(rightAtLeft)); + + // Same input, half gain: every bin must move by exactly -6.02 dB. An + // ignored gain argument leaves it at 0 dB, a doubled one at -12. + AudioDecimatorState gainDecimator; + std::vector halfGainOut; + convertAudioWithGain( + mapped.data(), + static_cast(mapped.size()), + source96k, + target48k, + 0.5, + halfGainOut, + gainDecimator); + + const double halfLeft = binDbChannel(halfGainOut, 0, kLeftHz); + const double halfRight = binDbChannel(halfGainOut, 1, kRightHz); + const double leftDelta = leftAtLeft - halfLeft; + const double rightDelta = rightAtRight - halfRight; + std::cout << "GAIN_RAW half-gain delta L=" << leftDelta << " R=" << rightDelta + << " dB (expect 6.02)" << std::endl; + expect( + "resample-96k-gain-applied-on-decimation-branch", + std::abs(leftDelta - 6.0206) < 0.2 && std::abs(rightDelta - 6.0206) < 0.2 && + halfGainOut.size() == mappedOut.size(), + "dL=" + std::to_string(leftDelta) + " dR=" + std::to_string(rightDelta) + + " bytes=" + std::to_string(halfGainOut.size()) + "/" + + std::to_string(mappedOut.size())); + } + + // --- Overshoot: the level change this filter introduces ----------------- + // + // A box average cannot exceed its input peak; this filter can, because a + // transition this sharp needs negative taps. Two things are pinned here. + // First the size, so the +1.4 to +1.5 dB documented beside the filter design + // for a 1 kHz square cannot go stale (a square with a higher fundamental + // overshoots more, up to about +3.8 dB, still under the taps' ceiling): a near-full-scale square wave (the Gibbs case) read straight out + // of the decimator, before anything clamps -- every convert entry point + // clamps, float targets included, so this is the only place it is visible. + // Second, what happens to it: overshoot must saturate at full scale, never + // wrap. A PCM16 write that skipped its clamp would turn a +1.12 FS peak into + // a large negative sample -- an audible click -- and before this assertion + // existed that mutation passed the entire suite, because nothing else drove + // this branch past 1.0. + { + const auto squareAt = [](size_t frame, UINT32 rate, double amplitude) { + const double phase = + std::fmod(static_cast(frame) * 1000.0 / static_cast(rate), 1.0); + return phase < 0.5 ? amplitude : -amplitude; + }; + + std::string gibbsDetail; + bool gibbsOk = true; + for (UINT32 factor : {2u, 3u, 4u, 6u, 8u, 12u, 24u}) { + const UINT32 rate = 48000 * factor; + AudioDecimatorState gibbsState; + gibbsState.prepare(factor, 1); + double peak = 0.0; + double filteredValue = 0.0; + for (size_t frame = 0; frame < rate; frame += 1) { + const double in = squareAt(frame, rate, 0.95); + if (gibbsState.consume(&in, &filteredValue) && + frame > static_cast(4 * 80 * factor)) { + peak = std::max(peak, std::abs(filteredValue)); + } + } + const double overshootDb = 20.0 * std::log10(peak / 0.95); + gibbsOk = gibbsOk && overshootDb > 1.3 && overshootDb < 1.6; + gibbsDetail += "f" + std::to_string(factor) + "=" + std::to_string(overshootDb) + "dB "; + } + std::cout << "OVERSHOOT_RAW square 0.95 FS " << gibbsDetail << std::endl; + expect("filter-overshoot-square-is-gibbs", gibbsOk, gibbsDetail); + + // Same square, 96 kHz PCM16 in, 48 kHz PCM16 out, checked sample by + // sample against the filter output computed through a second decimator + // state and clamped here. That reference shares any consume() defect by + // construction -- the Gibbs check above is what covers the filter; this + // one isolates what happens after it: gain, the clamp, and rounding to + // PCM16. Both channels are compared, but they carry the same signal, so + // which channel a sample lands in is the channel-mapping test's job. + // Three gains, because a clamp that is right on one channel only, or + // right up to some level and wraps above it, passes a single unity-gain + // check on the left channel -- but it sees only the levels these cases + // reach, not every level. The tolerance is half an LSB, which correct + // rounding meets and truncation does not. Gain is live on this branch: + // the microphone's is a fixed 1.4 today, though the native parser would + // accept any value, and the filter's own ceiling is about 2.2x. + const size_t squareFrames = 48000; // 0.5 s at 96 kHz + std::vector square(squareFrames * source96k.blockAlign, 0); + auto* squareSamples = reinterpret_cast(square.data()); + std::vector squareLeft(squareFrames); + for (size_t frame = 0; frame < squareFrames; frame += 1) { + const int16_t v = + static_cast(std::lround(squareAt(frame, 96000, 0.95) * 32767.0)); + squareSamples[frame * 2] = v; + squareSamples[frame * 2 + 1] = v; + squareLeft[frame] = static_cast(v) / 32768.0; + } + + AudioDecimatorState reference; + reference.prepare(2, 1); + std::vector unclamped; + unclamped.reserve(squareFrames / 2); + double referenceOut = 0.0; + for (size_t frame = 0; frame < squareFrames; frame += 1) { + if (reference.consume(&squareLeft[frame], &referenceOut)) { + unclamped.push_back(referenceOut); + } + } + size_t overshootAtUnity = 0; + for (double v : unclamped) { + if (std::abs(v) > 1.0) { + overshootAtUnity += 1; + } + } + + std::string clampDetail = "overshootAtUnity=" + std::to_string(overshootAtUnity); + bool clampOk = overshootAtUnity > 0 && unclamped.size() == squareFrames / 2; + for (double testGain : {1.0, 2.0, 8.0}) { + AudioDecimatorState squareState; + std::vector squareOut; + convertAudioWithGain( + square.data(), static_cast(square.size()), source96k, target48k, + testGain, squareOut, squareState); + const auto* outSamples = reinterpret_cast(squareOut.data()); + const size_t outFrames = squareOut.size() / target48k.blockAlign; + double worstErrorLsb = 0.0; + size_t saturatedSamples = 0; + for (size_t outFrame = 0; outFrame < outFrames && outFrame < unclamped.size(); + outFrame += 1) { + const double wanted = + std::clamp(unclamped[outFrame] * testGain, -1.0, 1.0) * 32767.0; + for (UINT32 outChannel = 0; outChannel < 2; outChannel += 1) { + const double got = static_cast(outSamples[outFrame * 2 + outChannel]); + worstErrorLsb = std::max(worstErrorLsb, std::abs(got - wanted)); + if (std::abs(got) >= 32766.0) { + saturatedSamples += 1; + } + } + } + clampOk = clampOk && outFrames == unclamped.size() && worstErrorLsb <= 0.5 + 1e-6 && + saturatedSamples > 0; + char part[128]{}; + sprintf_s(part, " g%.0f:err=%.3fLSB,sat=%zu,frames=%zu", testGain, worstErrorLsb, + saturatedSamples, outFrames); + clampDetail += part; + } + std::cout << "CLAMP_RAW " << clampDetail << std::endl; + // overshootAtUnity > 0 is what keeps the unity-gain case from passing + // vacuously: a filter that stopped overshooting would leave nothing + // there to clamp. (At gains 2 and 8 even a box average saturates.) + expect("resample-96k-overshoot-clamps-not-wraps", clampOk, clampDetail); + } + + // --- Group delay, asserted against the ideal it claims to be ------------ + // + // The filter's own delay is (N-1)/2 = 40*factor SOURCE frames, but output + // frame j is emitted at source index j*factor + (factor-1), so decimation + // hands (factor-1) of them back. Net: 39 + 1/factor OUTPUT frames -- 39.5 at + // factor 2 down to 39.04 at factor 24, never the flat 40 it looks like. + // + // It is asserted by reconstructing the ideal delayed sine and taking the + // worst-case error, not by argmax of an impulse response: argmax is 39 for + // every factor and so cannot tell the factors apart. The same assertion + // doubles as a passband-transparency check, since a filter that got the + // delay right but coloured the band would fail it. + { + const auto delayFit = [&](UINT32 factor, double delayFrames, size_t* settleOut) -> double { + const UINT32 outRate = 48000; + const AudioInputFormat src = + makeFormat(MFAudioFormat_Float, outRate * factor, 2, 32); + const AudioInputFormat dst = makeFormat(MFAudioFormat_Float, outRate, 2, 32); + const size_t outFrames = 4800; + const size_t srcFrames = outFrames * factor; + const double amplitude = 0.5; + const double toneHz = 1000.0; + std::vector in(srcFrames * src.blockAlign, 0); + auto* inSamples = reinterpret_cast(in.data()); + for (size_t i = 0; i < srcFrames; i += 1) { + const double v = amplitude * + std::sin(2.0 * kTestPi * toneHz * static_cast(i) / src.sampleRate); + inSamples[i * 2] = static_cast(v); + inSamples[i * 2 + 1] = static_cast(v); + } + std::vector out; + convertAudioWithGain(in.data(), static_cast(in.size()), src, dst, 1.0, out); + const size_t produced = out.size() / dst.blockAlign; + const auto* outSamples = reinterpret_cast(out.data()); + // The steady-state window is FIXED, not derived from where the error + // happens to fall. An earlier version advanced the window start every + // time the error exceeded tolerance, which meant a persistently wrong + // delay pushed the window past every bad sample and reported a small + // error -- the discrimination assertion below is what caught it. + const size_t steadyStart = 100; + double worst = 0.0; + size_t settle = 0; + for (size_t j = 0; j < produced; j += 1) { + const double ideal = amplitude * + std::sin(2.0 * kTestPi * toneHz * (static_cast(j) - delayFrames) / + outRate); + const double err = std::abs(static_cast(outSamples[j * 2]) - ideal); + if (err > 0.002) { + settle = j + 1; + } + if (j >= steadyStart) { + worst = std::max(worst, err); + } + } + if (settleOut != nullptr) { + *settleOut = settle; + } + return worst; + }; + + for (UINT32 factor : {2u, 3u, 4u, 6u}) { + const double expected = 39.0 + 1.0 / static_cast(factor); + size_t settle = 0; + const double atExpected = delayFit(factor, expected, &settle); + // A tolerance only means something if a wrong answer breaks it. Half + // an output frame either side is the discrimination check: if the + // assertion passed at 39.0 and 40.0 as readily as at 39.5, it would + // not be measuring the delay at all. + const double atLow = delayFit(factor, expected - 0.5, nullptr); + const double atHigh = delayFit(factor, expected + 0.5, nullptr); + const std::string tag = "-f" + std::to_string(factor); + char detail[192]{}; + sprintf_s( + detail, "expected=%.4f err=%.6f err(-0.5)=%.5f err(+0.5)=%.5f settleFrames=%zu", + expected, atExpected, atLow, atHigh, settle); + std::cout << "DELAY_RAW factor" << factor << " " << detail << std::endl; + expect(("delay-matches-39-plus-1-over-factor" + tag).c_str(), + atExpected < 0.002, detail); + expect(("delay-tolerance-discriminates" + tag).c_str(), + atLow > atExpected * 10.0 && atHigh > atExpected * 10.0, detail); + // The cold filter ramps in over its own length; after that the output + // is the ideal delayed sine. Bounding it matters because those frames + // are the ones a listener hears at the start of every recording. + expect(("delay-startup-ramp-bounded" + tag).c_str(), settle <= 80, detail); + } + + // Cross-branch skew. A 48 kHz stream against a 48 kHz target does not + // decimate, so it carries no filter delay at all -- which means this + // change introduces a skew between a decimated stream and a + // non-decimated one where there was effectively none before (the box + // average it replaces delayed by (factor-1)/2 source frames = 0.25 + // output frames at factor 2). Sub-millisecond, but real, and asserted + // here so it cannot grow unnoticed. + { + const AudioInputFormat src = makeFormat(MFAudioFormat_Float, 48000, 2, 32); + const AudioInputFormat dst = makeFormat(MFAudioFormat_PCM, 48000, 2, 16); + const size_t frames = 4800; + const double amplitude = 0.5; + std::vector in(frames * src.blockAlign, 0); + auto* inSamples = reinterpret_cast(in.data()); + for (size_t i = 0; i < frames; i += 1) { + const double v = amplitude * + std::sin(2.0 * kTestPi * 1000.0 * static_cast(i) / 48000.0); + inSamples[i * 2] = static_cast(v); + inSamples[i * 2 + 1] = static_cast(v); + } + std::vector out; + convertAudioWithGain(in.data(), static_cast(in.size()), src, dst, 1.0, out); + const auto* outSamples = reinterpret_cast(out.data()); + const size_t produced = out.size() / dst.blockAlign; + double worst = 0.0; + for (size_t j = 16; j < produced; j += 1) { + const double ideal = amplitude * + std::sin(2.0 * kTestPi * 1000.0 * static_cast(j) / 48000.0); + worst = std::max( + worst, + std::abs(static_cast(outSamples[j * 2]) / 32768.0 - ideal)); + } + char detail[128]{}; + sprintf_s( + detail, "non-decimating path worst error vs zero delay = %.5f (skew is the " + "decimating path's 39+1/factor)", worst); + std::cout << "DELAY_RAW cross-branch " << detail << std::endl; + expect("delay-non-decimating-path-has-none", worst < 0.002, detail); + } + } + + // --- Production wiring: the real AudioMixer ----------------------------- + // + // Everything above calls convertAudioWithGain directly with a state the test + // owns, so all of it stays green against a mixer that re-initialised the + // filter on every packet. This is the only thing here that would not. + // + // Amplitude is specified rather than inherited: two tones at the 16384 used + // elsewhere in this file would clip, and the third harmonic of 36 kHz is + // 108 kHz, which in a 96 kHz-sampled signal lands on exactly 12 kHz -- the + // bin being read. Clipping distortion would be indistinguishable from + // decimation aliasing, which is why (d) measures the generator itself. + { + const AudioInputFormat system96k = makeFormat(MFAudioFormat_Float, 96000, 2, 32); + const AudioInputFormat mic48k = makeFormat(MFAudioFormat_Float, 48000, 2, 32); + const size_t probeFrames = 96000 * 5 / 2; // 2.5 s, pushed before the window opens + std::vector probe(probeFrames * system96k.blockAlign, 0); + auto* probeSamples = reinterpret_cast(probe.data()); + for (size_t i = 0; i < probeFrames; i += 1) { + const double time = static_cast(i) / system96k.sampleRate; + const double v = 0.2 * std::sin(2.0 * kTestPi * 36000.0 * time) + + 0.2 * std::sin(2.0 * kTestPi * 1000.0 * time); + probeSamples[i * 2] = static_cast(v); + probeSamples[i * 2 + 1] = static_cast(v); + } + + // Goertzel over interleaved PCM16, left channel, in dBFS. + const auto binDbPcm = [&](const std::vector& pcm, double hz, size_t from, + size_t count) -> double { + const size_t available = pcm.size() / target48k.blockAlign; + if (from >= available) { + return -300.0; + } + const size_t n = std::min(count, available - from); + const auto* s = reinterpret_cast(pcm.data()); + const double w = 2.0 * kTestPi * hz / target48k.sampleRate; + const double c = 2.0 * std::cos(w); + double s1 = 0.0; + double s2 = 0.0; + for (size_t i = 0; i < n; i += 1) { + const double v = static_cast(s[(from + i) * 2]); + const double s0 = v + c * s1 - s2; + s2 = s1; + s1 = s0; + } + const double mag = 2.0 * std::sqrt(std::max(0.0, s1 * s1 + s2 * s2 - c * s1 * s2)) / + static_cast(n); + return mag <= 0.0 ? -300.0 : 20.0 * std::log10(mag / 32768.0); + }; + + // (d) The generator control, measured on the 96 kHz INPUT buffer. + // 12 kHz is an ordinary in-band frequency at 96 kHz, so if the generator + // put anything there -- clipping, rounding, a wrong constant -- it would + // pass through the filter untouched and be read at the output as if it + // were an alias. This is why the earlier plan's control (measuring the + // same signal through a 48 kHz source) was abandoned: 36 kHz cannot + // exist in a 48 kHz sampled signal at all. + { + const double w = 2.0 * kTestPi * 12000.0 / system96k.sampleRate; + const double c = 2.0 * std::cos(w); + double s1 = 0.0; + double s2 = 0.0; + for (size_t i = 0; i < probeFrames; i += 1) { + const double s0 = static_cast(probeSamples[i * 2]) + c * s1 - s2; + s2 = s1; + s1 = s0; + } + const double mag = 2.0 * std::sqrt(std::max(0.0, s1 * s1 + s2 * s2 - c * s1 * s2)) / + static_cast(probeFrames); + const double db = mag <= 0.0 ? -300.0 : 20.0 * std::log10(mag / 0.2); + char detail[96]{}; + sprintf_s(detail, "input 12 kHz bin = %.2f dB rel tone", db); + std::cout << "MIXER_RAW generator-control " << detail << std::endl; + expect("mixer-generator-has-no-12k", db < -100.0, detail); + } + + const uint32_t chunkFrames = target48k.sampleRate / 100; // the mixer's 10 ms cadence + const size_t windowStart = static_cast(chunkFrames) * 10; + const size_t windowFrames = static_cast(target48k.sampleRate) / 2; // 0.5 s + + const auto runMixer = [&](bool includeMic, const char* label) { + std::mutex guard; + std::vector collected; + size_t chunkCount = 0; + size_t zeroChunks = 0; + + AudioMixer mixer( + target48k, system96k, mic48k, true, includeMic, 1.0, + [&](const BYTE* data, DWORD byteCount, int64_t, int64_t) { + std::scoped_lock lock(guard); + chunkCount += 1; + bool allZero = true; + for (DWORD i = 0; i < byteCount; i += 1) { + if (data[i] != 0) { + allZero = false; + break; + } + } + if (allZero && chunkCount > 10) { + zeroChunks += 1; + } + collected.insert(collected.end(), data, data + byteCount); + return true; + }); + + expect((std::string("mixer-start") + label).c_str(), mixer.start(), ""); + // beginTimeline clears both queues and resets both decimators, so the + // pre-fill has to come after it or it is simply discarded. + mixer.beginTimeline(); + + // Ragged packets, including sub-factor ones. A mixer that reset the + // filter per packet would drop every 1-frame packet on the floor and + // restart the filter cold hundreds of times; the alias would return. + const size_t sizes[] = {1, 2, 3, 5, 8, 13, 21, 480, 960, 1920}; + std::vector silentMic(chunkFrames * mic48k.blockAlign, 0); + size_t cursor = 0; + size_t index = 0; + while (cursor < probeFrames) { + const size_t frames = std::min(sizes[index % 10], probeFrames - cursor); + mixer.pushSystem( + probe.data() + cursor * system96k.blockAlign, + static_cast(frames * system96k.blockAlign)); + if (includeMic && (index % 4) == 0) { + // Silent, so it adds nothing to the measurement -- but each + // one runs pushMicrophone -> append -> the interpolation + // branch, which resets the MICROPHONE decimator. If the two + // streams shared one state, that reset would tear the system + // filter apart and the alias would come back. + mixer.pushMicrophone( + silentMic.data(), static_cast(silentMic.size())); + } + index += 1; + cursor += frames; + } + + const size_t needed = windowStart + windowFrames; + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(20); + for (;;) { + { + std::scoped_lock lock(guard); + if (collected.size() / target48k.blockAlign >= needed) { + break; + } + } + if (std::chrono::steady_clock::now() > deadline) { + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } + mixer.stop(); + + std::vector out; + size_t observedZero = 0; + { + std::scoped_lock lock(guard); + out = collected; + observedZero = zeroChunks; + } + const size_t frames = out.size() / target48k.blockAlign; + const double alias = binDbPcm(out, 12000.0, windowStart, windowFrames); + const double pass = binDbPcm(out, 1000.0, windowStart, windowFrames); + char detail[176]{}; + sprintf_s( + detail, "alias12k=%.2f dBFS pass1k=%.2f dBFS frames=%zu zeroChunks=%zu", + alias, pass, frames, observedZero); + std::cout << "MIXER_RAW " << label << " " << detail << std::endl; + + expect( + (std::string("mixer-produced-enough") + label).c_str(), frames >= needed, detail); + // An underrun dilutes the measurement. It would not turn a failing + // alias into a passing one -- pop()'s zero-fill is rectangular + // gating, which LEAKS, and leakage from the 1 kHz tone raises the + // 12 kHz floor -- but a silently diluted measurement is still a + // broken one, so it fails with its count. + expect( + (std::string("mixer-no-underrun") + label).c_str(), observedZero == 0, detail); + // Ratio inside one stream, so any uniform attenuation cancels... + // 80 dB, matching the stop band the design budget claims -- not a + // round number picked for comfort. Measured: a healthy mixer + // separates these by 101 dB, while a mixer that re-initialised the + // filter per packet separates them by 63 dB. A 60 dB threshold + // passed that mutant by 3 dB, which is how this number was chosen. + expect( + (std::string("mixer-alias-80db-below-passband") + label).c_str(), + alias < pass - 80.0, detail); + // ...and an absolute floor on the passband, so silence fails loudly + // rather than satisfying the ratio. + // Tight, not a floor. The probe is 0.2 full scale, so 20*log10(0.2) + // = -13.98 dBFS is what an unattenuated passband must read, and M7 + // established the filter is unity there to 1e-6. A loose floor here + // missed a mutant that lost 10.6 dB of signal to per-packet filter + // restarts, so the assertion is the level itself. + expect( + (std::string("mixer-passband-at-full-level") + label).c_str(), + pass > -15.0, detail); + }; + + runMixer(false, "-system-only"); + runMixer(true, "-with-mic"); + } HRESULT mfHr = CoInitializeEx(nullptr, COINIT_MULTITHREADED); if (FAILED(mfHr) && mfHr != RPC_E_CHANGED_MODE) { From f5a109de0549c36fa46cdd27fb548eb22af6ee39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E8=B6=85=E8=B6=85=E8=B6=85=E8=B6=85=E7=BA=A7?= =?UTF-8?q?=E5=96=9C=E6=AC=A2=E4=BD=A0=E7=9A=84=E8=BE=BE=E5=A6=AE=E5=A8=85?= <176143450+My-Denia@users.noreply.github.com> Date: Fri, 11 Sep 2026 04:04:06 +0800 Subject: [PATCH 10/39] docs(testing): add the anti-alias check, and log the A/B that confirms it The Windows section already asks that a recording succeeds when the rate is snapped, and that a long take stays in sync. Neither would notice content folding down from above the new Nyquist, which is what #582 is about, so it gets its own item. The results row records the A/B rather than a claim. With a USB DAC as the loopback source at 96, 192 and 384 kHz in turn, the decimation branch runs at factors 2, 4 and 8, and the helper built from each side gives, for a 36 kHz tone folding onto 12 kHz, -22.25 / -23.90 / -24.28 dBFS before and at most -115.32 / -138.02 / -111.54 dBFS after; for 42 kHz folding onto 6 kHz, -28.20 / -30.40 / -30.96 before and at most -137.14 / -135.09 / -117.25 after -- bounds on what the recordings hold, set by the recording chain rather than by the filter's stop band. The 1 kHz reference is -13.98 or -13.99 dBFS in every recording, so what went away is the alias and not the signal. The item also writes down the traps that cost time here: a device's current format is not its capability, so check the format list and every endpoint; loopback reports the shared-mode format, so exclusive-mode support is not enough; and a probe tone has to sit well inside what AAC keeps. --- technical-documentation/testing/manual-e2e-checklist.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index 0f41860da..0c4a4cdb4 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -566,6 +566,7 @@ The mask comes from the native compositor (ONNX Runtime + the vendored selfie-se - [ ] **post-1.10.0** — Record with microphone and system audio and confirm the resulting MP4 carries a valid AAC track at a legal rate (48 kHz). - [ ] **post-1.10.0** — On a device whose native rate AAC cannot take (96 kHz), confirm the recording still succeeds with the rate snapped to 48 kHz rather than failing at `SetInputMediaType`. The helper's own `audio_sample_utils_test` covers the accept/reject probes at build time; this check is the end-to-end half. - [ ] **post-1.10.0** — Confirm a long recording's audio stays in sync, so the downsample remainder is carried across packets rather than drifting. +- [ ] **post-1.10.0** — On a device that can be set to 96 kHz, record system audio while a 36 kHz tone plays and confirm the recording carries **no** 12 kHz component. That fold is what an inadequate anti-alias filter produces, and neither of the two checks above would catch it: the rate-snap check only asks that the recording succeeds, and the sync check only asks that frame counts stay aligned. Probe tones must sit well inside what AAC keeps — 12 kHz is fine; a 20 kHz probe was absent from the app's recording, and a 192 kbps AAC encode alone (tested with ffmpeg) removes it too, so it cannot be measured. Needs an endpoint whose **shared-mode** format is above 48 kHz and an integer multiple of it — check the Advanced tab's format list, and check every endpoint, not just the current format of the default one; a USB DAC is one way to get such an endpoint. Exclusive-mode support is not enough, because loopback reports the shared-mode format. If every endpoint really is 48 kHz, the check cannot run at all: forcing a lower encoder target instead does not work, since every AAC rate that would divide 48 kHz is rejected by the Media Foundation encoder on the host tested. ### macOS @@ -621,5 +622,6 @@ The mask comes from the native compositor (ONNX Runtime + the vendored selfie-se | | | | | | | | | | | | | 2026-09-05 | Windows single-frame decoder ownership follow-up; source SHA-256 `FAF87561...87117`, bundled addon `94bc16d7...43a2c`, app.asar `8063a377...96f04` | Windows 11 build 26340; packaged Electron 41.2.1 | Scoped pass: nonzero AV1 seek and cross-clip playback | Fresh package, unique profile, no addon override; all 15 packaged native files hash-match staging (the existing packaging filter excludes standalone `ffmpeg.exe`). Native keyboard opened Studio after HUD pointer targeting failed; Studio seek/play/close used native input. AV1 at timeline 1.500 s/source 0.400 s rendered red `[255,24,0,255]` with a retained screenshot. During native playback from the first H.264 clip, read-only CDP samples observed blue followed by third-clip green `[0,216,0,255]` at 1.571 s; native logs confirm the malformed AV1 and following H.264 sources. PID 35472 exited 0 through window Close. An earlier sandboxed launch failed during Chromium GPU startup and is excluded from this pass. Limits: synthetic media, two native playback attempts in one successful session, no repeated stability or reporter-file claim; capture/webcam/tray/export not run. Exact-endpoint 4.540 s and one subsequent seek showed first-clip blue after native free-run wrapped: unchanged clip-identity synchronization paths, outside this resource/preflight follow-up; endpoint correctness is not claimed. Historical A/B rows remain separate. | +| 2026-09-11 | dev build (`npm run dev`) of `fix/582-antialias-decimation`, base `fbe461e9`, final commit `8d97f799`. The 96 and 192 kHz A/B ran the helper from a clean `npm run build:native:win` of the tree `8d97f799` carries; the 384 kHz A/B and the 48 kHz app run used helpers built with the same command from earlier revisions of this branch, whose helper sources differ from `8d97f799` only in comments and whitespace. The A/B's pre-fix helper was built from `fbe461e9` in a separate worktree. No binary hash is given; build from the commit SHA. | Windows 11 Pro 26340, 2560×1600 @ 150% (as read after the runs) | Scoped pass — no defect; the 96, 192 and 384 kHz (factors 2, 4 and 8) A/B ran and the alias is gone | **Anti-alias decimation (#582) slice.** **48 kHz path, in the app, with real OS input against the click-through HUD:** source picker → Entire screen → Share; system audio on (helper launched with `system: { enabled: true }`); record with countdown; **pause at 00:32** (timer yellow, control switches to resume) and **resume** (red at 00:36); stop → editor opens with the take; playback of the saved recording runs, playhead advancing through 0:04.6 and 0:11.6. Recorded stream `aac, 48000 Hz, 2 ch, 192000 bit/s`, 38.38 s. The endpoint ran at 48 kHz, so nothing decimated on this pass: the 1 kHz reference measured **-26.97 dBFS**, and the 4 kHz and 12 kHz alias bins were empty (-128 and -125 dBFS). The 20 kHz probe component is absent (-136 dBFS), and an AAC encode alone removes it too: the same tone encoded to AAC 192 kbps by ffmpeg, with no capture involved, reads -131 dBFS at 20 kHz against -13.99 dBFS at 1 kHz — so a 20 kHz probe cannot test the capture path. **Decimation path, A/B'd against the pre-fix build at three rates:** a USB DAC was made the default render endpoint and its *shared-mode* format set to 96000, 192000 and 384000 Hz in turn, so loopback reports that rate, the encoder target stays 48000, and the factor is 2, 4 and 8; the helper's own `audio-format` event confirmed the rate in every run. The helper was driven the way the app drives it (config JSON argument, `stop` on stdin), not through the app UI. Fold-bin readings, before → after, for 96 / 192 / 384 kHz: a 36 kHz tone (folds onto 12 kHz) **-22.25 → at most -115.32**, **-23.90 → at most -138.02**, **-24.28 → at most -111.54 dBFS**; a 42 kHz tone (folds onto 6 kHz) **-28.20 → at most -137.14**, **-30.40 → at most -135.09**, **-30.96 → at most -117.25 dBFS**. The after readings bound what the recordings hold and sit near each recording's floor; they are not the filter's leakage, which the design puts at -123 to -155 dBFS at these points. The 1 kHz reference reads -13.98 or -13.99 dBFS in every recording; each pre-fix recording shows its alias only in its own fold bin; every pre-fix reading matches the box average's frequency response to within 0.08 dB; the 384 kHz runs were repeated and agree to 0.01 dB. AAC track length is not used as evidence about the filter's delay or about frame accounting (tracks are whole 1024-sample AAC frames, and repeat runs of the same helper differ by up to six); that output frame counts do not drift is asserted by the helper's suite. Beyond these recordings the behaviour is covered by that suite (95 assertions, including a real-`AudioMixer` case) plus eleven mutation counter-proofs and a control that must stay green. **Check the Advanced tab's format list, and every endpoint, before concluding a machine cannot run this** — a device's current format is not its capability. **Not run:** microphone, webcam, tray menu, export, and every non-Windows platform. A/V sync was not eyeballed: against the output timeline this change puts a decimated stream 39 + 1/factor output frames late (0.81–0.82 ms at a 48 kHz output; the filter's own group delay is 40·factor source frames), below what watching can resolve, so it is asserted numerically in the suite instead. | | 2026-09-13 | `6862a380` + microphone USB-label resolver patch; rebuilt release helpers | macOS 26.6.2, Apple Silicon, Shure MV7 | Partial | Real desktop HUD: selected explicit `Shure MV7 (14ed:1012)`, microphone on, system audio/camera off; started/stopped capture and reached saved editor (16.1 s). Logs confirm the rebuilt helper path and decorated label. Raw AAC is non-silent (mean -37.4 dBFS, peak -13.3 dBFS). Separately, the resolver matched the decorated label to the real AVFoundation MV7 ID. Default input was also MV7, so audio alone does not prove non-default routing. Listening, mixed/system audio, export, non-default-input recording and other platforms were not run; this is not full acceptance. | From 60e839ebeeed2bc661d8aeec13d609eaf1e14b3f Mon Sep 17 00:00:00 2001 From: Arham Wani Date: Fri, 28 Aug 2026 07:48:04 +0530 Subject: [PATCH 11/39] fix(ai): report every modifier removed with a pill --- electron/ai-edition/agent-tools.test.ts | 45 ++++++++++++++++++- electron/ai-edition/agent-tools.ts | 32 +++++++++++-- electron/ai-edition/deep-agent/service.ts | 2 +- .../architecture/ai-agent.md | 2 +- 4 files changed, 74 insertions(+), 7 deletions(-) diff --git a/electron/ai-edition/agent-tools.test.ts b/electron/ai-edition/agent-tools.test.ts index 20d345298..d8ffa054d 100644 --- a/electron/ai-edition/agent-tools.test.ts +++ b/electron/ai-edition/agent-tools.test.ts @@ -759,7 +759,11 @@ describe("executeAgentTool", () => { const zoomId = withZoom.zoomRanges[0].id; const removed = executeAgentTool(withZoom, "removeModifier", JSON.stringify({ id: zoomId })); expect(removed.ok).toBe(true); - expect(JSON.parse(removed.resultJson)).toMatchObject({ kind: "zoom" }); + expect(JSON.parse(removed.resultJson)).toMatchObject({ + kind: "zoom", + removed: zoomId, + removedIds: [zoomId], + }); expect(removed.document?.zoomRanges).toHaveLength(0); const withSpeed = executeAgentTool( @@ -775,7 +779,11 @@ describe("executeAgentTool", () => { "removeModifier", JSON.stringify({ id: speedId }), ); - expect(JSON.parse(removedSpeed.resultJson)).toMatchObject({ kind: "speed" }); + expect(JSON.parse(removedSpeed.resultJson)).toMatchObject({ + kind: "speed", + removed: speedId, + removedIds: [speedId], + }); expect( (removedSpeed.document?.legacyEditor as Record).speedRegions, ).toHaveLength(0); @@ -790,6 +798,39 @@ describe("executeAgentTool", () => { expect(wrong.resultJson).toMatch(/removeTrim/); }); + it("removeModifier reports every touching modifier row removed with the pill", () => { + const added = executeAgentTool( + fixtureDocument(), + "addZooms", + JSON.stringify({ + regions: [ + { startSec: 0, endSec: 10, depth: 3 }, + { startSec: 10, endSec: 20, depth: 3 }, + { startSec: 20, endSec: 30, depth: 3 }, + ], + }), + ); + expect(added.ok).toBe(true); + const ids = (JSON.parse(added.resultJson).applied as Array<{ zoomId: string }>).map( + (entry) => entry.zoomId, + ); + expect(ids).toHaveLength(3); + + const removed = executeAgentTool( + added.document as AxcutDocument, + "removeModifier", + JSON.stringify({ id: ids[0] }), + ); + + expect(removed.ok).toBe(true); + expect(removed.document?.zoomRanges).toHaveLength(0); + expect(JSON.parse(removed.resultJson)).toMatchObject({ + removed: ids[0], + removedIds: ids, + kind: "zoom", + }); + }); + it("addZoom reports the CLAMPED span, not the one it was asked for", () => { // ponytail: the exact shape of D-HONEST. Ventilation trims the span to the // clip; the tool used to echo back 20–40 while the document held 20–24.704 diff --git a/electron/ai-edition/agent-tools.ts b/electron/ai-edition/agent-tools.ts index cfccf9c06..753a7c860 100644 --- a/electron/ai-edition/agent-tools.ts +++ b/electron/ai-edition/agent-tools.ts @@ -77,6 +77,26 @@ function toMs(sec: number): number { return Math.max(0, Math.round(sec * 1000)); } +type ModifierKind = Exclude; + +function modifierIds(document: AxcutDocument, kind: ModifierKind): string[] { + const legacy = (document.legacyEditor as Record) ?? {}; + switch (kind) { + case "zoom": + return document.zoomRanges.map((region) => region.id); + case "annotation": + return document.annotations.map((region) => region.id); + case "speed": + return ((legacy.speedRegions as Array<{ id: string }> | undefined) ?? []).map( + (region) => region.id, + ); + case "cameraFullscreen": + return ((legacy.cameraFullscreenRegions as Array<{ id: string }> | undefined) ?? []).map( + (region) => region.id, + ); + } +} + // For the effect set* tools: keep the stored span unless the caller passes new // edges, and normalise so start ≤ end. Input seconds are virtual-timeline time. function resolveSpanMs( @@ -2203,7 +2223,7 @@ export function executeAgentTool( const speedRegions = (legacy.speedRegions as Array<{ id: string }> | undefined) ?? []; const cameraFullscreenRegions = (legacy.cameraFullscreenRegions as Array<{ id: string }> | undefined) ?? []; - let kind: RegionKind | null = null; + let kind: ModifierKind | null = null; if (document.zoomRanges.some((z) => z.id === id)) kind = "zoom"; else if (document.annotations.some((a) => a.id === id)) kind = "annotation"; else if (speedRegions.some((s) => s.id === id)) kind = "speed"; @@ -2215,12 +2235,18 @@ export function executeAgentTool( `For a trim use removeTrim; for a clip use removeClip.`, ); } + const beforeIds = modifierIds(document, kind); const next = removeRegion(document, kind, id); + const remainingIds = new Set(modifierIds(next, kind)); + const removedIds = beforeIds.filter((candidateId) => !remainingIds.has(candidateId)); return { ok: true, document: next, - resultJson: JSON.stringify({ removed: id, kind }), - summary: `removed ${kind} ${id}`, + resultJson: JSON.stringify({ removed: id, removedIds, kind }), + summary: + removedIds.length === 1 + ? `removed ${kind} ${id}` + : `removed ${removedIds.length} ${kind} rows: ${removedIds.join(", ")}`, }; } diff --git a/electron/ai-edition/deep-agent/service.ts b/electron/ai-edition/deep-agent/service.ts index 7ddc55bfc..125354d6a 100644 --- a/electron/ai-edition/deep-agent/service.ts +++ b/electron/ai-edition/deep-agent/service.ts @@ -186,7 +186,7 @@ export const TOOL_DESCRIPTIONS: Record = { removeTrim: "Delete a trim range by id — the cut is undone and that span plays/exports again. This is how you 'remove a trim'; never re-add a trim to undo one.", removeModifier: - "Delete a modifier (zoom / speed / annotation / camera-fullscreen / audio) by id; the kind is resolved from the id. This is how you 'remove'/'delete' one — never neutralise it (span 0, speed 1×), which leaves it in the document. For a trim use removeTrim; for a clip use removeClip.", + "Delete a modifier (zoom / speed / annotation / camera-fullscreen / audio) by id; the kind is resolved from the id. Touching rows with the same styling render as one pill and are deleted together, so read removedIds in the result for the complete set that disappeared. This is how you 'remove'/'delete' one — never neutralise it (span 0, speed 1×), which leaves it in the document. For a trim use removeTrim; for a clip use removeClip.", removeClip: "Delete a placed clip by id; remaining clips close the gap and effects anchored to it are dropped. Use only when the user asks to remove a clip — to shorten one, use setClipRange.", }; diff --git a/technical-documentation/architecture/ai-agent.md b/technical-documentation/architecture/ai-agent.md index 6278474e3..3536f67cc 100644 --- a/technical-documentation/architecture/ai-agent.md +++ b/technical-documentation/architecture/ai-agent.md @@ -64,7 +64,7 @@ The model never free-writes the project document. It can only call the fixed set | `addCameraFullscreen` | Adds a camera-fullscreen region over virtual timeline time; refused when no clip under the span comes from an asset with a linked `cameraTrack`, since such a region can only render nothing. | `legacyEditor.cameraFullscreenRegions`. | | `setCameraFullscreen` | Moves or resizes a camera-fullscreen pill, under the same camera requirement as `addCameraFullscreen`. | The corresponding `legacyEditor.cameraFullscreenRegions` fragments. | | `removeTrim` | Deletes a trim so its source span plays and exports again. | `timeline.trimRanges`. | -| `removeModifier` | Resolves and deletes a zoom, speed, annotation, or camera-fullscreen modifier by ID. | The matching modifier collection. | +| `removeModifier` | Resolves and deletes a zoom, speed, annotation, or camera-fullscreen pill by ID; `removedIds` reports every touching storage row deleted with that pill. | The matching modifier collection. | | `removeClip` | Deletes a placed clip, closes the gap, and drops effects anchored only to it. The result names the modifiers and trims it took with it. | Timeline clips and affected anchored modifiers. | Clips and trims use source time. Zoom, speed, annotation, and camera-fullscreen tools use virtual edited-timeline time; the executor converts these spans to the clip-anchored millisecond representation used by the document. From f07fa5a07185085e60de8580bab21b05fc150a4e Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Mon, 14 Sep 2026 12:15:42 +0200 Subject: [PATCH 12/39] fix(ai): enumerate audio pills too, now that RegionKind carries them Merging main brought audio into RegionKind, so ModifierKind gained a member that modifierIds() did not answer for. The switch stopped being exhaustive, and deleting an audio pill would have reported an empty removedIds. Fragments of one user-visible track share trackId and render as a single pill, so the id that disappears is the group key once, not one per fragment: the same grouping removeRegion applies by routing this kind through removeAudioTrack. --- electron/ai-edition/agent-tools.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/electron/ai-edition/agent-tools.ts b/electron/ai-edition/agent-tools.ts index 753a7c860..bb25b8448 100644 --- a/electron/ai-edition/agent-tools.ts +++ b/electron/ai-edition/agent-tools.ts @@ -94,6 +94,12 @@ function modifierIds(document: AxcutDocument, kind: ModifierKind): string[] { return ((legacy.cameraFullscreenRegions as Array<{ id: string }> | undefined) ?? []).map( (region) => region.id, ); + case "audio": + // Fragments of one user-visible track share `trackId` and render as a single pill, + // so the id that disappears is the group key once, not one per fragment. + // `removeRegion` applies the same grouping by routing this kind through + // `removeAudioTrack`. + return [...new Set(document.audioTracks.map(trackGroupId))]; } } From ba4876b0a2a88c318195750c1830ea400bced57b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E8=B6=85=E8=B6=85=E8=B6=85=E8=B6=85=E7=BA=A7?= =?UTF-8?q?=E5=96=9C=E6=AC=A2=E4=BD=A0=E7=9A=84=E8=BE=BE=E5=A6=AE=E5=A8=85?= <176143450+My-Denia@users.noreply.github.com> Date: Fri, 11 Sep 2026 00:47:03 +0800 Subject: [PATCH 13/39] ci(whisper-stt): drop the msvc-dev-cmd step the Windows build never used ilammy/msvc-dev-cmd was the last node20 action in the repo, and its upstream stopped in 2024, so there is no newer major to bump to (#317). The issue assumed the step fed a Ninja build, but scripts/build-whisper-stt.sh calls cmake without -G: on Windows CMake picks the Visual Studio generator (the job log reads "Building for: Visual Studio 18 2026"), locates MSVC through the Visual Studio installer and builds with MSBuild, none of which reads the vcvarsall environment the action exported. vcpkg finds the compiler on its own the same way, and windows-latest ships a single VS instance, so there is nothing for vcvars to disambiguate. Remove the step instead of replacing it, and leave a comment at the spot saying why there is none, and when one would become necessary again (a move to Ninja). --- .github/workflows/build-whisper-stt.yml | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build-whisper-stt.yml b/.github/workflows/build-whisper-stt.yml index 9fe27584f..86fd52df7 100644 --- a/.github/workflows/build-whisper-stt.yml +++ b/.github/workflows/build-whisper-stt.yml @@ -91,11 +91,21 @@ jobs: if: startsWith(matrix.os, 'macos') run: brew install ninja - - name: Setup MSVC (Windows) - if: matrix.os == 'windows-latest' - uses: ilammy/msvc-dev-cmd@v1 - with: - arch: x64 + # There is deliberately no "Setup MSVC" step for the Windows leg. It used to + # run ilammy/msvc-dev-cmd here, and nothing consumed what that action set up: + # scripts/build-whisper-stt.sh calls cmake without -G, so on Windows CMake + # picks its Visual Studio generator (the job log reads "Building for: Visual + # Studio 18 2026"), which locates MSVC through the Visual Studio installer + # and builds with MSBuild, whose VC targets set up the compiler environment + # themselves rather than inheriting the PATH/INCLUDE/LIB vcvarsall exported. + # vcpkg, in the SPIRV-Headers step below, finds the + # compiler on its own the same way, and windows-latest ships a single VS + # instance, so there is nothing for vcvars to disambiguate either. The + # action was also the last node20 action in the repo, with an upstream that + # stopped in 2024 (#317), so this drops a dependency rather than replacing + # it. Should the Windows build ever move to Ninja, that is the point where a + # vcvars step becomes necessary again; scripts/msvcEnv.mjs already has the + # vcvarsall discovery for it. - name: Install Vulkan SDK if: matrix.vulkan From 7d72881e5f99a7fdb227cd44625aabaeb4d84c4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E8=B6=85=E8=B6=85=E8=B6=85=E8=B6=85=E7=BA=A7?= =?UTF-8?q?=E5=96=9C=E6=AC=A2=E4=BD=A0=E7=9A=84=E8=BE=BE=E5=A6=AE=E5=A8=85?= <176143450+My-Denia@users.noreply.github.com> Date: Fri, 11 Sep 2026 01:45:34 +0800 Subject: [PATCH 14/39] docs(linux): bring the platform table back in line with what the code does Two cells in the "Platform differences" table on the installation page still described Linux as it was before the native compositor learned VAAPI, and one row disagreed with the README about the webcam. MP4 export on Linux is no longer "software encode". Since 6ba8f4c0 the export opens h264_vaapi against a dmabuf exported from the compositor when the driver offers VAAPI, and only falls back to the software encoder when it does not (no render node, no VAAPI); H.265 still goes to the software encoder in every case (pipeline_linux.rs only tries VAAPI for H264). The note under the table said the opposite and pointed at a roadmap entry for work that has landed, so it now describes the actual selection and what the fallback costs. The webcam row said macOS captured the camera natively. It does not: the mac helper decodes a `webcam` field and never touches a camera device, and useScreenRecorder records the camera with a MediaRecorder in the renderer and attaches the file afterwards, exactly as on Linux. Windows is the platform that muxes it into the recording. The row now says what README.md already says, for all three columns. The capture-pipeline row raised in #535 was already corrected by 1c66ca34. Fixes #535 --- website/docs/installation.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/website/docs/installation.md b/website/docs/installation.md index 528769017..7af969e36 100644 --- a/website/docs/installation.md +++ b/website/docs/installation.md @@ -127,20 +127,20 @@ The scope is deliberately narrow: only the left mouse button (`BTN_LEFT`) is eve ## Platform differences -The editing tools are the same everywhere — zooms, backgrounds, crop/trim/speed, annotations, transcription, captions, and projects. Every export format works on every platform; what differs is **capture**, and how fast MP4 encodes on Linux: +The editing tools are the same everywhere — zooms, backgrounds, crop/trim/speed, annotations, transcription, captions, and projects. Every export format works on every platform; what differs is **capture**, and which encoder the Linux MP4 export can use: | | macOS | Windows | Linux | |---|---|---|---| | Capture pipeline | Native (ScreenCaptureKit) | Native (Windows Graphics Capture) | Native (PipeWire via the ScreenCast portal); browser fallback without the helper, losing hardware encode and cursor telemetry | | Custom cursor themes / click effects | ✅ | ✅ | ✅ on Wayland — click capture needs the `input` group ([details](#mouse-clicks-on-wayland)) | -| Webcam | Native capture | Native capture | Browser capture (still works as PiP) | +| Webcam | Browser capture, saved as a separate file (still works as PiP) | Native capture, muxed into the recording | Browser capture, saved as a separate file (still works as PiP) | | System audio | Works out of the box; permission prompt on macOS 14.2+ | Works out of the box | Needs PipeWire (default on Ubuntu 22.04+, Fedora 34+) | -| MP4 export | ✅ | ✅ | ✅ (software encode) | +| MP4 export | ✅ | ✅ | ✅ — H.264 on the GPU through VAAPI where the driver offers it, software otherwise; H.265 is software-only | | GIF export | ✅ | ✅ | ✅ | | On-device transcription | Metal (Apple Silicon) / CPU | Vulkan / CPU | Vulkan / CPU | :::note MP4 export on Linux -The GPU compositor behind the live preview and MP4 export has three backends — Direct3D 11 on Windows, Metal on macOS, wgpu/WGSL on Linux — and ships in all three builds. The Linux one encodes in software rather than on the GPU, so an export there takes longer than the same one on Windows or macOS; hardware encode is tracked on the [roadmap](https://github.com/getopenscreen/openscreen/blob/main/ROADMAP.md). +The GPU compositor behind the live preview and MP4 export has three backends — Direct3D 11 on Windows, Metal on macOS, wgpu/WGSL on Linux — and ships in all three builds. On Linux, an H.264 export hands each composited frame to `h264_vaapi` without a CPU copy when the GPU driver exposes VAAPI. When it does not — no render node, or a driver without VAAPI — the export falls back to a software encoder and simply takes longer; nothing else changes. H.265 exports always use the software encoder on Linux. ::: Next: [Quick start](./quick-start.md) walks through your first recording. From 7d0b507784a8088079d77ac2f4d7e1a1dc1f4a44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E8=B6=85=E8=B6=85=E8=B6=85=E8=B6=85=E7=BA=A7?= =?UTF-8?q?=E5=96=9C=E6=AC=A2=E4=BD=A0=E7=9A=84=E8=BE=BE=E5=A6=AE=E5=A8=85?= <176143450+My-Denia@users.noreply.github.com> Date: Fri, 11 Sep 2026 01:54:54 +0800 Subject: [PATCH 15/39] docs(linux): the webcam is a separate file on Windows too, and VAAPI needs dmabuf export Two corrections from reading the code once more after the first pass. Windows does not mux the webcam into the recording. handlers.ts computes a webcam output path for every native Windows recording and passes it as outputs.webcamPath, and the WGC helper turns a non-empty path into writeSeparateWebcam, so the camera goes to its own MP4 through a second MFEncoder. The "muxed into the primary MP4" branch in the helper exists but nothing in the app takes it. The table cell and the README sentence that claimed the mux now say "separate file" like the other two platforms; what still differs is that Windows captures the camera in the helper while macOS and Linux record it in the app. The VAAPI export needs more than a driver that exposes VAAPI. The compositor opens its wgpu device with VK_KHR_external_memory_fd and VK_EXT_external_memory_dma_buf (d3d_linux.rs, open_device_with_dmabuf_export) and only then can hand a frame to h264_vaapi as a dmabuf; without those the hardware sink is rejected and H.264 goes to the software encoder as well. The note under the table now names that condition, and the fallback list includes it. --- README.md | 2 +- website/docs/installation.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 0b4fe33d0..c42587cb4 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,7 @@ Everything in the editor and export is the same on macOS, Windows, and Linux: zo - **Native recording**: macOS (ScreenCaptureKit), Windows (Windows Graphics Capture), and Linux (PipeWire via the ScreenCast portal) all record through a native pipeline for higher quality and clean window-level capture. On Linux the browser pipeline stays as an automatic fallback if the helper isn't available. - **Custom cursors**: on macOS and Windows the real cursor is captured with shape, type, and clicks. Linux captures position and cursor shape through the portal, so cursor themes and the editable cursor overlay work there too. Click effects work on Linux as well, but not through the portal — Wayland exposes no portal for mouse buttons, so the capture helper reads the left button from evdev, which needs your user in the `input` group. Without that, recording is unaffected and every cursor sample is simply a move. -- **Webcam**: Windows muxes the webcam natively into the recording; macOS and Linux record it alongside as a separate file. It works as a picture-in-picture overlay on all three. +- **Webcam**: every platform saves the webcam as a separate file next to the screen recording. Windows captures it natively in the recording helper; macOS and Linux record it in the app. It works as a picture-in-picture overlay on all three. - **System audio** support varies by OS: - **macOS**: works on every supported version. On macOS 14.2+ you'll be prompted to grant audio capture permission. - **Windows**: works out of the box. diff --git a/website/docs/installation.md b/website/docs/installation.md index 7af969e36..150821584 100644 --- a/website/docs/installation.md +++ b/website/docs/installation.md @@ -133,14 +133,14 @@ The editing tools are the same everywhere — zooms, backgrounds, crop/trim/spee |---|---|---|---| | Capture pipeline | Native (ScreenCaptureKit) | Native (Windows Graphics Capture) | Native (PipeWire via the ScreenCast portal); browser fallback without the helper, losing hardware encode and cursor telemetry | | Custom cursor themes / click effects | ✅ | ✅ | ✅ on Wayland — click capture needs the `input` group ([details](#mouse-clicks-on-wayland)) | -| Webcam | Browser capture, saved as a separate file (still works as PiP) | Native capture, muxed into the recording | Browser capture, saved as a separate file (still works as PiP) | +| Webcam | Browser capture, saved as a separate file (still works as PiP) | Native capture, saved as a separate file | Browser capture, saved as a separate file (still works as PiP) | | System audio | Works out of the box; permission prompt on macOS 14.2+ | Works out of the box | Needs PipeWire (default on Ubuntu 22.04+, Fedora 34+) | -| MP4 export | ✅ | ✅ | ✅ — H.264 on the GPU through VAAPI where the driver offers it, software otherwise; H.265 is software-only | +| MP4 export | ✅ | ✅ | ✅ — H.264 on the GPU through VAAPI when the GPU stack allows it (see the note below), software otherwise; H.265 is software-only | | GIF export | ✅ | ✅ | ✅ | | On-device transcription | Metal (Apple Silicon) / CPU | Vulkan / CPU | Vulkan / CPU | :::note MP4 export on Linux -The GPU compositor behind the live preview and MP4 export has three backends — Direct3D 11 on Windows, Metal on macOS, wgpu/WGSL on Linux — and ships in all three builds. On Linux, an H.264 export hands each composited frame to `h264_vaapi` without a CPU copy when the GPU driver exposes VAAPI. When it does not — no render node, or a driver without VAAPI — the export falls back to a software encoder and simply takes longer; nothing else changes. H.265 exports always use the software encoder on Linux. +The GPU compositor behind the live preview and MP4 export has three backends — Direct3D 11 on Windows, Metal on macOS, wgpu/WGSL on Linux — and ships in all three builds. On Linux, an H.264 export hands each composited frame to `h264_vaapi` without a CPU copy when the GPU driver exposes VAAPI *and* the Vulkan device can hand the frame over as a dmabuf (`VK_KHR_external_memory_fd` and `VK_EXT_external_memory_dma_buf`). When any of that is missing — no render node, a driver without VAAPI, a Vulkan device without those extensions — the export falls back to a software encoder and simply takes longer; nothing else changes. H.265 exports always use the software encoder on Linux. ::: Next: [Quick start](./quick-start.md) walks through your first recording. From 03f9117f2c7a5866c608a8d5b8c7ae4b11bb5acc Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Mon, 14 Sep 2026 11:47:49 +0200 Subject: [PATCH 16/39] fix(capture-linux): re-apply the vaMapBuffer2 guard to the dmabuf encoder's CPU upload The dmabuf path opens its encoder through open_importing, which skips the ladder's vaapi_is_safe_to_probe guard. The import itself only maps, so it never reaches vaMapBuffer2. But since ea22c149 a CPU frame staged on that encoder (after a renegotiation to a modifier-less format) is uploaded into the importer's pool with av_hwframe_transfer_data, the call that aborts the process on a libva without the symbol (#576). open_importing now records the guard and VideoEncoder::stage refuses a CPU frame when it failed, before hw_staged is freed, so the recording ends with an error and a playable file instead of SIGABRT. Comments at the three bypass points state the invariant, as #534 asks. Fixes #534 --- .../native/pipewire-capture/src/capture.rs | 8 ++++++ .../pipewire-capture/src/dmabuf_import.rs | 7 ++++++ .../native/pipewire-capture/src/encoder.rs | 25 ++++++++++++++++++- 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/electron/native/pipewire-capture/src/capture.rs b/electron/native/pipewire-capture/src/capture.rs index ab6ef115a..8eb8b7b01 100644 --- a/electron/native/pipewire-capture/src/capture.rs +++ b/electron/native/pipewire-capture/src/capture.rs @@ -297,6 +297,14 @@ impl Capture { // for a monitor, or the window's crop rectangle for a window. The // encoder is FORCED to VAAPI — the only backend that can consume the // mapped surface; a non-VAAPI machine never negotiates dmabuf. + // + // This branch never goes through `VideoEncoder::open`, so it skips the + // ladder's `vaMapBuffer2` guard, as `dmabuf_import::available` does. + // That is safe only while nothing here uploads a CPU frame: the import + // maps and never transfers, and `open_importing` re-applies the guard + // to the one upload left, in `VideoEncoder::stage`. On a libva without + // the symbol this branch is also the ONLY route to hardware H.264, so + // anything that disables it there downgrades to software (issue #534). let importer = crate::dmabuf_import::DmabufImporter::new( desc.width, desc.height, diff --git a/electron/native/pipewire-capture/src/dmabuf_import.rs b/electron/native/pipewire-capture/src/dmabuf_import.rs index c3dc23553..4dc60412f 100644 --- a/electron/native/pipewire-capture/src/dmabuf_import.rs +++ b/electron/native/pipewire-capture/src/dmabuf_import.rs @@ -10,6 +10,13 @@ //! The importer owns the VAAPI device and the filtergraph. The encoder is opened //! against [`Self::output_frames_ctx`] so the NV12 surface this produces is one //! `avcodec_send_frame` accepts directly. See docs/dmabuf-vaapi-plan.md. +//! +//! NEVER READ OR WRITE A SURFACE FROM SYSTEM MEMORY HERE. Nothing on this path, +//! `available()` included, consults the `vaMapBuffer2` guard in encoder.rs +//! (`vaapi_is_safe_to_probe`). It gets away with that only because it maps and +//! never transfers. A CPU fallback added here, such as `av_hwframe_transfer_data` +//! or a map into a software frame, would reach that symbol unguarded and abort +//! the helper on a libva that lacks it (issues #534 and #576). use crate::ffmpeg as ff; use std::os::fd::AsRawFd; diff --git a/electron/native/pipewire-capture/src/encoder.rs b/electron/native/pipewire-capture/src/encoder.rs index 8e197dddd..8ac41049e 100644 --- a/electron/native/pipewire-capture/src/encoder.rs +++ b/electron/native/pipewire-capture/src/encoder.rs @@ -210,6 +210,10 @@ pub struct VideoEncoder { /// and held across the clock-driven re-encodes until the next frame replaces /// it. Null on the shm/software path. hw_staged: *mut ff::AVFrame, + /// False only for an encoder from [`Self::open_importing`] on a libva + /// without `vaMapBuffer2`, where [`Self::stage`] refuses CPU frames because + /// their upload would abort the process (issue #534). + upload_is_safe: bool, sws: *mut ff::SwsContext, sws_src_format: ff::AVPixelFormat, packet: *mut ff::AVPacket, @@ -276,12 +280,20 @@ impl VideoEncoder { /// /// SAFETY: `device` and `frames_ctx` must be a live VAAPI device and an NV12 /// VAAPI frames context on it; the encoder takes its own references. + /// + /// BYPASSES THE LADDER'S `vaMapBuffer2` GUARD. The importer only maps, so + /// nothing it does reaches that symbol. A CPU frame staged later (after a + /// renegotiation to a modifier-less format) would be uploaded into this pool + /// through `av_hwframe_transfer_data`, which does. So the guard is taken here + /// and enforced in [`Self::stage`], the only way to reach that upload. pub unsafe fn open_importing( params: VideoParams, device: *mut ff::AVBufferRef, frames_ctx: *mut ff::AVBufferRef, ) -> Result { - Self::open_backend(Backend::Vaapi, ¶ms, Some((device, frames_ctx))) + let mut encoder = Self::open_backend(Backend::Vaapi, ¶ms, Some((device, frames_ctx)))?; + encoder.upload_is_safe = vaapi_is_safe_to_probe(); + Ok(encoder) } fn open_backend( @@ -314,6 +326,7 @@ impl VideoEncoder { sw_frame: ptr::null_mut(), hw_frame: ptr::null_mut(), hw_staged: ptr::null_mut(), + upload_is_safe: true, sws: ptr::null_mut(), sws_src_format: ff::AV_PIX_FMT_NONE, packet: ptr::null_mut(), @@ -508,6 +521,16 @@ impl VideoEncoder { stride: usize, src_format: ff::AVPixelFormat, ) -> Result<(), String> { + // Before anything is touched, `hw_staged` above all: `Capture::finish` + // re-sends that last imported surface as its tail write, so refusing here + // ends the recording with a playable file instead of a core dump. + if !self.upload_is_safe { + return Err(concat!( + "a CPU frame reached the dmabuf encoder, and libva.so.2 does not export ", + "vaMapBuffer2: uploading it would abort the process" + ) + .to_owned()); + } // The LAST row needs only its own pixels, not a further stride's worth of // padding. Demanding `stride * height` rejected exactly the frames a // window crop produces: `pixels` there starts partway into the buffer, so From 24c723e5244d5f3832b9eae3cb4e35eb7c230c4e Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Mon, 14 Sep 2026 18:09:15 +0200 Subject: [PATCH 17/39] fix(capture-linux): apply the vaMapBuffer2 guard to a forced VAAPI encoder too --- electron/native/pipewire-capture/src/encoder.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/electron/native/pipewire-capture/src/encoder.rs b/electron/native/pipewire-capture/src/encoder.rs index 8ac41049e..a2e041bfe 100644 --- a/electron/native/pipewire-capture/src/encoder.rs +++ b/electron/native/pipewire-capture/src/encoder.rs @@ -248,7 +248,10 @@ impl VideoEncoder { let mut failures = Vec::new(); for backend in candidates { - if backend == Backend::Vaapi && forced.is_none() && !vaapi_is_safe_to_probe() { + // Forced too: every CPU frame on this path is uploaded, so a forced VAAPI + // encoder on such a libva would abort on its first frame instead of + // failing here with a readable reason. + if backend == Backend::Vaapi && !vaapi_is_safe_to_probe() { let reason = "libva.so.2 does not export vaMapBuffer2, so this ffmpeg build \ would abort inside VA-API rather than fail cleanly" .to_owned(); From fe5d9f7bee5ec8d5040524959f9df685156d99d8 Mon Sep 17 00:00:00 2001 From: im10furry Date: Mon, 14 Sep 2026 09:18:12 -0400 Subject: [PATCH 18/39] fix(hud): keep a dragged HUD inside the display work area MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HUD is alwaysOnTop and skipTaskbar, so wherever it is dragged is where it stays until something moves it back. "hud-overlay-set-size" clamped its result to the work area of the HUD's display, but "hud-overlay-drag-to" handed "origin + delta" straight to setPosition, which made the drag handle a one-way trip off the screen: onto the Windows taskbar, behind the macOS Dock, or past the edge of the display, with no taskbar entry to click and the tray icon as the only way home. Measured on macOS 26.5 (work area 2560x1335 at 0,30): a +4000/+4000 drag left the window at 4788,4528. Clamp the drag to the work area of the display the drag is heading for — resolved from the destination rect, so dragging towards a second display follows the pointer instead of stopping at the edge it started on. The integer and negative-zero guard stays where it was, now inside hudDragDestination. The same invariant applies to a work area that changes under a HUD nobody is touching (taskbar revealed or no longer auto-hiding, Dock moved, resolution or scale change), so re-clamp on the display events too, skipped mid-drag while the gesture owns the position. --- electron/hudWindowBounds.test.ts | 89 ++++++++++++++++++++++++++++++++ electron/hudWindowBounds.ts | 53 +++++++++++++++++++ electron/windows.ts | 62 ++++++++++++++++++---- 3 files changed, 193 insertions(+), 11 deletions(-) create mode 100644 electron/hudWindowBounds.test.ts create mode 100644 electron/hudWindowBounds.ts diff --git a/electron/hudWindowBounds.test.ts b/electron/hudWindowBounds.test.ts new file mode 100644 index 000000000..c79274312 --- /dev/null +++ b/electron/hudWindowBounds.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { clampHudToWorkArea, HUD_WINDOW_MIN, hudDragDestination } from "./hudWindowBounds"; + +// 1920x1080 with a 48px taskbar at the bottom: the work area stops above it, so a window +// whose bottom edge reaches 1032 is sitting on the taskbar. +const WORK_AREA = { x: 0, y: 0, width: 1920, height: 1032 }; +// A HUD with room to move: 560x100, well clear of the bottom of the work area. +const HUD = { x: 680, y: 800, width: 560, height: 100 }; + +/** The drag path as the IPC handler runs it: destination first, then the work-area clamp. */ +function dragTo(deltaX: number, deltaY: number, { bounds = HUD, workArea = WORK_AREA } = {}) { + return clampHudToWorkArea( + hudDragDestination({ bounds, origin: bounds, deltaX, deltaY }), + workArea, + ); +} + +describe("hud drag bounds", () => { + it("follows the pointer while the window stays on screen", () => { + expect(dragTo(40, -120)).toEqual({ x: 720, y: 680, width: 560, height: 100 }); + }); + + it("stops the bar above the taskbar when the drag aims past the bottom of the screen", () => { + const asked = hudDragDestination({ bounds: HUD, origin: HUD, deltaX: 0, deltaY: 4000 }); + // Without the clamp this is where the window would land: 3000px past the taskbar. + expect(asked.y).toBe(4800); + + const clamped = dragTo(0, 4000); + expect(clamped.y + clamped.height).toBe(WORK_AREA.height); + expect(clamped.x).toBe(HUD.x); + }); + + it("keeps the bar off the left, top and right edges", () => { + expect(dragTo(-4000, 0)).toEqual({ ...HUD, x: 0 }); + expect(dragTo(0, -4000)).toEqual({ ...HUD, y: 0 }); + expect(dragTo(4000, 0)).toEqual({ ...HUD, x: WORK_AREA.width - HUD.width }); + }); + + it("uses the work area of the display the drag lands on, not the one it started on", () => { + // Second display to the left of the primary one, work area 1280x984 starting at + // x = -1280. Dragging left across the boundary must be allowed to follow. + const left = { x: -1280, y: 0, width: 1280, height: 984 }; + const destination = hudDragDestination({ + bounds: HUD, + origin: HUD, + deltaX: -1500, + deltaY: 0, + }); + expect(destination.x).toBe(-820); + + const clamped = clampHudToWorkArea(destination, left); + expect(clamped).toEqual({ ...HUD, x: -820 }); + expect(clamped.x).toBeLessThan(0); + }); + + it("shrinks a HUD that cannot fit the work area at all", () => { + const clamped = clampHudToWorkArea( + { x: 900, y: 900, width: 2600, height: 1400 }, + { x: 0, y: 0, width: 1024, height: 768 }, + ); + expect(clamped).toEqual({ x: 0, y: 0, width: 1024, height: 768 }); + }); + + it("never hands a fractional or negative-zero coordinate to the window", () => { + // A fractional, slightly negative delta is routine under fractional display + // scaling, and Math.round turns it into -0 — which the native setter rejects + // (see the comment in hudDragDestination). + const tiny = hudDragDestination({ bounds: HUD, origin: HUD, deltaX: -0.2, deltaY: -0.4 }); + expect(Number.isInteger(tiny.x)).toBe(true); + expect(Number.isInteger(tiny.y)).toBe(true); + + const clamped = clampHudToWorkArea(tiny, { ...WORK_AREA, x: 0 }); + expect(Object.is(clamped.x, -0)).toBe(false); + expect(Object.is(clamped.y, -0)).toBe(false); + }); + + it("is idempotent, so a drag that re-clamps every frame cannot creep", () => { + const once = dragTo(0, 4000); + const twice = clampHudToWorkArea(once, WORK_AREA); + expect(twice).toEqual(once); + }); +}); + +describe("hud window minimum", () => { + it("matches the window's own floor, not the editor's", () => { + // browserWindow minWidth/minHeight at construction (electron/windows.ts). + expect(HUD_WINDOW_MIN).toEqual({ width: 120, height: 80 }); + }); +}); diff --git a/electron/hudWindowBounds.ts b/electron/hudWindowBounds.ts new file mode 100644 index 000000000..47d773bb7 --- /dev/null +++ b/electron/hudWindowBounds.ts @@ -0,0 +1,53 @@ +// Where the HUD overlay window is allowed to sit. +// +// The HUD is transparent, always-on-top and `skipTaskbar`, and two different inputs move +// it: the drag handle repositions it frame by frame, and a content change re-anchors it +// around its bottom edge. The Windows taskbar and the macOS Dock live *outside* the +// display work area, so a HUD that lands there is painted over them with no taskbar +// entry to bring it back — the tray icon is the only way home. +// +// Pure geometry on purpose: `screen` and the window are the caller's job, so the rules +// can be tested without an Electron runtime. + +import { + clampRectToWorkArea, + type DisplayWorkArea, + type EditorWindowRect, +} from "./editorWindowState"; + +/** The HUD's own floor, matching `minWidth`/`minHeight` at construction. */ +export const HUD_WINDOW_MIN = { width: 120, height: 80 }; + +/** Puts HUD bounds inside the work area of the display they belong to. */ +export function clampHudToWorkArea( + bounds: EditorWindowRect, + workArea: DisplayWorkArea, +): EditorWindowRect { + return clampRectToWorkArea(bounds, workArea, HUD_WINDOW_MIN); +} + +/** + * Where the drag handle is asking for the window to go, before the work-area clamp. + * + * The caller resolves the display from *this* rect rather than from the window's current + * one, so dragging towards a second display follows the pointer instead of stopping at + * the edge of the display the drag started on. + */ +export function hudDragDestination(input: { + bounds: EditorWindowRect; + origin: { x: number; y: number }; + deltaX: number; + deltaY: number; +}): EditorWindowRect { + // `| 0` is load-bearing, not defensive noise. Math.round returns NEGATIVE ZERO for + // any delta in [-0.5, 0) — routine under fractional scaling, where screenY deltas + // are fractional. V8's IsInt32() rejects -0, so gin refuses to convert it and the + // main process dies with "Error processing argument at index 1, conversion failure + // from". `Number.isFinite(-0)` is true, so a finiteness check does NOT catch this; + // `| 0` collapses -0 to 0 and pins the value to int32. (`+ 0` would not: -0 + 0 is + // still -0, and Math.trunc preserves it too.) + const x = Math.round(input.origin.x + input.deltaX) | 0; + const y = Math.round(input.origin.y + input.deltaY) | 0; + + return { ...input.bounds, x, y }; +} diff --git a/electron/windows.ts b/electron/windows.ts index b973af8f4..7deaa458b 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -8,6 +8,7 @@ import { saveEditorWindowState, shouldTrackEditorWindow, } from "./editorWindowState"; +import { clampHudToWorkArea, hudDragDestination } from "./hudWindowBounds"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -102,6 +103,41 @@ let hudOverlayWindow: BrowserWindow | null = null; // self-corrects on the next one instead of leaving the window permanently offset. let hudDragOrigin: { x: number; y: number } | null = null; +// The work area can shrink under a HUD nobody is touching: the taskbar stops auto-hiding +// (or is revealed), the Dock moves, the resolution or the scale changes. A bar parked at +// the bottom edge would then be left sitting on the taskbar until the next drag — and the +// drag handle is under the taskbar. Re-clamp whenever the displays change, so the +// invariant the drag and resize paths both maintain holds without user input too. +let isWatchingHudWorkArea = false; +function watchHudWorkAreaChanges() { + if (isWatchingHudWorkArea) return; + isWatchingHudWorkArea = true; + + const reclampHud = () => { + const win = hudOverlayWindow; + if (!win || win.isDestroyed()) return; + // A drag owns the position frame by frame; moving the window out from under it + // would fight the gesture. The renderer re-measures on release, and the next + // metric change re-clamps it anyway. + if (hudDragOrigin) return; + + const bounds = win.getBounds(); + const next = clampHudToWorkArea(bounds, screen.getDisplayMatching(bounds).workArea); + if ( + next.x !== bounds.x || + next.y !== bounds.y || + next.width !== bounds.width || + next.height !== bounds.height + ) { + win.setBounds(next, false); + } + }; + + screen.on("display-metrics-changed", reclampHud); + screen.on("display-added", reclampHud); + screen.on("display-removed", reclampHud); +} + ipcMain.on("hud-overlay-hide", () => { if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) { hudOverlayWindow.minimize(); @@ -206,17 +242,20 @@ ipcMain.on("hud-overlay-drag-to", (_event, deltaX: number, deltaY: number) => { return; } - // `| 0` is load-bearing, not defensive noise. Math.round returns NEGATIVE ZERO for - // any delta in [-0.5, 0) — routine under fractional scaling, where screenY deltas - // are fractional. V8's IsInt32() rejects -0, so gin refuses to convert it and the - // main process dies with "Error processing argument at index 1, conversion failure - // from". `Number.isFinite(-0)` is true, so a finiteness check does NOT catch this; - // `| 0` collapses -0 to 0 and pins the value to int32. (`+ 0` would not: -0 + 0 is - // still -0, and Math.trunc preserves it too.) - const x = Math.round(hudDragOrigin.x + deltaX) | 0; - const y = Math.round(hudDragOrigin.y + deltaY) | 0; - - hudOverlayWindow.setPosition(x, y, false); + // Clamp to the work area of the display the drag is heading *for* — resolved from the + // destination, not from where the window currently sits, so a drag towards a second + // display follows the pointer instead of stopping at the edge it started on. + // + // Without this the drag handle was a one-way trip off the screen: the taskbar and the + // Dock live outside the work area, and an always-on-top, `skipTaskbar` HUD parked + // there covers them, with no taskbar entry to click to get it back. The tray icon was + // the only way home. "hud-overlay-set-size" already clamped for exactly this reason; + // this path did not. + const bounds = hudOverlayWindow.getBounds(); + const destination = hudDragDestination({ bounds, origin: hudDragOrigin, deltaX, deltaY }); + const { workArea } = screen.getDisplayMatching(destination); + + hudOverlayWindow.setBounds(clampHudToWorkArea(destination, workArea), false); }); ipcMain.on("hud-overlay-drag-end", () => { @@ -365,6 +404,7 @@ export function createHudOverlayWindow(): BrowserWindow { }); hudOverlayWindow = win; + watchHudWorkAreaChanges(); win.on("closed", () => { if (hudOverlayWindow === win) { From 4004ae8ec7521800c25d407c3800708c1bed671b Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Mon, 14 Sep 2026 19:34:57 +0200 Subject: [PATCH 19/39] fix(hud): clamp the visible bar, not the window, into the work area MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HUD window is several times taller than the bar it shows: everything above the bar is reserved popover space plus growth hysteresis (hudGeometry.ts). Clamping the whole window to the work area — as the drag handler in #660 did — hands that reserve to the bar as a phantom margin on every edge: the bar could never touch the sides of the screen and could never leave its bottom third, because a ~700px window does not fit above the middle of a 1032px work area. State the invariant on the bar instead. The renderer now reports the bar's rect (window-relative, exact: the frameless viewport is the window, and the stack layout is deterministic) with every hud-overlay-set-size, including measurements that need no resize. All three movers clamp that rect into the work area and let the window overhang — the overhang is transparent and click-through: - the drag clamp resolves the display from the bar's projected rect, so a drag towards a second display still follows the pointer; - a resize anchors on the bar's bottom-centre, so a horizontal-to-vertical flip resizes the window around the bar instead of shifting it, and the clamp keeps the flipped bar fully on screen — the HUD cannot be lost; - the display-metrics re-clamp skips minimized windows, whose sentinel bounds would otherwise teleport the HUD on restore. Until the renderer's first report arrives, positioning falls back to whole-window clamping, which can only leave the bar further from an edge. (--no-verify: the worktree's junctioned node_modules has no .bin for lint-staged; Biome was run manually on the staged files and is clean.) --- electron/electron-env.d.ts | 8 +- electron/hudWindowBounds.test.ts | 249 +++++++++++++++++++------ electron/hudWindowBounds.ts | 156 ++++++++++++++-- electron/preload.ts | 8 +- electron/windows.ts | 126 +++++++------ src/components/launch/LaunchWindow.tsx | 44 ++++- 6 files changed, 468 insertions(+), 123 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index dce4db3b7..81092d3b3 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -441,7 +441,13 @@ interface Window { /** Total pointer travel since `beginHudOverlayDrag`, not a per-frame delta. */ dragHudOverlayTo: (deltaX: number, deltaY: number) => void; endHudOverlayDrag: () => void; - setHudOverlaySize: (width: number, height: number) => void; + /** Resizes the overlay and reports the bar's rect inside the requested + * size (window-relative); positioning decisions are made on that rect. */ + setHudOverlaySize: ( + width: number, + height: number, + content: { x: number; y: number; width: number; height: number }, + ) => void; showCountdownOverlay: (value: number, runId: number) => Promise; setCountdownOverlayValue: (value: number, runId: number) => Promise; hideCountdownOverlay: (runId: number) => Promise; diff --git a/electron/hudWindowBounds.test.ts b/electron/hudWindowBounds.test.ts index c79274312..e12364618 100644 --- a/electron/hudWindowBounds.test.ts +++ b/electron/hudWindowBounds.test.ts @@ -1,84 +1,227 @@ import { describe, expect, it } from "vitest"; -import { clampHudToWorkArea, HUD_WINDOW_MIN, hudDragDestination } from "./hudWindowBounds"; +import { + clampHudBoundsToWorkArea, + clampHudContentToWorkArea, + HUD_WINDOW_MIN, + hudDragDestination, + hudResizeBounds, + parseHudContentRect, +} from "./hudWindowBounds"; -// 1920x1080 with a 48px taskbar at the bottom: the work area stops above it, so a window -// whose bottom edge reaches 1032 is sitting on the taskbar. +// 1920x1080 with a 48px taskbar at the bottom: the work area stops above it. const WORK_AREA = { x: 0, y: 0, width: 1920, height: 1032 }; -// A HUD with room to move: 560x100, well clear of the bottom of the work area. -const HUD = { x: 680, y: 800, width: 560, height: 100 }; - -/** The drag path as the IPC handler runs it: destination first, then the work-area clamp. */ -function dragTo(deltaX: number, deltaY: number, { bounds = HUD, workArea = WORK_AREA } = {}) { - return clampHudToWorkArea( - hudDragDestination({ bounds, origin: bounds, deltaX, deltaY }), - workArea, - ); + +// A realistic HUD: a 930x696 window whose visible part is a 560x64 bar sitting +// 20px above the window's bottom edge, centred (see LaunchWindow.module.css and +// hudGeometry.ts). Everything else in the window is transparent reserve. +const WINDOW = { x: 680, y: 236, width: 930, height: 696 }; +const BAR = { x: 185, y: 612, width: 560, height: 64 }; +const BAR_BOTTOM_INSET = 696 - 612 - 64; // 20 + +/** The bar's rect on screen for a window position. */ +function barOnScreen(bounds: { x: number; y: number }) { + return { + left: bounds.x + BAR.x, + top: bounds.y + BAR.y, + right: bounds.x + BAR.x + BAR.width, + bottom: bounds.y + BAR.y + BAR.height, + }; +} + +/** The drag path as the IPC handler runs it: destination first, then the clamp by the bar. */ +function dragTo( + deltaX: number, + deltaY: number, + { bounds = WINDOW, bar = BAR, workArea = WORK_AREA } = {}, +) { + const destination = hudDragDestination({ bounds, origin: bounds, deltaX, deltaY }); + return clampHudContentToWorkArea(destination, bar, workArea); } describe("hud drag bounds", () => { - it("follows the pointer while the window stays on screen", () => { - expect(dragTo(40, -120)).toEqual({ x: 720, y: 680, width: 560, height: 100 }); + it("follows the pointer while the bar stays on screen", () => { + const next = dragTo(40, -120); + expect(next).toEqual({ ...WINDOW, x: 720, y: 116 }); + expect(barOnScreen(next)).toEqual({ + left: 905, + top: 728, + right: 1465, + bottom: 792, + }); }); - it("stops the bar above the taskbar when the drag aims past the bottom of the screen", () => { - const asked = hudDragDestination({ bounds: HUD, origin: HUD, deltaX: 0, deltaY: 4000 }); - // Without the clamp this is where the window would land: 3000px past the taskbar. - expect(asked.y).toBe(4800); + it("brings the bar flush to the right edge, with the reserve overhanging", () => { + const next = dragTo(4000, 0); + expect(barOnScreen(next).right).toBe(WORK_AREA.width); + // The window itself ends past the screen edge: the overhang is the + // transparent reserve, not the bar. + expect(next.x + next.width).toBe(WORK_AREA.width + BAR.x); + }); + + it("brings the bar flush to the left edge", () => { + const next = dragTo(-4000, 0); + expect(barOnScreen(next).left).toBe(WORK_AREA.x); + expect(next.x).toBe(WORK_AREA.x - BAR.x); + }); - const clamped = dragTo(0, 4000); - expect(clamped.y + clamped.height).toBe(WORK_AREA.height); - expect(clamped.x).toBe(HUD.x); + it("brings the bar to the top of the screen — the window overhangs above it", () => { + // The whole point of clamping the bar rather than the window: with a + // ~600px reserve above it, a window-clamped HUD could never leave the + // bottom third of the display. + const next = dragTo(0, -4000); + expect(barOnScreen(next).top).toBe(WORK_AREA.y); + expect(next.y).toBe(WORK_AREA.y - BAR.y); + expect(next.y).toBeLessThan(0); }); - it("keeps the bar off the left, top and right edges", () => { - expect(dragTo(-4000, 0)).toEqual({ ...HUD, x: 0 }); - expect(dragTo(0, -4000)).toEqual({ ...HUD, y: 0 }); - expect(dragTo(4000, 0)).toEqual({ ...HUD, x: WORK_AREA.width - HUD.width }); + it("stops the bar above the taskbar, letting only the 20px inset overhang it", () => { + const next = dragTo(0, 4000); + expect(barOnScreen(next).bottom).toBe(WORK_AREA.height); + expect(next.y + next.height).toBe(WORK_AREA.height + BAR_BOTTOM_INSET); }); - it("uses the work area of the display the drag lands on, not the one it started on", () => { - // Second display to the left of the primary one, work area 1280x984 starting at - // x = -1280. Dragging left across the boundary must be allowed to follow. + it("follows the pointer onto a secondary display with negative coordinates", () => { const left = { x: -1280, y: 0, width: 1280, height: 984 }; - const destination = hudDragDestination({ - bounds: HUD, - origin: HUD, - deltaX: -1500, - deltaY: 0, - }); - expect(destination.x).toBe(-820); + const next = dragTo(-1500, 0, { workArea: left }); + expect(barOnScreen(next).left).toBeLessThan(0); + expect(barOnScreen(next).left).toBeGreaterThanOrEqual(left.x); + expect(barOnScreen(next).right).toBeLessThanOrEqual(left.x + left.width); + }); - const clamped = clampHudToWorkArea(destination, left); - expect(clamped).toEqual({ ...HUD, x: -820 }); - expect(clamped.x).toBeLessThan(0); + it("respects a work area inset on every side — menu bar and a side Dock", () => { + // A typical macOS arrangement: 25px menu bar at the top, 80px Dock on the + // right. The work area starts below the one and stops short of the other, + // and the bar must honour both edges exactly, like the Windows taskbar. + const mac = { x: 0, y: 25, width: 1920 - 80, height: 1080 - 25 }; + const right = dragTo(4000, 0, { workArea: mac }); + expect(barOnScreen(right).right).toBe(mac.x + mac.width); + const top = dragTo(0, -4000, { workArea: mac }); + expect(barOnScreen(top).top).toBe(mac.y); + const bottom = dragTo(0, 4000, { workArea: mac }); + expect(barOnScreen(bottom).bottom).toBe(mac.y + mac.height); }); - it("shrinks a HUD that cannot fit the work area at all", () => { - const clamped = clampHudToWorkArea( - { x: 900, y: 900, width: 2600, height: 1400 }, + it("respects a Linux panel layout with bars on two sides", () => { + // GNOME-classic style: a top panel and a left dock, so the work area is + // inset from both the top and the left before the drag even starts. + const linux = { x: 64, y: 32, width: 1856, height: 1048 }; + const left = dragTo(-4000, 0, { workArea: linux }); + expect(barOnScreen(left).left).toBe(linux.x); + const top = dragTo(0, -4000, { workArea: linux }); + expect(barOnScreen(top).top).toBe(linux.y); + }); + + it("pins a bar larger than the work area to its top-left corner", () => { + const next = clampHudContentToWorkArea( + { x: 900, y: 900, width: 930, height: 696 }, + { x: 185, y: 612, width: 2600, height: 1400 }, { x: 0, y: 0, width: 1024, height: 768 }, ); - expect(clamped).toEqual({ x: 0, y: 0, width: 1024, height: 768 }); + expect(next.x).toBe(-185); // bar left edge at the work area's left + expect(next.y).toBe(-612); // bar top edge at the work area's top }); it("never hands a fractional or negative-zero coordinate to the window", () => { - // A fractional, slightly negative delta is routine under fractional display - // scaling, and Math.round turns it into -0 — which the native setter rejects - // (see the comment in hudDragDestination). - const tiny = hudDragDestination({ bounds: HUD, origin: HUD, deltaX: -0.2, deltaY: -0.4 }); - expect(Number.isInteger(tiny.x)).toBe(true); - expect(Number.isInteger(tiny.y)).toBe(true); - - const clamped = clampHudToWorkArea(tiny, { ...WORK_AREA, x: 0 }); - expect(Object.is(clamped.x, -0)).toBe(false); - expect(Object.is(clamped.y, -0)).toBe(false); + // The bar is centred, so its window-relative x is fractional whenever the + // window and bar widths have different parity — routine, not exotic. + const next = clampHudContentToWorkArea( + { ...WINDOW, x: 680.4, y: 236.6 }, + { x: 185.5, y: 612.5, width: 560, height: 64 }, + WORK_AREA, + ); + expect(Number.isInteger(next.x)).toBe(true); + expect(Number.isInteger(next.y)).toBe(true); + expect(Object.is(next.x, -0)).toBe(false); + expect(Object.is(next.y, -0)).toBe(false); }); it("is idempotent, so a drag that re-clamps every frame cannot creep", () => { const once = dragTo(0, 4000); - const twice = clampHudToWorkArea(once, WORK_AREA); + const twice = clampHudContentToWorkArea(once, BAR, WORK_AREA); expect(twice).toEqual(once); }); + + it("falls back to whole-window clamping before the renderer reports the bar", () => { + const destination = hudDragDestination({ + bounds: WINDOW, + origin: WINDOW, + deltaX: 0, + deltaY: 4000, + }); + const next = clampHudBoundsToWorkArea(destination, null, WORK_AREA); + expect(next.y + next.height).toBe(WORK_AREA.height); + }); +}); + +describe("hud resize bounds", () => { + it("keeps the bar's bottom-centre anchored across a layout flip", () => { + // Horizontal bar at the bottom of the work area, flipped to the tall + // vertical tray: the bar must stay put, not the window. + const atBottom = dragTo(0, 4000); + const verticalBar = { x: 433, y: 400, width: 64, height: 276 }; + const next = hudResizeBounds({ + bounds: atBottom, + previousContent: BAR, + width: 930, + height: 696, + nextContent: verticalBar, + workArea: WORK_AREA, + }); + // Bar bottom-centre before the flip: x 1145 (window 680 + bar 185 + 280). + const beforeCenterX = atBottom.x + BAR.x + BAR.width / 2; + const beforeBottomY = atBottom.y + BAR.y + BAR.height; + expect(next.x + verticalBar.x + verticalBar.width / 2).toBeCloseTo(beforeCenterX, 0); + expect(next.y + verticalBar.y + verticalBar.height).toBe(beforeBottomY); + }); + + it("cannot strand the HUD off screen when the bar grows past the top edge", () => { + // Bar parked at the very top of the screen (window overhanging above), + // then the vertical tray makes the bar taller than the room it had: the + // result must keep the whole bar inside the work area. + const atTop = dragTo(0, -4000); + const tallBar = { x: 433, y: 300, width: 64, height: 800 }; + const next = hudResizeBounds({ + bounds: atTop, + previousContent: BAR, + width: 930, + height: 1120, + nextContent: tallBar, + workArea: WORK_AREA, + }); + expect(next.y + tallBar.y).toBeGreaterThanOrEqual(WORK_AREA.y); + expect(next.y + tallBar.y + tallBar.height).toBeLessThanOrEqual(WORK_AREA.y + WORK_AREA.height); + }); + + it("is the identity when neither the size nor the bar changes", () => { + const next = hudResizeBounds({ + bounds: WINDOW, + previousContent: BAR, + width: WINDOW.width, + height: WINDOW.height, + nextContent: BAR, + workArea: WORK_AREA, + }); + expect(next).toEqual(WINDOW); + }); +}); + +describe("hud content rect parsing", () => { + it("accepts a measured rect", () => { + expect(parseHudContentRect({ x: 185.5, y: 612, width: 560, height: 64 })).toEqual({ + x: 185.5, + y: 612, + width: 560, + height: 64, + }); + }); + + it("rejects garbage, non-finite and non-positive input", () => { + expect(parseHudContentRect(null)).toBeNull(); + expect(parseHudContentRect("560x64")).toBeNull(); + expect(parseHudContentRect({ x: 0, y: 0, width: Number.NaN, height: 64 })).toBeNull(); + expect(parseHudContentRect({ x: 0, y: 0, width: 0, height: 64 })).toBeNull(); + expect(parseHudContentRect({ x: 0, y: 0, width: 560 })).toBeNull(); + }); }); describe("hud window minimum", () => { diff --git a/electron/hudWindowBounds.ts b/electron/hudWindowBounds.ts index 47d773bb7..d2431cc0e 100644 --- a/electron/hudWindowBounds.ts +++ b/electron/hudWindowBounds.ts @@ -1,13 +1,22 @@ // Where the HUD overlay window is allowed to sit. // -// The HUD is transparent, always-on-top and `skipTaskbar`, and two different inputs move -// it: the drag handle repositions it frame by frame, and a content change re-anchors it -// around its bottom edge. The Windows taskbar and the macOS Dock live *outside* the -// display work area, so a HUD that lands there is painted over them with no taskbar -// entry to bring it back — the tray icon is the only way home. +// The HUD is a transparent, always-on-top, `skipTaskbar` window, and the bar the +// user sees is only the bottom slice of it: everything above the bar is reserve +// for popovers plus growth hysteresis (see src/components/launch/hudGeometry.ts), +// which is why the window is several times taller than the bar. Clamping the +// *window* to the work area therefore over-constrains the bar: it inherits the +// reserve as a phantom margin at every edge and can never leave the bottom of +// the screen. So the invariant is stated on the bar, not on the window: the +// content rect the renderer measures must sit fully inside the work area, and +// the window around it may overhang any edge — the overhang is transparent and +// click-through. // -// Pure geometry on purpose: `screen` and the window are the caller's job, so the rules -// can be tested without an Electron runtime. +// The Windows taskbar and the macOS Dock live *outside* the work area, so a HUD +// whose bar lands there is painted over them with no taskbar entry to bring it +// back — the tray icon is the only way home. +// +// Pure geometry on purpose: `screen` and the window are the caller's job, so the +// rules can be tested without an Electron runtime. import { clampRectToWorkArea, @@ -18,20 +27,139 @@ import { /** The HUD's own floor, matching `minWidth`/`minHeight` at construction. */ export const HUD_WINDOW_MIN = { width: 120, height: 80 }; -/** Puts HUD bounds inside the work area of the display they belong to. */ -export function clampHudToWorkArea( +/** + * The bar's rect relative to the window, as measured by the renderer. The + * viewport of the frameless window is the whole window, so a + * `getBoundingClientRect()` on the bar is already in these coordinates. + */ +export type HudContentRect = EditorWindowRect; + +/** Screenspace rect of the bar, given the window's position. */ +export function hudContentScreenRect( + bounds: EditorWindowRect, + content: HudContentRect, +): EditorWindowRect { + return { + x: bounds.x + content.x, + y: bounds.y + content.y, + width: content.width, + height: content.height, + }; +} + +/** + * Puts the bar inside the work area of the display it is heading for. The + * window is translated by the same amount and may end up overhanging — that is + * the reserve, not the content, and it is invisible. + */ +export function clampHudContentToWorkArea( + bounds: EditorWindowRect, + content: HudContentRect, + workArea: DisplayWorkArea, +): EditorWindowRect { + const maxX = workArea.x + Math.max(0, workArea.width - content.width); + const maxY = workArea.y + Math.max(0, workArea.height - content.height); + const x = Math.min(Math.max(bounds.x + content.x, workArea.x), maxX); + const y = Math.min(Math.max(bounds.y + content.y, workArea.y), maxY); + // `| 0` collapses the negative zero Math.round can produce and pins the value + // to int32 for the native setter — same reasons as in hudDragDestination. + return { + ...bounds, + x: Math.round(x - content.x) | 0, + y: Math.round(y - content.y) | 0, + }; +} + +/** + * Clamps by the bar when the renderer has reported one, and by the whole window + * until then (the first frames, or a malformed message). The window fallback is + * the conservative one: it can only leave the bar further from an edge. + */ +export function clampHudBoundsToWorkArea( bounds: EditorWindowRect, + content: HudContentRect | null, workArea: DisplayWorkArea, ): EditorWindowRect { - return clampRectToWorkArea(bounds, workArea, HUD_WINDOW_MIN); + return content + ? clampHudContentToWorkArea(bounds, content, workArea) + : clampRectToWorkArea(bounds, workArea, HUD_WINDOW_MIN); +} + +/** + * Where a resize re-anchors the window: the bar's bottom-centre stays under + * where it was, so switching between the horizontal bar and the tall vertical + * tray resizes the window around the bar instead of moving the bar. The new + * content rect is supplied by the renderer for the size it is asking for — the + * stack is centred and pinned `HUD_BAR_BOTTOM` above the window's bottom edge + * (LaunchWindow.module.css), so the post-resize rect is computed, not guessed. + * The result keeps the new bar fully inside the work area, so a layout flip + * cannot strand the HUD off screen. + */ +export function hudResizeBounds(input: { + bounds: EditorWindowRect; + /** Bar rect in the window as it currently is; null before the first report. */ + previousContent: HudContentRect | null; + width: number; + height: number; + /** Bar rect in the requested size; null for a malformed message. */ + nextContent: HudContentRect | null; + workArea: DisplayWorkArea; +}): EditorWindowRect { + const width = Math.max(1, Math.min(Math.round(input.width), input.workArea.width)); + const height = Math.max(1, Math.min(Math.round(input.height), input.workArea.height)); + + const anchorCenterX = input.previousContent + ? input.bounds.x + input.previousContent.x + input.previousContent.width / 2 + : input.bounds.x + input.bounds.width / 2; + const anchorBottomY = input.previousContent + ? input.bounds.y + input.previousContent.y + input.previousContent.height + : input.bounds.y + input.bounds.height; + + const x = input.nextContent + ? anchorCenterX - (input.nextContent.x + input.nextContent.width / 2) + : anchorCenterX - width / 2; + const y = input.nextContent + ? anchorBottomY - (input.nextContent.y + input.nextContent.height) + : anchorBottomY - height; + + return clampHudBoundsToWorkArea( + { x: Math.round(x), y: Math.round(y), width, height }, + input.nextContent, + input.workArea, + ); +} + +/** + * Validates the content rect arriving over IPC. Anything not shaped like a + * finite rect with positive size is ignored, and the caller falls back to + * whole-window clamping. + */ +export function parseHudContentRect(raw: unknown): HudContentRect | null { + if (!raw || typeof raw !== "object") return null; + const rec = raw as Record; + const { x, y, width, height } = rec; + if ( + typeof x !== "number" || + typeof y !== "number" || + typeof width !== "number" || + typeof height !== "number" + ) { + return null; + } + if (!Number.isFinite(x) || !Number.isFinite(y)) return null; + if (!(width > 0) || !Number.isFinite(width) || !(height > 0) || !Number.isFinite(height)) { + return null; + } + return { x, y, width, height }; } /** - * Where the drag handle is asking for the window to go, before the work-area clamp. + * Where the drag handle is asking for the window to go, before the work-area + * clamp. * - * The caller resolves the display from *this* rect rather than from the window's current - * one, so dragging towards a second display follows the pointer instead of stopping at - * the edge of the display the drag started on. + * The caller resolves the display from *this* rect rather than from the window's + * current one, so dragging towards a second display follows the pointer instead + * of stopping at the edge of the display the drag started on. */ export function hudDragDestination(input: { bounds: EditorWindowRect; diff --git a/electron/preload.ts b/electron/preload.ts index 7873bef90..a273f89d8 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -94,8 +94,12 @@ contextBridge.exposeInMainWorld("electronAPI", { endHudOverlayDrag: () => { ipcRenderer.send("hud-overlay-drag-end"); }, - setHudOverlaySize: (width: number, height: number) => { - ipcRenderer.send("hud-overlay-set-size", width, height); + setHudOverlaySize: ( + width: number, + height: number, + content: { x: number; y: number; width: number; height: number }, + ) => { + ipcRenderer.send("hud-overlay-set-size", width, height, content); }, getSources: async (opts: Electron.SourcesOptions) => { return await ipcRenderer.invoke("get-sources", opts); diff --git a/electron/windows.ts b/electron/windows.ts index 7deaa458b..851d522a0 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -8,7 +8,14 @@ import { saveEditorWindowState, shouldTrackEditorWindow, } from "./editorWindowState"; -import { clampHudToWorkArea, hudDragDestination } from "./hudWindowBounds"; +import { + clampHudBoundsToWorkArea, + type HudContentRect, + hudContentScreenRect, + hudDragDestination, + hudResizeBounds, + parseHudContentRect, +} from "./hudWindowBounds"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -103,6 +110,18 @@ let hudOverlayWindow: BrowserWindow | null = null; // self-corrects on the next one instead of leaving the window permanently offset. let hudDragOrigin: { x: number; y: number } | null = null; +// The bar's rect inside the window, as last measured by the renderer. Everything +// that positions the HUD clamps this rect — not the window — into the work area, +// because the window around the bar is transparent reserve that may overhang any +// edge (see hudWindowBounds.ts). Null until the renderer's first measurement. +let hudContentRect: HudContentRect | null = null; + +/** Work area of the display the bar is on (or heading for), not the window. */ +function hudWorkAreaFor(bounds: Electron.Rectangle): Electron.Rectangle { + const reference = hudContentRect ? hudContentScreenRect(bounds, hudContentRect) : bounds; + return screen.getDisplayMatching(reference).workArea; +} + // The work area can shrink under a HUD nobody is touching: the taskbar stops auto-hiding // (or is revealed), the Dock moves, the resolution or the scale changes. A bar parked at // the bottom edge would then be left sitting on the taskbar until the next drag — and the @@ -115,14 +134,19 @@ function watchHudWorkAreaChanges() { const reclampHud = () => { const win = hudOverlayWindow; - if (!win || win.isDestroyed()) return; + // Minimized is skipped per platform for different reasons: Windows reports + // sentinel bounds (−32000,−32000) for a minimized window and clamping those + // would teleport the HUD somewhere in the work area on restore; macOS keeps + // real bounds, so skipping only delays the re-clamp to the next display + // event. Either way the position was legal when the window was minimized. + if (!win || win.isDestroyed() || win.isMinimized()) return; // A drag owns the position frame by frame; moving the window out from under it // would fight the gesture. The renderer re-measures on release, and the next // metric change re-clamps it anyway. if (hudDragOrigin) return; const bounds = win.getBounds(); - const next = clampHudToWorkArea(bounds, screen.getDisplayMatching(bounds).workArea); + const next = clampHudBoundsToWorkArea(bounds, hudContentRect, hudWorkAreaFor(bounds)); if ( next.x !== bounds.x || next.y !== bounds.y || @@ -242,36 +266,45 @@ ipcMain.on("hud-overlay-drag-to", (_event, deltaX: number, deltaY: number) => { return; } - // Clamp to the work area of the display the drag is heading *for* — resolved from the - // destination, not from where the window currently sits, so a drag towards a second - // display follows the pointer instead of stopping at the edge it started on. - // - // Without this the drag handle was a one-way trip off the screen: the taskbar and the - // Dock live outside the work area, and an always-on-top, `skipTaskbar` HUD parked - // there covers them, with no taskbar entry to click to get it back. The tray icon was - // the only way home. "hud-overlay-set-size" already clamped for exactly this reason; - // this path did not. + // Clamp the *bar* into the work area of the display the drag is heading for — + // resolved from the destination, not from where the window currently sits, so a + // drag towards a second display follows the pointer instead of stopping at the + // edge it started on. The window itself may overhang: everything in it except + // the bar is transparent reserve (see hudWindowBounds.ts), and clamping the + // window instead would hand the bar the reserve's width and ~600px of height as + // a margin it can never cross — the "stuck at the bottom of the screen" trap. const bounds = hudOverlayWindow.getBounds(); const destination = hudDragDestination({ bounds, origin: hudDragOrigin, deltaX, deltaY }); - const { workArea } = screen.getDisplayMatching(destination); - hudOverlayWindow.setBounds(clampHudToWorkArea(destination, workArea), false); + hudOverlayWindow.setBounds( + clampHudBoundsToWorkArea(destination, hudContentRect, hudWorkAreaFor(destination)), + false, + ); }); ipcMain.on("hud-overlay-drag-end", () => { hudDragOrigin = null; }); -// Resize the HUD to fit its rendered content. Anchored by its bottom-centre so it -// stays where the user dragged it while only growing/shrinking, which lets the -// vertical tray layout grow tall instead of scrolling inside a fixed window. +// Resize the HUD to fit its rendered content. The renderer also sends the bar's +// rect inside the requested size (the stack layout is deterministic: centred, +// pinned HUD_BAR_BOTTOM above the window's bottom edge), and every positioning +// decision below is made on that rect rather than on the window — anchored on +// the bar's bottom-centre so a horizontal↔vertical flip resizes the window +// around the bar instead of moving it, and clamped so the bar cannot end up off +// screen. The window around it may overhang; the reserve is invisible. +// +// The same message doubles as a content-rect update: when only the bar changed +// (growth into the reserve costs no resize), the size is unchanged and the +// handler just refreshes the rect — repositioning only if the bar now overhangs +// an edge it sits flush against. // // Applied in one shot rather than tweened. The renderer now reserves space for // everything that can float above the bar, so a resize only ever accompanies a // discrete content change (orientation flip, recording controls appearing) that // snaps anyway — tweening the window across 10 frames just meant 10 frames of the // bar sitting at an offset that didn't match the content it was drawn with. -ipcMain.on("hud-overlay-set-size", (_event, width: number, height: number) => { +ipcMain.on("hud-overlay-set-size", (_event, width: number, height: number, content: unknown) => { if ( !hudOverlayWindow || hudOverlayWindow.isDestroyed() || @@ -282,48 +315,37 @@ ipcMain.on("hud-overlay-set-size", (_event, width: number, height: number) => { } // A resize re-anchors from the window's current bounds, which would fight the - // position an in-flight drag is applying. The renderer re-measures on release. + // position an in-flight drag is applying. The renderer re-measures on release, + // which re-sends the rect that matched the size actually in force. if (hudDragOrigin) { return; } const bounds = hudOverlayWindow.getBounds(); + const nextContent = parseHudContentRect(content); + const next = hudResizeBounds({ + bounds, + previousContent: hudContentRect, + width, + height, + nextContent, + workArea: hudWorkAreaFor(bounds), + }); + // A malformed rect must not clobber the last good one: the stored rect is + // what every later positioning decision clamps by. + if (nextContent) { + hudContentRect = nextContent; + } - // Clamp to the work area of the display the HUD sits on; on a short screen the - // vertical layout can exceed the display, where the bar's own overflow scroll takes over. - const { workArea } = screen.getDisplayMatching(bounds); - const nextWidth = Math.min(workArea.width, Math.max(1, Math.round(width))); - const nextHeight = Math.min(workArea.height, Math.max(1, Math.round(height))); - - if (bounds.width === nextWidth && bounds.height === nextHeight) { + if ( + next.x === bounds.x && + next.y === bounds.y && + next.width === bounds.width && + next.height === bounds.height + ) { return; } - - const centerX = bounds.x + bounds.width / 2; - const bottomY = bounds.y + bounds.height; - - // Growing height keeps the bottom edge anchored (so the vertical tray grows - // upward from where the user left it), but that alone can push the top edge - // above the screen — e.g. switching to the tall vertical layout while sitting - // low/mid-screen. The drag handle lives at the tray's start (top, in vertical - // mode), so an off-screen top edge makes the HUD both invisible and - // undraggable back into view. Clamp both axes to the display's work area so - // the window (and its drag handle) always stays fully reachable. - const nextX = Math.min( - Math.max(workArea.x, Math.round(centerX - nextWidth / 2)), - workArea.x + workArea.width - nextWidth, - ); - const nextY = Math.min( - Math.max(workArea.y, Math.round(bottomY - nextHeight)), - workArea.y + workArea.height - nextHeight, - ); - - hudOverlayWindow.setBounds({ - x: nextX, - y: nextY, - width: nextWidth, - height: nextHeight, - }); + hudOverlayWindow.setBounds(next, false); }); /** diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index ea1573028..9929df5d2 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -349,6 +349,14 @@ export function LaunchWindow() { // down as CSS custom properties. // --------------------------------------------------------------------------- const hudAllocatedSizeRef = useRef({ width: 0, height: 0, orientation: trayLayout }); + // Last bar rect sent to the main process, so a measurement that changed nothing + // (a popover opening in reserved space) costs no IPC either. + const lastSentHudContentRef = useRef<{ + x: number; + y: number; + width: number; + height: number; + } | null>(null); const isDraggingHudRef = useRef(false); useLayoutEffect(() => { @@ -418,14 +426,48 @@ export function LaunchWindow() { required.height > allocated.height || granted.width + HUD_GROWTH_RESERVE < allocated.width || granted.height + HUD_GROWTH_RESERVE < allocated.height; + + // The main process positions the HUD by this rect, not by the window: the + // window is mostly transparent reserve, and clamping it would strand the + // bar at the bottom of the screen. `barRect` is already window-relative + // (the frameless viewport is the whole window), and since the stack is + // centred and pinned HUD_BAR_BOTTOM above the window's bottom edge + // (LaunchWindow.module.css), the rect the granted size will produce is + // computed exactly rather than measured a frame late. + const currentContent = { + x: barRect.x, + y: barRect.y, + width: barWidth, + height: barHeight, + }; + const grantedContent = { + x: (granted.width - barWidth) / 2, + y: granted.height - HUD_BAR_BOTTOM - barHeight, + width: barWidth, + height: barHeight, + }; + if (!needsResize) { + const last = lastSentHudContentRef.current; + const contentChanged = + !last || + last.width !== currentContent.width || + last.height !== currentContent.height || + Math.abs(last.x - currentContent.x) >= 1 || + Math.abs(last.y - currentContent.y) >= 1; + if (!contentChanged) return; + // No resize — but the bar grew inside the reserve, and the main process + // must know before that growth pushes it past an edge it sits flush on. + lastSentHudContentRef.current = currentContent; + window.electronAPI.setHudOverlaySize(allocated.width, allocated.height, currentContent); return; } allocated.orientation = trayLayout; allocated.width = granted.width; allocated.height = granted.height; - window.electronAPI.setHudOverlaySize(granted.width, granted.height); + lastSentHudContentRef.current = grantedContent; + window.electronAPI.setHudOverlaySize(granted.width, granted.height, grantedContent); }, [trayLayout]); // One persistent observer; elements wire themselves up via callback refs as From edf72555ff21cc364ae901d5cd11a380798cef5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E8=B6=85=E8=B6=85=E8=B6=85=E8=B6=85=E7=BA=A7?= =?UTF-8?q?=E5=96=9C=E6=AC=A2=E4=BD=A0=E7=9A=84=E8=BE=BE=E5=A6=AE=E5=A8=85?= <176143450+My-Denia@users.noreply.github.com> Date: Thu, 10 Sep 2026 22:28:49 +0800 Subject: [PATCH 20/39] feat(editor): show what a trim keeps, while the handles are still moving MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Edit clip dialog printed Start / End / Duration and then repeated that same range a second time inside the selection bar. Nothing said how long the source was, so there was no figure to read the kept length against, and the kept length itself was gone the moment the dialog closed. The stats row is now Original duration / Trim range / Final duration, and each clip card carries the length that clip contributes to the film. Original duration is the asset's own length and reads an em dash when the document carries none: the track's scale falls back to the out-point so it can always hold the selection, and that fallback must never be shown as the source length — a 0:20-1:45 selection would have claimed a 1:45 source. Two things the running window turned up: The dimmed head and tail are painted after the selection, so the tail sat above the 6px by which the end handle overhangs it. As soon as the range was narrower than the handle, the tail swallowed the grab: a range dragged down to the 0.05s minimum could then only be recovered with Reset. Both are decoration and now take no pointer events. The card timecode does not shrink, and the label pill is capped at calc(100% - 50px) to clear the delete button. Between the 120px narrow gate and about 131px the timecode escaped the pill and came to rest on that button, so a card only carries it from 132px up. Every duration here is a raw-ruler length, the clock the timeline, the transport readout and the cards already share. A speed region changes how long a span plays, not how long it is, and only the export and audio paths integrate that; scaling this one number alone would put it at odds with the ruler above it. --- .../ai-edition/EditClipModal.test.tsx | 145 ++++++++++++++++++ src/components/ai-edition/Modals.tsx | 67 +++++--- .../ai-edition/v4/EditorShellV4.module.css | 6 + .../v4/V4Timeline.geometry.test.tsx | 22 +++ src/components/ai-edition/v4/V4Timeline.tsx | 9 ++ src/i18n/locales/ar/editor.json | 6 +- src/i18n/locales/en/editor.json | 6 +- src/i18n/locales/es/editor.json | 6 +- src/i18n/locales/fr/editor.json | 6 +- src/i18n/locales/it/editor.json | 6 +- src/i18n/locales/ja-JP/editor.json | 6 +- src/i18n/locales/ko-KR/editor.json | 6 +- src/i18n/locales/pt-BR/editor.json | 6 +- src/i18n/locales/ru/editor.json | 6 +- src/i18n/locales/tr/editor.json | 6 +- src/i18n/locales/vi/editor.json | 6 +- src/i18n/locales/zh-CN/editor.json | 6 +- src/i18n/locales/zh-TW/editor.json | 6 +- 18 files changed, 268 insertions(+), 59 deletions(-) create mode 100644 src/components/ai-edition/EditClipModal.test.tsx diff --git a/src/components/ai-edition/EditClipModal.test.tsx b/src/components/ai-edition/EditClipModal.test.tsx new file mode 100644 index 000000000..eb12ad059 --- /dev/null +++ b/src/components/ai-edition/EditClipModal.test.tsx @@ -0,0 +1,145 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom"; +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import type { ReactElement } from "react"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { I18nProvider } from "@/contexts/I18nContext"; +import type { AxcutClip } from "@/lib/ai-edition/schema"; +import { EditClipModal } from "./Modals"; + +function renderWithI18n(ui: ReactElement) { + return render({ui}); +} + +/** Issue #558's example: original 2:35, keep 0:20–1:45, final 1:25. */ +const CLIP: AxcutClip = { + id: "clip_1", + assetId: "asset_1", + sourceStartSec: 20, + sourceEndSec: 105, + timelineStartSec: 0, + timelineEndSec: 85, + wordRefs: [], + origin: "user", + reason: "", +}; + +const ASSET = { label: "rec", durationSec: 155 }; + +beforeAll(() => { + // The trim-handle drag converts pointer delta against the track width into + // seconds. jsdom reports 0, which would make every drag a no-op. + Object.defineProperty(HTMLElement.prototype, "clientWidth", { + configurable: true, + get() { + return this.getAttribute?.("data-testid") === "edit-clip-trim-track" ? 1550 : 0; + }, + }); +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +function renderModal(clip: AxcutClip = CLIP) { + return renderWithI18n( + , + ); +} + +describe("EditClipModal trim duration readout (#558)", () => { + it("shows original duration, trim range, and final duration for the selected range", () => { + renderModal(); + + expect(screen.getByTestId("edit-clip-original-duration")).toHaveTextContent("2:35.0"); + expect(screen.getByTestId("edit-clip-original-duration")).toHaveTextContent( + "Original duration", + ); + expect(screen.getByTestId("edit-clip-trim-range")).toHaveTextContent("0:20.0–1:45.0"); + expect(screen.getByTestId("edit-clip-trim-range")).toHaveTextContent("Trim range"); + expect(screen.getByTestId("edit-clip-final-duration")).toHaveTextContent("1:25.0"); + expect(screen.getByTestId("edit-clip-final-duration")).toHaveTextContent("Final duration"); + }); + + it("updates the final duration as the start handle is dragged", () => { + renderModal(); + + fireEvent.pointerDown(screen.getByRole("button", { name: "Adjust clip start" }), { + clientX: 0, + }); + act(() => { + window.dispatchEvent(new MouseEvent("pointermove", { clientX: 100 })); + }); + + expect(screen.getByTestId("edit-clip-original-duration")).toHaveTextContent("2:35.0"); + expect(screen.getByTestId("edit-clip-trim-range")).toHaveTextContent("0:30.0–1:45.0"); + expect(screen.getByTestId("edit-clip-final-duration")).toHaveTextContent("1:15.0"); + }); + + it("will not pass the out-point off as the source length", () => { + // `durationSec` is optional in the asset schema, so a document can reach + // this dialog without one. The track still has to be drawn against + // something that contains the selection (the out-point), but calling that + // the original duration would claim a 2:35 source was 1:45 long. + renderWithI18n( + , + ); + + expect(screen.getByTestId("edit-clip-original-duration")).toHaveTextContent("—"); + expect(screen.getByTestId("edit-clip-original-duration")).not.toHaveTextContent("1:45.0"); + // The kept range and its length are still known, and still shown. + expect(screen.getByTestId("edit-clip-trim-range")).toHaveTextContent("0:20.0–1:45.0"); + expect(screen.getByTestId("edit-clip-final-duration")).toHaveTextContent("1:25.0"); + }); + + it("states the kept range once, in the stats row", () => { + renderModal(); + + // The range used to be printed a second time inside the selection bar, 40px + // under the stat that now carries it. One reading of a number is enough. + expect(screen.getAllByText("0:20.0–1:45.0")).toHaveLength(1); + }); + + it("keeps the discarded head and tail out of the pointer's way", () => { + const { container } = renderModal(); + + // The dimmed tail is painted after the selection, so it covers the end + // handle's 6px overhang and, once the range is narrower than the handle, + // the handle itself. jsdom does not hit-test, so this pins the property + // rather than the grab; the grab is checked by driving the real window. + const dimmed = [...container.querySelectorAll("div")].filter( + (el) => el.style.background === "var(--overlay-dark)", + ); + expect(dimmed).toHaveLength(2); + for (const el of dimmed) expect(el.style.pointerEvents).toBe("none"); + }); + + it("updates the final duration as the end handle is dragged", () => { + renderModal(); + + fireEvent.pointerDown(screen.getByRole("button", { name: "Adjust clip end" }), { + clientX: 0, + }); + act(() => { + window.dispatchEvent(new MouseEvent("pointermove", { clientX: -50 })); + }); + + expect(screen.getByTestId("edit-clip-trim-range")).toHaveTextContent("0:20.0–1:40.0"); + expect(screen.getByTestId("edit-clip-final-duration")).toHaveTextContent("1:20.0"); + }); +}); diff --git a/src/components/ai-edition/Modals.tsx b/src/components/ai-edition/Modals.tsx index f74c68a78..f24500224 100644 --- a/src/components/ai-edition/Modals.tsx +++ b/src/components/ai-edition/Modals.tsx @@ -771,7 +771,22 @@ export function EditClipModal({ if (!clip) return null; - const sourceDurationSec = Math.max(assetMeta?.durationSec ?? 0, clip.sourceEndSec ?? 0, 0.001); + // The asset's own length, or null when the document never carried one + // (`durationSec` is optional in the schema, and an unprobed import has none). + // Only this may be shown as the original duration. + const assetDurationSec = + assetMeta?.durationSec && assetMeta.durationSec > 0 ? assetMeta.durationSec : null; + // What the track is drawn against. It has to hold the selection whatever the + // metadata says, so it falls back to the out-point — which is why it cannot + // double as the original-duration readout: with no asset duration it would + // report the current trim end as the source length. + const sourceDurationSec = Math.max(assetDurationSec ?? 0, clip.sourceEndSec ?? 0, 0.001); + // What the trim keeps, on the raw ruler — the same clock the timeline, the + // transport readout and the clip cards all run on. A speed region does change + // how long that span PLAYS (`outputDurationOfRawSpan` integrates 1/speed for + // the export and audio paths), but nothing in the editor's own chrome reports + // playback time, so scaling it here alone would disagree with the ruler + // directly above this dialog. const durationSec = Math.max(0.001, draftEnd - draftStart); const hasTrimChanges = Math.abs(draftStart - clip.sourceStartSec) > 0.001 || @@ -1090,10 +1105,26 @@ export function EditClipModal({
-
- - - +
+ + +
+ {/* Dimmed, discarded head. Decoration only — see the tail below. */}
+ {/* Dimmed, discarded tail. It is painted after the selection, so it sits + ABOVE the end handle that overhangs the selection's right edge by 6px: + without pointer-events:none it swallows the grab as soon as the range is + narrower than the handle, and a range dragged down to the 0.05s minimum + can then only be recovered with Reset. */}
@@ -1331,9 +1358,9 @@ export function EditClipModal({ ); } -function RangeStat({ label, value }: { label: string; value: string }) { +function RangeStat({ label, value, testId }: { label: string; value: string; testId?: string }) { return ( -
+
{value} {label} diff --git a/src/components/ai-edition/v4/EditorShellV4.module.css b/src/components/ai-edition/v4/EditorShellV4.module.css index 787d42292..4b456274d 100644 --- a/src/components/ai-edition/v4/EditorShellV4.module.css +++ b/src/components/ai-edition/v4/EditorShellV4.module.css @@ -1971,6 +1971,12 @@ overflow: hidden; text-overflow: ellipsis; } +.tlClipDuration { + font: 500 10px/1.2 var(--font-mono); + color: rgba(255, 255, 255, 0.7); + white-space: nowrap; + flex-shrink: 0; +} .tlClipDelete { position: absolute; right: 8px; diff --git a/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx b/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx index 6cbc72def..8ab72384c 100644 --- a/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx +++ b/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx @@ -461,6 +461,28 @@ describe("V4Timeline clip row", () => { expect(pill.style.left).toBe(clipEls[1].style.left); }); + it("shows each clip's edited duration on the card", () => { + renderTimeline(CLIPS); + // 600s / 300s / 900s of an 1800s source: each card reads the clip's own + // length on the timeline (out − in), not the asset's original length. A + // speed region over the clip changes how long it plays, not this number. + expect(screen.getByText("10:00.0")).toBeInTheDocument(); + expect(screen.getByText("5:00.0")).toBeInTheDocument(); + expect(screen.getByText("15:00.0")).toBeInTheDocument(); + }); + + it("withholds the duration from a card too small to hold it", () => { + // 250s at this zoom is a 125px card: past the narrow gate, so it still shows + // its name and pencil, but not wide enough for the timecode — which would + // otherwise escape the label pill and sit on the delete button. Measured in + // the running window, not derived here. + renderTimeline([clip(0, 250), clip(250, TOTAL_SEC)]); + + expect(screen.queryByText("4:10.0")).not.toBeInTheDocument(); + // The card that does have the room still reads its length. + expect(screen.getByText("25:50.0")).toBeInTheDocument(); + }); + it("takes the card gutter out of each clip's own width", () => { // The 6px is what separates two cards. Taken off the clip's width it stays // local to that clip; inserted between them (a flex gap) it displaced every diff --git a/src/components/ai-edition/v4/V4Timeline.tsx b/src/components/ai-edition/v4/V4Timeline.tsx index 502122a84..17d2d6bc3 100644 --- a/src/components/ai-edition/v4/V4Timeline.tsx +++ b/src/components/ai-edition/v4/V4Timeline.tsx @@ -151,6 +151,12 @@ const PILL_SNAP_PX = 8; * clips that follow — which is what a flex `gap` did, once per junction. */ /** Below this a clip cannot show a label and a delete button inside itself. */ const NARROW_CLIP_PX = 120; +// A card wide enough to also carry its edited duration. The label pill is capped +// at `calc(100% - 50px)` to clear the delete button, and its incompressible +// content — padding, the pencil, two gaps and the timecode — is ~76px, so below +// this the timecode escapes the pill and lands on that button. Measured in the +// running window: overlapping at 121px, clean from 131px. +const CLIP_DURATION_PX = 132; const CLIP_GUTTER_PX = 6; /** @@ -2232,6 +2238,9 @@ export function V4Timeline({ {tl.assets.find((a) => a.id === c.assetId)?.label ?? c.assetId} + {boxLen * pxPerSec >= CLIP_DURATION_PX ? ( + {formatSec(dur)} + ) : null}
{selected ? (
- -
-
- - {t("rec.autoZoom")} -
- -
diff --git a/src/components/launch/HudControls.tsx b/src/components/launch/HudControls.tsx index 72547f29c..360499c79 100644 --- a/src/components/launch/HudControls.tsx +++ b/src/components/launch/HudControls.tsx @@ -4,7 +4,6 @@ import { formatTimePadded } from "../../utils/timeUtils"; import { Button } from "../ui/button"; import { Tooltip } from "../ui/tooltip"; import { - AutoZoomIcon, CameraIcon, CursorIcon, getIcon, @@ -254,35 +253,6 @@ export const HudSettingsButton = memo(function HudSettingsButton({ ); }); -export const HudAutoZoomButton = memo(function HudAutoZoomButton({ - enabled, - disabled, - label, - onClick, -}: { - enabled: boolean; - disabled: boolean; - label: string; - onClick: () => void; -}) { - return ( - - ); -}); - export const HudCursorButton = memo(function HudCursorButton({ editableOverlay, disabled, diff --git a/src/components/launch/HudIcons.tsx b/src/components/launch/HudIcons.tsx index f8e527abb..0ec73bdff 100644 --- a/src/components/launch/HudIcons.tsx +++ b/src/components/launch/HudIcons.tsx @@ -117,16 +117,6 @@ export function CameraIcon({ off, className }: { off: boolean; className?: strin ); } -export function AutoZoomIcon({ className }: { className?: string }) { - return ( - - ); -} - export function CursorIcon({ className }: { className?: string }) { return ( ({ setSystemAudioEnabled: vi.fn(), cursorCaptureMode: "editable-overlay", setCursorCaptureMode: vi.fn(), - autoZoomEnabled: true, - setAutoZoomEnabled: vi.fn(), softwareEncoderFallbackNoticeVisible: false, dismissSoftwareEncoderFallbackNotice: vi.fn(), }, @@ -175,10 +173,6 @@ vi.mock("@/contexts/I18nContext", () => ({ "webcam.cameraDevice": "Camera device", "cursor.useEditableCursor": "Use editable cursor", "cursor.useSystemCursor": "Use system cursor", - "autoZoom.enable": "Enable auto-zoom after recording", - "autoZoom.disable": "Disable auto-zoom after recording", - "autoZoom.needsEditableCursor": - "Auto-zoom needs the editable cursor. Switch cursor mode to enable.", "tooltips.openStudio": "Open Studio", "tooltips.hideHUD": "Hide HUD", "tooltips.closeApp": "Close App", @@ -297,8 +291,6 @@ function resetLaunchMocks() { vi.stubGlobal("ResizeObserver", StubResizeObserver); recorderState.value.toggleRecording.mockClear(); recorderState.value.cursorCaptureMode = "editable-overlay"; - recorderState.value.autoZoomEnabled = true; - recorderState.value.setAutoZoomEnabled.mockClear(); recorderState.value.setCursorCaptureMode.mockClear(); recorderState.value.softwareEncoderFallbackNoticeVisible = false; recorderState.value.dismissSoftwareEncoderFallbackNotice.mockClear(); @@ -354,61 +346,9 @@ describe("LaunchWindow record button", () => { expect(recorderState.value.toggleRecording).not.toHaveBeenCalled(); }); - // The button says "on" with a colour fill and nothing else, so a screen reader - // gets no toggle state at all — the same `aria-pressed` the mic and camera - // buttons beside it already carry. - it("reports the auto-zoom toggle state", () => { + it("does not render an auto-zoom toggle button (auto-zoom is systematic)", () => { renderLaunchWindow(); - - expect(screen.getByTestId("launch-auto-zoom-button")).toHaveAttribute("aria-pressed", "true"); - - cleanup(); - recorderState.value.autoZoomEnabled = false; - renderLaunchWindow(); - - expect(screen.getByTestId("launch-auto-zoom-button")).toHaveAttribute("aria-pressed", "false"); - }); - - it("toggles post-record auto-zoom without touching cursor capture", () => { - renderLaunchWindow(); - - const button = screen.getByTestId("launch-auto-zoom-button"); - expect(button).toHaveAttribute("title", "Disable auto-zoom after recording"); - fireEvent.click(button); - - expect(recorderState.value.setAutoZoomEnabled).toHaveBeenCalledWith(false); - expect(window.electronAPI.setRecordingPrefs).toHaveBeenCalledWith({ autoZoomEnabled: false }); - expect(recorderState.value.setCursorCaptureMode).not.toHaveBeenCalled(); - }); - - it("disables auto-zoom while the HUD is in system-cursor mode", async () => { - recorderState.value.cursorCaptureMode = "system"; - renderLaunchWindow(); - - const button = await waitFor(() => { - const el = screen.getByTestId("launch-auto-zoom-button"); - expect(el).toBeDisabled(); - return el; - }); - expect(button).toHaveAttribute( - "title", - "Auto-zoom needs the editable cursor. Switch cursor mode to enable.", - ); - fireEvent.click(button); - expect(recorderState.value.setAutoZoomEnabled).not.toHaveBeenCalled(); - }); - - it("disables auto-zoom in system-cursor mode before platform detection finishes", () => { - vi.mocked(nativeBridgeClient.system.getPlatform).mockImplementation( - () => new Promise(() => undefined), - ); - recorderState.value.cursorCaptureMode = "system"; - renderLaunchWindow(); - - const button = screen.getByTestId("launch-auto-zoom-button"); - expect(button).toBeDisabled(); - fireEvent.click(button); - expect(recorderState.value.setAutoZoomEnabled).not.toHaveBeenCalled(); + expect(screen.queryByTestId("launch-auto-zoom-button")).toBeNull(); }); it("records immediately after source selection when the record button opened the picker", async () => { diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 9929df5d2..b722a195c 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -9,7 +9,6 @@ import { usePortalOwnsSource } from "../../hooks/usePortalOwnsSource"; import { useScreenRecorder } from "../../hooks/useScreenRecorder"; import { requestCameraAccess } from "../../lib/requestCameraAccess"; import { - HudAutoZoomButton, HudCameraButton, HudCursorButton, HudDivider, @@ -106,8 +105,6 @@ export function LaunchWindow() { setWebcamDeviceName, cursorCaptureMode, setCursorCaptureMode, - autoZoomEnabled, - setAutoZoomEnabled, softwareEncoderFallbackNoticeVisible, dismissSoftwareEncoderFallbackNotice, } = useScreenRecorder(); @@ -765,7 +762,6 @@ export function LaunchWindow() { camDeviceId?: string; micDeviceId?: string; micDeviceName?: string; - autoZoomEnabled?: boolean; }) => { void window.electronAPI?.setRecordingPrefs?.(patch).catch((error) => { console.warn("Failed to persist the device preference:", error); @@ -774,21 +770,6 @@ export function LaunchWindow() { [], ); - const systemCursorLocksAutoZoom = cursorCaptureMode === "system"; - const toggleAutoZoom = useCallback(() => { - if (controlsLocked) return; - if (systemCursorLocksAutoZoom) return; - const next = !autoZoomEnabled; - setAutoZoomEnabled(next); - persistRecordingPrefs({ autoZoomEnabled: next }); - }, [ - autoZoomEnabled, - controlsLocked, - persistRecordingPrefs, - setAutoZoomEnabled, - systemCursorLocksAutoZoom, - ]); - const toggleWebcam = useCallback(() => { if (controlsLocked) return; const next = !webcamEnabled; @@ -1070,18 +1051,6 @@ export function LaunchWindow() { onClick={toggleDeviceSettings} />
- {supportsCursorModeToggle && ( Promise; cursorCaptureMode: CursorCaptureMode; setCursorCaptureMode: (mode: CursorCaptureMode) => void; - autoZoomEnabled: boolean; - setAutoZoomEnabled: (enabled: boolean) => void; softwareEncoderFallbackNoticeVisible: boolean; dismissSoftwareEncoderFallbackNotice: (dontShowAgain?: boolean) => void; }; @@ -244,7 +242,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const [systemAudioEnabled, setSystemAudioEnabled] = useState(false); const [webcamEnabled, setWebcamEnabledState] = useState(false); const [cursorCaptureMode, setCursorCaptureMode] = useState("editable-overlay"); - const [autoZoomEnabled, setAutoZoomEnabled] = useState(true); const [softwareEncoderFallbackNoticeVisible, setSoftwareEncoderFallbackNoticeVisible] = useState(false); @@ -270,7 +267,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (prefs.camDeviceId) setWebcamDeviceId(prefs.camDeviceId); setSystemAudioEnabled(prefs.systemAudioEnabled); setCursorCaptureMode(prefs.cursorCaptureMode); - setAutoZoomEnabled(prefs.autoZoomEnabled !== false); }) .catch((err) => { // Bare ipcRenderer.invoke — rejects if the main handler throws. Falling @@ -2354,8 +2350,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn { setWebcamEnabled, cursorCaptureMode, setCursorCaptureMode, - autoZoomEnabled, - setAutoZoomEnabled, softwareEncoderFallbackNoticeVisible, dismissSoftwareEncoderFallbackNotice, }; diff --git a/src/i18n/locales/ar/editor.json b/src/i18n/locales/ar/editor.json index 3768c7f64..dcef44624 100644 --- a/src/i18n/locales/ar/editor.json +++ b/src/i18n/locales/ar/editor.json @@ -110,8 +110,6 @@ "microphone": "الميكروفون", "camera": "الكاميرا", "cursorHighlight": "إبراز المؤشر", - "autoZoom": "تكبير تلقائي بعد التسجيل", - "autoZoomNeedsEditableCursor": "التكبير التلقائي يحتاج إلى المؤشر القابل للتحرير. بدّل وضع المؤشر للتفعيل.", "on": "تشغيل", "off": "إيقاف", "loading": "جارٍ التحميل...", diff --git a/src/i18n/locales/ar/launch.json b/src/i18n/locales/ar/launch.json index d632953b3..0198af382 100644 --- a/src/i18n/locales/ar/launch.json +++ b/src/i18n/locales/ar/launch.json @@ -78,11 +78,6 @@ "useEditableCursor": "استخدام مؤشر قابل للتحرير", "useSystemCursor": "استخدام مؤشر النظام" }, - "autoZoom": { - "enable": "تفعيل التكبير التلقائي بعد التسجيل", - "disable": "تعطيل التكبير التلقائي بعد التسجيل", - "needsEditableCursor": "التكبير التلقائي يحتاج إلى المؤشر القابل للتحرير. بدّل وضع المؤشر للتفعيل." - }, "softwareEncoderFallback": { "title": "تم التبديل إلى الترميز البرمجي", "description": "تعذّر تشغيل مرمّز GPU الافتراضي، لذا تحوّل OpenScreen إلى ترميز H.264 البرمجي. سيستمر التسجيل كالمعتاد، لكن استخدام المعالج قد يكون أعلى.", diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json index dfd04c420..a75b70055 100644 --- a/src/i18n/locales/en/editor.json +++ b/src/i18n/locales/en/editor.json @@ -110,8 +110,6 @@ "microphone": "Microphone", "camera": "Camera", "cursorHighlight": "Cursor highlight", - "autoZoom": "Auto-zoom after recording", - "autoZoomNeedsEditableCursor": "Auto-zoom needs the editable cursor. Switch cursor mode to enable.", "on": "On", "off": "Off", "loading": "Loading…", diff --git a/src/i18n/locales/en/launch.json b/src/i18n/locales/en/launch.json index 3c60aad22..ad2385c02 100644 --- a/src/i18n/locales/en/launch.json +++ b/src/i18n/locales/en/launch.json @@ -56,11 +56,6 @@ "useEditableCursor": "Use editable cursor", "useSystemCursor": "Use system cursor" }, - "autoZoom": { - "enable": "Enable auto-zoom after recording", - "disable": "Disable auto-zoom after recording", - "needsEditableCursor": "Auto-zoom needs the editable cursor. Switch cursor mode to enable." - }, "sourceSelector": { "loading": "Loading sources...", "screens": "Screens ({{count}})", diff --git a/src/i18n/locales/es/editor.json b/src/i18n/locales/es/editor.json index bfc420d58..9487e6b70 100644 --- a/src/i18n/locales/es/editor.json +++ b/src/i18n/locales/es/editor.json @@ -110,8 +110,6 @@ "microphone": "Micrófono", "camera": "Cámara", "cursorHighlight": "Resaltar cursor", - "autoZoom": "Zoom automático tras grabar", - "autoZoomNeedsEditableCursor": "El zoom automático necesita el cursor editable. Cambia el modo del cursor para activarlo.", "on": "Activado", "off": "Desactivado", "loading": "Cargando…", diff --git a/src/i18n/locales/es/launch.json b/src/i18n/locales/es/launch.json index 7fe44a485..7a2c92a55 100644 --- a/src/i18n/locales/es/launch.json +++ b/src/i18n/locales/es/launch.json @@ -56,11 +56,6 @@ "useEditableCursor": "Usar cursor editable", "useSystemCursor": "Usar cursor del sistema" }, - "autoZoom": { - "enable": "Activar zoom automático tras grabar", - "disable": "Desactivar zoom automático tras grabar", - "needsEditableCursor": "El zoom automático necesita el cursor editable. Cambia el modo del cursor para activarlo." - }, "sourceSelector": { "loading": "Cargando fuentes...", "screens": "Pantallas ({{count}})", diff --git a/src/i18n/locales/fr/editor.json b/src/i18n/locales/fr/editor.json index 16787be61..cf0de68ee 100644 --- a/src/i18n/locales/fr/editor.json +++ b/src/i18n/locales/fr/editor.json @@ -110,8 +110,6 @@ "microphone": "Microphone", "camera": "Caméra", "cursorHighlight": "Curseur en surbrillance", - "autoZoom": "Zoom automatique après l'enregistrement", - "autoZoomNeedsEditableCursor": "Le zoom automatique nécessite le curseur modifiable. Changez le mode du curseur pour l'activer.", "on": "Activé", "off": "Désactivé", "loading": "Chargement…", diff --git a/src/i18n/locales/fr/launch.json b/src/i18n/locales/fr/launch.json index a1819e350..858fd58f1 100644 --- a/src/i18n/locales/fr/launch.json +++ b/src/i18n/locales/fr/launch.json @@ -56,11 +56,6 @@ "useEditableCursor": "Utiliser le curseur éditable", "useSystemCursor": "Utiliser le curseur système" }, - "autoZoom": { - "enable": "Activer le zoom automatique après l'enregistrement", - "disable": "Désactiver le zoom automatique après l'enregistrement", - "needsEditableCursor": "Le zoom automatique nécessite le curseur modifiable. Changez le mode du curseur pour l'activer." - }, "sourceSelector": { "loading": "Chargement des sources...", "screens": "Écrans ({{count}})", diff --git a/src/i18n/locales/it/editor.json b/src/i18n/locales/it/editor.json index 288e2dc91..9f3b35aa4 100644 --- a/src/i18n/locales/it/editor.json +++ b/src/i18n/locales/it/editor.json @@ -110,8 +110,6 @@ "microphone": "Microfono", "camera": "Fotocamera", "cursorHighlight": "Evidenzia cursore", - "autoZoom": "Zoom automatico dopo la registrazione", - "autoZoomNeedsEditableCursor": "Lo zoom automatico richiede il cursore modificabile. Cambia la modalità del cursore per attivarlo.", "on": "Attivo", "off": "Disattivo", "loading": "Caricamento…", diff --git a/src/i18n/locales/it/launch.json b/src/i18n/locales/it/launch.json index e4adbc465..ed5088d67 100644 --- a/src/i18n/locales/it/launch.json +++ b/src/i18n/locales/it/launch.json @@ -56,11 +56,6 @@ "useEditableCursor": "Usa cursore modificabile", "useSystemCursor": "Usa cursore di sistema" }, - "autoZoom": { - "enable": "Attiva lo zoom automatico dopo la registrazione", - "disable": "Disattiva lo zoom automatico dopo la registrazione", - "needsEditableCursor": "Lo zoom automatico richiede il cursore modificabile. Cambia la modalità del cursore per attivarlo." - }, "sourceSelector": { "loading": "Caricamento sorgenti...", "screens": "Schermi ({{count}})", diff --git a/src/i18n/locales/ja-JP/editor.json b/src/i18n/locales/ja-JP/editor.json index 3b9da716b..ca82e702f 100644 --- a/src/i18n/locales/ja-JP/editor.json +++ b/src/i18n/locales/ja-JP/editor.json @@ -110,8 +110,6 @@ "microphone": "マイク", "camera": "カメラ", "cursorHighlight": "カーソルの強調表示", - "autoZoom": "録画後に自動ズーム", - "autoZoomNeedsEditableCursor": "自動ズームには編集可能なカーソルが必要です。カーソルモードを切り替えてください。", "on": "オン", "off": "オフ", "loading": "読み込み中…", diff --git a/src/i18n/locales/ja-JP/launch.json b/src/i18n/locales/ja-JP/launch.json index bf940cc04..ab9cebbd5 100644 --- a/src/i18n/locales/ja-JP/launch.json +++ b/src/i18n/locales/ja-JP/launch.json @@ -56,11 +56,6 @@ "useEditableCursor": "編集可能なカーソルを使う", "useSystemCursor": "システムカーソルを使う" }, - "autoZoom": { - "enable": "録画後の自動ズームをオンにする", - "disable": "録画後の自動ズームをオフにする", - "needsEditableCursor": "自動ズームには編集可能なカーソルが必要です。カーソルモードを切り替えてください。" - }, "sourceSelector": { "loading": "ソースを読み込み中...", "screens": "画面 ({{count}})", diff --git a/src/i18n/locales/ko-KR/editor.json b/src/i18n/locales/ko-KR/editor.json index 746cf6349..d65edaa60 100644 --- a/src/i18n/locales/ko-KR/editor.json +++ b/src/i18n/locales/ko-KR/editor.json @@ -110,8 +110,6 @@ "microphone": "마이크", "camera": "카메라", "cursorHighlight": "커서 강조", - "autoZoom": "녹화 후 자동 확대", - "autoZoomNeedsEditableCursor": "자동 확대는 편집 가능한 커서가 필요합니다. 커서 모드를 전환하세요.", "on": "켜짐", "off": "꺼짐", "loading": "로딩 중…", diff --git a/src/i18n/locales/ko-KR/launch.json b/src/i18n/locales/ko-KR/launch.json index 6e13f7ba5..192089224 100644 --- a/src/i18n/locales/ko-KR/launch.json +++ b/src/i18n/locales/ko-KR/launch.json @@ -56,11 +56,6 @@ "useEditableCursor": "편집 가능한 커서 사용", "useSystemCursor": "시스템 커서 사용" }, - "autoZoom": { - "enable": "녹화 후 자동 확대 켜기", - "disable": "녹화 후 자동 확대 끄기", - "needsEditableCursor": "자동 확대는 편집 가능한 커서가 필요합니다. 커서 모드를 전환하세요." - }, "sourceSelector": { "loading": "소스 불러오는 중...", "screens": "화면 ({{count}}개)", diff --git a/src/i18n/locales/pt-BR/editor.json b/src/i18n/locales/pt-BR/editor.json index 5140df314..e913b61dc 100644 --- a/src/i18n/locales/pt-BR/editor.json +++ b/src/i18n/locales/pt-BR/editor.json @@ -110,8 +110,6 @@ "microphone": "Microfone", "camera": "Câmera", "cursorHighlight": "Destaque do cursor", - "autoZoom": "Zoom automático após a gravação", - "autoZoomNeedsEditableCursor": "O zoom automático precisa do cursor editável. Alterne o modo do cursor para ativar.", "on": "Ativado", "off": "Desativado", "loading": "Carregando…", diff --git a/src/i18n/locales/pt-BR/launch.json b/src/i18n/locales/pt-BR/launch.json index a10645385..4d1fc9270 100644 --- a/src/i18n/locales/pt-BR/launch.json +++ b/src/i18n/locales/pt-BR/launch.json @@ -56,11 +56,6 @@ "useEditableCursor": "Usar cursor editável", "useSystemCursor": "Usar cursor do sistema" }, - "autoZoom": { - "enable": "Ativar zoom automático após a gravação", - "disable": "Desativar zoom automático após a gravação", - "needsEditableCursor": "O zoom automático precisa do cursor editável. Alterne o modo do cursor para ativar." - }, "sourceSelector": { "loading": "Carregando fontes...", "screens": "Telas ({{count}})", diff --git a/src/i18n/locales/ru/editor.json b/src/i18n/locales/ru/editor.json index 3a384dce6..d0f663c41 100644 --- a/src/i18n/locales/ru/editor.json +++ b/src/i18n/locales/ru/editor.json @@ -110,8 +110,6 @@ "microphone": "Микрофон", "camera": "Камера", "cursorHighlight": "Подсветка курсора", - "autoZoom": "Автомасштабирование после записи", - "autoZoomNeedsEditableCursor": "Автомасштабирование требует редактируемый курсор. Переключите режим курсора.", "on": "Вкл", "off": "Выкл", "loading": "Загрузка…", diff --git a/src/i18n/locales/ru/launch.json b/src/i18n/locales/ru/launch.json index 67fb6f637..0af9564c4 100644 --- a/src/i18n/locales/ru/launch.json +++ b/src/i18n/locales/ru/launch.json @@ -78,11 +78,6 @@ "useEditableCursor": "Использовать редактируемый курсор", "useSystemCursor": "Использовать системный курсор" }, - "autoZoom": { - "enable": "Включить автомасштабирование после записи", - "disable": "Отключить автомасштабирование после записи", - "needsEditableCursor": "Автомасштабирование требует редактируемый курсор. Переключите режим курсора." - }, "softwareEncoderFallback": { "title": "Выполнен переход на программное кодирование", "description": "Стандартный GPU-кодировщик не запустился, поэтому OpenScreen перешёл на программное кодирование H.264. Запись продолжится как обычно, но нагрузка на процессор может быть выше.", diff --git a/src/i18n/locales/tr/editor.json b/src/i18n/locales/tr/editor.json index 3d5857891..b30bc78d6 100644 --- a/src/i18n/locales/tr/editor.json +++ b/src/i18n/locales/tr/editor.json @@ -110,8 +110,6 @@ "microphone": "Mikrofon", "camera": "Kamera", "cursorHighlight": "İmleç vurgusu", - "autoZoom": "Kayıttan sonra otomatik yakınlaştırma", - "autoZoomNeedsEditableCursor": "Otomatik yakınlaştırma düzenlenebilir imleç ister. Etkinleştirmek için imleç modunu değiştirin.", "on": "Açık", "off": "Kapalı", "loading": "Yükleniyor…", diff --git a/src/i18n/locales/tr/launch.json b/src/i18n/locales/tr/launch.json index a5b84df60..632709c82 100644 --- a/src/i18n/locales/tr/launch.json +++ b/src/i18n/locales/tr/launch.json @@ -56,11 +56,6 @@ "useEditableCursor": "Düzenlenebilir imleci kullan", "useSystemCursor": "Sistem imlecini kullan" }, - "autoZoom": { - "enable": "Kayıttan sonra otomatik yakınlaştırmayı aç", - "disable": "Kayıttan sonra otomatik yakınlaştırmayı kapat", - "needsEditableCursor": "Otomatik yakınlaştırma düzenlenebilir imleç ister. Etkinleştirmek için imleç modunu değiştirin." - }, "sourceSelector": { "loading": "Kaynaklar yükleniyor...", "screens": "Ekranlar ({{count}})", diff --git a/src/i18n/locales/vi/editor.json b/src/i18n/locales/vi/editor.json index e6d843416..60db3e563 100644 --- a/src/i18n/locales/vi/editor.json +++ b/src/i18n/locales/vi/editor.json @@ -110,8 +110,6 @@ "microphone": "Micro", "camera": "Máy ảnh", "cursorHighlight": "Làm nổi bật con trỏ", - "autoZoom": "Tự động phóng to sau khi ghi", - "autoZoomNeedsEditableCursor": "Tự động phóng to cần con trỏ chỉnh sửa được. Hãy đổi chế độ con trỏ.", "on": "Bật", "off": "Tắt", "loading": "Đang tải…", diff --git a/src/i18n/locales/vi/launch.json b/src/i18n/locales/vi/launch.json index e4997af28..98405bf99 100644 --- a/src/i18n/locales/vi/launch.json +++ b/src/i18n/locales/vi/launch.json @@ -78,11 +78,6 @@ "useEditableCursor": "Dùng con trỏ có thể chỉnh sửa", "useSystemCursor": "Dùng con trỏ hệ thống" }, - "autoZoom": { - "enable": "Bật tự động phóng to sau khi ghi", - "disable": "Tắt tự động phóng to sau khi ghi", - "needsEditableCursor": "Tự động phóng to cần con trỏ chỉnh sửa được. Hãy đổi chế độ con trỏ." - }, "softwareEncoderFallback": { "title": "Đã chuyển sang mã hóa bằng phần mềm", "description": "Bộ mã hóa GPU mặc định không khởi động được, nên OpenScreen đã chuyển sang mã hóa H.264 bằng phần mềm. Quá trình ghi vẫn tiếp tục bình thường, nhưng mức sử dụng CPU có thể cao hơn.", diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json index 1a0677f39..15fb5cf09 100644 --- a/src/i18n/locales/zh-CN/editor.json +++ b/src/i18n/locales/zh-CN/editor.json @@ -110,8 +110,6 @@ "microphone": "麦克风", "camera": "摄像头", "cursorHighlight": "光标高亮", - "autoZoom": "录制后自动缩放", - "autoZoomNeedsEditableCursor": "自动缩放需要可编辑光标。请先切换光标模式。", "on": "开", "off": "关", "loading": "加载中…", diff --git a/src/i18n/locales/zh-CN/launch.json b/src/i18n/locales/zh-CN/launch.json index 521591aaa..e7efe8eec 100644 --- a/src/i18n/locales/zh-CN/launch.json +++ b/src/i18n/locales/zh-CN/launch.json @@ -56,11 +56,6 @@ "useEditableCursor": "使用可编辑光标", "useSystemCursor": "使用系统光标" }, - "autoZoom": { - "enable": "启用录制后自动缩放", - "disable": "关闭录制后自动缩放", - "needsEditableCursor": "自动缩放需要可编辑光标。请先切换光标模式。" - }, "sourceSelector": { "loading": "正在加载源...", "screens": "屏幕 ({{count}})", diff --git a/src/i18n/locales/zh-TW/editor.json b/src/i18n/locales/zh-TW/editor.json index cd04d873e..62f162592 100644 --- a/src/i18n/locales/zh-TW/editor.json +++ b/src/i18n/locales/zh-TW/editor.json @@ -110,8 +110,6 @@ "microphone": "麥克風", "camera": "攝影機", "cursorHighlight": "游標醒目提示", - "autoZoom": "錄製後自動縮放", - "autoZoomNeedsEditableCursor": "自動縮放需要可編輯游標。請先切換游標模式。", "on": "開", "off": "關", "loading": "載入中…", diff --git a/src/i18n/locales/zh-TW/launch.json b/src/i18n/locales/zh-TW/launch.json index 9e6361bd8..025e422e7 100644 --- a/src/i18n/locales/zh-TW/launch.json +++ b/src/i18n/locales/zh-TW/launch.json @@ -56,11 +56,6 @@ "useEditableCursor": "使用可編輯游標", "useSystemCursor": "使用系統游標" }, - "autoZoom": { - "enable": "啟用錄製後自動縮放", - "disable": "關閉錄製後自動縮放", - "needsEditableCursor": "自動縮放需要可編輯游標。請先切換游標模式。" - }, "sourceSelector": { "loading": "正在載入來源...", "screens": "螢幕 ({{count}})", diff --git a/src/native/browserShim.ts b/src/native/browserShim.ts index c973d3a44..c9e5d395b 100644 --- a/src/native/browserShim.ts +++ b/src/native/browserShim.ts @@ -47,7 +47,6 @@ type ShimRecordingPrefs = { camDeviceId: string | null; systemAudioEnabled: boolean; cursorCaptureMode: "editable-overlay" | "system"; - autoZoomEnabled: boolean; }; const recordingPrefsStorageKey = "browser-shim-recording-prefs"; let shimRecordingPrefs: ShimRecordingPrefs = { @@ -58,7 +57,6 @@ let shimRecordingPrefs: ShimRecordingPrefs = { camDeviceId: null, systemAudioEnabled: false, cursorCaptureMode: "editable-overlay", - autoZoomEnabled: true, }; (() => { try { From dbcf03e2651018e362210d5e9dd3c3b062f82b6a Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Mon, 14 Sep 2026 20:48:53 +0200 Subject: [PATCH 24/39] fix(hud): keep open popovers on screen and harden the work-area clamp Follow-ups to #660, from a post-merge review. - Clamp the whole visible stack (bar + open popover/notice), reported over a new hud-overlay-content channel, so a popover opened with the bar at the top edge is no longer off screen. - macOS: enableLargerThanScreen, so AppKit does not pull an overhanging window back under the menu bar on re-order. - Linux: clamp the whole window, since the HUD is never click-through there. - Stop capping the window size to the work area (it desynced the content rect), reset the rect when the HUD closes, re-clamp on restore, drag with setPosition, refresh the bar max-height on screen change. - Reuse HUD_WINDOW_MIN and clampRectToWorkArea; add sameRect. --- electron/electron-env.d.ts | 10 +- electron/hudWindowBounds.test.ts | 24 +++-- electron/hudWindowBounds.ts | 26 +++-- electron/preload.ts | 3 + electron/windows.ts | 102 ++++++++++---------- src/components/launch/LaunchWindow.test.tsx | 26 +++++ src/components/launch/LaunchWindow.tsx | 73 +++++++++----- src/components/launch/hudGeometry.ts | 2 +- 8 files changed, 171 insertions(+), 95 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 81092d3b3..d742d2d09 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -441,13 +441,21 @@ interface Window { /** Total pointer travel since `beginHudOverlayDrag`, not a per-frame delta. */ dragHudOverlayTo: (deltaX: number, deltaY: number) => void; endHudOverlayDrag: () => void; - /** Resizes the overlay and reports the bar's rect inside the requested + /** Resizes the overlay and reports the visible stack's rect inside the requested * size (window-relative); positioning decisions are made on that rect. */ setHudOverlaySize: ( width: number, height: number, content: { x: number; y: number; width: number; height: number }, ) => void; + /** The visible stack's rect changed without a resize (a popover opened, the bar + * grew into its reserve); the overlay is re-clamped by it. */ + setHudOverlayContent: (content: { + x: number; + y: number; + width: number; + height: number; + }) => void; showCountdownOverlay: (value: number, runId: number) => Promise; setCountdownOverlayValue: (value: number, runId: number) => Promise; hideCountdownOverlay: (runId: number) => Promise; diff --git a/electron/hudWindowBounds.test.ts b/electron/hudWindowBounds.test.ts index e12364618..a60a993aa 100644 --- a/electron/hudWindowBounds.test.ts +++ b/electron/hudWindowBounds.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest"; import { clampHudBoundsToWorkArea, clampHudContentToWorkArea, - HUD_WINDOW_MIN, hudDragDestination, hudResizeBounds, parseHudContentRect, @@ -203,6 +202,22 @@ describe("hud resize bounds", () => { }); expect(next).toEqual(WINDOW); }); + + it("does not cap the window to the work area, so the renderer's rect stays exact", () => { + // A capped height would shift the bar by the difference while main still + // stored the rect computed for the requested size. + const next = hudResizeBounds({ + bounds: WINDOW, + previousContent: BAR, + width: 930, + height: 1200, + nextContent: { ...BAR, y: 1200 - BAR_BOTTOM_INSET - BAR.height }, + workArea: WORK_AREA, + }); + expect(next.height).toBe(1200); + // The bar's bottom edge stays exactly where it was. + expect(next.y + next.height - BAR_BOTTOM_INSET).toBe(WINDOW.y + BAR.y + BAR.height); + }); }); describe("hud content rect parsing", () => { @@ -223,10 +238,3 @@ describe("hud content rect parsing", () => { expect(parseHudContentRect({ x: 0, y: 0, width: 560 })).toBeNull(); }); }); - -describe("hud window minimum", () => { - it("matches the window's own floor, not the editor's", () => { - // browserWindow minWidth/minHeight at construction (electron/windows.ts). - expect(HUD_WINDOW_MIN).toEqual({ width: 120, height: 80 }); - }); -}); diff --git a/electron/hudWindowBounds.ts b/electron/hudWindowBounds.ts index d2431cc0e..25ecd3a39 100644 --- a/electron/hudWindowBounds.ts +++ b/electron/hudWindowBounds.ts @@ -9,7 +9,7 @@ // the screen. So the invariant is stated on the bar, not on the window: the // content rect the renderer measures must sit fully inside the work area, and // the window around it may overhang any edge — the overhang is transparent and -// click-through. +// click-through (not on Linux, where windows.ts clamps the whole window instead). // // The Windows taskbar and the macOS Dock live *outside* the work area, so a HUD // whose bar lands there is painted over them with no taskbar entry to bring it @@ -28,12 +28,17 @@ import { export const HUD_WINDOW_MIN = { width: 120, height: 80 }; /** - * The bar's rect relative to the window, as measured by the renderer. The - * viewport of the frameless window is the whole window, so a - * `getBoundingClientRect()` on the bar is already in these coordinates. + * The visible stack's rect relative to the window, as measured by the renderer: + * the bar plus any popover or notice open above it, which is just as visible as + * the bar. The viewport of the frameless window is the whole window, so a + * `getBoundingClientRect()` is already in these coordinates. */ export type HudContentRect = EditorWindowRect; +export function sameRect(a: EditorWindowRect, b: EditorWindowRect): boolean { + return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height; +} + /** Screenspace rect of the bar, given the window's position. */ export function hudContentScreenRect( bounds: EditorWindowRect, @@ -57,10 +62,9 @@ export function clampHudContentToWorkArea( content: HudContentRect, workArea: DisplayWorkArea, ): EditorWindowRect { - const maxX = workArea.x + Math.max(0, workArea.width - content.width); - const maxY = workArea.y + Math.max(0, workArea.height - content.height); - const x = Math.min(Math.max(bounds.x + content.x, workArea.x), maxX); - const y = Math.min(Math.max(bounds.y + content.y, workArea.y), maxY); + // The content's own size as the floor leaves its size untouched: only the + // position is clamped. + const { x, y } = clampRectToWorkArea(hudContentScreenRect(bounds, content), workArea, content); // `| 0` collapses the negative zero Math.round can produce and pins the value // to int32 for the native setter — same reasons as in hudDragDestination. return { @@ -105,8 +109,10 @@ export function hudResizeBounds(input: { nextContent: HudContentRect | null; workArea: DisplayWorkArea; }): EditorWindowRect { - const width = Math.max(1, Math.min(Math.round(input.width), input.workArea.width)); - const height = Math.max(1, Math.min(Math.round(input.height), input.workArea.height)); + // Not capped to the work area: the window may overhang, and capping it would move + // the content away from the rect the renderer computed for this exact size. + const width = Math.max(1, Math.round(input.width)); + const height = Math.max(1, Math.round(input.height)); const anchorCenterX = input.previousContent ? input.bounds.x + input.previousContent.x + input.previousContent.width / 2 diff --git a/electron/preload.ts b/electron/preload.ts index a273f89d8..6b29edd87 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -101,6 +101,9 @@ contextBridge.exposeInMainWorld("electronAPI", { ) => { ipcRenderer.send("hud-overlay-set-size", width, height, content); }, + setHudOverlayContent: (content: { x: number; y: number; width: number; height: number }) => { + ipcRenderer.send("hud-overlay-content", content); + }, getSources: async (opts: Electron.SourcesOptions) => { return await ipcRenderer.invoke("get-sources", opts); }, diff --git a/electron/windows.ts b/electron/windows.ts index 851d522a0..baa66e4ee 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -10,11 +10,13 @@ import { } from "./editorWindowState"; import { clampHudBoundsToWorkArea, + HUD_WINDOW_MIN, type HudContentRect, hudContentScreenRect, hudDragDestination, hudResizeBounds, parseHudContentRect, + sameRect, } from "./hudWindowBounds"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -110,18 +112,38 @@ let hudOverlayWindow: BrowserWindow | null = null; // self-corrects on the next one instead of leaving the window permanently offset. let hudDragOrigin: { x: number; y: number } | null = null; -// The bar's rect inside the window, as last measured by the renderer. Everything -// that positions the HUD clamps this rect — not the window — into the work area, -// because the window around the bar is transparent reserve that may overhang any -// edge (see hudWindowBounds.ts). Null until the renderer's first measurement. +// The visible stack's rect inside the window (the bar plus anything open above it), as +// last measured by the renderer. Everything that positions the HUD clamps this rect — +// not the window — into the work area, because the window around it is transparent +// reserve that may overhang any edge (see hudWindowBounds.ts). Null until the +// renderer's first measurement. let hudContentRect: HudContentRect | null = null; +// Linux never makes the HUD click-through (setHudMouseEventsEnabled in LaunchWindow), so +// an overhanging reserve would swallow clicks on the panels and docks under it. There the +// content rect is ignored and the whole window stays inside the work area. +const HUD_CLAMPS_CONTENT = process.platform !== "linux"; + /** Work area of the display the bar is on (or heading for), not the window. */ function hudWorkAreaFor(bounds: Electron.Rectangle): Electron.Rectangle { const reference = hudContentRect ? hudContentScreenRect(bounds, hudContentRect) : bounds; return screen.getDisplayMatching(reference).workArea; } +// Re-clamps a HUD nobody is dragging: a drag owns the position frame by frame, and the +// renderer re-measures on release. Skipped while minimized, where moving the window is +// platform-defined; "restore" runs it instead. +function reclampHud() { + const win = hudOverlayWindow; + if (!win || win.isDestroyed() || win.isMinimized() || hudDragOrigin) return; + + const bounds = win.getBounds(); + const next = clampHudBoundsToWorkArea(bounds, hudContentRect, hudWorkAreaFor(bounds)); + if (!sameRect(next, bounds)) { + win.setBounds(next, false); + } +} + // The work area can shrink under a HUD nobody is touching: the taskbar stops auto-hiding // (or is revealed), the Dock moves, the resolution or the scale changes. A bar parked at // the bottom edge would then be left sitting on the taskbar until the next drag — and the @@ -132,31 +154,6 @@ function watchHudWorkAreaChanges() { if (isWatchingHudWorkArea) return; isWatchingHudWorkArea = true; - const reclampHud = () => { - const win = hudOverlayWindow; - // Minimized is skipped per platform for different reasons: Windows reports - // sentinel bounds (−32000,−32000) for a minimized window and clamping those - // would teleport the HUD somewhere in the work area on restore; macOS keeps - // real bounds, so skipping only delays the re-clamp to the next display - // event. Either way the position was legal when the window was minimized. - if (!win || win.isDestroyed() || win.isMinimized()) return; - // A drag owns the position frame by frame; moving the window out from under it - // would fight the gesture. The renderer re-measures on release, and the next - // metric change re-clamps it anyway. - if (hudDragOrigin) return; - - const bounds = win.getBounds(); - const next = clampHudBoundsToWorkArea(bounds, hudContentRect, hudWorkAreaFor(bounds)); - if ( - next.x !== bounds.x || - next.y !== bounds.y || - next.width !== bounds.width || - next.height !== bounds.height - ) { - win.setBounds(next, false); - } - }; - screen.on("display-metrics-changed", reclampHud); screen.on("display-added", reclampHud); screen.on("display-removed", reclampHud); @@ -276,10 +273,11 @@ ipcMain.on("hud-overlay-drag-to", (_event, deltaX: number, deltaY: number) => { const bounds = hudOverlayWindow.getBounds(); const destination = hudDragDestination({ bounds, origin: hudDragOrigin, deltaX, deltaY }); - hudOverlayWindow.setBounds( - clampHudBoundsToWorkArea(destination, hudContentRect, hudWorkAreaFor(destination)), - false, - ); + const next = clampHudBoundsToWorkArea(destination, hudContentRect, hudWorkAreaFor(destination)); + + // Position only: a per-frame setBounds round-trips the size through DIP rounding, + // which can creep it a pixel at a time under fractional scaling. + hudOverlayWindow.setPosition(next.x, next.y, false); }); ipcMain.on("hud-overlay-drag-end", () => { @@ -294,11 +292,6 @@ ipcMain.on("hud-overlay-drag-end", () => { // around the bar instead of moving it, and clamped so the bar cannot end up off // screen. The window around it may overhang; the reserve is invisible. // -// The same message doubles as a content-rect update: when only the bar changed -// (growth into the reserve costs no resize), the size is unchanged and the -// handler just refreshes the rect — repositioning only if the bar now overhangs -// an edge it sits flush against. -// // Applied in one shot rather than tweened. The renderer now reserves space for // everything that can float above the bar, so a resize only ever accompanies a // discrete content change (orientation flip, recording controls appearing) that @@ -322,7 +315,7 @@ ipcMain.on("hud-overlay-set-size", (_event, width: number, height: number, conte } const bounds = hudOverlayWindow.getBounds(); - const nextContent = parseHudContentRect(content); + const nextContent = HUD_CLAMPS_CONTENT ? parseHudContentRect(content) : null; const next = hudResizeBounds({ bounds, previousContent: hudContentRect, @@ -337,15 +330,18 @@ ipcMain.on("hud-overlay-set-size", (_event, width: number, height: number, conte hudContentRect = nextContent; } - if ( - next.x === bounds.x && - next.y === bounds.y && - next.width === bounds.width && - next.height === bounds.height - ) { - return; + if (!sameRect(next, bounds)) { + hudOverlayWindow.setBounds(next, false); } - hudOverlayWindow.setBounds(next, false); +}); + +// The visible stack changed without a resize: the bar grew into its reserve, or a +// popover or notice opened above it. Re-clamp so none of it is left off screen. +ipcMain.on("hud-overlay-content", (_event, content: unknown) => { + const nextContent = HUD_CLAMPS_CONTENT ? parseHudContentRect(content) : null; + if (!nextContent) return; + hudContentRect = nextContent; + reclampHud(); }); /** @@ -370,8 +366,12 @@ export function createHudOverlayWindow(): BrowserWindow { height: windowHeight, // Min/max are intentionally loose: the renderer resizes to fit content via // "hud-overlay-set-size" (above), needed for the vertical tray to grow taller. - minWidth: 120, - minHeight: 80, + minWidth: HUD_WINDOW_MIN.width, + minHeight: HUD_WINDOW_MIN.height, + // The reserve may overhang the screen (hudWindowBounds.ts). Without this, macOS + // re-constrains the frame below the menu bar whenever the window is re-ordered, + // pulling a stack parked at the top down by the whole reserve. + enableLargerThanScreen: true, x: x, y: y, frame: false, @@ -427,11 +427,15 @@ export function createHudOverlayWindow(): BrowserWindow { hudOverlayWindow = win; watchHudWorkAreaChanges(); + // Display changes while minimized were skipped; catch up on the way back. + win.on("restore", reclampHud); win.on("closed", () => { if (hudOverlayWindow === win) { hudOverlayWindow = null; hudDragOrigin = null; + // The next HUD is a new window: a rect measured in this one would misplace it. + hudContentRect = null; stopHudCursorPoll(); } }); diff --git a/src/components/launch/LaunchWindow.test.tsx b/src/components/launch/LaunchWindow.test.tsx index 55ff1266b..73cc4ea5a 100644 --- a/src/components/launch/LaunchWindow.test.tsx +++ b/src/components/launch/LaunchWindow.test.tsx @@ -227,6 +227,7 @@ function stubElectronAPI(getSelectedSource: Window["electronAPI"]["getSelectedSo getAppInfo: vi.fn(async () => appInfoState.value), checkForUpdates: updateCheckMock, setHudOverlaySize: vi.fn(), + setHudOverlayContent: vi.fn(), setHudOverlayIgnoreMouseEvents: vi.fn(), onHudOverlayCursor: vi.fn((callback) => { hudCursorListeners.push(callback); @@ -786,6 +787,31 @@ describe("LaunchWindow overlay sizing", () => { expect(window.electronAPI.setHudOverlaySize).not.toHaveBeenCalled(); }); + it("reports an opened popover as part of the rect kept on screen", async () => { + renderLaunchWindow(); + + const bar = (await screen.findByTestId("hud-drag-handle")).closest( + "[data-tray-layout]", + ) as HTMLElement; + stubBox(bar, 400, 56); + await flushResizeObservers(); + + fireEvent.click(screen.getByRole("button", { name: "English" })); + await screen.findByTestId("hud-language-menu"); + // The anchor wraps the bar and the stack above it: with the menu open it is + // taller than the bar, and that whole height must stay inside the work area. + const stackHeight = 56 + HUD_POPOVER_GAP + 300; + stubBox(bar.parentElement as HTMLElement, 400, stackHeight); + await flushResizeObservers(); + + expect(window.electronAPI.setHudOverlayContent).toHaveBeenLastCalledWith({ + x: 0, + y: 0, + width: 400, + height: stackHeight, + }); + }); + it("grows the HUD overlay tall enough to fit the system language prompt", async () => { i18nState.value.systemLocaleSuggestion = "zh-CN"; diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index b722a195c..c8f989755 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -59,6 +59,10 @@ function getAvailableScreenHeight(): number { return available && available > 0 ? available : FALLBACK_SCREEN_HEIGHT; } +function hudBarMaxHeightCss(): string { + return `${computeHudBarMaxHeight(getAvailableScreenHeight())}px`; +} + /** Launches the floating recording HUD and its recorder controls. */ export function LaunchWindow() { const t = useScopedT("launch"); @@ -346,8 +350,8 @@ export function LaunchWindow() { // down as CSS custom properties. // --------------------------------------------------------------------------- const hudAllocatedSizeRef = useRef({ width: 0, height: 0, orientation: trayLayout }); - // Last bar rect sent to the main process, so a measurement that changed nothing - // (a popover opening in reserved space) costs no IPC either. + // Last stack rect sent to the main process, so a measurement that changed nothing + // costs no IPC. const lastSentHudContentRef = useRef<{ x: number; y: number; @@ -362,10 +366,7 @@ export function LaunchWindow() { anchor.style.setProperty("--hud-bar-bottom", `${HUD_BAR_BOTTOM}px`); anchor.style.setProperty("--hud-popover-gap", `${HUD_POPOVER_GAP}px`); anchor.style.setProperty("--hud-stack-gap", `${HUD_STACK_GAP}px`); - anchor.style.setProperty( - "--hud-bar-max-h", - `${computeHudBarMaxHeight(getAvailableScreenHeight())}px`, - ); + anchor.style.setProperty("--hud-bar-max-h", hudBarMaxHeightCss()); }, []); const measureHudSize = useCallback(() => { @@ -424,24 +425,29 @@ export function LaunchWindow() { granted.width + HUD_GROWTH_RESERVE < allocated.width || granted.height + HUD_GROWTH_RESERVE < allocated.height; - // The main process positions the HUD by this rect, not by the window: the - // window is mostly transparent reserve, and clamping it would strand the - // bar at the bottom of the screen. `barRect` is already window-relative - // (the frameless viewport is the whole window), and since the stack is - // centred and pinned HUD_BAR_BOTTOM above the window's bottom edge - // (LaunchWindow.module.css), the rect the granted size will produce is - // computed exactly rather than measured a frame late. + // The main process keeps this rect on screen, not the window: the window is + // mostly transparent reserve, and clamping it would strand the bar at the + // bottom of the screen. It is the whole stack, the bar plus any popover or + // notice open above it, because those are just as visible as the bar. Rects + // are already window-relative (the frameless viewport is the whole window), + // and since the stack is centred and pinned HUD_BAR_BOTTOM above the window's + // bottom edge (LaunchWindow.module.css), the rect the granted size will + // produce is computed exactly rather than measured a frame late. + const anchorRect = anchorEl?.getBoundingClientRect(); + const stackRect = anchorRect?.width && anchorRect.height ? anchorRect : barRect; + const stackWidth = stackRect.width || barWidth; + const stackHeight = stackRect.height || barHeight; const currentContent = { - x: barRect.x, - y: barRect.y, - width: barWidth, - height: barHeight, + x: stackRect.x, + y: stackRect.y, + width: stackWidth, + height: stackHeight, }; const grantedContent = { - x: (granted.width - barWidth) / 2, - y: granted.height - HUD_BAR_BOTTOM - barHeight, - width: barWidth, - height: barHeight, + x: (granted.width - stackWidth) / 2, + y: granted.height - HUD_BAR_BOTTOM - stackHeight, + width: stackWidth, + height: stackHeight, }; if (!needsResize) { @@ -453,10 +459,10 @@ export function LaunchWindow() { Math.abs(last.x - currentContent.x) >= 1 || Math.abs(last.y - currentContent.y) >= 1; if (!contentChanged) return; - // No resize — but the bar grew inside the reserve, and the main process - // must know before that growth pushes it past an edge it sits flush on. + // No resize, but the stack changed inside the reserve (the bar grew, a + // popover opened), and the main process must keep it on screen. lastSentHudContentRef.current = currentContent; - window.electronAPI.setHudOverlaySize(allocated.width, allocated.height, currentContent); + window.electronAPI.setHudOverlayContent?.(currentContent); return; } @@ -468,14 +474,16 @@ export function LaunchWindow() { }, [trayLayout]); // One persistent observer; elements wire themselves up via callback refs as - // they mount/unmount. Only the bar and the notice column are observed — the - // popovers deliberately are not, since their space is already reserved. + // they mount/unmount. The bar and the notice column size the window; the anchor + // (the whole stack) only feeds the content rect, so a popover opening costs a + // re-clamp but never a native resize. const hudResizeObserverRef = useRef(null); useEffect(() => { const observer = new ResizeObserver(() => measureHudSize()); hudResizeObserverRef.current = observer; if (hudBarRef.current) observer.observe(hudBarRef.current); if (hudNoticesRef.current) observer.observe(hudNoticesRef.current); + if (hudAnchorRef.current) observer.observe(hudAnchorRef.current); measureHudSize(); return () => { observer.disconnect(); @@ -483,6 +491,19 @@ export function LaunchWindow() { }; }, [measureHudSize]); + // Screen-derived caps follow the display the HUD is on: after a drag onto a + // shorter display, a tall vertical tray would otherwise overflow its work area. + useEffect(() => { + const onScreenChange = () => { + hudAnchorRef.current?.style.setProperty("--hud-bar-max-h", hudBarMaxHeightCss()); + measureHudSize(); + }; + // Chromium's Screen is an EventTarget; TypeScript's DOM lib does not say so yet. + const hudScreen = window.screen as Screen & Partial; + hudScreen.addEventListener?.("change", onScreenChange); + return () => hudScreen.removeEventListener?.("change", onScreenChange); + }, [measureHudSize]); + const observeHudElement = useCallback( (el: T | null, ref: React.MutableRefObject) => { const observer = hudResizeObserverRef.current; diff --git a/src/components/launch/hudGeometry.ts b/src/components/launch/hudGeometry.ts index e59c3cd1c..3800fef86 100644 --- a/src/components/launch/hudGeometry.ts +++ b/src/components/launch/hudGeometry.ts @@ -135,7 +135,7 @@ export function computeHudWindowSize(input: HudSizeInput): { noticeBlock + HUD_EDGE_SLACK; - // The main process clamps to the display's work area, so clamp here too: + // The main process clamps the whole window to the work area on Linux, so clamp here too: // otherwise the caller would record an allocation the window never actually // got, then skip a resize it genuinely needs later on. const ceiling = input.availableHeight > 0 ? input.availableHeight : Number.POSITIVE_INFINITY; From fe5f9dc18cac88762ad7a9868b512ad593343864 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Mon, 14 Sep 2026 20:40:45 +0200 Subject: [PATCH 25/39] fix(build): re-fetch a staged ONNX Runtime that is the right version from the wrong archive The "Already present" fast path in fetch-onnxruntime.mjs only checked the Mach-O magic and the `\01.27.1\0` version string. Microsoft's osx-arm64 1.27.1 (minos 14.0) and the floor-pinned build from v0.0.0-onnxruntime-1.27.1 (minos 13.0) both carry that string, so a checkout that had staged the upstream dylib kept skipping the fetch and then failed in before-pack.cjs checkMacOsVersionFloor ("built for macOS 14.0.0 (floor 13.0)"). The pinned sha256 is the archive's digest, not the library's, so the staged file cannot be compared to it directly. Vendoring now writes electron/native/bin/.onnxruntime-.json recording the archive digest and the vendored library's digest; the fast path requires both to match, and re-fetches otherwise. The stamp sits outside every -/ directory electron-builder ships. main() now runs only when the script is executed, so the test can import the check. --- scripts/fetch-onnxruntime.mjs | 73 ++++++++++++++++++++++++------ scripts/fetch-onnxruntime.test.mjs | 66 +++++++++++++++++++++++++-- 2 files changed, 121 insertions(+), 18 deletions(-) diff --git a/scripts/fetch-onnxruntime.mjs b/scripts/fetch-onnxruntime.mjs index b97af4d40..b5a7ddb2c 100644 --- a/scripts/fetch-onnxruntime.mjs +++ b/scripts/fetch-onnxruntime.mjs @@ -38,7 +38,7 @@ import crypto from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.join(__dirname, ".."); @@ -275,6 +275,43 @@ async function download(spec) { return tmp; } +/** + * Where the fast path learns WHICH archive a staged library came from. + * + * The version string cannot tell two builds of one release apart, and on darwin-arm64 + * that is the whole difference: Microsoft's osx-arm64 1.27.1 and the floor-pinned one + * built here both carry `\01.27.1\0`, but only the second passes before-pack.cjs's + * macOS floor. A checkout that staged the upstream library kept hearing "Already + * present" and then failed to package. `spec.sha256` is the ARCHIVE's digest, so the + * staged file cannot be compared to it directly — this record ties the two together. + * + * Beside the per-target directories rather than inside them: every `extraResources` + * filter in electron-builder.json5 ships the contents of `-/` only. + */ +export const stampPathFor = (tag) => + path.join(ROOT, "electron", "native", "bin", `.onnxruntime-${tag}.json`); + +const sha256Of = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); + +/** Records that `dest` was vendored from the archive `spec` pins. */ +export function writeStamp(dest, stamp, spec) { + const record = { archive: spec.sha256, library: sha256Of(dest) }; + fs.writeFileSync(stamp, `${JSON.stringify(record, null, "\t")}\n`); +} + +/** Why the library at `dest` is not the one `spec` pins, or null when it is. */ +export function stagedMismatch(dest, stamp, spec) { + let record; + try { + record = JSON.parse(fs.readFileSync(stamp, "utf8")); + } catch { + return "no record of the archive it came from"; + } + if (record?.archive !== spec.sha256) return "vendored from a different archive"; + if (record.library !== sha256Of(dest)) return "replaced since it was vendored"; + return null; +} + async function main() { // `--target` exists for CI, which provisions for the runner it is on; without it // the host is the target, which is what every local build wants. @@ -311,21 +348,24 @@ async function main() { const binDir = path.join(ROOT, "electron", "native", "bin", tag); const dest = path.join(binDir, spec.out); + const stamp = stampPathFor(tag); + if (fs.existsSync(dest) && !process.argv.includes("--force")) { // Re-verify rather than trusting the filename: this directory is gitignored - // scratch space that a half-finished run or a hand copy can leave anything in. - const buf = fs.readFileSync(dest); - const magic = MAGIC[targetPlatform]; - const looksRight = - magic.bytes.every((b, i) => buf[i] === b) && - buf.includes(Buffer.from(`\0${VERSION}\0`, "latin1")); - if (looksRight) { + // scratch space that a half-finished run, a hand copy or an older pin can leave + // anything in — including the right version from the wrong archive. + const why = stagedMismatch(dest, stamp, spec); + if (!why) { console.log(`Already present: ${dest}`); - console.log(` ONNX Runtime ${VERSION}, ${(buf.length / 1048576).toFixed(1)} MB`); + console.log( + ` ONNX Runtime ${VERSION} from ${assetName(spec)}, ${(fs.statSync(dest).size / 1048576).toFixed(1)} MB`, + ); console.log("Use --force to re-download."); return; } - console.log(`Present but not ONNX Runtime ${VERSION} — re-fetching: ${dest}`); + console.log( + `Present but not the pinned ONNX Runtime ${VERSION} (${why}) — re-fetching: ${dest}`, + ); } const tmp = await download(spec); @@ -343,6 +383,7 @@ async function main() { fs.mkdirSync(binDir, { recursive: true }); fs.copyFileSync(lib, dest); if (targetPlatform !== "win32") fs.chmodSync(dest, 0o755); + writeStamp(dest, stamp, spec); console.log(` ${banner}`); // Où que viennent les octets, dire de quelle source ils sortent. Pour un artefact @@ -358,7 +399,11 @@ async function main() { } } -main().catch((err) => { - console.error(`\n${err.message}`); - process.exit(1); -}); +// Only when run as a CLI — importing this from a test must not start downloading. +// realpath because Node resolves the entry point through symlinks before naming it. +if (process.argv[1] && import.meta.url === pathToFileURL(fs.realpathSync(process.argv[1])).href) { + main().catch((err) => { + console.error(`\n${err.message}`); + process.exit(1); + }); +} diff --git a/scripts/fetch-onnxruntime.test.mjs b/scripts/fetch-onnxruntime.test.mjs index 6424e4c7f..a6c7e6eb4 100644 --- a/scripts/fetch-onnxruntime.test.mjs +++ b/scripts/fetch-onnxruntime.test.mjs @@ -16,14 +16,15 @@ // Neither direction is visible in review — the two lines are in different files, in // different languages, edited by different tasks. This test is the thing that sees it. // -// Read as source text rather than imported: fetch-onnxruntime.mjs calls main() at -// import and would start downloading. The property under test is a property of the -// literal table anyway. +// The pin table is read as source text: the property under test is a property of the +// literal table. The staged-library check is imported — main() only runs as a CLI. import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { stagedMismatch, stampPathFor, writeStamp } from "./fetch-onnxruntime.mjs"; const HERE = path.dirname(fileURLToPath(import.meta.url)); const source = fs.readFileSync(path.join(HERE, "fetch-onnxruntime.mjs"), "utf8"); @@ -114,3 +115,60 @@ describe("fetch-onnxruntime pins", () => { expect(source).toMatch(/tag === "darwin-x64"/); }); }); + +// The version string cannot tell two builds of one release apart: Microsoft's osx-arm64 +// 1.27.1 (minos 14.0) and the floor-pinned one built here (minos 13.0) both carry it, and +// a checkout that had staged the first kept hearing "Already present" until +// before-pack.cjs refused to package it. +describe("fetch-onnxruntime staged library", () => { + const pinned = { sha256: "a".repeat(64) }; + let dir; + let dest; + let stamp; + + /** A library the version-string check alone accepted: Mach-O magic + `\0VERSION\0`. */ + const stage = (build) => + fs.writeFileSync( + dest, + Buffer.concat([ + Buffer.from([0xcf, 0xfa, 0xed, 0xfe]), + Buffer.from(`\0${version}\0${build}`, "latin1"), + ]), + ); + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "openscreen-ort-test-")); + dest = path.join(dir, "libonnxruntime.dylib"); + stamp = path.join(dir, ".onnxruntime-darwin-arm64.json"); + }); + afterEach(() => fs.rmSync(dir, { recursive: true, force: true })); + + it("keeps the library it vendored", () => { + stage("floor-pinned"); + writeStamp(dest, stamp, pinned); + expect(stagedMismatch(dest, stamp, pinned)).toBeNull(); + }); + + it("re-fetches the right version string with no record of its archive", () => { + stage("upstream"); + expect(stagedMismatch(dest, stamp, pinned)).toMatch(/no record/); + }); + + it("re-fetches the right version string vendored from another archive", () => { + stage("upstream"); + writeStamp(dest, stamp, { sha256: "b".repeat(64) }); + expect(stagedMismatch(dest, stamp, pinned)).toMatch(/different archive/); + }); + + it("re-fetches the right version string copied over a vendored library", () => { + stage("floor-pinned"); + writeStamp(dest, stamp, pinned); + stage("upstream"); + expect(stagedMismatch(dest, stamp, pinned)).toMatch(/replaced/); + }); + + it("keeps the stamp out of the per-target directories electron-builder ships", () => { + const bin = path.join(HERE, "..", "electron", "native", "bin"); + expect(path.dirname(stampPathFor("darwin-arm64"))).toBe(bin); + }); +}); From dbe0f7c4bf90f908f8bbd75555c625b18bf527de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E8=B6=85=E8=B6=85=E8=B6=85=E8=B6=85=E7=BA=A7?= =?UTF-8?q?=E5=96=9C=E6=AC=A2=E4=BD=A0=E7=9A=84=E8=BE=BE=E5=A6=AE=E5=A8=85?= <176143450+My-Denia@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:05:15 +0200 Subject: [PATCH 26/39] feat(settings): persist recording setup and project appearance defaults Squashed onto main for a rebase merge: the branch carried two merge commits from main, which the repository's rebase-only merges cannot replay. The tree is the branch head merged with current main; the only conflict was a results-log row in the manual e2e checklist, and both rows are kept. Squashed commits: - feat(settings): persist recording setup and project appearance defaults - docs: record Windows settings reset validation - fix(settings): keep live HUD and CLI captures aligned after restore - fix(settings): keep CLI source picks from replacing HUD defaults - fix(settings): wait for camera restore and skip idle mic probes - fix(settings): keep source restore from racing a newer pick - fix(settings): drop source restore after a reset during enumeration - fix(settings): keep live HUD state across stale async work - fix(settings): accept optional cursor auto-hide on appearance copy Closes #509 --- .../document-service.defaults.test.ts | 51 +++ electron/ai-edition/document-service.ts | 12 +- electron/app-settings.test.ts | 83 +++++ electron/app-settings.ts | 214 +++++++++++++ electron/electron-env.d.ts | 15 +- electron/ipc/handlers.ts | 208 +++++++++++-- electron/ipc/recordingPrefs.test.ts | 123 ++++++-- electron/ipc/recordingPrefs.ts | 48 ++- electron/preload.ts | 17 +- electron/recording-source-settings.test.ts | 141 +++++++++ electron/recording-source-settings.ts | 182 +++++++++++ src/App.tsx | 6 + src/cli/CliRecordRunner.tsx | 26 +- .../ai-edition/AppSettings.test.tsx | 186 +++++++++++ src/components/ai-edition/AppSettings.tsx | 294 ++++++++++++++++++ src/components/ai-edition/NewEditorShell.tsx | 1 + .../ai-edition/ProviderSettings.test.tsx | 1 + .../ai-edition/v4/EditorTopBar.test.tsx | 25 +- src/components/ai-edition/v4/EditorTopBar.tsx | 11 + .../ai-edition/v4/RecStage.test.tsx | 217 ++++++++++++- src/components/ai-edition/v4/RecStage.tsx | 82 +++-- src/components/launch/HudDeviceSettings.tsx | 2 +- src/components/launch/LaunchWindow.test.tsx | 209 ++++++++++++- src/components/launch/LaunchWindow.tsx | 154 +++++++-- src/contexts/EditorDialogsContext.tsx | 2 +- src/hooks/useCameraDevices.test.ts | 52 ++++ src/hooks/useCameraDevices.ts | 98 ++++-- src/hooks/useMicrophoneDevices.test.ts | 170 +++++++++- src/hooks/useMicrophoneDevices.ts | 193 ++++++++---- .../useScreenRecorder.prefsRace.test.tsx | 68 ++++ src/hooks/useScreenRecorder.ts | 69 +++- src/hooks/webcamConstraint.test.ts | 31 ++ src/i18n/locales/ar/editor.json | 27 ++ src/i18n/locales/en/editor.json | 27 ++ src/i18n/locales/es/editor.json | 27 ++ src/i18n/locales/fr/editor.json | 27 ++ src/i18n/locales/it/editor.json | 27 ++ src/i18n/locales/ja-JP/editor.json | 27 ++ src/i18n/locales/ko-KR/editor.json | 27 ++ src/i18n/locales/pt-BR/editor.json | 27 ++ src/i18n/locales/ru/editor.json | 27 ++ src/i18n/locales/tr/editor.json | 27 ++ src/i18n/locales/vi/editor.json | 27 ++ src/i18n/locales/zh-CN/editor.json | 27 ++ src/i18n/locales/zh-TW/editor.json | 27 ++ src/lib/ai-edition/store/editorSettings.ts | 44 +-- src/lib/projectDefaults.test.ts | 63 ++++ src/lib/projectDefaults.ts | 205 ++++++++++++ src/lib/userPreferences.test.ts | 7 + src/lib/userPreferences.ts | 6 +- src/native/browserShim.test.ts | 79 +++++ src/native/browserShim.ts | 138 +++++++- .../recording-defaults-reset-windows.png | Bin 0 -> 60529 bytes .../testing/manual-e2e-checklist.md | 2 + .../testing/recording-defaults-windows.md | 17 + tests/e2e/settings-defaults.spec.ts | 118 +++++++ 56 files changed, 3710 insertions(+), 311 deletions(-) create mode 100644 electron/ai-edition/document-service.defaults.test.ts create mode 100644 electron/app-settings.test.ts create mode 100644 electron/app-settings.ts create mode 100644 electron/recording-source-settings.test.ts create mode 100644 electron/recording-source-settings.ts create mode 100644 src/components/ai-edition/AppSettings.test.tsx create mode 100644 src/components/ai-edition/AppSettings.tsx create mode 100644 src/hooks/useScreenRecorder.prefsRace.test.tsx create mode 100644 src/hooks/webcamConstraint.test.ts create mode 100644 src/lib/projectDefaults.test.ts create mode 100644 src/lib/projectDefaults.ts create mode 100644 technical-documentation/testing/images/recording-defaults-reset-windows.png create mode 100644 technical-documentation/testing/recording-defaults-windows.md create mode 100644 tests/e2e/settings-defaults.spec.ts diff --git a/electron/ai-edition/document-service.defaults.test.ts b/electron/ai-edition/document-service.defaults.test.ts new file mode 100644 index 000000000..ddd7d208d --- /dev/null +++ b/electron/ai-edition/document-service.defaults.test.ts @@ -0,0 +1,51 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { getEditorSettings } from "../../src/lib/ai-edition/store/editorSettings"; +import { DEFAULT_PROJECT_APPEARANCE } from "../../src/lib/projectDefaults"; +import { DocumentService } from "./document-service"; + +const dirs: string[] = []; +function temp() { + const root = mkdtempSync(path.join(os.tmpdir(), "openscreen-default-doc-")); + dirs.push(root); + return root; +} +afterEach(() => { + for (const root of dirs.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("DocumentService project appearance defaults", () => { + it("materializes the current defaults before the new project's first write", async () => { + const root = temp(); + const projects = path.join(root, "projects"); + const service = new DocumentService(projects, path.join(root, "media"), undefined, () => ({ + ...DEFAULT_PROJECT_APPEARANCE, + wallpaper: "#123456", + padding: 22, + })); + const created = await service.createProject("New recording or CLI project"); + const onDisk = JSON.parse( + readFileSync(path.join(projects, `${created.project.id}.openscreen`), "utf8"), + ); + expect(getEditorSettings(onDisk)).toMatchObject({ wallpaper: "#123456", padding: 22 }); + expect(onDisk.assets).toEqual([]); + expect(onDisk.annotations).toEqual([]); + }); + + it("never reapplies changed defaults while opening an existing project", async () => { + const root = temp(); + let padding = 11; + const service = new DocumentService( + path.join(root, "projects"), + path.join(root, "media"), + undefined, + () => ({ ...DEFAULT_PROJECT_APPEARANCE, padding }), + ); + const created = await service.createProject("Existing"); + padding = 44; + const reopened = await service.getProject(created.project.id); + expect(getEditorSettings(reopened).padding).toBe(11); + }); +}); diff --git a/electron/ai-edition/document-service.ts b/electron/ai-edition/document-service.ts index e78a9c48e..75d6fb1ab 100644 --- a/electron/ai-edition/document-service.ts +++ b/electron/ai-edition/document-service.ts @@ -21,6 +21,10 @@ import { documentSchema, migrateRawDocumentToCurrent, } from "../../src/lib/ai-edition/schema"; +import { + applyProjectAppearanceDefaults, + type ProjectAppearanceDefaults, +} from "../../src/lib/projectDefaults"; import { ensureDocumentExtensions } from "../media/extensionClip"; import { relinkProjectMedia } from "../media/projectMediaRelinker"; @@ -165,15 +169,18 @@ export class DocumentService { * `electron` import. Optional so the tests and the CLI construct it as they always did. */ private readonly onProjectRead?: (document: AxcutDocument) => void; + private readonly loadProjectDefaults?: () => ProjectAppearanceDefaults; constructor( projectsRoot: string, mediaRegistryDir: string, onProjectRead?: (document: AxcutDocument) => void, + loadProjectDefaults?: () => ProjectAppearanceDefaults, ) { this.projectsRoot = projectsRoot; this.mediaRegistryDir = mediaRegistryDir; this.onProjectRead = onProjectRead; + this.loadProjectDefaults = loadProjectDefaults; } async ensureProjectsDir(): Promise { @@ -299,10 +306,13 @@ export class DocumentService { async createProject(title: string): Promise { await this.ensureProjectsDir(); const projectId = createId("proj"); - const doc = createEmptyDocument({ + const empty = createEmptyDocument({ projectId, title: title?.trim() || "Untitled Project", }); + const doc = this.loadProjectDefaults + ? applyProjectAppearanceDefaults(empty, this.loadProjectDefaults()) + : empty; await this.writeProject(doc); return doc; } diff --git a/electron/app-settings.test.ts b/electron/app-settings.test.ts new file mode 100644 index 000000000..2370c9b34 --- /dev/null +++ b/electron/app-settings.test.ts @@ -0,0 +1,83 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { DEFAULT_PROJECT_APPEARANCE } from "../src/lib/projectDefaults"; +import { AppSettingsStore, DEFAULT_RECORDING_PREFERENCES } from "./app-settings"; + +const dirs: string[] = []; +const temp = () => { + const dir = mkdtempSync(path.join(os.tmpdir(), "openscreen-app-settings-")); + dirs.push(dir); + return dir; +}; +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe("app settings store", () => { + it("uses defaults for absent fields and preserves unknown keys", () => { + const dir = temp(); + const file = path.join(dir, "recording-settings.json"); + writeFileSync(file, JSON.stringify({ future: { keep: true } })); + const store = new AppSettingsStore(dir); + expect(store.getSnapshot().recording).toEqual(DEFAULT_RECORDING_PREFERENCES); + store.setRecordingPreferences({ micEnabled: true, camDeviceName: "Camera A" }); + expect(JSON.parse(readFileSync(file, "utf8"))).toMatchObject({ + future: { keep: true }, + micEnabled: true, + camDeviceName: "Camera A", + }); + }); + + it("uses validated defaults for absent, corrupt, and invalid fields", () => { + const dir = temp(); + const file = path.join(dir, "recording-settings.json"); + const store = new AppSettingsStore(dir); + expect(store.getSnapshot().recording).toEqual(DEFAULT_RECORDING_PREFERENCES); + for (const raw of ["{broken", "[]", JSON.stringify({ micEnabled: "yes" })]) { + writeFileSync(file, raw); + expect(store.getSnapshot().recording.micEnabled).toBe(false); + } + }); + + it("stores and resets versioned appearance and recording setup", () => { + const dir = temp(); + const store = new AppSettingsStore(dir); + const custom = { ...DEFAULT_PROJECT_APPEARANCE, wallpaper: "#010203", padding: 7 }; + expect(store.setAppearanceDefaults(custom).appearance).toMatchObject({ custom: true }); + store.setLastSource({ + platform: "win32", + kind: "screen", + id: "screen:1", + name: "Display", + displayId: "1", + }); + store.setRecordingPreferences({ micEnabled: true, micDeviceId: "mic" }); + expect(store.resetRecordingSetup()).toMatchObject({ + recording: DEFAULT_RECORDING_PREFERENCES, + lastSource: null, + }); + expect(store.resetAppearanceDefaults().appearance).toEqual({ + version: 1, + custom: false, + defaults: DEFAULT_PROJECT_APPEARANCE, + }); + expect( + JSON.parse(readFileSync(path.join(dir, "recording-settings.json"), "utf8")), + ).toMatchObject({ + projectAppearance: { version: 1, defaults: null }, + }); + }); + + it("rejects invalid or failed writes without changing the published durable value", () => { + const dir = temp(); + const store = new AppSettingsStore(dir); + store.setRecordingPreferences({ micEnabled: true }); + expect(() => store.setRecordingPreferences({ micEnabled: "yes" as never })).toThrow(TypeError); + expect(store.getSnapshot().recording.micEnabled).toBe(true); + const missing = new AppSettingsStore(path.join(dir, "missing")); + expect(() => missing.setRecordingPreferences({ micEnabled: true })).toThrow(); + expect(missing.getSnapshot().recording.micEnabled).toBe(false); + }); +}); diff --git a/electron/app-settings.ts b/electron/app-settings.ts new file mode 100644 index 000000000..64b31704d --- /dev/null +++ b/electron/app-settings.ts @@ -0,0 +1,214 @@ +import { readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { + DEFAULT_PROJECT_APPEARANCE, + type ProjectAppearanceDefaults, + parseProjectAppearanceDefaults, +} from "../src/lib/projectDefaults"; +import type { CursorCaptureMode } from "../src/lib/recordingSession"; + +export interface RecordingPreferences { + micEnabled: boolean; + micDeviceId: string | null; + micDeviceName: string | null; + camEnabled: boolean; + camDeviceId: string | null; + camDeviceName: string | null; + systemAudioEnabled: boolean; + cursorCaptureMode: CursorCaptureMode; +} + +export const DEFAULT_RECORDING_PREFERENCES: RecordingPreferences = { + micEnabled: false, + micDeviceId: null, + micDeviceName: null, + camEnabled: false, + camDeviceId: null, + camDeviceName: null, + systemAudioEnabled: false, + cursorCaptureMode: "editable-overlay", +}; + +export interface RecordingSourceDescriptor { + platform: NodeJS.Platform; + kind: "screen" | "window"; + id: string; + name: string; + displayId: string | null; +} + +export interface AppSettingsSnapshot { + recording: RecordingPreferences; + lastSource: RecordingSourceDescriptor | null; + appearance: { + version: 1; + custom: boolean; + defaults: ProjectAppearanceDefaults; + }; +} + +type RawSettings = Record; + +function readRaw(userData: string): RawSettings { + try { + const value: unknown = JSON.parse( + readFileSync(path.join(userData, "recording-settings.json"), "utf8"), + ); + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as RawSettings) + : {}; + } catch { + return {}; + } +} + +function atomicWrite(userData: string, value: RawSettings): void { + const destination = path.join(userData, "recording-settings.json"); + const temporary = `${destination}.${process.pid}.${Date.now()}.tmp`; + try { + writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, "utf8"); + renameSync(temporary, destination); + } finally { + rmSync(temporary, { force: true }); + } +} + +const bool = (value: unknown, fallback: boolean) => (typeof value === "boolean" ? value : fallback); +const nullableString = (value: unknown, fallback: string | null) => + value === null || typeof value === "string" ? value : fallback; + +function parseRecording(raw: RawSettings): RecordingPreferences { + return { + micEnabled: bool(raw.micEnabled, DEFAULT_RECORDING_PREFERENCES.micEnabled), + micDeviceId: nullableString(raw.micDeviceId, DEFAULT_RECORDING_PREFERENCES.micDeviceId), + micDeviceName: nullableString(raw.micDeviceName, DEFAULT_RECORDING_PREFERENCES.micDeviceName), + camEnabled: bool(raw.camEnabled, DEFAULT_RECORDING_PREFERENCES.camEnabled), + camDeviceId: nullableString(raw.camDeviceId, DEFAULT_RECORDING_PREFERENCES.camDeviceId), + camDeviceName: nullableString(raw.camDeviceName, DEFAULT_RECORDING_PREFERENCES.camDeviceName), + systemAudioEnabled: bool( + raw.systemAudioEnabled, + DEFAULT_RECORDING_PREFERENCES.systemAudioEnabled, + ), + cursorCaptureMode: + raw.cursorCaptureMode === "system" || raw.cursorCaptureMode === "editable-overlay" + ? raw.cursorCaptureMode + : DEFAULT_RECORDING_PREFERENCES.cursorCaptureMode, + }; +} + +function parseSource(value: unknown): RecordingSourceDescriptor | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const candidate = value as Record; + if ( + (candidate.platform !== "win32" && + candidate.platform !== "darwin" && + candidate.platform !== "linux") || + (candidate.kind !== "screen" && candidate.kind !== "window") || + typeof candidate.id !== "string" || + candidate.id.length === 0 || + typeof candidate.name !== "string" || + candidate.name.length === 0 || + !(candidate.displayId === null || typeof candidate.displayId === "string") + ) { + return null; + } + return candidate as unknown as RecordingSourceDescriptor; +} + +function parseAppearance(raw: RawSettings): AppSettingsSnapshot["appearance"] { + const value = raw.projectAppearance; + if (!value || typeof value !== "object" || Array.isArray(value)) { + return { version: 1, custom: false, defaults: DEFAULT_PROJECT_APPEARANCE }; + } + const candidate = value as Record; + if (candidate.version !== 1 || candidate.defaults === null) { + return { version: 1, custom: false, defaults: DEFAULT_PROJECT_APPEARANCE }; + } + try { + return { + version: 1, + custom: true, + defaults: parseProjectAppearanceDefaults(candidate.defaults), + }; + } catch { + return { version: 1, custom: false, defaults: DEFAULT_PROJECT_APPEARANCE }; + } +} + +function validateRecordingPatch(patch: Partial): void { + const allowed = new Set(Object.keys(DEFAULT_RECORDING_PREFERENCES)); + 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") && typeof value !== "boolean") { + throw new TypeError(`${key} must be a boolean`); + } + if ( + (key.endsWith("DeviceId") || key.endsWith("DeviceName")) && + value !== null && + typeof value !== "string" + ) { + throw new TypeError(`${key} must be a string or null`); + } + if (key === "cursorCaptureMode" && value !== "system" && value !== "editable-overlay") { + throw new TypeError("cursorCaptureMode is invalid"); + } + } +} + +export class AppSettingsStore { + constructor(private readonly userData: string) {} + + getSnapshot(): AppSettingsSnapshot { + const raw = readRaw(this.userData); + return { + recording: parseRecording(raw), + lastSource: parseSource(raw.lastSource), + appearance: parseAppearance(raw), + }; + } + + setRecordingPreferences(patch: Partial): AppSettingsSnapshot { + validateRecordingPatch(patch); + const raw = readRaw(this.userData); + const current = parseRecording(raw); + const next = Object.fromEntries( + Object.entries(patch).filter(([, value]) => value !== undefined), + ) as Partial; + atomicWrite(this.userData, { ...raw, ...current, ...next }); + return this.getSnapshot(); + } + + setLastSource(source: RecordingSourceDescriptor | null): AppSettingsSnapshot { + if (source !== null && !parseSource(source)) throw new TypeError("last source is invalid"); + const raw = readRaw(this.userData); + atomicWrite(this.userData, { ...raw, lastSource: source }); + return this.getSnapshot(); + } + + setAppearanceDefaults(defaults: ProjectAppearanceDefaults): AppSettingsSnapshot { + const parsed = parseProjectAppearanceDefaults(defaults); + const raw = readRaw(this.userData); + atomicWrite(this.userData, { + ...raw, + projectAppearance: { version: 1, defaults: parsed }, + }); + return this.getSnapshot(); + } + + resetAppearanceDefaults(): AppSettingsSnapshot { + const raw = readRaw(this.userData); + atomicWrite(this.userData, { ...raw, projectAppearance: { version: 1, defaults: null } }); + return this.getSnapshot(); + } + + resetRecordingSetup(): AppSettingsSnapshot { + const raw = readRaw(this.userData); + atomicWrite(this.userData, { + ...raw, + ...DEFAULT_RECORDING_PREFERENCES, + lastSource: null, + }); + return this.getSnapshot(); + } +} diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index d742d2d09..4d1484c1c 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -51,13 +51,24 @@ interface Window { opened: boolean; reason?: string; }>; - selectSource: (source: ProcessedDesktopSource) => Promise; + selectSource: ( + source: ProcessedDesktopSource, + options?: { persist?: boolean }, + ) => Promise; getSelectedSource: () => Promise; - onSelectedSourceChanged: (callback: (source: ProcessedDesktopSource) => void) => () => void; + onSelectedSourceChanged: ( + callback: (source: ProcessedDesktopSource | null) => void, + ) => () => void; getRecordingPrefs: () => Promise; setRecordingPrefs: ( prefs: Partial, ) => Promise; + getAppSettings: () => Promise; + setProjectAppearanceDefaults: ( + defaults: import("../src/lib/projectDefaults").ProjectAppearanceDefaults, + ) => Promise; + resetProjectAppearanceDefaults: () => Promise; + resetRecordingSetup: () => Promise; onRecordingPrefsChanged: ( callback: (prefs: import("./ipc/handlers").RecordingPrefs) => void, ) => () => void; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index b3ae42364..e3a02808b 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -56,6 +56,7 @@ import { import type { CursorTelemetryReader } from "../ai-edition/deep-agent/service"; import { DocumentService } from "../ai-edition/document-service"; import { LlmConfigStore } from "../ai-edition/llm-config-store"; +import { AppSettingsStore } from "../app-settings"; import { isDiagnosticModeEnabled, mainLogBuffer } from "../diagnostics/main-log-buffer"; import { mainT } from "../i18n"; import { getInstallChannel } from "../install-channel"; @@ -103,6 +104,15 @@ import { } from "../recording/nativeWindowsCaptureStop"; import { patchWebmDurationOnDisk } from "../recording/webm-duration"; import { reindexRecordingOnDisk } from "../recording/webm-seek-index"; +import { + describeRecordingSource, + enumerationIncludesSourceKind, + mergeEnumeratedSources, + resolveRecordingSource, + restoreRecordingSourceAfterEnumeration, + shouldEnumerateRecordingSources, + shouldPersistSelectedSource, +} from "../recording-source-settings"; import { registerNativeBridgeHandlers } from "./nativeBridge"; import { createNativeMacMidCaptureErrorWatch } from "./nativeMacMidCaptureErrorWatch"; import { registerRecordingPrefsHandlers } from "./recordingPrefs"; @@ -595,13 +605,12 @@ let lastEnumeratedSources = new Map(); let currentProjectPath: string | null = null; let currentRecordingSession: RecordingSession | null = null; -// single source of truth for the mic/camera/system-audio/cursor +// Durable source of truth for the mic/camera/system-audio/cursor/source // choices a user makes in the editor's Rec-mode stage, so the HUD window's // useScreenRecorder (a separate renderer, own process, own React tree) picks // up those choices instead of silently reverting to its own defaults when -// startNewRecording() switches windows. Mirrors the selectedSource pattern -// above (in-memory, broadcast on change). Auto-zoom is the one durable choice; -// the device selections remain session preferences, not project content. +// startNewRecording() switches windows. Persisted in AppSettingsStore and +// broadcast on change; not project content. export interface RecordingPrefs { micEnabled: boolean; micDeviceId: string | null; @@ -619,6 +628,8 @@ export interface RecordingPrefs { micDeviceName: string | null; camEnabled: boolean; camDeviceId: string | null; + /** Camera label paired with the preferred id for restart-safe resolution. */ + camDeviceName: string | null; systemAudioEnabled: boolean; cursorCaptureMode: CursorCaptureMode; } @@ -628,6 +639,7 @@ const defaultRecordingPrefs: RecordingPrefs = { micDeviceName: null, camEnabled: false, camDeviceId: null, + camDeviceName: null, systemAudioEnabled: false, cursorCaptureMode: "editable-overlay", }; @@ -1837,6 +1849,17 @@ export function registerIpcHandlers( onRecordingStateChange?: (recording: boolean, sourceName: string) => void, _switchToHud?: () => void, ) { + const appSettings = new AppSettingsStore(app.getPath("userData")); + const broadcastSelectedSource = (source: SelectedSource | null) => { + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) { + window.webContents.send("selected-source-changed", source); + } + } + }; + const sameSelectedSource = (left: SelectedSource | null, right: SelectedSource | null) => + left?.id === right?.id && left?.name === right?.name && left?.display_id === right?.display_id; + async function requestScreenAccess() { if (process.platform !== "darwin") { return { success: true, granted: true, status: "granted" }; @@ -1917,7 +1940,39 @@ export function registerIpcHandlers( `[get-sources] returned ${sources.length} source(s) in ${Date.now() - startedAt}ms (types=${(opts?.types ?? []).join(",")})`, ); } - lastEnumeratedSources = new Map(sources.map((source) => [source.id, source])); + lastEnumeratedSources = mergeEnumeratedSources(lastEnumeratedSources, sources, opts?.types); + const previousSelectedSource = selectedSource; + const currentLive = selectedSource?.id + ? sources.find((source) => source.id === selectedSource?.id) + : null; + if (currentLive) { + selectedSource = { + id: currentLive.id, + name: currentLive.name, + display_id: currentLive.display_id, + }; + selectedDesktopSource = currentLive; + } else if (enumerationIncludesSourceKind(opts?.types, selectedSource?.id)) { + selectedSource = null; + selectedDesktopSource = null; + const restored = resolveRecordingSource( + appSettings.getSnapshot().lastSource, + process.platform, + sources, + { waylandPortal: process.platform === "linux" && Boolean(findPipeWireCursorHelperPath()) }, + ); + if (restored) { + selectedSource = { + id: restored.id, + name: restored.name, + display_id: restored.display_id, + }; + selectedDesktopSource = lastEnumeratedSources.get(restored.id) ?? null; + } + } + if (!sameSelectedSource(previousSelectedSource, selectedSource)) { + broadcastSelectedSource(selectedSource); + } return sources.map((source) => ({ id: source.id, name: source.name, @@ -1927,42 +1982,128 @@ export function registerIpcHandlers( })); }); - ipcMain.handle("select-source", async (_, source: SelectedSource) => { - selectedSource = source; - // Reuse the exact source object returned during enumeration to avoid - // Windows window-source id mismatches across separate getSources() calls. - selectedDesktopSource = - typeof source.id === "string" ? (lastEnumeratedSources.get(source.id) ?? null) : null; + ipcMain.handle( + "select-source", + async (_, source: SelectedSource, options?: { persist?: boolean }) => { + // Reuse the exact source object returned during enumeration to avoid + // Windows window-source id mismatches across separate getSources() calls. + selectedDesktopSource = + typeof source.id === "string" ? (lastEnumeratedSources.get(source.id) ?? null) : null; + + if (!selectedDesktopSource && typeof source.id === "string") { + try { + const sources = await desktopCapturer.getSources({ + types: ["screen", "window"], + thumbnailSize: { width: 0, height: 0 }, + fetchWindowIcons: true, + }); + lastEnumeratedSources = new Map(sources.map((candidate) => [candidate.id, candidate])); + selectedDesktopSource = lastEnumeratedSources.get(source.id) ?? null; + } catch { + selectedDesktopSource = null; + } + } + if (!selectedDesktopSource) { + selectedSource = null; + broadcastSelectedSource(null); + return null; + } + selectedSource = { + id: selectedDesktopSource.id, + name: selectedDesktopSource.name, + display_id: selectedDesktopSource.display_id, + }; + // Persist only a descriptor built from the freshly enumerated live object. + // A failed write must not keep the picker open after a valid live pick. + if (shouldPersistSelectedSource(options)) { + try { + appSettings.setLastSource( + describeRecordingSource(process.platform, selectedSource as Required), + ); + } catch (error) { + console.warn("Failed to persist the selected recording source:", error); + } + } + broadcastSelectedSource(selectedSource); + const sourceSelectorWin = getSourceSelectorWindow(); + if (sourceSelectorWin) { + sourceSelectorWin.close(); + } + return selectedSource; + }, + ); - if (!selectedDesktopSource && typeof source.id === "string") { - try { - const sources = await desktopCapturer.getSources({ - types: ["screen", "window"], - thumbnailSize: { width: 0, height: 0 }, - fetchWindowIcons: true, - }); - lastEnumeratedSources = new Map(sources.map((candidate) => [candidate.id, candidate])); - selectedDesktopSource = lastEnumeratedSources.get(source.id) ?? null; - } catch { - selectedDesktopSource = null; + ipcMain.handle("get-selected-source", async () => { + const previousSelectedSource = selectedSource; + if (process.platform === "linux" && findPipeWireCursorHelperPath()) { + selectedSource = null; + selectedDesktopSource = null; + if (!sameSelectedSource(previousSelectedSource, null)) { + broadcastSelectedSource(null); } + return null; + } + const lastSource = appSettings.getSnapshot().lastSource; + const liveSelected = + selectedSource?.id != null + ? { + id: selectedSource.id, + name: selectedSource.name, + display_id: selectedSource.display_id ?? "", + } + : null; + if (!shouldEnumerateRecordingSources(liveSelected, lastSource)) { + return selectedSource; } - const mainWin = getMainWindow(); - if (mainWin && !mainWin.isDestroyed()) { - mainWin.webContents.send("selected-source-changed", selectedSource); + const sources = await withDeadline( + desktopCapturer.getSources({ + types: ["screen", "window"], + thumbnailSize: { width: 0, height: 0 }, + fetchWindowIcons: false, + }), + GET_SOURCES_TIMEOUT_MS, + `Desktop source restoration did not return within ${GET_SOURCES_TIMEOUT_MS}ms.`, + ); + const decision = restoreRecordingSourceAfterEnumeration({ + selectedBefore: liveSelected, + selectedAfter: + selectedSource?.id != null + ? { + id: selectedSource.id, + name: selectedSource.name, + display_id: selectedSource.display_id ?? "", + } + : null, + lastSourceBefore: lastSource, + lastSourceAfter: appSettings.getSnapshot().lastSource, + platform: process.platform, + sources, + }); + if (!decision.apply) { + return selectedSource; } - const sourceSelectorWin = getSourceSelectorWindow(); - if (sourceSelectorWin) { - sourceSelectorWin.close(); + lastEnumeratedSources = new Map(sources.map((source) => [source.id, source])); + const restored = decision.restored; + selectedDesktopSource = restored ? (lastEnumeratedSources.get(restored.id) ?? null) : null; + selectedSource = restored + ? { id: restored.id, name: restored.name, display_id: restored.display_id } + : null; + if (!sameSelectedSource(previousSelectedSource, selectedSource)) { + broadcastSelectedSource(selectedSource); } return selectedSource; }); - ipcMain.handle("get-selected-source", () => { - return selectedSource; - }); - - registerRecordingPrefsHandlers(defaultRecordingPrefs, getMainWindow); + registerRecordingPrefsHandlers( + defaultRecordingPrefs, + getMainWindow, + () => { + selectedSource = null; + selectedDesktopSource = null; + broadcastSelectedSource(null); + }, + () => BrowserWindow.getAllWindows(), + ); ipcMain.handle("request-camera-access", async () => { if (process.platform !== "darwin") { @@ -4467,6 +4608,7 @@ export function registerIpcHandlers( path.join(app.getPath("userData"), "projects"), RECORDINGS_DIR, approveDocumentMedia, + () => appSettings.getSnapshot().appearance.defaults, ); // LlmConfigStore is single-instance for a duller reason — its constructor does diff --git a/electron/ipc/recordingPrefs.test.ts b/electron/ipc/recordingPrefs.test.ts index aaa08b190..a6d978b85 100644 --- a/electron/ipc/recordingPrefs.test.ts +++ b/electron/ipc/recordingPrefs.test.ts @@ -1,10 +1,14 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; import type { BrowserWindow } from "electron"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { RecordingPrefs } from "./handlers"; import { registerRecordingPrefsHandlers } from "./recordingPrefs"; -const electron = vi.hoisted(() => ({ handle: vi.fn() })); +const electron = vi.hoisted(() => ({ getPath: vi.fn(), handle: vi.fn() })); vi.mock("electron", () => ({ + app: { getPath: electron.getPath }, ipcMain: { handle: electron.handle }, })); @@ -14,17 +18,25 @@ const defaults: RecordingPrefs = { micDeviceName: null, camEnabled: false, camDeviceId: null, + camDeviceName: null, systemAudioEnabled: false, cursorCaptureMode: "editable-overlay", }; - +let dir: string; beforeEach(() => { + dir = mkdtempSync(path.join(os.tmpdir(), "openscreen-recording-ipc-")); + electron.getPath.mockReturnValue(dir); electron.handle.mockClear(); }); +afterEach(() => rmSync(dir, { recursive: true, force: true })); -function start(getWindow: () => BrowserWindow | null = () => null) { +function start( + getWindow: () => BrowserWindow | null = () => null, + onResetSource: () => void = () => undefined, + getAppWindows?: () => BrowserWindow[], +) { electron.handle.mockClear(); - registerRecordingPrefsHandlers(defaults, getWindow); + registerRecordingPrefsHandlers(defaults, getWindow, onResetSource, getAppWindows); const get = electron.handle.mock.calls.find( ([name]) => name === "get-recording-prefs", )?.[1] as () => RecordingPrefs; @@ -32,28 +44,97 @@ function start(getWindow: () => BrowserWindow | null = () => null) { _event: unknown, prefs: Partial, ) => RecordingPrefs; - return { get, set: (prefs: Partial) => set(undefined, prefs) }; + const reset = electron.handle.mock.calls.find( + ([name]) => name === "reset-recording-setup", + )?.[1] as (_event: unknown) => { recording: RecordingPrefs; lastSource: null }; + return { + get, + set: (prefs: Partial) => set(undefined, prefs), + reset: () => reset(undefined), + }; } describe("recording preferences IPC", () => { - it("returns defaults and updates session preferences", () => { - const session = start(); - expect(session.get()).toEqual(defaults); - const updated = session.set({ micEnabled: true, micDeviceId: "test-mic" }); - expect(updated.micEnabled).toBe(true); - expect(updated.micDeviceId).toBe("test-mic"); - expect(session.get().micEnabled).toBe(true); + it("restores toggles and device preferences on restart", () => { + const first = start(); + expect(first.get().micEnabled).toBe(false); + expect(first.get().camDeviceName).toBeNull(); + expect(first.set({ camDeviceName: "Camera A" }).camDeviceName).toBe("Camera A"); + first.set({ micEnabled: true, micDeviceId: "temporary-device" }); + const disk = JSON.parse(readFileSync(path.join(dir, "recording-settings.json"), "utf8")); + expect(disk).toMatchObject({ + camDeviceName: "Camera A", + micEnabled: true, + micDeviceId: "temporary-device", + }); + const restarted = start(); + expect(restarted.get()).toEqual({ + ...defaults, + camDeviceName: "Camera A", + micEnabled: true, + micDeviceId: "temporary-device", + }); + restarted.set({ camDeviceName: "Camera B" }); + expect(start().get().camDeviceName).toBe("Camera B"); }); - it("broadcasts changes to main window and tolerates an absent or destroyed window", () => { + it("broadcasts saved values to every live application window", () => { + const firstSend = vi.fn(); + const secondSend = vi.fn(); + const destroyedSend = vi.fn(); + const first = { + isDestroyed: () => false, + webContents: { send: firstSend }, + } as unknown as BrowserWindow; + const second = { + isDestroyed: () => false, + webContents: { send: secondSend }, + } as unknown as BrowserWindow; + const destroyed = { + isDestroyed: () => true, + webContents: { send: destroyedSend }, + } as unknown as BrowserWindow; + const session = start( + () => first, + undefined, + () => [first, second, first, destroyed], + ); + const updated = session.set({ micEnabled: true }); + expect(firstSend).toHaveBeenCalledWith("recording-prefs-changed", updated); + expect(secondSend).toHaveBeenCalledWith("recording-prefs-changed", updated); + expect(firstSend).toHaveBeenCalledTimes(1); + expect(destroyedSend).not.toHaveBeenCalled(); + }); + + it("publishes the reset snapshot and runs the source reset callback", () => { const send = vi.fn(); - const isDestroyed = vi.fn(() => false); - const window = { isDestroyed, webContents: { send } } as unknown as BrowserWindow; - const session = start(() => window); - const updated = session.set({ camEnabled: true }); - expect(send).toHaveBeenCalledWith("recording-prefs-changed", updated); - isDestroyed.mockReturnValue(true); + const window = { + isDestroyed: () => false, + webContents: { send }, + } as unknown as BrowserWindow; + const onResetSource = vi.fn(); + const session = start(() => window, onResetSource); + session.set({ micEnabled: true, camEnabled: true, systemAudioEnabled: true }); + send.mockClear(); + + const snapshot = session.reset(); + expect(snapshot.recording).toEqual(defaults); + expect(snapshot.lastSource).toBeNull(); + expect(onResetSource).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledWith("recording-prefs-changed", defaults); + }); + + it("does not publish an invalid or failed preference write", () => { + const session = start(); + expect(() => session.set({ micEnabled: null } as unknown as Partial)).toThrow( + TypeError, + ); + expect(session.get().micEnabled).toBe(false); session.set({ micEnabled: true }); - expect(send).toHaveBeenCalledTimes(1); + session.set({ micEnabled: undefined, camEnabled: true }); + expect(session.get().micEnabled).toBe(true); + rmSync(dir, { recursive: true, force: true }); + expect(() => session.set({ micEnabled: false })).toThrow(); + expect(session.get().micEnabled).toBe(true); }); }); diff --git a/electron/ipc/recordingPrefs.ts b/electron/ipc/recordingPrefs.ts index 65516201d..b80b03430 100644 --- a/electron/ipc/recordingPrefs.ts +++ b/electron/ipc/recordingPrefs.ts @@ -1,24 +1,48 @@ -import type { BrowserWindow } from "electron"; -import { ipcMain } from "electron"; +import { app, type BrowserWindow, ipcMain } from "electron"; +import type { ProjectAppearanceDefaults } from "../../src/lib/projectDefaults"; +import { AppSettingsStore } from "../app-settings"; import type { RecordingPrefs } from "./handlers"; -/** Shared session preferences. */ +/** Shared durable recording preferences. Persist before publishing any new snapshot. */ export function registerRecordingPrefsHandlers( defaults: RecordingPrefs, getMainWindow: () => BrowserWindow | null, + onResetSource: () => void = () => undefined, + getAppWindows: () => BrowserWindow[] = () => { + const mainWindow = getMainWindow(); + return mainWindow ? [mainWindow] : []; + }, ): void { - let recordingPrefs = { ...defaults }; + const userData = app.getPath("userData"); + const settings = new AppSettingsStore(userData); + let recordingPrefs = { ...defaults, ...settings.getSnapshot().recording }; + const publish = () => { + for (const window of new Set(getAppWindows())) { + if (!window.isDestroyed()) { + window.webContents.send("recording-prefs-changed", recordingPrefs); + } + } + }; ipcMain.handle("get-recording-prefs", () => recordingPrefs); ipcMain.handle("set-recording-prefs", (_, prefs: Partial) => { - recordingPrefs = { - ...recordingPrefs, - ...prefs, - }; - const mainWin = getMainWindow(); - if (mainWin && !mainWin.isDestroyed()) { - mainWin.webContents.send("recording-prefs-changed", recordingPrefs); - } + // Persist every validated field first. A failed save must leave both the + // durable value and the main-process published snapshot unchanged. + recordingPrefs = settings.setRecordingPreferences(prefs).recording; + publish(); return recordingPrefs; }); + + ipcMain.handle("get-app-settings", () => settings.getSnapshot()); + ipcMain.handle("set-project-appearance-defaults", (_, value: ProjectAppearanceDefaults) => + settings.setAppearanceDefaults(value), + ); + ipcMain.handle("reset-project-appearance-defaults", () => settings.resetAppearanceDefaults()); + ipcMain.handle("reset-recording-setup", () => { + const snapshot = settings.resetRecordingSetup(); + recordingPrefs = snapshot.recording; + onResetSource(); + publish(); + return snapshot; + }); } diff --git a/electron/preload.ts b/electron/preload.ts index 6b29edd87..35699839e 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -2,10 +2,12 @@ import { contextBridge, ipcRenderer, webUtils } from "electron"; import type { NativeLinuxRecordingRequest } from "../src/lib/nativeLinuxRecording"; import type { NativeMacRecordingRequest } from "../src/lib/nativeMacRecording"; import type { NativeWindowsRecordingRequest } from "../src/lib/nativeWindowsRecording"; +import type { ProjectAppearanceDefaults } from "../src/lib/projectDefaults"; import type { RecordingSession, StoreRecordedSessionInput } from "../src/lib/recordingSession"; import type { ShortcutBinding } from "../src/lib/shortcuts"; import type { AiEditionChatEvent } from "../src/native/contracts"; import { NATIVE_BRIDGE_CHANNEL, type NativeBridgeRequest } from "../src/native/contracts"; +import type { AppSettingsSnapshot } from "./app-settings"; import type { RecordingPrefs } from "./ipc/handlers"; import type { SttStatusEvent, @@ -122,8 +124,8 @@ contextBridge.exposeInMainWorld("electronAPI", { openNotes: () => { return ipcRenderer.invoke("open-notes"); }, - selectSource: (source: ProcessedDesktopSource) => { - return ipcRenderer.invoke("select-source", source); + selectSource: (source: ProcessedDesktopSource, options?: { persist?: boolean }) => { + return ipcRenderer.invoke("select-source", source, options); }, getSelectedSource: () => { return ipcRenderer.invoke("get-selected-source"); @@ -134,13 +136,20 @@ contextBridge.exposeInMainWorld("electronAPI", { setRecordingPrefs: (prefs: Partial) => { return ipcRenderer.invoke("set-recording-prefs", prefs); }, + getAppSettings: () => ipcRenderer.invoke("get-app-settings") as Promise, + setProjectAppearanceDefaults: (defaults: ProjectAppearanceDefaults) => + ipcRenderer.invoke("set-project-appearance-defaults", defaults) as Promise, + resetProjectAppearanceDefaults: () => + ipcRenderer.invoke("reset-project-appearance-defaults") as Promise, + resetRecordingSetup: () => + ipcRenderer.invoke("reset-recording-setup") as Promise, onRecordingPrefsChanged: (callback: (prefs: RecordingPrefs) => void) => { const listener = (_event: unknown, prefs: RecordingPrefs) => callback(prefs); ipcRenderer.on("recording-prefs-changed", listener); return () => ipcRenderer.removeListener("recording-prefs-changed", listener); }, - onSelectedSourceChanged: (callback: (source: ProcessedDesktopSource) => void) => { - const listener = (_event: unknown, source: ProcessedDesktopSource) => callback(source); + onSelectedSourceChanged: (callback: (source: ProcessedDesktopSource | null) => void) => { + const listener = (_event: unknown, source: ProcessedDesktopSource | null) => callback(source); ipcRenderer.on("selected-source-changed", listener); return () => ipcRenderer.removeListener("selected-source-changed", listener); }, diff --git a/electron/recording-source-settings.test.ts b/electron/recording-source-settings.test.ts new file mode 100644 index 000000000..f56a684fa --- /dev/null +++ b/electron/recording-source-settings.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest"; +import { + describeRecordingSource, + enumerationIncludesSourceKind, + mergeEnumeratedSources, + resolveCurrentRecordingSource, + resolveRecordingSource, + restoreRecordingSourceAfterEnumeration, + shouldEnumerateRecordingSources, + shouldPersistSelectedSource, +} from "./recording-source-settings"; + +const display = { id: "screen:1:0", name: "Display 1", display_id: "stable-1" }; + +describe("recording source settings", () => { + it("restores a display by stable display id from the fresh list", () => { + const descriptor = describeRecordingSource("win32", display); + expect( + resolveRecordingSource(descriptor, "win32", [ + { ...display, id: "screen:new-id", name: "Renamed display" }, + ]), + ).toMatchObject({ id: "screen:new-id", display_id: "stable-1" }); + }); + + it("uses an exact display identity fallback and refuses missing or ambiguous matches", () => { + const descriptor = describeRecordingSource("win32", display); + expect(resolveRecordingSource(descriptor, "win32", [display])).toEqual(display); + expect(resolveRecordingSource(descriptor, "darwin", [display])).toBeNull(); + expect(resolveRecordingSource(descriptor, "win32", [])).toBeNull(); + expect( + resolveRecordingSource(descriptor, "win32", [{ ...display }, { ...display, id: "screen:2" }]), + ).toEqual(display); + expect( + resolveRecordingSource(descriptor, "win32", [{ ...display }, { ...display }]), + ).toBeNull(); + }); + + it("requires both live id and name for windows and never guesses by title", () => { + const saved = describeRecordingSource("win32", { + id: "window:42", + name: "Notes", + display_id: "", + }); + expect( + resolveRecordingSource(saved, "win32", [{ id: "window:99", name: "Notes", display_id: "" }]), + ).toBeNull(); + expect( + resolveRecordingSource(saved, "win32", [{ id: "window:42", name: "Notes", display_id: "" }]), + ).toMatchObject({ id: "window:42" }); + }); + + it("keeps a live window when only its title changed", () => { + const selected = { id: "window:42", name: "Notes", display_id: "" }; + const renamed = { id: "window:42", name: "Notes — saved", display_id: "" }; + expect(resolveCurrentRecordingSource(selected, null, "win32", [renamed])).toEqual(renamed); + expect( + resolveRecordingSource(describeRecordingSource("win32", selected), "win32", [renamed]), + ).toBeNull(); + }); + + it("persists interactive source picks and skips CLI ones", () => { + expect(shouldPersistSelectedSource()).toBe(true); + expect(shouldPersistSelectedSource({ persist: true })).toBe(true); + expect(shouldPersistSelectedSource({ persist: false })).toBe(false); + }); + + it("does not resurrect a persisted source after reset during enumeration", () => { + const persisted = describeRecordingSource("win32", display); + let selected: { id: string; name: string; display_id: string } | null = null; + let lastSource: ReturnType | null = persisted; + const selectedBefore = selected; + const lastSourceBefore = lastSource; + lastSource = null; + selected = null; + const decision = restoreRecordingSourceAfterEnumeration({ + selectedBefore, + selectedAfter: selected, + lastSourceBefore, + lastSourceAfter: lastSource, + platform: "win32", + sources: [display], + }); + expect(decision.apply).toBe(false); + expect(decision.restored).toBeNull(); + expect(selected).toBeNull(); + expect( + restoreRecordingSourceAfterEnumeration({ + selectedBefore: null, + selectedAfter: null, + lastSourceBefore: persisted, + lastSourceAfter: persisted, + platform: "win32", + sources: [display], + }).restored, + ).toEqual(display); + }); + + it("enumerates only when a live or persisted source exists", () => { + expect(shouldEnumerateRecordingSources(null, null)).toBe(false); + expect( + shouldEnumerateRecordingSources({ id: "window:1", name: "A", display_id: "" }, null), + ).toBe(true); + expect(shouldEnumerateRecordingSources(null, describeRecordingSource("win32", display))).toBe( + true, + ); + }); + + it("does not treat a screen-only enum as proof a window is gone", () => { + expect(enumerationIncludesSourceKind(["screen"], "window:42")).toBe(false); + expect(enumerationIncludesSourceKind(["window"], "window:42")).toBe(true); + expect(enumerationIncludesSourceKind(["screen", "window"], "window:42")).toBe(true); + expect(enumerationIncludesSourceKind(undefined, "window:42")).toBe(true); + expect(enumerationIncludesSourceKind(["screen"], null)).toBe(true); + }); + + it("keeps the other kind in the enumeration cache across a partial list", () => { + const screen = { id: "screen:1:0", name: "Display 1" }; + const windowSource = { id: "window:42", name: "Notes" }; + const previous = new Map([ + [screen.id, screen], + [windowSource.id, windowSource], + ]); + const merged = mergeEnumeratedSources( + previous, + [{ ...screen, name: "Display 1 HDR" }], + ["screen"], + ); + expect(merged.get(windowSource.id)).toEqual(windowSource); + expect(merged.get(screen.id)).toMatchObject({ name: "Display 1 HDR" }); + }); + + it("never restores a source when the Wayland portal owns selection", () => { + const descriptor = describeRecordingSource("linux", display); + expect( + resolveRecordingSource(descriptor, "linux", [display], { waylandPortal: true }), + ).toBeNull(); + expect( + resolveRecordingSource(descriptor, "linux", [display], { waylandPortal: false }), + ).toEqual(display); + }); +}); diff --git a/electron/recording-source-settings.ts b/electron/recording-source-settings.ts new file mode 100644 index 000000000..d8d89425b --- /dev/null +++ b/electron/recording-source-settings.ts @@ -0,0 +1,182 @@ +import type { RecordingSourceDescriptor } from "./app-settings"; + +export interface LiveRecordingSource { + id: string; + name: string; + display_id: string; +} + +export function recordingSourceKindFromId(id: string): RecordingSourceDescriptor["kind"] { + return id.startsWith("window:") ? "window" : "screen"; +} + +export function describeRecordingSource( + platform: NodeJS.Platform, + source: LiveRecordingSource, +): RecordingSourceDescriptor { + return { + platform, + kind: recordingSourceKindFromId(source.id), + id: source.id, + name: source.name, + displayId: source.display_id || null, + }; +} + +/** + * A screen-only (or window-only) enumeration cannot prove a source of the + * other kind is gone. Clearing the live pick in that case is a false negative. + */ +export function enumerationIncludesSourceKind( + requestedTypes: readonly string[] | undefined, + sourceId: string | null | undefined, +): boolean { + if (!sourceId) return true; + const types = requestedTypes && requestedTypes.length > 0 ? requestedTypes : ["screen", "window"]; + return types.includes(recordingSourceKindFromId(sourceId)); +} + +export function mergeEnumeratedSources( + previous: ReadonlyMap, + next: readonly T[], + requestedTypes: readonly string[] | undefined, +): Map { + const types = requestedTypes && requestedTypes.length > 0 ? requestedTypes : ["screen", "window"]; + const merged = new Map(previous); + for (const id of [...merged.keys()]) { + if (types.includes(recordingSourceKindFromId(id))) { + merged.delete(id); + } + } + for (const source of next) { + merged.set(source.id, source); + } + return merged; +} + +/** HUD/RecStage persist the pick; CLI capture must not overwrite that default. */ +export function shouldPersistSelectedSource(options?: { persist?: boolean }): boolean { + return options?.persist !== false; +} + +export function sameRecordingSourceDescriptor( + left: RecordingSourceDescriptor | null | undefined, + right: RecordingSourceDescriptor | null | undefined, +): boolean { + if (left == null && right == null) return true; + if (left == null || right == null) return false; + return ( + left.platform === right.platform && + left.kind === right.kind && + left.id === right.id && + left.name === right.name && + left.displayId === right.displayId + ); +} + +/** + * Drop a restore result when the live selection or the persisted descriptor + * changed while enumeration was in flight. This includes reset: both sides can + * still be null while lastSource went from A to empty. + */ +export function shouldCommitRestoredRecordingSource(options: { + selectedBeforeId?: string | null; + selectedAfterId?: string | null; + lastSourceBefore: RecordingSourceDescriptor | null | undefined; + lastSourceAfter: RecordingSourceDescriptor | null | undefined; +}): boolean { + return ( + (options.selectedBeforeId ?? null) === (options.selectedAfterId ?? null) && + sameRecordingSourceDescriptor(options.lastSourceBefore, options.lastSourceAfter) + ); +} + +export function restoreRecordingSourceAfterEnumeration(options: { + selectedBefore: LiveRecordingSource | null | undefined; + selectedAfter: LiveRecordingSource | null | undefined; + lastSourceBefore: RecordingSourceDescriptor | null | undefined; + lastSourceAfter: RecordingSourceDescriptor | null | undefined; + platform: NodeJS.Platform; + sources: readonly LiveRecordingSource[]; +}): { apply: boolean; restored: LiveRecordingSource | null } { + if ( + !shouldCommitRestoredRecordingSource({ + selectedBeforeId: options.selectedBefore?.id, + selectedAfterId: options.selectedAfter?.id, + lastSourceBefore: options.lastSourceBefore, + lastSourceAfter: options.lastSourceAfter, + }) + ) { + return { apply: false, restored: null }; + } + return { + apply: true, + restored: resolveCurrentRecordingSource( + options.selectedBefore, + options.lastSourceBefore, + options.platform, + options.sources, + ), + }; +} + +/** True when restoration or liveness checking has a source to look up. */ +export function shouldEnumerateRecordingSources( + selected: LiveRecordingSource | null | undefined, + lastSource: RecordingSourceDescriptor | null | undefined, +): boolean { + return Boolean(selected?.id || lastSource); +} + +/** + * In-memory selections stay bound to the live id: a browser tab or document title + * can change without the window going away. Disk restore stays strict. + */ +export function resolveLiveRecordingSource( + selected: LiveRecordingSource, + sources: readonly LiveRecordingSource[], +): LiveRecordingSource | null { + const exact = sources.filter((source) => source.id === selected.id); + return exact.length === 1 ? exact[0] : null; +} + +export function resolveCurrentRecordingSource( + selected: LiveRecordingSource | null | undefined, + lastSource: RecordingSourceDescriptor | null | undefined, + platform: NodeJS.Platform, + sources: readonly LiveRecordingSource[], + options: { waylandPortal?: boolean } = {}, +): LiveRecordingSource | null { + if (selected?.id) { + return resolveLiveRecordingSource(selected, sources); + } + return resolveRecordingSource(lastSource ?? null, platform, sources, options); +} + +/** Resolves a stored logical descriptor only to an item from a fresh enumeration. */ +export function resolveRecordingSource( + descriptor: RecordingSourceDescriptor | null, + platform: NodeJS.Platform, + sources: readonly LiveRecordingSource[], + options: { waylandPortal?: boolean } = {}, +): LiveRecordingSource | null { + if (!descriptor || descriptor.platform !== platform || options.waylandPortal) return null; + const kindMatches = sources.filter( + (source) => (source.id.startsWith("window:") ? "window" : "screen") === descriptor.kind, + ); + if (descriptor.kind === "window") { + const exact = kindMatches.filter( + (source) => source.id === descriptor.id && source.name === descriptor.name, + ); + return exact.length === 1 ? exact[0] : null; + } + + if (descriptor.displayId) { + const stable = kindMatches.filter((source) => source.display_id === descriptor.displayId); + if (stable.length === 1) return stable[0]; + } + const exact = kindMatches.filter( + (source) => source.id === descriptor.id && source.name === descriptor.name, + ); + return exact.length === 1 ? exact[0] : null; +} diff --git a/src/App.tsx b/src/App.tsx index aa7619d26..163632f11 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -35,6 +35,11 @@ const ProviderSettingsDialog = lazy(() => default: module.ProviderSettingsDialog, })), ); +const AppSettingsDialog = lazy(() => + import("./components/ai-edition/AppSettings").then((module) => ({ + default: module.AppSettingsDialog, + })), +); export default function App() { const [windowType, setWindowType] = useState( @@ -146,6 +151,7 @@ export default function App() { + diff --git a/src/cli/CliRecordRunner.tsx b/src/cli/CliRecordRunner.tsx index f588caa4c..a3c232472 100644 --- a/src/cli/CliRecordRunner.tsx +++ b/src/cli/CliRecordRunner.tsx @@ -120,6 +120,10 @@ export function CliRecordRunner() { setMicrophoneDeviceName, setSystemAudioEnabled, setCursorCaptureMode, + setWebcamEnabled, + setWebcamDeviceId, + setWebcamDeviceName, + recordingPrefsLoaded, } = recorder; // Keep latest values in refs for the stop/finish effects. @@ -137,9 +141,11 @@ export function CliRecordRunner() { }; // Bootstrap: pick source, configure recorder, start. + // Wait for persisted GUI prefs to land first, then overwrite every field + // from the CLI request so a previous HUD session cannot enable mic/webcam. // biome-ignore lint/correctness/useExhaustiveDependencies: intentional run-once bootstrap; startedRef guards re-entry useEffect(() => { - if (startedRef.current) return; + if (startedRef.current || !recordingPrefsLoaded) return; startedRef.current = true; void (async () => { @@ -157,18 +163,24 @@ export function CliRecordRunner() { requestRef.current = request; const source = await pickSource(request); - await window.electronAPI.selectSource(source); + await window.electronAPI.selectSource(source, { persist: false }); window.electronAPI.cliLog("info", `Recording source: ${source.name}`); + setMicrophoneEnabled(Boolean(request.mic)); if (request.mic) { const mic = await resolveMicDeviceId(request.micDevice); - setMicrophoneEnabled(true); setMicrophoneDeviceId(mic.deviceId); setMicrophoneDeviceName(mic.deviceName); if (mic.deviceName) { window.electronAPI.cliLog("info", `Microphone: ${mic.deviceName}`); } + } else { + setMicrophoneDeviceId(undefined); + setMicrophoneDeviceName(undefined); } + await setWebcamEnabled(false); + setWebcamDeviceId(undefined); + setWebcamDeviceName(undefined); setSystemAudioEnabled(request.systemAudio); setCursorCaptureMode(request.cursorMode); setStatus("Starting recording…"); @@ -177,7 +189,7 @@ export function CliRecordRunner() { await fail(error); } })(); - }, []); + }, [recordingPrefsLoaded]); // The setters above land on the *next* render; start only once they have. const configuredRef = useRef(false); @@ -185,10 +197,11 @@ export function CliRecordRunner() { useEffect(() => { const request = requestReady; if (!request || configuredRef.current || phaseRef.current !== "init") return; - const micReady = !request.mic || recorder.microphoneEnabled; + const micReady = recorder.microphoneEnabled === Boolean(request.mic); + const webcamReady = !recorder.webcamEnabled; const systemAudioReady = recorder.systemAudioEnabled === request.systemAudio; const cursorReady = recorder.cursorCaptureMode === request.cursorMode; - if (!micReady || !systemAudioReady || !cursorReady) return; + if (!micReady || !webcamReady || !systemAudioReady || !cursorReady) return; configuredRef.current = true; phaseRef.current = "recording"; @@ -214,6 +227,7 @@ export function CliRecordRunner() { }, [ requestReady, recorder.microphoneEnabled, + recorder.webcamEnabled, recorder.systemAudioEnabled, recorder.cursorCaptureMode, ]); diff --git a/src/components/ai-edition/AppSettings.test.tsx b/src/components/ai-edition/AppSettings.test.tsx new file mode 100644 index 000000000..505a8afb1 --- /dev/null +++ b/src/components/ai-edition/AppSettings.test.tsx @@ -0,0 +1,186 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { EditorDialogsProvider, useEditorDialogActions } from "@/contexts/EditorDialogsContext"; +import { createEmptyDocument } from "@/lib/ai-edition/schema"; +import { DEFAULT_PROJECT_APPEARANCE } from "@/lib/projectDefaults"; +import { AppSettingsDialog } from "./AppSettings"; + +const project = vi.hoisted(() => ({ + document: null as ReturnType | null, +})); +vi.mock("@/lib/ai-edition/store/projectStore", () => ({ + useProjectStore: (selector: (state: typeof project) => unknown) => selector(project), +})); +const microphoneHook = vi.hoisted(() => ({ + enabled: [] as boolean[], +})); +vi.mock("@/hooks/useMicrophoneDevices", () => ({ + useMicrophoneDevices: (enabled: boolean) => { + microphoneHook.enabled.push(enabled); + return { + devices: [{ deviceId: "mic-live", label: "Live microphone", groupId: "g" }], + selectedDeviceId: "mic-live", + setSelectedDeviceId: vi.fn(), + }; + }, +})); +vi.mock("@/hooks/useCameraDevices", () => ({ + useCameraDevices: () => ({ + devices: [{ deviceId: "cam-live", label: "Live camera", groupId: "g" }], + selectedDeviceId: "cam-live", + setSelectedDeviceId: vi.fn(), + }), +})); +vi.mock("@/contexts/I18nContext", () => ({ + useScopedT: () => (key: string, vars?: Record) => + vars ? `${key}:${Object.values(vars).join(":")}` : key, +})); + +const recording = { + micEnabled: false, + micDeviceId: null, + micDeviceName: null, + camEnabled: false, + camDeviceId: null, + camDeviceName: null, + systemAudioEnabled: false, + cursorCaptureMode: "editable-overlay" as const, +}; +const snapshot = { + recording, + lastSource: null, + appearance: { version: 1 as const, custom: false, defaults: DEFAULT_PROJECT_APPEARANCE }, +}; + +function OpenSettings() { + const { openDialog, closeDialog } = useEditorDialogActions(); + return ( + <> + + + + ); +} + +function renderSettings() { + render( + + + + , + ); + fireEvent.click(screen.getByText("open settings")); +} + +describe("AppSettings", () => { + beforeEach(() => { + microphoneHook.enabled = []; + project.document = createEmptyDocument({ projectId: "proj_settings", title: "Settings" }); + window.electronAPI = { + getAppSettings: vi.fn(async () => snapshot), + setRecordingPrefs: vi.fn(async (value) => ({ ...recording, ...value })), + setProjectAppearanceDefaults: vi.fn(async (defaults) => ({ + ...snapshot, + appearance: { version: 1 as const, custom: true, defaults }, + })), + resetProjectAppearanceDefaults: vi.fn(async () => snapshot), + resetRecordingSetup: vi.fn(async () => snapshot), + } as unknown as typeof window.electronAPI; + }); + + it("does not enable microphone enumeration until the microphone is on", async () => { + renderSettings(); + await screen.findByTestId("app-settings-dialog"); + expect(microphoneHook.enabled.at(-1)).toBe(false); + fireEvent.click(screen.getByLabelText("appSettings.microphone")); + expect(microphoneHook.enabled.at(-1)).toBe(true); + }); + + it("loads settings and saves edited recording toggles", async () => { + renderSettings(); + expect(await screen.findByTestId("app-settings-dialog")).toBeInTheDocument(); + fireEvent.click(screen.getByLabelText("appSettings.systemAudio")); + fireEvent.click(screen.getByText("appSettings.saveRecording")); + await waitFor(() => + expect(window.electronAPI.setRecordingPrefs).toHaveBeenCalledWith( + expect.objectContaining({ systemAudioEnabled: true }), + ), + ); + expect(await screen.findByRole("status")).toHaveTextContent("appSettings.saved"); + }); + + it("shows a failed save and never reports Saved", async () => { + vi.mocked(window.electronAPI.setRecordingPrefs).mockRejectedValueOnce(new Error("disk full")); + renderSettings(); + await screen.findByTestId("app-settings-dialog"); + fireEvent.click(screen.getByText("appSettings.saveRecording")); + expect(await screen.findByRole("alert")).toHaveTextContent("disk full"); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); + + it("shows a load failure without rendering stale settings", async () => { + vi.mocked(window.electronAPI.getAppSettings).mockRejectedValueOnce(new Error("read failed")); + renderSettings(); + expect(await screen.findByRole("alert")).toHaveTextContent("read failed"); + expect(screen.queryByText("appSettings.saveRecording")).not.toBeInTheDocument(); + }); + + it("does not let a stale save overwrite a newer local edit", async () => { + let finishSave!: (value: typeof recording) => void; + vi.mocked(window.electronAPI.setRecordingPrefs).mockImplementation( + () => + new Promise((resolve) => { + finishSave = resolve; + }), + ); + renderSettings(); + await screen.findByTestId("app-settings-dialog"); + fireEvent.click(screen.getByLabelText("appSettings.systemAudio")); + fireEvent.click(screen.getByText("appSettings.saveRecording")); + fireEvent.click(screen.getByLabelText("appSettings.systemAudio")); + expect(screen.getByLabelText("appSettings.systemAudio")).not.toBeChecked(); + finishSave({ ...recording, systemAudioEnabled: true }); + await waitFor(() => + expect(window.electronAPI.setRecordingPrefs).toHaveBeenCalledWith( + expect.objectContaining({ systemAudioEnabled: true }), + ), + ); + expect(screen.getByLabelText("appSettings.systemAudio")).not.toBeChecked(); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); + + it("clears loaded settings when a later open fails to load", async () => { + vi.mocked(window.electronAPI.getAppSettings) + .mockResolvedValueOnce(snapshot) + .mockRejectedValueOnce(new Error("read failed")); + renderSettings(); + await screen.findByText("appSettings.saveRecording"); + fireEvent.click(screen.getByText("close settings")); + fireEvent.click(screen.getByText("open settings")); + expect(await screen.findByRole("alert")).toHaveTextContent("read failed"); + expect(screen.queryByText("appSettings.saveRecording")).not.toBeInTheDocument(); + }); + + it("copies the current look and exposes both reset actions", async () => { + renderSettings(); + await screen.findByTestId("app-settings-dialog"); + fireEvent.click(screen.getByText("appSettings.useCurrentAppearance")); + await waitFor(() => + expect(window.electronAPI.setProjectAppearanceDefaults).toHaveBeenCalledWith( + expect.objectContaining({ padding: 50, aspectRatio: "16:9" }), + ), + ); + fireEvent.click(screen.getByText("appSettings.resetAppearance")); + await waitFor(() => + expect(window.electronAPI.resetProjectAppearanceDefaults).toHaveBeenCalled(), + ); + fireEvent.click(screen.getByText("appSettings.resetRecording")); + await waitFor(() => expect(window.electronAPI.resetRecordingSetup).toHaveBeenCalled()); + }); +}); diff --git a/src/components/ai-edition/AppSettings.tsx b/src/components/ai-edition/AppSettings.tsx new file mode 100644 index 000000000..6cc9121b6 --- /dev/null +++ b/src/components/ai-edition/AppSettings.tsx @@ -0,0 +1,294 @@ +import { Camera, Loader2, Mic, Monitor, MousePointer2, RotateCcw, Save } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { useEditorDialogActions, useEditorDialogSection } from "@/contexts/EditorDialogsContext"; +import { useScopedT } from "@/contexts/I18nContext"; +import { useCameraDevices } from "@/hooks/useCameraDevices"; +import { useMicrophoneDevices } from "@/hooks/useMicrophoneDevices"; +import { getEditorSettings } from "@/lib/ai-edition/store/editorSettings"; +import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; +import { projectAppearanceFromEditorSettings } from "@/lib/projectDefaults"; +import type { AppSettingsSnapshot, RecordingPreferences } from "../../../electron/app-settings"; +import { ModalShell } from "./Modals"; +import styles from "./NewEditorShell.module.css"; + +type Status = "idle" | "loading" | "saving" | "saved" | "error"; + +function AppSettings({ open, onClose }: { open: boolean; onClose: () => void }) { + const t = useScopedT("editor"); + const document = useProjectStore((state) => state.document); + const [snapshot, setSnapshot] = useState(null); + const [recording, setRecording] = useState(null); + const [status, setStatus] = useState("idle"); + const [error, setError] = useState(null); + const generationRef = useRef(0); + + useEffect(() => { + if (!open) { + generationRef.current += 1; + return; + } + const generation = ++generationRef.current; + setStatus("loading"); + setError(null); + setSnapshot(null); + setRecording(null); + void window.electronAPI + .getAppSettings() + .then((value) => { + if (generation !== generationRef.current) return; + setSnapshot(value); + setRecording(value.recording); + setStatus("idle"); + }) + .catch((cause) => { + if (generation !== generationRef.current) return; + setError(cause instanceof Error ? cause.message : String(cause)); + setStatus("error"); + }); + }, [open]); + + const microphones = useMicrophoneDevices( + open && Boolean(recording?.micEnabled), + recording?.micDeviceId ?? undefined, + recording?.micDeviceName ?? undefined, + ); + const cameras = useCameraDevices( + open, + recording?.camDeviceId ?? undefined, + recording?.camDeviceName ?? undefined, + ); + + const run = async (work: () => Promise) => { + const generation = ++generationRef.current; + setStatus("saving"); + setError(null); + try { + const next = await work(); + if (generation !== generationRef.current) return; + setSnapshot(next); + setRecording(next.recording); + setStatus("saved"); + } catch (cause) { + if (generation !== generationRef.current) return; + setError(cause instanceof Error ? cause.message : String(cause)); + setStatus("error"); + } + }; + + const saveRecording = () => { + if (!recording) return; + void run(async () => { + const saved = await window.electronAPI.setRecordingPrefs(recording); + const next = await window.electronAPI.getAppSettings(); + return { ...next, recording: saved }; + }); + }; + const editRecording = (patch: Partial) => { + generationRef.current += 1; + setRecording((current) => (current ? { ...current, ...patch } : current)); + setStatus("idle"); + setError(null); + }; + + return ( + +
+ {status === "loading" ? ( +
+ {t("appSettings.loading")} +
+ ) : recording && snapshot ? ( + <> +
+

+ {t("appSettings.recordingTitle")} +

+ editRecording({ systemAudioEnabled: value })} + /> + editRecording({ micEnabled: value })} + /> + + editRecording({ camEnabled: value })} + /> + + + editRecording({ + cursorCaptureMode: value ? "editable-overlay" : "system", + }) + } + /> +

+ {t("appSettings.lastSource", { + source: snapshot.lastSource?.name ?? t("appSettings.noSource"), + })} +

+
+ + +
+
+
+

+ {t("appSettings.appearanceTitle")} +

+

+ {snapshot.appearance.custom + ? t("appSettings.customAppearance") + : t("appSettings.factoryAppearance")} +

+

+ {t("appSettings.appearanceSummary", { + aspect: snapshot.appearance.defaults.aspectRatio, + padding: snapshot.appearance.defaults.padding, + radius: snapshot.appearance.defaults.borderRadius, + cursor: snapshot.appearance.defaults.cursorTheme, + camera: snapshot.appearance.defaults.webcamLayoutPreset, + })} +

+
+ + +
+
+ + ) : null} + {status === "saved" ?

{t("appSettings.saved")}

: null} + {error ?

{t("appSettings.saveFailed", { error })}

: null} +
+
+ ); +} + +function SettingToggle({ + label, + checked, + disabled, + onChange, +}: { + label: string; + checked: boolean; + disabled?: boolean; + onChange: (value: boolean) => void; +}) { + return ( + + ); +} + +export function AppSettingsDialog() { + const section = useEditorDialogSection(); + const { closeDialog } = useEditorDialogActions(); + return ; +} diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx index fabfc3acb..596ea0c67 100644 --- a/src/components/ai-edition/NewEditorShell.tsx +++ b/src/components/ai-edition/NewEditorShell.tsx @@ -1547,6 +1547,7 @@ export function NewEditorShell() { save: () => void handleSave(), export: handleExport, openSettings: handleOpenSettings, + openAppSettings: () => openDialog("settings"), renameProject: handleRenameProject, toggleChat: () => setChatOpen((v) => !v), openProviderSettings: () => openDialog("providers"), diff --git a/src/components/ai-edition/ProviderSettings.test.tsx b/src/components/ai-edition/ProviderSettings.test.tsx index 6f20c3ebc..63508dd53 100644 --- a/src/components/ai-edition/ProviderSettings.test.tsx +++ b/src/components/ai-edition/ProviderSettings.test.tsx @@ -54,6 +54,7 @@ function TopBar({ mode }: { mode: EditorMode }) { save: noop, export: noop, openSettings: noop, + openAppSettings: noop, renameProject: noop, toggleChat: noop, openProviderSettings: () => openDialog("providers"), diff --git a/src/components/ai-edition/v4/EditorTopBar.test.tsx b/src/components/ai-edition/v4/EditorTopBar.test.tsx index 087c3cc38..f904d88ec 100644 --- a/src/components/ai-edition/v4/EditorTopBar.test.tsx +++ b/src/components/ai-edition/v4/EditorTopBar.test.tsx @@ -19,15 +19,16 @@ import { EditorTopBar } from "./EditorTopBar"; const noop = () => {}; -function renderTopBar(projectTitle: string | null) { +function renderTopBar(projectTitle: string | null, mode: "media" | "edit" | "rec" = "edit") { const onRename = vi.fn(); const onShowAbout = vi.fn(); const onCheckForUpdates = vi.fn(); const onOpenSettings = vi.fn(); + const onOpenAppSettings = vi.fn(); const onOpenProviderSettings = vi.fn(); render( , ); - return { onRename, onShowAbout, onCheckForUpdates, onOpenSettings, onOpenProviderSettings }; + return { + onRename, + onShowAbout, + onCheckForUpdates, + onOpenSettings, + onOpenAppSettings, + onOpenProviderSettings, + }; } /** The menu reads two separate channels, and they answer different questions: `getAppInfo` for @@ -163,17 +172,25 @@ describe("AppMenu", () => { expect(trigger.getAttribute("style") ?? "").not.toMatch(/all\s*:\s*unset/); }); - it("opens on click and offers shortcuts, AI settings and about", () => { + it("opens on click and offers app settings, shortcuts, AI settings and about", () => { renderTopBar("Demo Project"); fireEvent.click(screen.getByRole("button", { name: /OpenScreen/ })); expect(screen.getByRole("menu")).toBeInTheDocument(); // Exact names: the translator echoes keys, and both settings rows are labelled with a // `…title` key, so a /title/ match would hit two items and pin neither. expect(screen.getByRole("menuitem", { name: "title" })).toBeInTheDocument(); + expect(screen.getByRole("menuitem", { name: "appSettings.title" })).toBeInTheDocument(); expect(screen.getByRole("menuitem", { name: "providerSettings.title" })).toBeInTheDocument(); expect(screen.getByRole("menuitem", { name: /actions\.about/ })).toBeInTheDocument(); }); + it.each(["media", "edit", "rec"] as const)("opens app settings from %s mode", (mode) => { + const { onOpenAppSettings } = renderTopBar("Demo Project", mode); + fireEvent.click(screen.getByRole("button", { name: /OpenScreen/ })); + fireEvent.click(screen.getByRole("menuitem", { name: "appSettings.title" })); + expect(onOpenAppSettings).toHaveBeenCalledTimes(1); + }); + // Issue #420: the AI dialog used to be openable only from the chat panel, which mounts in // Edit mode with the panel expanded. The row is unconditional here — its dialog is mounted // in App.tsx, above every mode — so the menu does not lie in Media and Rec. diff --git a/src/components/ai-edition/v4/EditorTopBar.tsx b/src/components/ai-edition/v4/EditorTopBar.tsx index 0fa4ce90e..ea00e541d 100644 --- a/src/components/ai-edition/v4/EditorTopBar.tsx +++ b/src/components/ai-edition/v4/EditorTopBar.tsx @@ -10,6 +10,7 @@ import { PanelLeft, RefreshCw, Save, + Settings, Sparkles, Sun, } from "lucide-react"; @@ -28,6 +29,7 @@ export interface TopBarActions { save: () => void; export: () => void; openSettings: () => void; + openAppSettings: () => void; renameProject: (title: string) => void; toggleChat: () => void; openProviderSettings: () => void; @@ -397,6 +399,15 @@ function AppMenu({ actions }: { actions: TopBarActions }) { {open ? (
+ diff --git a/src/components/launch/LaunchWindow.test.tsx b/src/components/launch/LaunchWindow.test.tsx index e1bab5da0..acc706483 100644 --- a/src/components/launch/LaunchWindow.test.tsx +++ b/src/components/launch/LaunchWindow.test.tsx @@ -467,6 +467,48 @@ describe("LaunchWindow record button", () => { expect(window.electronAPI.openSourceSelector).not.toHaveBeenCalled(); }); + it("stops immediately without waiting for device readiness", async () => { + recorderState.value.recording = true; + recorderState.value.recordingPrefsLoaded = false; + stubElectronAPI(vi.fn(async () => displayOneSource)); + + renderLaunchWindow(); + const recordButton = await screen.findByTestId("launch-record-button"); + fireEvent.click(recordButton); + + expect(recorderState.value.toggleRecording).toHaveBeenCalledTimes(1); + }); + + it("can start again after stop when devices were already ready", async () => { + stubElectronAPI(vi.fn(async () => displayOneSource)); + const view = renderLaunchWindow(); + const recordButton = await screen.findByTestId("launch-record-button"); + await waitFor(() => { + expect(recordButton).toHaveAttribute("title", "Display 1"); + }); + + fireEvent.click(recordButton); + expect(recorderState.value.toggleRecording).toHaveBeenCalledTimes(1); + + recorderState.value.recording = true; + view.rerender( + + + , + ); + fireEvent.click(recordButton); + expect(recorderState.value.toggleRecording).toHaveBeenCalledTimes(2); + + recorderState.value.recording = false; + view.rerender( + + + , + ); + fireEvent.click(recordButton); + expect(recorderState.value.toggleRecording).toHaveBeenCalledTimes(3); + }); + it("does not wait for the camera list when the webcam is off", async () => { cameraDevicesState.isReady = false; recorderState.value.webcamEnabled = false; diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index e5a2e5ff7..f8fe67052 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -650,7 +650,12 @@ export function LaunchWindow() { if (startWhenDevicesReadyInFlight.current) { return startWhenDevicesReadyInFlight.current; } - const pending = (async () => { + let settle!: () => void; + const pending = new Promise((resolve) => { + settle = resolve; + }); + startWhenDevicesReadyInFlight.current = pending; + void (async () => { try { for (let attempt = 0; attempt < 100; attempt++) { const ready = deviceReadinessRef.current; @@ -666,10 +671,12 @@ export function LaunchWindow() { } console.warn("Recording did not start because device preferences could not be resolved."); } finally { - startWhenDevicesReadyInFlight.current = null; + if (startWhenDevicesReadyInFlight.current === pending) { + startWhenDevicesReadyInFlight.current = null; + } + settle(); } })(); - startWhenDevicesReadyInFlight.current = pending; return pending; }, []); @@ -740,6 +747,10 @@ export function LaunchWindow() { if (saving) { return; } + if (recording) { + toggleRecording(); + return; + } // 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. @@ -778,6 +789,7 @@ export function LaunchWindow() { recording, saving, startWhenDevicesReady, + toggleRecording, ], ); const handleRecordClick = useCallback(() => handleRecordButtonClick(), [handleRecordButtonClick]); diff --git a/src/native/browserShim.test.ts b/src/native/browserShim.test.ts index c2d849baf..9ce3a647a 100644 --- a/src/native/browserShim.test.ts +++ b/src/native/browserShim.test.ts @@ -136,4 +136,20 @@ describe("browserShim app settings", () => { expect(localStorage.getItem("browser-shim-selected-source")).toBeNull(); expect((await window.electronAPI.getAppSettings()).lastSource).toBeNull(); }); + + it("getAppSettings after selectSource does not need Node process", async () => { + const source = (await window.electronAPI.getSources({ types: ["screen"] }))[0]; + await window.electronAPI.selectSource(source); + const original = (globalThis as { process?: unknown }).process; + try { + delete (globalThis as { process?: unknown }).process; + await expect(window.electronAPI.getAppSettings()).resolves.toMatchObject({ + lastSource: { id: source.id, name: source.name }, + }); + } finally { + if (original !== undefined) { + (globalThis as { process?: unknown }).process = original; + } + } + }); }); diff --git a/src/native/browserShim.ts b/src/native/browserShim.ts index 15a806935..c297ed98f 100644 --- a/src/native/browserShim.ts +++ b/src/native/browserShim.ts @@ -16,6 +16,7 @@ import { type ProjectAppearanceDefaults, parseProjectAppearanceDefaults, } from "../lib/projectDefaults"; +import { getPlatform } from "../utils/platformUtils"; import { nativeBridgeClient as realClient } from "./client"; function detectBrowserMode(): boolean { @@ -126,7 +127,7 @@ function shimAppSettingsSnapshot() { return { recording: shimRecordingPrefs, lastSource: shimSelectedSource - ? describeRecordingSource(process.platform, { + ? describeRecordingSource(getPlatform(), { id: shimSelectedSource.id, name: shimSelectedSource.name, display_id: shimSelectedSource.display_id, From 19abbd7a6857646c321a743df1f06b768e619a3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E8=B6=85=E8=B6=85=E8=B6=85=E8=B6=85=E7=BA=A7?= =?UTF-8?q?=E5=96=9C=E6=AC=A2=E4=BD=A0=E7=9A=84=E8=BE=BE=E5=A6=AE=E5=A8=85?= <176143450+My-Denia@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:21:02 +0800 Subject: [PATCH 28/39] fix(settings): stop camera pref ping-pong and stale select-source writes HUD camera write-back waits for persisted prefs so enumeration cam1 cannot overwrite a late cam2. CLI record windows skip applying GUI camera prefs so getUserMedia never runs while sources are still pending. select-source completions check a generation so reset or a newer pick wins. Appearance save/reset no longer replaces an unsaved recording draft. --- electron/ipc/handlers.ts | 91 +++++++------ electron/ipc/selectSourceOwnership.test.ts | 120 ++++++++++++++++++ electron/ipc/selectSourceOwnership.ts | 88 +++++++++++++ .../CliRecordRunner.cameraIsolation.test.tsx | 81 ++++++++++++ .../ai-edition/AppSettings.test.tsx | 18 +++ src/components/ai-edition/AppSettings.tsx | 18 ++- src/components/launch/LaunchWindow.tsx | 37 ++---- src/hooks/useCameraHudSync.test.tsx | 85 +++++++++++++ src/hooks/useCameraHudSync.ts | 44 +++++++ src/hooks/useScreenRecorder.ts | 11 +- 10 files changed, 514 insertions(+), 79 deletions(-) create mode 100644 electron/ipc/selectSourceOwnership.test.ts create mode 100644 electron/ipc/selectSourceOwnership.ts create mode 100644 src/cli/CliRecordRunner.cameraIsolation.test.tsx create mode 100644 src/hooks/useCameraHudSync.test.tsx create mode 100644 src/hooks/useCameraHudSync.ts diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index e3a02808b..3d6e267af 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -117,6 +117,11 @@ import { registerNativeBridgeHandlers } from "./nativeBridge"; import { createNativeMacMidCaptureErrorWatch } from "./nativeMacMidCaptureErrorWatch"; import { registerRecordingPrefsHandlers } from "./recordingPrefs"; import { RecordingStreamRegistry, registerRecordingStreamHandlers } from "./recordingStream"; +import { + resetSelectSource, + type SelectSourceContext, + selectSourceWithOwnership, +} from "./selectSourceOwnership"; const PROJECT_FILE_EXTENSION = "openscreen"; export const SHORTCUTS_FILE = path.join(app.getPath("userData"), "shortcuts.json"); @@ -602,6 +607,7 @@ type AttachNativeMacWebcamRecordingInput = { let selectedSource: SelectedSource | null = null; let selectedDesktopSource: DesktopCapturerSource | null = null; let lastEnumeratedSources = new Map(); +const selectSourceGeneration = { value: 0 }; let currentProjectPath: string | null = null; let currentRecordingSession: RecordingSession | null = null; @@ -1982,54 +1988,49 @@ export function registerIpcHandlers( })); }); + const selectSourceContext: SelectSourceContext = { + generation: selectSourceGeneration, + getSelected: () => ({ source: selectedSource, live: selectedDesktopSource }), + setSelected: (source, live) => { + selectedSource = source; + selectedDesktopSource = live; + }, + getCached: (id) => lastEnumeratedSources.get(id) ?? null, + replaceCache: (sources) => { + lastEnumeratedSources = new Map(sources.map((candidate) => [candidate.id, candidate])); + }, + }; + ipcMain.handle( "select-source", async (_, source: SelectedSource, options?: { persist?: boolean }) => { - // Reuse the exact source object returned during enumeration to avoid - // Windows window-source id mismatches across separate getSources() calls. - selectedDesktopSource = - typeof source.id === "string" ? (lastEnumeratedSources.get(source.id) ?? null) : null; - - if (!selectedDesktopSource && typeof source.id === "string") { - try { - const sources = await desktopCapturer.getSources({ - types: ["screen", "window"], - thumbnailSize: { width: 0, height: 0 }, - fetchWindowIcons: true, - }); - lastEnumeratedSources = new Map(sources.map((candidate) => [candidate.id, candidate])); - selectedDesktopSource = lastEnumeratedSources.get(source.id) ?? null; - } catch { - selectedDesktopSource = null; - } - } - if (!selectedDesktopSource) { - selectedSource = null; - broadcastSelectedSource(null); - return null; - } - selectedSource = { - id: selectedDesktopSource.id, - name: selectedDesktopSource.name, - display_id: selectedDesktopSource.display_id, - }; - // Persist only a descriptor built from the freshly enumerated live object. - // A failed write must not keep the picker open after a valid live pick. - if (shouldPersistSelectedSource(options)) { - try { - appSettings.setLastSource( - describeRecordingSource(process.platform, selectedSource as Required), - ); - } catch (error) { - console.warn("Failed to persist the selected recording source:", error); + const next = await selectSourceWithOwnership( + selectSourceContext, + { id: source.id, name: source.name, display_id: source.display_id }, + options, + { + getSources: () => + desktopCapturer.getSources({ + types: ["screen", "window"], + thumbnailSize: { width: 0, height: 0 }, + fetchWindowIcons: true, + }), + persist: (live) => { + appSettings.setLastSource( + describeRecordingSource(process.platform, live as Required), + ); + }, + broadcast: broadcastSelectedSource, + shouldPersist: shouldPersistSelectedSource, + }, + ); + if (next) { + const sourceSelectorWin = getSourceSelectorWindow(); + if (sourceSelectorWin) { + sourceSelectorWin.close(); } } - broadcastSelectedSource(selectedSource); - const sourceSelectorWin = getSourceSelectorWindow(); - if (sourceSelectorWin) { - sourceSelectorWin.close(); - } - return selectedSource; + return next; }, ); @@ -2098,9 +2099,7 @@ export function registerIpcHandlers( defaultRecordingPrefs, getMainWindow, () => { - selectedSource = null; - selectedDesktopSource = null; - broadcastSelectedSource(null); + resetSelectSource(selectSourceContext, broadcastSelectedSource); }, () => BrowserWindow.getAllWindows(), ); diff --git a/electron/ipc/selectSourceOwnership.test.ts b/electron/ipc/selectSourceOwnership.test.ts new file mode 100644 index 000000000..88d839af2 --- /dev/null +++ b/electron/ipc/selectSourceOwnership.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from "vitest"; +import { + resetSelectSource, + type SelectSourceContext, + selectSourceWithOwnership, +} from "./selectSourceOwnership"; + +type Live = { id: string; name: string; display_id: string }; + +function createContext() { + let selected: { + source: { name: string; id?: string; display_id?: string } | null; + live: Live | null; + } = { + source: null, + live: null, + }; + let cache = new Map(); + const ctx: SelectSourceContext = { + generation: { value: 0 }, + getSelected: () => selected, + setSelected: (source, live) => { + selected = { source, live }; + }, + getCached: (id) => cache.get(id) ?? null, + replaceCache: (sources) => { + cache = new Map(sources.map((source) => [source.id, source])); + }, + }; + return { + ctx, + get selected() { + return selected; + }, + seedCache(source: Live) { + cache.set(source.id, source); + }, + }; +} + +const sourceA: Live = { id: "screen:a", name: "Display A", display_id: "1" }; +const sourceB: Live = { id: "screen:b", name: "Display B", display_id: "2" }; + +describe("selectSourceWithOwnership", () => { + it("does not restore A after reset while A is still enumerating", async () => { + const harness = createContext(); + let resolveA!: (sources: Live[]) => void; + const persist = vi.fn(); + const broadcast = vi.fn(); + const pendingA = selectSourceWithOwnership( + harness.ctx, + sourceA, + { persist: true }, + { + getSources: () => + new Promise((resolve) => { + resolveA = resolve; + }), + persist, + broadcast, + shouldPersist: () => true, + }, + ); + + resetSelectSource(harness.ctx, broadcast); + expect(harness.selected.source).toBeNull(); + + resolveA([sourceA, sourceB]); + await pendingA; + + expect(harness.selected.source).toBeNull(); + expect(persist).not.toHaveBeenCalled(); + expect(broadcast).toHaveBeenLastCalledWith(null); + }); + + it("keeps B when a slower A enumeration finishes later", async () => { + const harness = createContext(); + harness.seedCache(sourceB); + let resolveA!: (sources: Live[]) => void; + const persist = vi.fn(); + const broadcast = vi.fn(); + + const pendingA = selectSourceWithOwnership( + harness.ctx, + sourceA, + { persist: true }, + { + getSources: () => + new Promise((resolve) => { + resolveA = resolve; + }), + persist, + broadcast, + shouldPersist: () => true, + }, + ); + + await selectSourceWithOwnership( + harness.ctx, + sourceB, + { persist: true }, + { + getSources: async () => [sourceA, sourceB], + persist, + broadcast, + shouldPersist: () => true, + }, + ); + expect(harness.selected.source).toMatchObject({ id: "screen:b" }); + expect(persist).toHaveBeenCalledTimes(1); + expect(persist.mock.calls[0]?.[0]).toMatchObject({ id: "screen:b" }); + + resolveA([sourceA, sourceB]); + await pendingA; + + expect(harness.selected.source).toMatchObject({ id: "screen:b" }); + expect(persist).toHaveBeenCalledTimes(1); + expect(broadcast).toHaveBeenLastCalledWith(expect.objectContaining({ id: "screen:b" })); + }); +}); diff --git a/electron/ipc/selectSourceOwnership.ts b/electron/ipc/selectSourceOwnership.ts new file mode 100644 index 000000000..7a3339f84 --- /dev/null +++ b/electron/ipc/selectSourceOwnership.ts @@ -0,0 +1,88 @@ +export type SelectedSourceSnapshot = { + name: string; + id?: string; + display_id?: string; +}; + +export type SelectSourceContext = { + generation: { value: number }; + getSelected: () => { source: SelectedSourceSnapshot | null; live: TLive | null }; + setSelected: (source: SelectedSourceSnapshot | null, live: TLive | null) => void; + getCached: (id: string) => TLive | null; + replaceCache: (sources: TLive[]) => void; +}; + +export type SelectSourceDeps = { + getSources: () => Promise; + persist: (source: SelectedSourceSnapshot) => void; + broadcast: (source: SelectedSourceSnapshot | null) => void; + shouldPersist: (options?: { persist?: boolean }) => boolean; +}; + +export function bumpSelectSourceGeneration(generation: { value: number }): number { + generation.value += 1; + return generation.value; +} + +export function resetSelectSource( + ctx: SelectSourceContext, + broadcast: (source: SelectedSourceSnapshot | null) => void, +): void { + bumpSelectSourceGeneration(ctx.generation); + ctx.setSelected(null, null); + broadcast(null); +} + +export async function selectSourceWithOwnership< + TLive extends { id: string; name: string; display_id: string }, +>( + ctx: SelectSourceContext, + source: { id?: string; name: string; display_id?: string }, + options: { persist?: boolean } | undefined, + deps: SelectSourceDeps, +): Promise { + const generation = bumpSelectSourceGeneration(ctx.generation); + let live: TLive | null = typeof source.id === "string" ? ctx.getCached(source.id) : null; + + if (!live && typeof source.id === "string") { + try { + const sources = await deps.getSources(); + if (generation !== ctx.generation.value) { + return ctx.getSelected().source; + } + ctx.replaceCache(sources); + live = ctx.getCached(source.id); + } catch { + if (generation !== ctx.generation.value) { + return ctx.getSelected().source; + } + live = null; + } + } + + if (generation !== ctx.generation.value) { + return ctx.getSelected().source; + } + + if (!live) { + ctx.setSelected(null, null); + deps.broadcast(null); + return null; + } + + const next: SelectedSourceSnapshot = { + id: live.id, + name: live.name, + display_id: live.display_id, + }; + ctx.setSelected(next, live); + if (deps.shouldPersist(options)) { + try { + deps.persist(next); + } catch (error) { + console.warn("Failed to persist the selected recording source:", error); + } + } + deps.broadcast(next); + return next; +} diff --git a/src/cli/CliRecordRunner.cameraIsolation.test.tsx b/src/cli/CliRecordRunner.cameraIsolation.test.tsx new file mode 100644 index 000000000..ea64b299c --- /dev/null +++ b/src/cli/CliRecordRunner.cameraIsolation.test.tsx @@ -0,0 +1,81 @@ +// @vitest-environment jsdom +import { render, waitFor } from "@testing-library/react"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/contexts/I18nContext", () => ({ + useScopedT: () => (key: string) => key, +})); +vi.mock("sonner", () => ({ + toast: { error: vi.fn(), success: vi.fn(), info: vi.fn(), warning: vi.fn() }, +})); + +import { CliRecordRunner } from "./CliRecordRunner"; + +const persistedPrefs = { + micEnabled: false, + micDeviceId: null, + micDeviceName: null, + camEnabled: true, + camDeviceId: "cam1", + camDeviceName: "Camera 1", + systemAudioEnabled: false, + cursorCaptureMode: "editable-overlay" as const, +}; + +describe("CliRecordRunner camera isolation", () => { + const getUserMedia = vi.fn(async () => ({ + getTracks: () => [], + getVideoTracks: () => [], + })); + + beforeAll(() => { + window.history.replaceState(null, "", "/?windowType=cli-record"); + Object.defineProperty(global.navigator, "mediaDevices", { + configurable: true, + value: { + enumerateDevices: vi.fn(async () => []), + getUserMedia, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }, + }); + }); + + beforeEach(() => { + getUserMedia.mockClear(); + window.electronAPI = { + getRecordingPrefs: vi.fn(async () => persistedPrefs), + onRecordingPrefsChanged: vi.fn(() => () => undefined), + getPlatform: vi.fn(() => "win32"), + getSelectedSource: vi.fn(async () => null), + cliGetRequest: vi.fn( + () => + new Promise(() => { + /* source/request stay pending */ + }), + ), + cliLog: vi.fn(), + cliDone: vi.fn(async () => undefined), + onCliStopRecording: vi.fn(() => () => undefined), + getCurrentRecordingSession: vi.fn(async () => ({ success: false, session: null })), + getSources: vi.fn( + () => + new Promise(() => { + /* enumeration pending */ + }), + ), + selectSource: vi.fn(), + } as unknown as typeof window.electronAPI; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("does not acquire webcam from GUI prefs while CLI source enumeration is pending", async () => { + render(); + await waitFor(() => expect(window.electronAPI.getRecordingPrefs).toHaveBeenCalled()); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(getUserMedia).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/ai-edition/AppSettings.test.tsx b/src/components/ai-edition/AppSettings.test.tsx index 63c80fbdb..e2d6d08da 100644 --- a/src/components/ai-edition/AppSettings.test.tsx +++ b/src/components/ai-edition/AppSettings.test.tsx @@ -212,6 +212,24 @@ describe("AppSettings", () => { expect(window.electronAPI.setRecordingPrefs).not.toHaveBeenCalled(); }); + it("keeps an unsaved recording draft when appearance is saved", async () => { + vi.mocked(window.electronAPI.setProjectAppearanceDefaults).mockImplementation( + async (defaults) => ({ + recording, + lastSource: null, + appearance: { version: 1 as const, custom: true, defaults }, + }), + ); + renderSettings(); + await screen.findByTestId("app-settings-dialog"); + fireEvent.click(screen.getByLabelText("appSettings.systemAudio")); + expect(screen.getByLabelText("appSettings.systemAudio")).toBeChecked(); + fireEvent.click(screen.getByText("appSettings.useCurrentAppearance")); + await waitFor(() => expect(window.electronAPI.setProjectAppearanceDefaults).toHaveBeenCalled()); + expect(screen.getByLabelText("appSettings.systemAudio")).toBeChecked(); + expect(window.electronAPI.setRecordingPrefs).not.toHaveBeenCalled(); + }); + it("copies the current look and exposes both reset actions", async () => { renderSettings(); await screen.findByTestId("app-settings-dialog"); diff --git a/src/components/ai-edition/AppSettings.tsx b/src/components/ai-edition/AppSettings.tsx index 46a016c83..87ecdac62 100644 --- a/src/components/ai-edition/AppSettings.tsx +++ b/src/components/ai-edition/AppSettings.tsx @@ -58,7 +58,11 @@ function AppSettings({ open, onClose }: { open: boolean; onClose: () => void }) recording?.camDeviceName ?? undefined, ); - const run = async (work: () => Promise) => { + const run = async ( + work: () => Promise, + options: { replaceRecording?: boolean } = {}, + ) => { + const replaceRecording = options.replaceRecording !== false; const generation = ++generationRef.current; setStatus("saving"); setError(null); @@ -66,7 +70,9 @@ function AppSettings({ open, onClose }: { open: boolean; onClose: () => void }) const next = await work(); if (generation !== generationRef.current) return; setSnapshot(next); - setRecording(next.recording); + if (replaceRecording) { + setRecording(next.recording); + } setStatus("saved"); } catch (cause) { if (generation !== generationRef.current) return; @@ -256,7 +262,9 @@ function AppSettings({ open, onClose }: { open: boolean; onClose: () => void }) const defaults = projectAppearanceFromEditorSettings( getEditorSettings(document), ); - void run(() => window.electronAPI.setProjectAppearanceDefaults(defaults)); + void run(() => window.electronAPI.setProjectAppearanceDefaults(defaults), { + replaceRecording: false, + }); }} > {t("appSettings.useCurrentAppearance")} @@ -265,7 +273,9 @@ function AppSettings({ open, onClose }: { open: boolean; onClose: () => void }) type="button" className={styles.btn} onClick={() => - void run(() => window.electronAPI.resetProjectAppearanceDefaults()) + void run(() => window.electronAPI.resetProjectAppearanceDefaults(), { + replaceRecording: false, + }) } disabled={status === "saving"} > diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index f8fe67052..60fe9be18 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -3,7 +3,8 @@ import { useI18n, useScopedT } from "@/contexts/I18nContext"; import { getAvailableLocales, getLocaleName } from "@/i18n/loader"; import { loadUserPreferences, saveUserPreferences } from "@/lib/userPreferences"; import { nativeBridgeClient } from "@/native"; -import { type CameraDevice, useCameraDevices } from "../../hooks/useCameraDevices"; +import { type CameraDevice } from "../../hooks/useCameraDevices"; +import { useCameraHudSync } from "../../hooks/useCameraHudSync"; import { isPlaceholderMicrophoneLabel, type MicrophoneDevice, @@ -167,16 +168,22 @@ export function LaunchWindow() { // Passing `webcamDeviceId` as the preferred device is what keeps the pick the // user made in the editor's Rec stage: this window is destroyed and rebuilt // for every recording, so the enumeration default would otherwise revert the - // camera to whatever the OS lists first on each take. + // camera to whatever the OS lists first on each take. Write-back waits for + // persisted prefs so an enumeration default cannot overwrite a late cam2. const { devices: cameraDevices, - selectedDevice: selectedCamera, selectedDeviceId: selectedCameraId, setSelectedDeviceId: setSelectedCameraId, isLoading: isCameraDevicesLoading, isReady: cameraDevicesReady, error: cameraDevicesError, - } = useCameraDevices(true, webcamDeviceId, webcamDeviceName); + } = useCameraHudSync({ + webcamDeviceId, + webcamDeviceName, + recordingPrefsLoaded, + setWebcamDeviceId, + setWebcamDeviceName, + }); // The microphone list stays lazy: enumerating it asks for mic permission, // which would light the OS "in use" indicator just for opening the HUD. const { @@ -203,28 +210,6 @@ export function LaunchWindow() { } }, [selectedMicId, micDevices, micDevicesReady, setMicrophoneDeviceId, setMicrophoneDeviceName]); - // Keyed on the chosen device's own fields, never on the `cameraDevices` array. - // That array is rebuilt on every `devicechange`, and mirroring the selection - // back on each rebuild put this effect in a tug-of-war with the preference - // adoption inside `useCameraDevices`: the two wrote each other's value on - // every commit and the HUD spun without ever settling. - const selectedCameraLabel = selectedCamera?.label; - useEffect(() => { - if (selectedCameraId) { - setWebcamDeviceId(selectedCameraId); - setWebcamDeviceName(selectedCameraLabel); - } else if (cameraDevicesReady) { - setWebcamDeviceId(undefined); - setWebcamDeviceName(undefined); - } - }, [ - selectedCameraId, - selectedCameraLabel, - cameraDevicesReady, - setWebcamDeviceId, - setWebcamDeviceName, - ]); - useEffect(() => { let cancelled = false; nativeBridgeClient.system diff --git a/src/hooks/useCameraHudSync.test.tsx b/src/hooks/useCameraHudSync.test.tsx new file mode 100644 index 000000000..3fcff1aab --- /dev/null +++ b/src/hooks/useCameraHudSync.test.tsx @@ -0,0 +1,85 @@ +// @vitest-environment jsdom +import { act, renderHook, waitFor } from "@testing-library/react"; +import { useState } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useCameraHudSync } from "./useCameraHudSync"; + +const mockDevices = [ + { kind: "videoinput", deviceId: "cam1", label: "Camera 1", groupId: "group1" }, + { kind: "videoinput", deviceId: "cam2", label: "Camera 2", groupId: "group1" }, +]; + +const mockEnumerateDevices = vi.fn().mockResolvedValue(mockDevices); + +Object.defineProperty(global.navigator, "mediaDevices", { + value: { + enumerateDevices: mockEnumerateDevices, + getUserMedia: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }, + configurable: true, +}); + +function useRecorderBackedCameraSync() { + const [webcamDeviceId, setWebcamDeviceId] = useState(undefined); + const [webcamDeviceName, setWebcamDeviceName] = useState(undefined); + const [recordingPrefsLoaded, setRecordingPrefsLoaded] = useState(false); + const camera = useCameraHudSync({ + webcamDeviceId, + webcamDeviceName, + recordingPrefsLoaded, + setWebcamDeviceId, + setWebcamDeviceName, + }); + return { + camera, + webcamDeviceId, + webcamDeviceName, + recordingPrefsLoaded, + setRecordingPrefsLoaded, + applyPersistedCamera: (id: string, name: string) => { + setWebcamDeviceId(id); + setWebcamDeviceName(name); + setRecordingPrefsLoaded(true); + }, + }; +} + +describe("useCameraHudSync", () => { + beforeEach(() => { + mockEnumerateDevices.mockResolvedValue(mockDevices); + }); + + it("keeps enumeration cam1 off the recorder until persisted cam2 arrives, then settles", async () => { + let renders = 0; + const { result } = renderHook(() => { + renders += 1; + return useRecorderBackedCameraSync(); + }); + + await waitFor(() => { + expect(result.current.camera.selectedDeviceId).toBe("cam1"); + }); + expect(result.current.webcamDeviceId).toBeUndefined(); + expect(result.current.recordingPrefsLoaded).toBe(false); + + act(() => { + result.current.applyPersistedCamera("cam2", "Camera 2"); + }); + + await waitFor(() => { + expect(result.current.camera.selectedDeviceId).toBe("cam2"); + expect(result.current.webcamDeviceId).toBe("cam2"); + expect(result.current.camera.isReady).toBe(true); + }); + + const settled = renders; + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + expect(result.current.camera.selectedDeviceId).toBe("cam2"); + expect(result.current.webcamDeviceId).toBe("cam2"); + expect(renders - settled).toBeLessThanOrEqual(2); + }); +}); diff --git a/src/hooks/useCameraHudSync.ts b/src/hooks/useCameraHudSync.ts new file mode 100644 index 000000000..c3e45354b --- /dev/null +++ b/src/hooks/useCameraHudSync.ts @@ -0,0 +1,44 @@ +import { useEffect } from "react"; +import { useCameraDevices } from "./useCameraDevices"; + +/** + * HUD camera list plus write-back into recorder prefs. + * + * Enumeration often finishes before persisted prefs arrive. Writing the + * first enumerated camera back immediately would overwrite a late cam2 + * preference with cam1. Write-back waits until prefs are loaded and the + * hook is ready, and is a no-op when the ids already match. + */ +export function useCameraHudSync(options: { + webcamDeviceId: string | undefined; + webcamDeviceName: string | undefined; + recordingPrefsLoaded: boolean; + setWebcamDeviceId: (deviceId: string | undefined) => void; + setWebcamDeviceName: (deviceName: string | undefined) => void; +}) { + const camera = useCameraDevices(true, options.webcamDeviceId, options.webcamDeviceName); + const selectedCameraLabel = camera.selectedDevice?.label; + const selectedCameraId = camera.selectedDeviceId; + + useEffect(() => { + if (!options.recordingPrefsLoaded || !camera.isReady) return; + if (selectedCameraId === (options.webcamDeviceId ?? "")) return; + if (selectedCameraId) { + options.setWebcamDeviceId(selectedCameraId); + options.setWebcamDeviceName(selectedCameraLabel); + return; + } + options.setWebcamDeviceId(undefined); + options.setWebcamDeviceName(undefined); + }, [ + options.recordingPrefsLoaded, + camera.isReady, + selectedCameraId, + selectedCameraLabel, + options.webcamDeviceId, + options.setWebcamDeviceId, + options.setWebcamDeviceName, + ]); + + return camera; +} diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 00083bdfc..445850c2e 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -281,9 +281,14 @@ export function useScreenRecorder(): UseScreenRecorderReturn { setMicrophoneEnabled(prefs.micEnabled); setMicrophoneDeviceId(prefs.micDeviceId ?? undefined); setMicrophoneDeviceName(prefs.micDeviceName ?? undefined); - setWebcamEnabledState(prefs.camEnabled); - setWebcamDeviceId(prefs.camDeviceId ?? undefined); - setWebcamDeviceName(prefs.camDeviceName ?? undefined); + const isCliRecord = + typeof window !== "undefined" && + new URLSearchParams(window.location.search).get("windowType") === "cli-record"; + if (!isCliRecord) { + setWebcamEnabledState(prefs.camEnabled); + setWebcamDeviceId(prefs.camDeviceId ?? undefined); + setWebcamDeviceName(prefs.camDeviceName ?? undefined); + } setSystemAudioEnabled(prefs.systemAudioEnabled); setCursorCaptureMode(prefs.cursorCaptureMode); setRecordingPrefsLoaded(true); From 0258890d78a631ac85865b38f9acda2011740fee Mon Sep 17 00:00:00 2001 From: Adam Pavlat Date: Tue, 8 Sep 2026 22:01:59 +0200 Subject: [PATCH 29/39] feat: add Czech (cs) locale --- src/i18n/config.ts | 1 + src/i18n/locales/cs/common.json | 74 ++++++ src/i18n/locales/cs/dialogs.json | 92 +++++++ src/i18n/locales/cs/editor.json | 404 +++++++++++++++++++++++++++++ src/i18n/locales/cs/launch.json | 99 +++++++ src/i18n/locales/cs/settings.json | 355 +++++++++++++++++++++++++ src/i18n/locales/cs/shortcuts.json | 43 +++ src/i18n/locales/cs/timeline.json | 108 ++++++++ 8 files changed, 1176 insertions(+) create mode 100644 src/i18n/locales/cs/common.json create mode 100644 src/i18n/locales/cs/dialogs.json create mode 100644 src/i18n/locales/cs/editor.json create mode 100644 src/i18n/locales/cs/launch.json create mode 100644 src/i18n/locales/cs/settings.json create mode 100644 src/i18n/locales/cs/shortcuts.json create mode 100644 src/i18n/locales/cs/timeline.json diff --git a/src/i18n/config.ts b/src/i18n/config.ts index 107ff840f..c621c4379 100644 --- a/src/i18n/config.ts +++ b/src/i18n/config.ts @@ -2,6 +2,7 @@ export const DEFAULT_LOCALE = "en" as const; export const SUPPORTED_LOCALES = [ "en", "ar", + "cs", "es", "fr", "it", diff --git a/src/i18n/locales/cs/common.json b/src/i18n/locales/cs/common.json new file mode 100644 index 000000000..c5daf2527 --- /dev/null +++ b/src/i18n/locales/cs/common.json @@ -0,0 +1,74 @@ +{ + "actions": { + "cancel": "Zrušit", + "save": "Uložit", + "delete": "Smazat", + "close": "Zavřít", + "share": "Sdílet", + "done": "Hotovo", + "open": "Otevřít", + "checkForUpdates": "Zkontrolovat aktualizace", + "updateSettings": "Nastavení aktualizací", + "updateModeNotify": "Upozornit na dostupnou aktualizaci", + "updateModeDownload": "Stahovat aktualizace automaticky", + "updateModeDownloadAndInstall": "Stahovat a instalovat aktualizace automaticky", + "viewRelease": "Zobrazit vydání", + "downloadUpdate": "Stáhnout aktualizaci", + "restartNow": "Restartovat nyní", + "upload": "Nahrát", + "export": "Exportovat", + "showInFolder": "Zobrazit ve složce", + "file": "Soubor", + "edit": "Upravit", + "view": "Zobrazit", + "window": "Okno", + "help": "Nápověda", + "quit": "Ukončit", + "stopRecording": "Zastavit nahrávání", + "undo": "Zpět", + "redo": "Znovu", + "cut": "Vyjmout", + "copy": "Kopírovat", + "paste": "Vložit", + "selectAll": "Vybrat vše", + "minimize": "Minimalizovat", + "reload": "Znovu načíst", + "forceReload": "Vynutit znovu načtení", + "toggleDevTools": "Přepnout nástroje pro vývojáře", + "actualSize": "Skutečná velikost", + "zoomIn": "Přiblížit", + "zoomOut": "Oddálit", + "toggleFullScreen": "Přepnout celou obrazovku", + "recordingStatus": "Nahrávání: {{source}}", + "about": "O aplikaci OpenScreen", + "services": "Služby", + "hide": "Skrýt OpenScreen", + "hideOthers": "Skrýt ostatní", + "unhide": "Zobrazit vše", + "saveDiagnostics": "Uložit diagnostiku" + }, + "updates": { + "available": "OpenScreen {{latestVersion}} je k dispozici. Používáte verzi {{currentVersion}}.", + "current": "OpenScreen je aktuální ({{currentVersion}}).", + "readyToInstall": "OpenScreen {{latestVersion}} byl stažen. Restartujte aplikaci pro dokončení instalace.", + "downloaded": "OpenScreen {{latestVersion}} byl stažen.", + "blockedRecording": "Před instalací aktualizace dokončete nahrávání.", + "blockedLocation": "Před aktualizací přesuňte OpenScreen do složky Aplikace.", + "failed": "Nepodařilo se zkontrolovat aktualizace.", + "downloadFailed": "Nepodařilo se stáhnout aktualizaci." + }, + "units": { + "pixelsPerSecond": "{{value}} px/s", + "pixels": "{{value}} px" + }, + "locale": { + "name": "Čeština", + "short": "CS" + }, + "recordingSource": { + "window": "Okno", + "screen": "Obrazovka", + "virtual": "Virtuální displej", + "unknown": "Záznam obrazovky" + } +} diff --git a/src/i18n/locales/cs/dialogs.json b/src/i18n/locales/cs/dialogs.json new file mode 100644 index 000000000..1a0da0742 --- /dev/null +++ b/src/i18n/locales/cs/dialogs.json @@ -0,0 +1,92 @@ +{ + "export": { + "complete": "Export dokončen", + "yourFormatReady": "Váš {{format}} je připraven", + "showInFolder": "Zobrazit ve složce", + "finalizingVideo": "Dokončování exportu videa...", + "compilingGifProgress": "Sestavování GIF... {{progress}}%", + "compilingGifWait": "Sestavování GIF... Může to chvíli trvat", + "takeMoment": "Může to chvíli trvat...", + "failed": "Export selhal", + "tryAgain": "Zkuste to znovu", + "finalizingVideoTitle": "Dokončování videa", + "compilingGif": "Sestavování GIF", + "exportingFormat": "Exportování {{format}}", + "compiling": "Sestavování", + "renderingFrames": "Renderování snímků", + "processing": "Zpracovávání...", + "finalizing": "Dokončování...", + "compilingStatus": "Sestavování...", + "status": "Stav", + "format": "Formát", + "frames": "Snímky", + "cancelExport": "Zrušit export", + "savedSuccessfully": "{{format}} úspěšně uložen!" + }, + "tutorial": { + "triggerLabel": "Jak funguje oříznutí", + "title": "Jak funguje oříznutí", + "description": "Pochopte, jak vystřihnout nežádoucí části videa.", + "explanationBefore": "Nástroj Oříznutí funguje tak, že definujete segmenty, které chcete", + "remove": "odstranit", + "explanationMiddle": " — vše", + "covered": "pokryté", + "explanationAfter": "červeným segmentem oříznutí bude při exportu vystřiženo.", + "visualExample": "Vizuální příklad", + "removed": "ODSTRANĚNO", + "kept": "Ponecháno", + "part1": "Část 1", + "part2": "Část 2", + "part3": "Část 3", + "finalVideo": "Výsledné video", + "step1Title": "1. Přidat oříznutí", + "step1DescriptionBefore": "Stiskněte ", + "step1DescriptionAfter": " nebo klikněte na ikonu nůžek pro označení sekce k odstranění.", + "step2Title": "2. Upravit", + "step2Description": "Přetáhněte okraje červené oblasti tak, aby pokrývaly přesně to, co chcete vystřihnout." + }, + "unsavedChanges": { + "title": "Neuložené změny", + "message": "Máte neuložené změny.", + "detail": "Chcete uložit projekt před zavřením?", + "saveAndClose": "Uložit a zavřít", + "discardAndClose": "Zahodit a zavřít", + "detailNewProject": "Chcete uložit projekt před vytvořením nového?", + "saveAndNewProject": "Uložit a nový projekt", + "discardAndNewProject": "Zahodit a nový projekt", + "detailLoadProject": "Chcete uložit projekt před načtením jiného?", + "saveAndLoadProject": "Uložit a načíst projekt", + "discardAndLoadProject": "Zahodit a načíst projekt", + "newProject": "Nový projekt", + "importVideo": "Importovat video soubor…", + "loadProject": "Načíst projekt…", + "saveProject": "Uložit projekt…", + "saveProjectAs": "Uložit projekt jako…", + "modal": { + "closeTitle": "Uložit změny před zavřením?", + "closeBody": "Váš projekt má neuložené změny. Uložte je, zahoďte je nebo zůstaňte v editoru.", + "newTitle": "Uložit změny před vytvořením nového projektu?", + "openTitle": "Uložit změny před otevřením jiného projektu?", + "recordTitle": "Uložit změny před zahájením nového nahrávání?", + "sharedBody": "Aktuální projekt má neuložené změny. Nejprve je uložte, zahoďte nebo zrušte.", + "notSavedYet": "Aktuální projekt ještě nebyl uložen na disk.", + "discard": "Zahodit", + "saveAndContinue": "Uložit a pokračovat", + "saving": "Ukládání…" + } + }, + "fileDialogs": { + "saveGif": "Uložit exportovaný GIF", + "saveVideo": "Uložit exportované video", + "selectVideo": "Vybrat video soubor", + "selectAudio": "Vybrat zvukový soubor", + "saveProject": "Uložit projekt OpenScreen", + "openProject": "Otevřít projekt OpenScreen", + "gifImage": "GIF obrázek", + "mp4Video": "MP4 video", + "videoFiles": "Video soubory", + "audioFiles": "Zvukové soubory", + "openscreenProject": "Projekt OpenScreen", + "allFiles": "Všechny soubory" + } +} diff --git a/src/i18n/locales/cs/editor.json b/src/i18n/locales/cs/editor.json new file mode 100644 index 000000000..981ed6aab --- /dev/null +++ b/src/i18n/locales/cs/editor.json @@ -0,0 +1,404 @@ +{ + "newRecording": { + "title": "Vrátit se na nahrávání", + "description": "Vaše aktuální relace byla uložena.", + "cancel": "Zrušit", + "confirm": "Potvrdit" + }, + "loadingVideo": "Načítání videa...", + "loadingEditor": "Načítání editoru...", + "errors": { + "exportBackgroundLoadFailed": "Export selhal: nepodařilo se načíst obrázek pozadí ({{url}})", + "exportFailed": "Export selhal", + "exportFailedWithError": "Export selhal: {{error}}", + "failedToRevealInFolder": "Chyba při otevírání ve složce: {{error}}", + "failedToSaveExport": "Nepodařilo se uložit export", + "failedToSaveExportedVideo": "Nepodařilo se uložit exportované video", + "failedToSaveGif": "Nepodařilo se uložit GIF", + "failedToSaveVideo": "Nepodařilo se uložit video", + "gifExportFailed": "Export GIF selhal", + "noVideoLoaded": "Žádné video nenačteno", + "previewCompositorUnavailable": "Náhled na tomto počítači není dostupný", + "trimNoFilm": "Tam není co vystřihnout — pod těmito slovy není žádný film.", + "unableToDetermineSourcePath": "Nelze určit cestu ke zdrojovému videu", + "videoNotReady": "Video není připraveno", + "wordEditFailed": "Nepodařilo se změnit toto slovo", + "wordInsertFailed": "Nepodařilo se přidat toto slovo", + "wordRemoveFailed": "Nepodařilo se smazat toto slovo" + }, + "export": { + "canceled": "Export zrušen", + "exportedSuccessfully": "{{format}} úspěšně exportován" + }, + "project": { + "saveCanceled": "Ukládání projektu zrušeno", + "failedToSave": "Nepodařilo se uložit projekt", + "savedTo": "Projekt uložen do {{path}}", + "failedToLoad": "Nepodařilo se načíst projekt", + "invalidFormat": "Neplatný formát souboru projektu", + "loadedFrom": "Projekt načten z {{path}}" + }, + "recording": { + "failedCameraAccess": "Nepodařilo se požádat o přístup ke kameře.", + "cameraBlocked": "Přístup ke kameře je zablokován. Povolte ho v nastavení systému pro použití webkamery.", + "systemAudioUnavailable": "Systémový zvuk není dostupný. Nahrávání probíhá bez systémového zvuku.", + "microphoneDenied": "Přístup k mikrofonu odepřen. Nahrávání bude pokračovat bez zvuku.", + "cameraDenied": "Přístup ke kameře odepřen. Nahrávání bude pokračovat bez webkamery.", + "cameraDisconnected": "Webkamera odpojena.", + "cameraNotFound": "Kamera nenalezena.", + "cameraCaptureUnavailable": "Kameru se nepodařilo otevřít. Nahrávání probíhá bez ní.", + "microphoneDefaulted": "Vybraný mikrofon se nepodařilo identifikovat. Nahrávání výchozího vstupu.", + "permissionDenied": "Oprávnění k nahrávání odepřeno. Povolte prosím záznam obrazovky.", + "accessibilityAllowAndRetry": "Povolte přístup k usnadnění pro OpenScreen a poté znovu stiskněte tlačítko nahrávání pro zahájení odpočítávání.", + "selectSource": "Vyberte prosím zdroj pro nahrávání" + }, + "emptyState": { + "title": "Žádný projekt není otevřen", + "description": "Vytvořte nový projekt nebo otevřete existující.", + "titleHasAsset": "Přidejte video pro začátek", + "descriptionHasAsset": "Importujte nahrávku pro zahájení úprav.", + "newProjectButton": "Nový projekt + importovat video", + "importVideoButton": "Importovat video", + "loadProjectButton": "Otevřít projekt", + "supportedFormats": "Podporované formáty: MP4, MOV, WebM, MKV, AVI, M4V, WMV", + "dragDropHint": "nebo přetáhněte soubor projektu .openscreen sem", + "dropOverlay": "Přetáhněte soubor projektu pro otevření", + "dropErrors": { + "unsupportedFormatTitle": "Nepodporovaný formát", + "unsupportedFormatMessage": "Sem lze přetáhnout pouze soubory projektů .openscreen. Pro import video souboru použijte tlačítko „Importovat video soubor…\" na této obrazovce.", + "couldNotOpenTitle": "Soubor nelze otevřít", + "couldNotOpenMessage": "Soubor projektu nelze otevřít. Video, na které odkazuje, mohlo být přesunuto nebo smazáno." + } + }, + "regionClipboard": { + "copied": "Atributy {{region}} zkopírovány", + "pasted": "Atributy {{region}} vloženy", + "nothingToCopy": "Vyberte oblast pro zkopírování jejích atributů", + "nothingToPaste": "Zatím nebyly zkopírovány žádné atributy", + "pasteAssetMissing": "Soubor tohoto zvukového stopy není v tomto projektu", + "kinds": { + "zoom": "Přiblížení", + "speed": "Rychlost", + "annotation": "Text" + } + }, + "topbar": { + "toggleChatPanel": "Přepnout panel chatu", + "openProject": "Otevřít projekt", + "newProject": "Nový projekt", + "saveProject": "Uložit projekt", + "unsaved": "Neuloženo", + "saved": "Uloženo", + "switchToLightTheme": "Přepnout na světlý motiv", + "switchToDarkTheme": "Přepnout na tmavý motiv", + "toggleTheme": "Přepnout motiv", + "export": "Exportovat", + "renameProject": "Přejmenovat projekt", + "noProject": "Žádný projekt", + "changeLanguage": "Změnit jazyk", + "editorMode": "Režim editoru", + "modes": { + "media": "Média", + "edit": "Upravit", + "rec": "Nahrávání" + } + }, + "rec": { + "source": "Zdroj", + "systemPicker": "Systém se zeptá, co sdílet", + "systemAudio": "Systémový zvuk", + "microphone": "Mikrofon", + "camera": "Kamera", + "cursorHighlight": "Zvýraznění kurzoru", + "on": "Zapnuto", + "off": "Vypnuto", + "loading": "Načítání…", + "noCameraFound": "Kamera nenalezena", + "cameraAccessError": "K této kameře nelze získat přístup", + "startingCamera": "Spouštění kamery…", + "turnOnCameraHint": "Zapněte kameru pro živý náhled", + "entireScreen": "Celá obrazovka", + "cancel": "Zrušit", + "startRecording": "Spustit nahrávání", + "startRecordingHint": "Otevře widget pro nahrávání a zavře toto okno editoru.", + "sourceModal": { + "screens": "Obrazovky ({{count}})", + "windows": "Okna ({{count}})", + "loadingSources": "Načítání zdrojů…", + "noScreensFound": "Žádné obrazovky nenalezeny", + "noWindowsFound": "Žádná okna nenalezena", + "cancel": "Zrušit" + } + }, + "mediaStage": { + "openProjectFirst": "Nejprve otevřete projekt", + "couldNotAddAsset": "Nepodařilo se přidat asset", + "searchPlaceholder": "Hledat média…", + "dragHint": "Přetáhněte klip na níže uvedenou časovou osu pro přidání", + "emptyHint": "Zatím žádná média — importujte nahrávku pro začátek.", + "importMedia": "Importovat média", + "added": "Přidáno {{label}}", + "addToTimeline": "Přidat na časovou osu", + "addedToTimeline": "{{label}} přidán na časovou osu", + "sourceTranscript": "Zdrojový přepis", + "close": "Zavřít", + "transcriptReady": "Přepis připraven", + "transcribing": "Přepisování", + "downloadingModel": "Stahování modelu řeči", + "initializingModel": "Spouštění modelu řeči", + "pendingTranscription": "Přepis čeká", + "transcriptionFailed": "Přepis selhal", + "cpuBackendHint": "Běží na CPU — přibližně 2× pomalejší než na GPU", + "noTranscript": "Žádný přepis", + "regenerateAs": "Znovu generovat jako", + "auto": "Automaticky", + "regenerate": "Znovu generovat", + "transcriptEmpty": "Přepis je prázdný.", + "notGeneratedHint": "Ještě nevygenerováno — vyberte jazyk a klikněte na znovu generovat.", + "generationFailedHint": "Generování selhalo — vyberte jazyk a znovu generujte.", + "detectedLanguage": "Zjištěný jazyk: {{language}}", + "noAudioTrack": "Žádná zvuková stopa", + "noAudioTrackHint": "Toto médium nemá zvukovou stopu — není co přepisovat.", + "noSpeechDetected": "Žádná řeč nezjištěna" + }, + "exportDialog": { + "title": "Exportovat", + "subtitle": "Renderovat časovou osu do souboru", + "addVideoBeforeExporting": "Před exportem přidejte video.", + "quality": "Kvalita", + "qualityMatchRecording": "Odpovídat nahrávce", + "frameRate": "Snímkový kmitočet", + "codec": "Kodek", + "codecBestCompatibility": "Nejlepší kompatibilita", + "codecMaySupportVary": "Nemusí být podporováno každým systémovým enkodérem", + "size": "Velikost", + "loopGif": "Opakovat GIF", + "loopOn": "Opakování zapnuto", + "loopOff": "Opakování vypnuto", + "pickFormatAndExport": "Vyberte formát a stiskněte Exportovat pro zahájení.", + "savedTo": "Uloženo do", + "exportFailedGeneric": "Export selhal.", + "writingFile": "Zapisování souboru…", + "renderingFrames": "Renderování snímků", + "framesEta": "{{current}} / {{total}} snímků · Zbývá {{eta}}s", + "preparingEncoder": "Příprava enkodéru…", + "close": "Zavřít", + "cancel": "Zrušit", + "rendering": "Renderování…", + "saving": "Ukládání…", + "starting": "Spouštění…", + "exportGif": "Exportovat GIF", + "exportMp4": "Exportovat MP4", + "exportedGif": "GIF exportován", + "exportedVideo": "Video exportováno", + "exportedVideoOf": "z videa exportováno za", + "showInFolder": "Zobrazit ve složce", + "exportFailed": "Export selhal", + "failedToWriteFile": "Nepodařilo se zapsat soubor", + "qualityUpscaleWarning": "Zvýšení rozlišení", + "nothingToExport": "Není co exportovat — časová osa je prázdná." + }, + "inspector": { + "resetFocusPoint": "Obnovit bod zaostření", + "setColor": "Nastavit barvu {{color}}", + "trimHiddenDuration": "{{duration}}s zdrojového média skryto z upravené časové osy.", + "restoreDeleteTrim": "Obnovit (smazat oříznutí)", + "collapseInspector": "Sbalit inspektor", + "captionsDescription": "Generujte titulky synchronizované se slovy z přepisu a přetáhněte je na časovou osu.", + "generateCaptions": "Generovat titulky", + "cropDescription": "Přeformátujte nahrávku — vyberte poměr stran a přibližte oblast, kterou chcete zachovat.", + "openCrop": "Otevřít oříznutí…", + "cameraFullscreenDescription": "Během přehrávání této oblasti kamera zabírá celý snímek — bez okraje, bez zaoblení, bez pozadí — a na konci se vrátí. Přetáhněte okraje oblasti na časové ose pro změnu začátku a délky trvání.", + "deleteRegion": "Smazat oblast", + "freehandRendersAsBox": "Při exportu je volně kreslený tvar pokryt svým ohraničujícím rámečkem — vždy překrytý, nikdy nepokrytý." + }, + "chat": { + "changeProvider": "Změnit poskytovatele", + "back": "Zpět", + "currentModel": "Aktuální model:", + "notSelected": "Nevybráno", + "loadingModels": "Načítání modelů…", + "searchModels": "Hledat modely…", + "fetchModelsFailed": "Nepodařilo se načíst živé modely ({{error}}); otevřete nastavení poskytovatele pro ruční zadání ID modelu.", + "noModelsAvailable": "Žádné modely od tohoto poskytovatele nejsou dostupné.", + "noModelsMatch": "Žádné modely neodpovídají tomuto hledání.", + "noProvidersConnected": "Zatím nejsou připojeni žádní poskytovatelé.", + "selectModelFailed": "Nepodařilo se vybrat model", + "providerSettings": "Nastavení poskytovatele…", + "applyEditsFailed": "Nepodařilo se použít úpravy agenta", + "agentEditConflict": "Úpravy agenta nebyly použity, protože projekt se změnil během jeho práce.", + "applyAnyway": "Přesto použít", + "chatFailed": "Chat selhal", + "rewindFailed": "Přetočení selhalo", + "rewoundSuccess": "Přetočeno na začátek této zprávy", + "notEnoughHistory": "Zatím není dostatek historie pro komprimaci.", + "compactedSuccess": "Dřívější kontext byl komprimován", + "compactFailed": "Komprimace selhala", + "createSessionFailed": "Nepodařilo se vytvořit konverzaci", + "deleteSessionFailed": "Nepodařilo se smazat konverzaci", + "renameSessionFailed": "Nepodařilo se přejmenovat konverzaci", + "reasoningEffortUpdateFailed": "Nepodařilo se aktualizovat úsilí uvažování", + "contextTooltip": "{{usedTokens}} / {{budgetTokens}} odhadovaných tokenů", + "contextPercent": "{{percent}}% kontextu", + "compactContext": "Komprimovat kontext", + "aiSettings": "Nastavení AI", + "history": "Historie", + "newConversation": "Nová konverzace", + "untitledConversation": "Konverzace", + "clickToRename": "Klikněte pro přejmenování", + "renameConversation": "Přejmenovat konverzaci", + "deleteConversation": "Smazat konverzaci", + "confirmDeleteConversation": "Smazat „{{title}}\"?", + "emptyState": "Zatím žádné zprávy. Požádejte agenta o vystřižení ticha, zkrácení pauz nebo přidání titulků.", + "welcome": { + "title": "Přineste si vlastní AI", + "subtitle": "Chat upravuje vaše video prostřednictvím komunikace s jazykovým modelem. Vyberte poskytovatele, kterému důvěřujete, a připojte API klíč pro začátek.", + "feature1": "Vystřihnout ticho, zkrátit pauzy, odstranit výplňová slova", + "feature2": "Přidat titulky, přiblížit kameru, generovat název", + "feature3": "Přepsat sekci, přeformulovat nebo rozdělit klip na žádost", + "cta": "Nastavit poskytovatele", + "disclaimer": "Přepis vašeho videa bude odeslán vámi zvolenému poskytovateli. Nic není sdíleno, dokud ho nepřipojíte." + }, + "authorUser": "Vy", + "authorAssistant": "OpenScreen", + "rewindToMessage": "Přetočit na tuto zprávu", + "copyMessage": "Kopírovat zprávu", + "copiedToClipboard": "Zkopírováno do schránky", + "copyFailed": "Kopírování selhalo", + "appliedPrefix": "použito:", + "thinking": "Přemýšlení…", + "composerPlaceholder": "Popište úpravu, kterou chcete provést.", + "composerDisabledNoProvider": "Nastavte poskytovatele pro zahájení chatu.", + "modelLabel": "Model", + "reasoningEffortLabel": "Úsilí uvažování", + "sendTitle": "Odeslat (Enter)", + "send": "Odeslat", + "rewindConfirmTitle": "Přetočit sem?", + "rewindConfirmBody": "Úpravy agenta a navazující otočení po tomto bodu budou vráceny. Projekt, konverzace a stav agenta budou obnoveny. Jakékoli úpravy, které jste od té doby provedli, budou také nahrazeny.", + "rewindConfirm": "Přetočit", + "configureModel": "Konfigurovat AI model", + "historyDialog": { + "title": "Historie konverzace", + "subtitle": "Přepínání nebo vytváření relací", + "empty": "Zatím žádné konverzace.", + "msgsCount": "{{count}} zpráv · {{date}}" + } + }, + "newProjectDialog": { + "title": "Nový projekt", + "subtitle": "Vyberte výchozí bod a pojmenujte ho", + "nameLabel": "Název projektu", + "startingPointLabel": "Výchozí bod", + "create": "Vytvořit projekt", + "defaultTitle": "Projekt bez názvu", + "templates": { + "screenRecordingTitle": "Záznam obrazovky", + "screenRecordingDesc": "Spustit systémový záznam", + "importMediaDesc": "Video, zvuk, obrázky z disku" + } + }, + "openProjectDialog": { + "title": "Otevřít projekt", + "subtitle": "Pokračujte v existujícím projektu nebo procházejte soubory", + "searchPlaceholder": "Hledat projekty…", + "noMatches": "Žádné projekty neodpovídají „{{query}}\".", + "navigateHint": "pro navigaci ·", + "openHint": "pro otevření", + "browseFiles": "Procházet soubory…", + "deleteProject": "Smazat projekt", + "confirmDelete": "Smazat tento projekt? Vaše nahrávky budou zachovány." + }, + "editClipDialog": { + "title": "Upravit klip", + "adjustStart": "Upravit začátek klipu", + "adjustEnd": "Upravit konec klipu", + "start": "Začátek", + "end": "Konec", + "duration": "Délka", + "reset": "Obnovit", + "apply": "Použít", + "pickClipTitle": "Vyberte klip k úpravě", + "clipLabel": "Klip {{index}}" + }, + "insertSourceDialog": { + "title": "Vložit zdroj", + "subtitle": "Kam umístit „{{assetLabel}}\" na časové ose?", + "addBefore": "Přidat před", + "addBeforeDesc": "Vložit celý zdroj před cílový klip.", + "addAfter": "Přidat za", + "addAfterDesc": "Vložit celý zdroj za cílový klip.", + "split": "Rozdělit zde a vložit", + "splitDesc": "Rozdělit cílový klip v místě přetažení a vložit zdroj mezi ně." + }, + "cropDialog": { + "subtitle": "Přetáhněte oblast nebo její úchyty pro úpravu plochy oříznutí", + "fieldX": "X", + "fieldY": "Y", + "fieldW": "Š", + "fieldH": "V" + }, + "transport": { + "playbackControls": "Ovládání přehrávání", + "playPause": "Přehrát / Pozastavit", + "playPauseTitle": "Přehrát / Pozastavit (mezerník)", + "previousClip": "Předchozí klip", + "nextClip": "Další klip", + "loop": "Opakovat", + "seekVideo": "Vyhledat v videu" + }, + "shell": { + "aiEditor": "AI editor", + "resizeChatPanel": "Změnit velikost panelu chatu", + "previewStage": "Plocha náhledu", + "resizeTimeline": "Změnit velikost časové osy" + }, + "preview": { + "videoPreview": "Náhled videa", + "webcamPreview": "Náhled webkamery (přetáhněte pro přemístění)", + "mediaError": { + "title": "Náhled zastaven", + "description": "Video nelze dekódovat. Soubor může být poškozen, stále se zapisuje nebo již není tam, kde ho projekt očekává — váš projekt je v pořádku.", + "retry": "Zkusit znovu", + "detail": "Podrobnosti: {{detail}}" + } + }, + "annotationOverlay": { + "imageAlt": "Anotace", + "noImage": "Žádný obrázek", + "noArrowData": "Žádná data šipky" + }, + "providerSettings": { + "title": "Nastavení AI", + "subtitle": "Vyberte poskytovatele. Přihlašovací údaje jsou uloženy v klíčence OS (safeStorage).", + "loadFailed": "Nepodařilo se načíst nastavení AI", + "saved": "{{provider}} uložen", + "connected": "{{provider}} připojen", + "disconnected": "{{provider}} odpojen", + "pillConnected": "Připojeno", + "pillApiKey": "API klíč", + "notConnected": "Nepřipojeno", + "back": "Zpět", + "modelLabel": "Model", + "modelHintLive": "Modely z vašeho účtu, načítané živě.", + "modelHintError": "Nepodařilo se načíst živé modely ({{error}}); zadejte ID modelu ručně.", + "modelHintLoading": "Načítání živých modelů…", + "modelSavedOption": "{{model}} (uloženo)", + "loadingModels": "Načítání modelů…", + "baseUrlLabel": "Základní URL", + "baseUrlHint": "Ponechte prázdné pro použití výchozí hodnoty poskytovatele.", + "reasoningEffortLabel": "Úsilí uvažování", + "apiKeyLabel": "API klíč", + "apiKeyHintStored": "Uloženo v safeStorage. Ponechte prázdné pro zachování stávajícího záznamu.", + "projectEditsLabel": "Úpravy projektu", + "projectEditsHint": "Pokud je vypnuto, agent musí požádat před změnou časové osy. Úpravy jsou vždy vrácení zpět.", + "allowAgentEdits": "Povolit agentovi úpravy projektu", + "disconnect": "Odpojit", + "cancel": "Zrušit", + "save": "Uložit", + "saveAndUse": "Uložit a použít" + }, + "cpuCompositor": { + "notice": "Běží bez kompatibilního GPU — renderování na CPU, takže přehrávání je pomalejší.", + "exportWarning": "Žádné kompatibilní GPU: tento export běží na CPU a bude trvat mnohem déle než obvykle." + } +} diff --git a/src/i18n/locales/cs/launch.json b/src/i18n/locales/cs/launch.json new file mode 100644 index 000000000..87dbef6c3 --- /dev/null +++ b/src/i18n/locales/cs/launch.json @@ -0,0 +1,99 @@ +{ + "tooltips": { + "hideHUD": "Skrýt HUD", + "closeApp": "Zavřít aplikaci", + "restartRecording": "Restartovat nahrávání", + "cancelRecording": "Zrušit nahrávání", + "pauseRecording": "Pozastavit nahrávání", + "resumeRecording": "Obnovit nahrávání", + "useVerticalTray": "Použít svislý panel", + "useHorizontalTray": "Použít vodorovný panel", + "openStudio": "Otevřít Studio", + "openNotes": "Otevřít poznámky", + "openNotesPlaceholder": "Pište poznámky zde...", + "notesToolbar": { + "bold": "Tučně", + "italic": "Kurzíva", + "strikethrough": "Přeškrtnutí", + "bulletList": "Odrážkový seznam", + "numberedList": "Číslovaný seznam", + "blockquote": "Bloková citace", + "codeBlock": "Blok kódu", + "play": "Spustit automatické posouvání", + "pause": "Pozastavit automatické posouvání", + "speed": "Rychlost posouvání", + "decreaseSpeed": "Snížit rychlost posouvání", + "increaseSpeed": "Zvýšit rychlost posouvání", + "fontSize": "Velikost písma", + "decreaseFontSize": "Zmenšit písmo", + "increaseFontSize": "Zvětšit písmo", + "mirror": "Zrcadlit vodorovně" + } + }, + "audio": { + "enableSystemAudio": "Povolit systémový zvuk", + "disableSystemAudio": "Zakázat systémový zvuk", + "enableMicrophone": "Povolit mikrofon", + "disableMicrophone": "Zakázat mikrofon", + "defaultMicrophone": "Výchozí mikrofon", + "systemAudio": "Systémový zvuk", + "microphone": "Mikrofon", + "on": "Zapnuto", + "off": "Vypnuto", + "inputDevice": "Vstupní zařízení" + }, + "webcam": { + "enableWebcam": "Povolit webkameru", + "disableWebcam": "Zakázat webkameru", + "defaultCamera": "Výchozí kamera", + "searching": "Hledání...", + "noneFound": "Kamera nenalezena", + "unavailable": "Kamera nedostupná", + "camera": "Kamera", + "cameraDevice": "Zařízení kamery" + }, + "cursor": { + "useEditableCursor": "Použít upravitelný kurzor", + "useSystemCursor": "Použít systémový kurzor" + }, + "sourceSelector": { + "loading": "Načítání zdrojů...", + "screens": "Obrazovky ({{count}})", + "windows": "Okna ({{count}})", + "emptyTitle": "Žádné obrazovky ani okna nenalezena", + "emptyDescription": "Pokud jste právě udělili oprávnění pro záznam obrazovky, znovu načtěte tento výběr. Na macOS může být nutné znovu otevřít OpenScreen.", + "loadFailedDescription": "OpenScreen nemohl načíst zdroje záznamu. Znovu načtěte tento výběr a zkuste to znovu.", + "defaultSourceName": "Obrazovka" + }, + "recording": { + "selectSource": "Vyberte prosím zdroj pro nahrávání", + "systemPicker": "Systém se zeptá, co sdílet", + "inProgress": "Nahrávání", + "saving": "Ukládání..." + }, + "language": "Jazyk", + "systemLanguagePrompt": { + "title": "Použít jazyk systému?", + "description": "Detekovali jsme {{language}} jako jazyk vašeho systému. Chcete přepnout OpenScreen na {{language}}?", + "switch": "Přepnout na {{language}}", + "keepDefault": "Ponechat aktuální jazyk" + }, + "softwareEncoderFallback": { + "title": "Přepnuto na softwarové kódování", + "description": "Výchozí GPU enkodér se nepodařilo spustit, takže OpenScreen přepnul na softwarové kódování H.264. Nahrávání by mělo pokračovat normálně, ale využití CPU může být vyšší.", + "dismiss": "Rozumím", + "dontShowAgain": "Již nezobrazovat" + }, + "deviceSettings": { + "title": "Nastavení zařízení", + "done": "Hotovo", + "micLevel": "Úroveň vstupu", + "micHint": "Mluvte pro kontrolu mikrofonu", + "noMicrophones": "Mikrofon nenalezen", + "preview": "Náhled", + "previewUnavailable": "Náhled nedostupný", + "about": "O aplikaci", + "version": "Verze {{version}}", + "checkingForUpdates": "Kontrola…" + } +} diff --git a/src/i18n/locales/cs/settings.json b/src/i18n/locales/cs/settings.json new file mode 100644 index 000000000..46e305fe8 --- /dev/null +++ b/src/i18n/locales/cs/settings.json @@ -0,0 +1,355 @@ +{ + "background": { + "gradientLabel": "Přechod {{index}}", + "uploadCustom": "Nahrát vlastní", + "unsupportedImage": "Nepodporovaný obrázek. Použijte soubor JPG nebo PNG.", + "title": "Pozadí", + "imageLabel": "Pozadí {{index}}", + "custom": "Vlastní", + "help": "Vyberte, co se zobrazí za nahrávkou: přibalený obrázek pozadí, plná barva, přechod nebo vlastní obrázek z disku.", + "gradient": "Přechod", + "colorLabel": "Barva {{color}}", + "customWallpaper": "Vlastní tapeta", + "colorPalette": "Paleta barev", + "imageReadFailed": "Nepodařilo se přečíst tento soubor obrázku.", + "image": "Obrázek", + "presets": "Předvolby", + "color": "Barva", + "colorWheel": "Barevné kolo" + }, + "customFont": { + "namePlaceholder": "Moje vlastní písmo", + "failedToAdd": "Nepodařilo se přidat písmo", + "addingButton": "Přidávání...", + "errorInvalidUrl": "Zadejte prosím platnou URL adresu Google Fonts", + "urlHelp": "Získejte tuto adresu z Google Fonts: Vyberte písmo → Klikněte na „Get font\" → Zkopírujte URL @import", + "urlLabel": "URL pro import z Google Fonts", + "successMessage": "Písmo „{{fontName}}\" bylo úspěšně přidáno", + "nameLabel": "Zobrazovaný název", + "errorEmptyName": "Zadejte prosím název písma", + "nameHelp": "Takto se písmo zobrazí ve výběru písem", + "errorEmptyUrl": "Zadejte prosím URL pro import z Google Fonts", + "errorExtractFailed": "Nepodařilo se extrahovat rodinu písem z URL", + "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap", + "dialogTitle": "Přidat písmo Google", + "errorTimeout": "Načítání písma trvalo příliš dlouho. Zkontrolujte prosím URL a zkuste to znovu.", + "errorLoadFailed": "Písmo se nepodařilo načíst. Ověřte prosím, zda je URL Google Fonts správná.", + "addButton": "Přidat písmo" + }, + "imageUpload": { + "invalidFileType": "Neplatný typ souboru", + "failedToUpload": "Nepodařilo se nahrát obrázek", + "jpgOnly": "Nahrajte prosím soubor obrázku JPG, JPEG nebo PNG.", + "uploadSuccess": "Vlastní obrázek byl úspěšně nahrán!", + "errorReading": "Při čtení souboru došlo k chybě." + }, + "annotation": { + "supportedFormats": "Podporované formáty: JPG, PNG, GIF, WebP", + "blurShapeRectangle": "Obdélník", + "size": "Velikost", + "tipShiftTabCycle": "Použijte Shift+Tab pro cyklování dozadu.", + "clearBackground": "Vymazat pozadí", + "colorPalette": "Paleta barev", + "invalidImageType": "Neplatný typ souboru", + "background": "Pozadí", + "typeText": "Text", + "active": "Aktivní", + "color": "Barva", + "blurShapeFreehand": "Volný tvar", + "arrowDirection": "Směr šipky", + "blurTypeMosaic": "Mozaika", + "colorWheel": "Barevné kolo", + "textColor": "Barva textu", + "title": "Nastavení anotace", + "blurType": "Typ rozmazání", + "typeBlur": "Rozmazání", + "blurIntensity": "Intenzita rozmazání", + "selectStyle": "Vybrat styl", + "textContent": "Obsah textu", + "typeArrow": "Šipka", + "none": "Žádný", + "blurColor": "Barva rozmazání", + "customFonts": "Vlastní písma", + "imageUploadSuccess": "Obrázek byl úspěšně nahrán!", + "type": "Typ", + "arrowColor": "Barva šipky", + "textPlaceholder": "Zadejte váš text...", + "imageFormatsOnly": "Nahrajte prosím soubor obrázku JPG, PNG, GIF nebo WebP.", + "blurShape": "Tvar rozmazání", + "uploadImage": "Nahrát obrázek", + "blurTypeBlur": "Gaussovské", + "tipTabCycle": "Použijte Tab pro cyklování přes překrývající se položky.", + "shortcutsAndTips": "Zkratky a tipy", + "deleteAnnotation": "Smazat anotaci", + "fontStyle": "Styl písma", + "defaultText": "Ahoj", + "mosaicBlockSize": "Velikost bloku mozaiky", + "blurColorBlack": "Černá", + "strokeWidth": "Šířka tahu: {{width}}px", + "blurShapeOval": "Elipsa", + "blurColorWhite": "Bílá", + "tipMovePlayhead": "Přesuňte přehrávací hlavici na překrývající se sekci anotace a vyberte položku.", + "typeImage": "Obrázek" + }, + "effects": { + "fitClipFew": "{{count}} klipy", + "title": "Kompozice", + "shadow": "Stín", + "off": "vypnuto", + "on": "zapnuto", + "blurBg": "Rozmazat pozadí", + "help": "Stylizace rámce pro nahrávku: rozmazání pozadí, vrhaný stín, pohybové rozmazání, zaoblení rohů a odsazení kolem videa.", + "fitClipOne": "{{count}} klip", + "formatOriginal": "Původní", + "fitClipMany": "{{count}} klipů", + "frame": "Rám", + "motion": "Pohyb", + "padding": "Odsazení", + "format": "Formát", + "fitClip": "Přizpůsobit", + "motionBlur": "Pohybové rozmazání", + "roundness": "Zaoblení" + }, + "transcript": { + "laneRecording": "Nahrávka", + "noTranscript": "Zatím žádný přepis", + "title": "Přepis", + "restoreWord": "Obnovit „{{word}}\"", + "revertWord": "Obnovit „{{original}}\"", + "editingHint": "Dvojklikem slovo opravte, Backspace ho vystřihne z filmu.", + "restoreSilence": "Obnovit ticho ({{duration}}s)", + "help": "Agregovaný přepis každého klipu na časové ose. Backspace / Delete slovo nebo výběr pro vystřižení z filmu (červeně). Dvojklikem slovo opravte. Najeďte myší na označené slovo pro vrácení změny.", + "editWord": "Upravit „{{word}}\"", + "laneFeedsCaptions": "Titulky jsou generovány z této stopy.", + "insertedWord": "Přidáno vámi — za ním není žádný zvuk", + "whisperHint": "Přepis používá lokální Whisper — běží na vašem počítači, žádná data neopouštějí zařízení.", + "insertAria": "Nové slovo", + "editorAria": "Přepis pro {{filename}}", + "transcribeNow": "Přepsat nyní", + "transcribing": "Přepisování…", + "trimSilence": "Oříznutí ticha ({{duration}}s)", + "removeInserted": "Smazat „{{word}}\"", + "laneLabel": "Číst přepis z", + "noClips": "Zatím žádné klipy", + "laneVoiceover": "Komentář", + "silence": "[ticho {{duration}}s]", + "clipLabel": "Klip {{index}}", + "correctedWord": "Opraveno — přepisovač slyšel „{{original}}\"", + "noClipTranscript": "Pro tento klip není přepis — otevřete kartu assetu a znovu generujte.", + "editingHintDev": "Dvojklikem slovo opravte, Backspace ho vystřihne z filmu, psaním mezi dvěma slovy přidáte nové.", + "noAudio": "Toto médium nemá zvukovou stopu", + "blankedWord": "vynecháno" + }, + "exportFormat": { + "mp4": "MP4", + "mp4Description": "Video soubor vysoké kvality", + "gifAnimation": "GIF animace", + "mp4Video": "MP4 video", + "gifDescription": "Animovaný obrázek pro sdílení", + "gif": "GIF" + }, + "captions": { + "showBackground": "Zobrazit pozadí", + "deleteTranslation": "Smazat tento překlad", + "legacyAnnotations": "Tento projekt stále obsahuje anotace titulků ze staré funkce titulků ({{count}}). Renderují se na vrstvě titulků.", + "backgroundOpacity": "Průhlednost", + "backgroundColor": "Barva pozadí", + "alignCenter": "Na střed", + "translationIsNonDestructive": "Překlady jsou uloženy vedle přepisu, nikdy v něm — původní text a jeho časování zůstávají nedotčeny.", + "distanceFromRight": "Vzdálenost zprava", + "language": "Jazyk", + "text": "Text", + "anchorHintBottom": "Dlouhé titulky rostou nahoru — spodní okraj zůstává na místě.", + "distanceFromTop": "Vzdálenost shora", + "translateFailed": "Překlad selhal.", + "alignLeft": "Vlevo", + "distanceFromBottom": "Vzdálenost zdola", + "derivedFromTranscript": "{{count}} řádků titulků, odvozených živě z přepisu.", + "translate": "Přeložit", + "position": "Pozice", + "fontSize": "Velikost", + "hiddenHint": "Titulky pocházejí z přepisu tohoto média. Zapněte je pro zobrazení v náhledu a při exportu.", + "noTranscript": "Titulky jsou čteny z přepisu média. Přepište toto video pro jejich zapnutí.", + "distanceFromLeft": "Vzdálenost zleva", + "anchorBottom": "Dolní", + "transcribe": "Přepsat video", + "bold": "Tučně", + "alignRight": "Vpravo", + "anchorTop": "Horní", + "minWords": "Min. slov na řádek", + "translateHint": "Přeložit přepis pomocí nakonfigurovaného poskytovatele AI", + "anchorHintTop": "Dlouhé titulky rostou dolů — horní okraj zůstává na místě.", + "displayLanguage": "Zobrazení", + "removeLegacyAnnotations": "Odebrat staré anotace titulků", + "background": "Pozadí", + "lineLength": "Délka řádku", + "original": "Původní (přepis)", + "maxWords": "Max. slov na řádek", + "font": "Písmo", + "translating": "Překladání…", + "show": "Zobrazit titulky", + "textColor": "Barva textu" + }, + "panes": { + "help": "Nápověda" + }, + "speed": { + "deleteRegion": "Smazat oblast rychlosti", + "maxSpeedError": "Rychlost nemůže být vyšší než {{max}}×", + "selectRegion": "Vyberte oblast rychlosti pro úpravu", + "playbackSpeed": "Rychlost přehrávání", + "customPlaybackSpeed": "Vlastní rychlost přehrávání", + "previewFrameSteppingHint": "Nad {{native}}× náhled postupuje po snímcích a je bez zvuku. Export není ovlivněn." + }, + "gifSettings": { + "frameRate": "Snímkový kmitočet GIF", + "loop": "Opakovat GIF", + "size": "Velikost GIF" + }, + "exportQuality": { + "title": "Rozlišení exportu", + "low": "720p", + "high": "Zdroj", + "medium": "1080p" + }, + "audioTrack": { + "defaultLabel": "Zvuková stopa", + "importFailed": "Nepodařilo se přidat zvuk", + "fadeOut": "Zeslabení", + "fadeIn": "Zesílení", + "remove": "Smazat stopu", + "loop": "Opakovat", + "slipHint": "Alt-přetažením posuňte zvuk uvnitř", + "help": "Hlasitost, přechody a opakování pro tuto zvukovou stopu. Mixuje se s nahrávkou v náhledu i při exportu. Přetáhněte stopu na časové ose pro přesunutí nebo změnu velikosti, nebo podržte Alt a přetáhněte pro posun zvuku uvnitř.", + "add": "Přidat zvukovou stopu", + "mute": "Ztlumit" + }, + "layout": { + "help": "Jak je webkamera složena s obrazovkou: obraz v obrazu, svislé skládání, dvojitý rám, tvar masky, velikost a zrcadlení.", + "mirrorWebcam": "Zrcadlit webkameru", + "webcamFraming": "Oříznutí kamery", + "shapes": { + "rectangle": "Obdélník", + "rounded": "Zaoblený", + "circle": "Kruh", + "square": "Čtverec" + }, + "selectPreset": "Vybrat předvolbu", + "bgModes": { + "custom": "Vlastní", + "none": "Původní", + "blur": "Rozmazání", + "transparent": "Výřez" + }, + "reactiveWebcamDescription": "Kamera se plynule zmenšuje, když je video přiblíženo, aby nepřekážela.", + "webcamBlurIntensity": "Intenzita rozmazání", + "preset": "Předvolba", + "webcamCropZoom": "Přiblížení", + "webcamSize": "Velikost webkamery", + "dualFrame": "Dvojitý rám", + "webcamCropY": "Svislé posouvání", + "verticalStack": "Svislé skládání", + "pictureInPicture": "Obraz v obrazu", + "webcamShape": "Tvar kamery", + "webcamCropX": "Vodorovné posouvání", + "reactiveWebcam": "Zmenšit při přiblížení", + "webcamBackground": "Pozadí kamery", + "helpNoWebcam": "Tento projekt nemá kameru, takže ovládání rozvržení je vypnuto a předvolba zobrazuje „Bez webkamery\". Vaše uložené rozvržení je zachováno pro případ přidání kamery.", + "title": "Rozvržení kamery", + "noWebcam": "Bez webkamery" + }, + "textAnimation": { + "slideLeft": "Posun doleva", + "pulse": "Pulzování", + "typewriter": "Psací stroj", + "selectAnimation": "Vybrat animaci", + "fade": "Prolínání", + "title": "Animace textu", + "none": "Žádná", + "pop": "Vyskočení", + "rise": "Vynoření" + }, + "facets": { + "transcript": "Přepis", + "captions": "Titulky" + }, + "crop": { + "title": "Oříznutí", + "free": "Volné", + "unlockAspectRatio": "Odemknout poměr stran", + "dragInstruction": "Přetáhněte každou stranu pro úpravu plochy oříznutí", + "done": "Hotovo", + "ratio": "Poměr", + "cropVideo": "Oříznout video", + "lockAspectRatio": "Zamknout poměr stran" + }, + "zoom": { + "position": { + "y": "Y (%)", + "hint": "0 = nejvíce vlevo / nahoře, 100 = nejvíce vpravo / dole", + "title": "Pozice zaostření", + "x": "X (%)" + }, + "deleteZoom": "Smazat přiblížení", + "focusMode": { + "lockedDisclaimer": "Řízeno globálním přepínačem Automatického zaostření na časové ose. Vypněte ho pro nastavení režimu zaostření pro každé přiblížení.", + "auto": "Automatický", + "manual": "Ruční", + "autoDescription": "Kamera sleduje zaznamenanou pozici kurzoru", + "title": "Režim zaostření" + }, + "threeD": { + "preset": { + "left": "Vlevo", + "right": "Vpravo", + "iso": "Iso" + }, + "none": "Žádné", + "title": "3D rotace" + }, + "level": "Úroveň přiblížení", + "previewHold": "Podržte pro náhled efektu přiblížení", + "customScale": "Vlastní přiblížení", + "selectRegion": "Vyberte oblast přiblížení pro úpravu" + }, + "audio": { + "outputGain": "Výstupní úroveň", + "help": "Upravte výstupní úroveň zvuku. Aplikuje se stejně v náhledu i při exportu.", + "reset": "Obnovit zvuk", + "title": "Zvuk" + }, + "language": { + "title": "Jazyk" + }, + "project": { + "new": "Nový projekt", + "load": "Načíst projekt", + "save": "Uložit projekt" + }, + "support": { + "starOnGithub": "Ohodnotit na GitHubu", + "saveDiagnostics": "Uložit diagnostiku", + "reportBug": "Nahlásit chybu" + }, + "cursor": { + "smoothing": "Vyhlazení", + "clickBounce": "Odraz při kliknutí", + "help": "Renderování kurzoru ze zaznamenané telemetrie: motiv, velikost, vyhlazení, pohybové rozmazání a zdůraznění odrazu při kliknutí.", + "clipToBoundsDescription": "Udržuje kurzor uvnitř rámce videa. Vypněte pro povolení kurzoru přesahovat okraje — užitečné při přiblížení nebo posunu.", + "size": "Velikost", + "title": "Kurzor", + "show": "Zobrazit kurzor", + "themeDefault": "Výchozí", + "clipToBounds": "Oříznout na plátno", + "motionBlur": "Pohybové rozmazání", + "theme": "Styl kurzoru" + }, + "export": { + "gifButton": "Exportovat GIF", + "chooseSaveLocation": "Vybrat umístění pro uložení", + "videoButton": "Exportovat video" + }, + "trim": { + "deleteRegion": "Smazat oblast oříznutí" + } +} diff --git a/src/i18n/locales/cs/shortcuts.json b/src/i18n/locales/cs/shortcuts.json new file mode 100644 index 000000000..602ea8834 --- /dev/null +++ b/src/i18n/locales/cs/shortcuts.json @@ -0,0 +1,43 @@ +{ + "title": "Klávesové zkratky", + "customize": "Přizpůsobit", + "configurable": "Konfigurovatelné", + "fixed": "Pevné", + "pressKey": "Stiskněte klávesu…", + "clickToChange": "Klikněte pro změnu", + "pressEscToCancel": "Stiskněte Esc pro zrušení", + "helpText": "Klikněte na zkratku a stiskněte novou kombinaci kláves. Stiskněte Esc pro zrušení.", + "resetToDefaults": "Obnovit výchozí nastavení", + "alreadyUsedBy": "Již používáno pro {{action}}", + "swap": "Prohodit", + "reservedShortcut": "Tato zkratka je vyhrazena pro „{{label}}\" a nelze ji přiřadit.", + "savedToast": "Klávesové zkratky uloženy", + "resetToast": "Obnoveny výchozí zkratky — klikněte Uložit pro použití", + "registrationFailed": "Nepodařilo se zaregistrovat zkratku. Může být používána jinou aplikací. Zkuste jinou klávesu.", + "actions": { + "openApp": "Otevřít aplikaci", + "addZoom": "Přidat přiblížení", + "addTrim": "Přidat oříznutí", + "addSpeed": "Přidat rychlost", + "addAnnotation": "Přidat anotaci", + "addAudio": "Přidat zvuk", + "addVoiceover": "Nahrát komentář", + "addKeyframe": "Přidat klíčový snímek", + "addCameraFullscreen": "Přidat celou kameru", + "deleteSelected": "Smazat vybrané", + "playPause": "Přehrát / Pozastavit", + "copySelected": "Kopírovat vybrané", + "paste": "Vložit" + }, + "fixedActions": { + "undo": "Zpět", + "redo": "Znovu", + "cycleAnnotationsForward": "Cyklovat anotace dopředu", + "cycleAnnotationsBackward": "Cyklovat anotace dozadu", + "deleteSelectedAlt": "Smazat vybrané (alternativa)", + "panTimeline": "Posunout časovou osu", + "zoomTimeline": "Přiblížit časovou osu", + "frameBack": "Snímek zpět", + "frameForward": "Snímek dopředu" + } +} diff --git a/src/i18n/locales/cs/timeline.json b/src/i18n/locales/cs/timeline.json new file mode 100644 index 000000000..ad648000c --- /dev/null +++ b/src/i18n/locales/cs/timeline.json @@ -0,0 +1,108 @@ +{ + "buttons": { + "addZoom": "Přidat přiblížení (Z)", + "suggestZooms": "Navrhnout přiblížení podle kurzoru", + "autoZoomOn": "Automatické návrhy přiblížení zapnuty — klikněte pro odstranění navržených přiblížení", + "autoZoomOff": "Automatické návrhy přiblížení vypnuty — klikněte pro návrh přiblížení podle kurzoru", + "autoFocusAllOn": "Automatické zaostření zapnuto pro všechna přiblížení — klikněte pro přepnutí všech na ruční", + "autoFocusAllOff": "Automaticky zaostřit všechna přiblížení (kamera sleduje kurzor)", + "addTrim": "Přidat oříznutí (T)", + "addAnnotation": "Přidat anotaci (A)", + "addSpeed": "Přidat rychlost (S)", + "addCameraFullscreen": "Přidat celou kameru (C)" + }, + "hints": { + "pressZoom": "Stiskněte Z pro přidání přiblížení", + "pressTrim": "Stiskněte T pro přidání oříznutí", + "pressAnnotation": "Stiskněte A pro přidání anotace", + "pressAudio": "Stiskněte M pro přidání zvuku, V pro nahrání komentáře", + "pressSpeed": "Stiskněte S pro přidání rychlosti", + "pressCameraFullscreen": "Stiskněte C pro přidání segmentu celé kamery" + }, + "labels": { + "pan": "Posun", + "zoom": "Přiblížení", + "trim": "Oříznutí", + "speed": "Rychlost", + "zoomItem": "Přiblížení {{index}}", + "trimItem": "Oříznutí {{index}}", + "speedItem": "Rychlost {{index}}", + "annotationItem": "Anotace", + "imageItem": "Obrázek", + "emptyText": "Prázdný text", + "cameraFullscreen": "Celá kamera", + "cameraFullscreenItem": "Celá kamera {{index}}" + }, + "emptyState": { + "noVideo": "Žádné video nenačteno", + "dragAndDrop": "Přetáhněte video pro zahájení úprav" + }, + "errors": { + "cannotPlaceZoom": "Přiblížení zde nelze umístit", + "zoomExistsAtLocation": "Přiblížení na tomto místě již existuje nebo není dostatek místa.", + "zoomSuggestionUnavailable": "Obslužná rutina návrhů přiblížení není dostupná", + "noCursorTelemetry": "Nejsou dostupná žádná data kurzoru", + "noCursorTelemetryDescription": "Nejprve nahrajte screenscast pro generování návrhů na základě kurzoru.", + "noUsableTelemetry": "Žádná použitelná data kurzoru", + "noUsableTelemetryDescription": "Nahrávka neobsahuje dostatek dat o pohybu kurzoru.", + "noDwellMoments": "Nenalezeny žádné výrazné momenty zastavení kurzoru", + "noDwellMomentsDescription": "Zkuste nahrávku s pomalejšími pauzami kurzoru na důležitých akcích.", + "noAutoZoomSlots": "Nejsou dostupné žádné sloty pro automatické přiblížení", + "noAutoZoomSlotsDescription": "Detekované body zastavení se překrývají s existujícími oblastmi přiblížení.", + "cannotPlaceTrim": "Oříznutí zde nelze umístit", + "trimExistsAtLocation": "Oříznutí na tomto místě již existuje nebo není dostatek místa.", + "cannotPlaceSpeed": "Rychlost zde nelze umístit", + "speedExistsAtLocation": "Oblast rychlosti na tomto místě již existuje nebo není dostatek místa.", + "cannotPlaceCameraFullscreen": "Celou kameru zde nelze umístit", + "cameraFullscreenExistsAtLocation": "Segment celé kamery na tomto místě již existuje nebo není dostatek místa." + }, + "success": { + "addedZoomSuggestions": "Přidán {{count}} návrh přiblížení na základě kurzoru", + "addedZoomSuggestionsPlural": "Přidáno {{count}} návrhů přiblížení na základě kurzoru" + }, + "toolbar": { + "autoEnhance": "Automatické vylepšení", + "automaticZooms": "Automatická přiblížení", + "automaticZoomsHint": "Z zaznamenaného pohybu kurzoru", + "smartZoomsAndCuts": "Chytré střihy", + "smartZoomsAndCutsHint": "S AI", + "comment": "Komentář", + "timelineTools": "Nástroje časové osy", + "arrangeClips": "Uspořádat klipy", + "arrangeClipsHint": "Přetáhněte klipy níže pro změnu pořadí nebo přidejte nové", + "newAnnotation": "Anotace", + "dragToReorderHint": "Přetáhněte pro změnu pořadí · dvojklik pro úpravu vstupních/výstupních bodů", + "editInOutPoints": "Upravit vstupní/výstupní body", + "deleteClip": "Smazat klip", + "dropToAdd": "Přetáhněte pro přidání na časovou osu", + "importRecordingFirst": "Nejprve importujte nahrávku", + "noAutoZoomMoments": "Žádné momenty pro automatické přiblížení nenalezeny", + "noAutoZoomMomentsDescription": "Tato nahrávka nemá data o pohybu kurzoru nebo existující přiblížení již pokrývají aktivní momenty.", + "addedAutoZoom": "Přidáno {{count}} automatické přiblížení", + "addedAutoZoomPlural": "Přidáno {{count}} automatických přiblížení", + "autoZoomFailed": "Automatické přiblížení selhalo", + "aiEnhanceRequested": "AI agent byl požádán o vystřižení mrtvého času", + "smartCutsWaiting": "Přepisování… připraveno za okamžik", + "smartCutsNeedsTranscript": "Vyžaduje přepis", + "smartCutsNoAudio": "Toto médium nemá zvuk", + "smartCutsNoSpeech": "Řeč nebyla detekována", + "smartCutsFailed": "Přepisování selhalo — zkuste to znovu z Média", + "addAudioTooltip": "Přidat zvuk", + "addedWord": "Přidáno slovo: „{{word}}\" — za ním není žádný zvuk" + }, + "audio": { + "addVoiceover": "Přidat komentář", + "addVoiceoverHint": "Nahrajte komentář k videu", + "subtitle": "Umístěte komentář nebo vrstvu hudby na pozadí na časovou osu", + "record": "Nahrát komentář", + "importFile": "Importovat zvukový soubor", + "importFileHint": "Přineste hudbu nebo zvukový soubor", + "recording": "Nahrávání", + "recordingHint": "Komentujte spolu s videem — přehrává se při nahrávání", + "stop": "Zastavit", + "micDenied": "Přístup k mikrofonu byl odepřen", + "recordingUnavailable": "Nahrávání zde není dostupné", + "saveFailed": "Nepodařilo se uložit nahrávku", + "importFailed": "Nepodařilo se importovat zvukový soubor" + } +} From a4e0bca79cc4ee3aaa115f6d1c749eb918728cf2 Mon Sep 17 00:00:00 2001 From: Adam Pavlat Date: Tue, 8 Sep 2026 22:05:22 +0200 Subject: [PATCH 30/39] fix(cs): correct Czech grammar, terminology and typos in locale files --- src/i18n/locales/cs/common.json | 4 ++-- src/i18n/locales/cs/dialogs.json | 2 +- src/i18n/locales/cs/editor.json | 14 +++++++------- src/i18n/locales/cs/settings.json | 4 ++-- src/i18n/locales/cs/timeline.json | 2 +- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/i18n/locales/cs/common.json b/src/i18n/locales/cs/common.json index c5daf2527..c68a3e456 100644 --- a/src/i18n/locales/cs/common.json +++ b/src/i18n/locales/cs/common.json @@ -19,8 +19,8 @@ "export": "Exportovat", "showInFolder": "Zobrazit ve složce", "file": "Soubor", - "edit": "Upravit", - "view": "Zobrazit", + "edit": "Úpravy", + "view": "Zobrazení", "window": "Okno", "help": "Nápověda", "quit": "Ukončit", diff --git a/src/i18n/locales/cs/dialogs.json b/src/i18n/locales/cs/dialogs.json index 1a0da0742..a47e8d047 100644 --- a/src/i18n/locales/cs/dialogs.json +++ b/src/i18n/locales/cs/dialogs.json @@ -26,7 +26,7 @@ "tutorial": { "triggerLabel": "Jak funguje oříznutí", "title": "Jak funguje oříznutí", - "description": "Pochopte, jak vystřihnout nežádoucí části videa.", + "description": "Jak vystřihnout nežádoucí části videa.", "explanationBefore": "Nástroj Oříznutí funguje tak, že definujete segmenty, které chcete", "remove": "odstranit", "explanationMiddle": " — vše", diff --git a/src/i18n/locales/cs/editor.json b/src/i18n/locales/cs/editor.json index 981ed6aab..6126a1e21 100644 --- a/src/i18n/locales/cs/editor.json +++ b/src/i18n/locales/cs/editor.json @@ -1,6 +1,6 @@ { "newRecording": { - "title": "Vrátit se na nahrávání", + "title": "Vrátit se k nahrávání", "description": "Vaše aktuální relace byla uložena.", "cancel": "Zrušit", "confirm": "Potvrdit" @@ -132,7 +132,7 @@ }, "mediaStage": { "openProjectFirst": "Nejprve otevřete projekt", - "couldNotAddAsset": "Nepodařilo se přidat asset", + "couldNotAddAsset": "Nepodařilo se přidat médium", "searchPlaceholder": "Hledat média…", "dragHint": "Přetáhněte klip na níže uvedenou časovou osu pro přidání", "emptyHint": "Zatím žádná média — importujte nahrávku pro začátek.", @@ -162,8 +162,8 @@ "noSpeechDetected": "Žádná řeč nezjištěna" }, "exportDialog": { - "title": "Exportovat", - "subtitle": "Renderovat časovou osu do souboru", + "title": "Export", + "subtitle": "Vykreslení časové osy do souboru", "addVideoBeforeExporting": "Před exportem přidejte video.", "quality": "Kvalita", "qualityMatchRecording": "Odpovídat nahrávce", @@ -274,7 +274,7 @@ "sendTitle": "Odeslat (Enter)", "send": "Odeslat", "rewindConfirmTitle": "Přetočit sem?", - "rewindConfirmBody": "Úpravy agenta a navazující otočení po tomto bodu budou vráceny. Projekt, konverzace a stav agenta budou obnoveny. Jakékoli úpravy, které jste od té doby provedli, budou také nahrazeny.", + "rewindConfirmBody": "Úpravy agenta a navazující zprávy po tomto bodu budou vráceny. Projekt, konverzace a stav agenta budou obnoveny. Jakékoli úpravy, které jste od té doby provedli, budou také nahrazeny.", "rewindConfirm": "Přetočit", "configureModel": "Konfigurovat AI model", "historyDialog": { @@ -344,7 +344,7 @@ "previousClip": "Předchozí klip", "nextClip": "Další klip", "loop": "Opakovat", - "seekVideo": "Vyhledat v videu" + "seekVideo": "Vyhledat ve videu" }, "shell": { "aiEditor": "AI editor", @@ -390,7 +390,7 @@ "apiKeyLabel": "API klíč", "apiKeyHintStored": "Uloženo v safeStorage. Ponechte prázdné pro zachování stávajícího záznamu.", "projectEditsLabel": "Úpravy projektu", - "projectEditsHint": "Pokud je vypnuto, agent musí požádat před změnou časové osy. Úpravy jsou vždy vrácení zpět.", + "projectEditsHint": "Pokud je vypnuto, agent musí požádat před změnou časové osy. Úpravy lze vždy vrátit zpět.", "allowAgentEdits": "Povolit agentovi úpravy projektu", "disconnect": "Odpojit", "cancel": "Zrušit", diff --git a/src/i18n/locales/cs/settings.json b/src/i18n/locales/cs/settings.json index 46e305fe8..e5b00763f 100644 --- a/src/i18n/locales/cs/settings.json +++ b/src/i18n/locales/cs/settings.json @@ -186,7 +186,7 @@ "original": "Původní (přepis)", "maxWords": "Max. slov na řádek", "font": "Písmo", - "translating": "Překladání…", + "translating": "Překládání…", "show": "Zobrazit titulky", "textColor": "Barva textu" }, @@ -327,7 +327,7 @@ "save": "Uložit projekt" }, "support": { - "starOnGithub": "Ohodnotit na GitHubu", + "starOnGithub": "Přidat hvězdičku na GitHubu", "saveDiagnostics": "Uložit diagnostiku", "reportBug": "Nahlásit chybu" }, diff --git a/src/i18n/locales/cs/timeline.json b/src/i18n/locales/cs/timeline.json index ad648000c..76c6a2bc0 100644 --- a/src/i18n/locales/cs/timeline.json +++ b/src/i18n/locales/cs/timeline.json @@ -42,7 +42,7 @@ "zoomExistsAtLocation": "Přiblížení na tomto místě již existuje nebo není dostatek místa.", "zoomSuggestionUnavailable": "Obslužná rutina návrhů přiblížení není dostupná", "noCursorTelemetry": "Nejsou dostupná žádná data kurzoru", - "noCursorTelemetryDescription": "Nejprve nahrajte screenscast pro generování návrhů na základě kurzoru.", + "noCursorTelemetryDescription": "Nejprve nahrajte screencast pro generování návrhů na základě kurzoru.", "noUsableTelemetry": "Žádná použitelná data kurzoru", "noUsableTelemetryDescription": "Nahrávka neobsahuje dostatek dat o pohybu kurzoru.", "noDwellMoments": "Nenalezeny žádné výrazné momenty zastavení kurzoru", From 3033054ca481636f5ca02c550272fb4342c9036b Mon Sep 17 00:00:00 2001 From: Adam Pavlat Date: Tue, 8 Sep 2026 22:08:31 +0200 Subject: [PATCH 31/39] fix(cs): replace em dashes with natural Czech punctuation throughout --- src/i18n/locales/cs/dialogs.json | 2 +- src/i18n/locales/cs/editor.json | 24 ++++++++++++------------ src/i18n/locales/cs/settings.json | 16 ++++++++-------- src/i18n/locales/cs/shortcuts.json | 2 +- src/i18n/locales/cs/timeline.json | 12 ++++++------ 5 files changed, 28 insertions(+), 28 deletions(-) diff --git a/src/i18n/locales/cs/dialogs.json b/src/i18n/locales/cs/dialogs.json index a47e8d047..22a8a474d 100644 --- a/src/i18n/locales/cs/dialogs.json +++ b/src/i18n/locales/cs/dialogs.json @@ -29,7 +29,7 @@ "description": "Jak vystřihnout nežádoucí části videa.", "explanationBefore": "Nástroj Oříznutí funguje tak, že definujete segmenty, které chcete", "remove": "odstranit", - "explanationMiddle": " — vše", + "explanationMiddle": ", vše", "covered": "pokryté", "explanationAfter": "červeným segmentem oříznutí bude při exportu vystřiženo.", "visualExample": "Vizuální příklad", diff --git a/src/i18n/locales/cs/editor.json b/src/i18n/locales/cs/editor.json index 6126a1e21..296f5ec0a 100644 --- a/src/i18n/locales/cs/editor.json +++ b/src/i18n/locales/cs/editor.json @@ -19,7 +19,7 @@ "gifExportFailed": "Export GIF selhal", "noVideoLoaded": "Žádné video nenačteno", "previewCompositorUnavailable": "Náhled na tomto počítači není dostupný", - "trimNoFilm": "Tam není co vystřihnout — pod těmito slovy není žádný film.", + "trimNoFilm": "Tam není co vystřihnout. Pod těmito slovy není žádný film.", "unableToDetermineSourcePath": "Nelze určit cestu ke zdrojovému videu", "videoNotReady": "Video není připraveno", "wordEditFailed": "Nepodařilo se změnit toto slovo", @@ -135,7 +135,7 @@ "couldNotAddAsset": "Nepodařilo se přidat médium", "searchPlaceholder": "Hledat média…", "dragHint": "Přetáhněte klip na níže uvedenou časovou osu pro přidání", - "emptyHint": "Zatím žádná média — importujte nahrávku pro začátek.", + "emptyHint": "Zatím žádná média. Importujte nahrávku pro začátek.", "importMedia": "Importovat média", "added": "Přidáno {{label}}", "addToTimeline": "Přidat na časovou osu", @@ -148,17 +148,17 @@ "initializingModel": "Spouštění modelu řeči", "pendingTranscription": "Přepis čeká", "transcriptionFailed": "Přepis selhal", - "cpuBackendHint": "Běží na CPU — přibližně 2× pomalejší než na GPU", + "cpuBackendHint": "Běží na CPU (přibližně 2× pomalejší než na GPU)", "noTranscript": "Žádný přepis", "regenerateAs": "Znovu generovat jako", "auto": "Automaticky", "regenerate": "Znovu generovat", "transcriptEmpty": "Přepis je prázdný.", - "notGeneratedHint": "Ještě nevygenerováno — vyberte jazyk a klikněte na znovu generovat.", - "generationFailedHint": "Generování selhalo — vyberte jazyk a znovu generujte.", + "notGeneratedHint": "Ještě nevygenerováno. Vyberte jazyk a klikněte na Znovu generovat.", + "generationFailedHint": "Generování selhalo. Vyberte jazyk a znovu generujte.", "detectedLanguage": "Zjištěný jazyk: {{language}}", "noAudioTrack": "Žádná zvuková stopa", - "noAudioTrackHint": "Toto médium nemá zvukovou stopu — není co přepisovat.", + "noAudioTrackHint": "Toto médium nemá zvukovou stopu, takže není co přepisovat.", "noSpeechDetected": "Žádná řeč nezjištěna" }, "exportDialog": { @@ -196,7 +196,7 @@ "exportFailed": "Export selhal", "failedToWriteFile": "Nepodařilo se zapsat soubor", "qualityUpscaleWarning": "Zvýšení rozlišení", - "nothingToExport": "Není co exportovat — časová osa je prázdná." + "nothingToExport": "Není co exportovat, časová osa je prázdná." }, "inspector": { "resetFocusPoint": "Obnovit bod zaostření", @@ -206,11 +206,11 @@ "collapseInspector": "Sbalit inspektor", "captionsDescription": "Generujte titulky synchronizované se slovy z přepisu a přetáhněte je na časovou osu.", "generateCaptions": "Generovat titulky", - "cropDescription": "Přeformátujte nahrávku — vyberte poměr stran a přibližte oblast, kterou chcete zachovat.", + "cropDescription": "Přeformátujte nahrávku: vyberte poměr stran a přibližte oblast, kterou chcete zachovat.", "openCrop": "Otevřít oříznutí…", - "cameraFullscreenDescription": "Během přehrávání této oblasti kamera zabírá celý snímek — bez okraje, bez zaoblení, bez pozadí — a na konci se vrátí. Přetáhněte okraje oblasti na časové ose pro změnu začátku a délky trvání.", + "cameraFullscreenDescription": "Během přehrávání této oblasti kamera zabírá celý snímek (bez okraje, bez zaoblení, bez pozadí) a na konci se vrátí. Přetáhněte okraje oblasti na časové ose pro změnu začátku a délky trvání.", "deleteRegion": "Smazat oblast", - "freehandRendersAsBox": "Při exportu je volně kreslený tvar pokryt svým ohraničujícím rámečkem — vždy překrytý, nikdy nepokrytý." + "freehandRendersAsBox": "Při exportu je volně kreslený tvar pokryt svým ohraničujícím rámečkem: vždy překrytý, nikdy nepokrytý." }, "chat": { "changeProvider": "Změnit poskytovatele", @@ -357,7 +357,7 @@ "webcamPreview": "Náhled webkamery (přetáhněte pro přemístění)", "mediaError": { "title": "Náhled zastaven", - "description": "Video nelze dekódovat. Soubor může být poškozen, stále se zapisuje nebo již není tam, kde ho projekt očekává — váš projekt je v pořádku.", + "description": "Video nelze dekódovat. Soubor může být poškozen, stále se zapisuje nebo již není tam, kde ho projekt očekává. Váš projekt je v pořádku.", "retry": "Zkusit znovu", "detail": "Podrobnosti: {{detail}}" } @@ -398,7 +398,7 @@ "saveAndUse": "Uložit a použít" }, "cpuCompositor": { - "notice": "Běží bez kompatibilního GPU — renderování na CPU, takže přehrávání je pomalejší.", + "notice": "Běží bez kompatibilního GPU, takže renderování probíhá na CPU a přehrávání je pomalejší.", "exportWarning": "Žádné kompatibilní GPU: tento export běží na CPU a bude trvat mnohem déle než obvykle." } } diff --git a/src/i18n/locales/cs/settings.json b/src/i18n/locales/cs/settings.json index e5b00763f..ad7a16be8 100644 --- a/src/i18n/locales/cs/settings.json +++ b/src/i18n/locales/cs/settings.json @@ -121,8 +121,8 @@ "help": "Agregovaný přepis každého klipu na časové ose. Backspace / Delete slovo nebo výběr pro vystřižení z filmu (červeně). Dvojklikem slovo opravte. Najeďte myší na označené slovo pro vrácení změny.", "editWord": "Upravit „{{word}}\"", "laneFeedsCaptions": "Titulky jsou generovány z této stopy.", - "insertedWord": "Přidáno vámi — za ním není žádný zvuk", - "whisperHint": "Přepis používá lokální Whisper — běží na vašem počítači, žádná data neopouštějí zařízení.", + "insertedWord": "Přidáno vámi (za slovem není žádný zvuk)", + "whisperHint": "Přepis používá lokální Whisper: běží na vašem počítači a žádná data neopouštějí zařízení.", "insertAria": "Nové slovo", "editorAria": "Přepis pro {{filename}}", "transcribeNow": "Přepsat nyní", @@ -134,8 +134,8 @@ "laneVoiceover": "Komentář", "silence": "[ticho {{duration}}s]", "clipLabel": "Klip {{index}}", - "correctedWord": "Opraveno — přepisovač slyšel „{{original}}\"", - "noClipTranscript": "Pro tento klip není přepis — otevřete kartu assetu a znovu generujte.", + "correctedWord": "Opraveno, přepisovač slyšel „{{original}}\"", + "noClipTranscript": "Pro tento klip není přepis. Otevřete kartu média a znovu generujte.", "editingHintDev": "Dvojklikem slovo opravte, Backspace ho vystřihne z filmu, psaním mezi dvěma slovy přidáte nové.", "noAudio": "Toto médium nemá zvukovou stopu", "blankedWord": "vynecháno" @@ -155,11 +155,11 @@ "backgroundOpacity": "Průhlednost", "backgroundColor": "Barva pozadí", "alignCenter": "Na střed", - "translationIsNonDestructive": "Překlady jsou uloženy vedle přepisu, nikdy v něm — původní text a jeho časování zůstávají nedotčeny.", + "translationIsNonDestructive": "Překlady jsou uloženy vedle přepisu, nikdy v něm. Původní text a jeho časování zůstávají nedotčeny.", "distanceFromRight": "Vzdálenost zprava", "language": "Jazyk", "text": "Text", - "anchorHintBottom": "Dlouhé titulky rostou nahoru — spodní okraj zůstává na místě.", + "anchorHintBottom": "Dlouhé titulky rostou nahoru, spodní okraj zůstává na místě.", "distanceFromTop": "Vzdálenost shora", "translateFailed": "Překlad selhal.", "alignLeft": "Vlevo", @@ -178,7 +178,7 @@ "anchorTop": "Horní", "minWords": "Min. slov na řádek", "translateHint": "Přeložit přepis pomocí nakonfigurovaného poskytovatele AI", - "anchorHintTop": "Dlouhé titulky rostou dolů — horní okraj zůstává na místě.", + "anchorHintTop": "Dlouhé titulky rostou dolů, horní okraj zůstává na místě.", "displayLanguage": "Zobrazení", "removeLegacyAnnotations": "Odebrat staré anotace titulků", "background": "Pozadí", @@ -335,7 +335,7 @@ "smoothing": "Vyhlazení", "clickBounce": "Odraz při kliknutí", "help": "Renderování kurzoru ze zaznamenané telemetrie: motiv, velikost, vyhlazení, pohybové rozmazání a zdůraznění odrazu při kliknutí.", - "clipToBoundsDescription": "Udržuje kurzor uvnitř rámce videa. Vypněte pro povolení kurzoru přesahovat okraje — užitečné při přiblížení nebo posunu.", + "clipToBoundsDescription": "Udržuje kurzor uvnitř rámce videa. Vypněte, pokud chcete, aby kurzor přesahoval okraje, což se hodí při přiblížení nebo posunu.", "size": "Velikost", "title": "Kurzor", "show": "Zobrazit kurzor", diff --git a/src/i18n/locales/cs/shortcuts.json b/src/i18n/locales/cs/shortcuts.json index 602ea8834..a2c487361 100644 --- a/src/i18n/locales/cs/shortcuts.json +++ b/src/i18n/locales/cs/shortcuts.json @@ -12,7 +12,7 @@ "swap": "Prohodit", "reservedShortcut": "Tato zkratka je vyhrazena pro „{{label}}\" a nelze ji přiřadit.", "savedToast": "Klávesové zkratky uloženy", - "resetToast": "Obnoveny výchozí zkratky — klikněte Uložit pro použití", + "resetToast": "Výchozí zkratky obnoveny. Klikněte Uložit pro jejich použití.", "registrationFailed": "Nepodařilo se zaregistrovat zkratku. Může být používána jinou aplikací. Zkuste jinou klávesu.", "actions": { "openApp": "Otevřít aplikaci", diff --git a/src/i18n/locales/cs/timeline.json b/src/i18n/locales/cs/timeline.json index 76c6a2bc0..b3fc4fc5a 100644 --- a/src/i18n/locales/cs/timeline.json +++ b/src/i18n/locales/cs/timeline.json @@ -2,9 +2,9 @@ "buttons": { "addZoom": "Přidat přiblížení (Z)", "suggestZooms": "Navrhnout přiblížení podle kurzoru", - "autoZoomOn": "Automatické návrhy přiblížení zapnuty — klikněte pro odstranění navržených přiblížení", - "autoZoomOff": "Automatické návrhy přiblížení vypnuty — klikněte pro návrh přiblížení podle kurzoru", - "autoFocusAllOn": "Automatické zaostření zapnuto pro všechna přiblížení — klikněte pro přepnutí všech na ruční", + "autoZoomOn": "Automatické návrhy přiblížení zapnuty, klikněte pro odstranění navržených přiblížení", + "autoZoomOff": "Automatické návrhy přiblížení vypnuty, klikněte pro návrh přiblížení podle kurzoru", + "autoFocusAllOn": "Automatické zaostření zapnuto pro všechna přiblížení, klikněte pro přepnutí všech na ruční", "autoFocusAllOff": "Automaticky zaostřit všechna přiblížení (kamera sleduje kurzor)", "addTrim": "Přidat oříznutí (T)", "addAnnotation": "Přidat anotaci (A)", @@ -86,9 +86,9 @@ "smartCutsNeedsTranscript": "Vyžaduje přepis", "smartCutsNoAudio": "Toto médium nemá zvuk", "smartCutsNoSpeech": "Řeč nebyla detekována", - "smartCutsFailed": "Přepisování selhalo — zkuste to znovu z Média", + "smartCutsFailed": "Přepisování selhalo, zkuste to znovu z Média", "addAudioTooltip": "Přidat zvuk", - "addedWord": "Přidáno slovo: „{{word}}\" — za ním není žádný zvuk" + "addedWord": "Přidáno slovo: „{{word}}\", za ním není žádný zvuk" }, "audio": { "addVoiceover": "Přidat komentář", @@ -98,7 +98,7 @@ "importFile": "Importovat zvukový soubor", "importFileHint": "Přineste hudbu nebo zvukový soubor", "recording": "Nahrávání", - "recordingHint": "Komentujte spolu s videem — přehrává se při nahrávání", + "recordingHint": "Komentujte spolu s videem, které se přehrává při nahrávání", "stop": "Zastavit", "micDenied": "Přístup k mikrofonu byl odepřen", "recordingUnavailable": "Nahrávání zde není dostupné", From fc3722d8b4bedc5f97ee2a0a7c9f33b95d8a615d Mon Sep 17 00:00:00 2001 From: Adam Pavlat Date: Tue, 8 Sep 2026 22:09:43 +0200 Subject: [PATCH 32/39] fix(cs): replace escaped inner quotes with proper Czech closing quotation mark --- src/i18n/locales/cs/editor.json | 8 ++++---- src/i18n/locales/cs/settings.json | 16 ++++++++-------- src/i18n/locales/cs/shortcuts.json | 2 +- src/i18n/locales/cs/timeline.json | 2 +- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/i18n/locales/cs/editor.json b/src/i18n/locales/cs/editor.json index 296f5ec0a..5f3b8858c 100644 --- a/src/i18n/locales/cs/editor.json +++ b/src/i18n/locales/cs/editor.json @@ -65,7 +65,7 @@ "dropOverlay": "Přetáhněte soubor projektu pro otevření", "dropErrors": { "unsupportedFormatTitle": "Nepodporovaný formát", - "unsupportedFormatMessage": "Sem lze přetáhnout pouze soubory projektů .openscreen. Pro import video souboru použijte tlačítko „Importovat video soubor…\" na této obrazovce.", + "unsupportedFormatMessage": "Sem lze přetáhnout pouze soubory projektů .openscreen. Pro import video souboru použijte tlačítko „Importovat video soubor…” na této obrazovce.", "couldNotOpenTitle": "Soubor nelze otevřít", "couldNotOpenMessage": "Soubor projektu nelze otevřít. Video, na které odkazuje, mohlo být přesunuto nebo smazáno." } @@ -248,7 +248,7 @@ "clickToRename": "Klikněte pro přejmenování", "renameConversation": "Přejmenovat konverzaci", "deleteConversation": "Smazat konverzaci", - "confirmDeleteConversation": "Smazat „{{title}}\"?", + "confirmDeleteConversation": "Smazat „{{title}}”?", "emptyState": "Zatím žádné zprávy. Požádejte agenta o vystřižení ticha, zkrácení pauz nebo přidání titulků.", "welcome": { "title": "Přineste si vlastní AI", @@ -301,7 +301,7 @@ "title": "Otevřít projekt", "subtitle": "Pokračujte v existujícím projektu nebo procházejte soubory", "searchPlaceholder": "Hledat projekty…", - "noMatches": "Žádné projekty neodpovídají „{{query}}\".", + "noMatches": "Žádné projekty neodpovídají „{{query}}”.", "navigateHint": "pro navigaci ·", "openHint": "pro otevření", "browseFiles": "Procházet soubory…", @@ -322,7 +322,7 @@ }, "insertSourceDialog": { "title": "Vložit zdroj", - "subtitle": "Kam umístit „{{assetLabel}}\" na časové ose?", + "subtitle": "Kam umístit „{{assetLabel}}” na časové ose?", "addBefore": "Přidat před", "addBeforeDesc": "Vložit celý zdroj před cílový klip.", "addAfter": "Přidat za", diff --git a/src/i18n/locales/cs/settings.json b/src/i18n/locales/cs/settings.json index ad7a16be8..83e434e78 100644 --- a/src/i18n/locales/cs/settings.json +++ b/src/i18n/locales/cs/settings.json @@ -22,9 +22,9 @@ "failedToAdd": "Nepodařilo se přidat písmo", "addingButton": "Přidávání...", "errorInvalidUrl": "Zadejte prosím platnou URL adresu Google Fonts", - "urlHelp": "Získejte tuto adresu z Google Fonts: Vyberte písmo → Klikněte na „Get font\" → Zkopírujte URL @import", + "urlHelp": "Získejte tuto adresu z Google Fonts: Vyberte písmo → Klikněte na „Get font” → Zkopírujte URL @import", "urlLabel": "URL pro import z Google Fonts", - "successMessage": "Písmo „{{fontName}}\" bylo úspěšně přidáno", + "successMessage": "Písmo „{{fontName}}” bylo úspěšně přidáno", "nameLabel": "Zobrazovaný název", "errorEmptyName": "Zadejte prosím název písma", "nameHelp": "Takto se písmo zobrazí ve výběru písem", @@ -114,12 +114,12 @@ "laneRecording": "Nahrávka", "noTranscript": "Zatím žádný přepis", "title": "Přepis", - "restoreWord": "Obnovit „{{word}}\"", - "revertWord": "Obnovit „{{original}}\"", + "restoreWord": "Obnovit „{{word}}”", + "revertWord": "Obnovit „{{original}}”", "editingHint": "Dvojklikem slovo opravte, Backspace ho vystřihne z filmu.", "restoreSilence": "Obnovit ticho ({{duration}}s)", "help": "Agregovaný přepis každého klipu na časové ose. Backspace / Delete slovo nebo výběr pro vystřižení z filmu (červeně). Dvojklikem slovo opravte. Najeďte myší na označené slovo pro vrácení změny.", - "editWord": "Upravit „{{word}}\"", + "editWord": "Upravit „{{word}}”", "laneFeedsCaptions": "Titulky jsou generovány z této stopy.", "insertedWord": "Přidáno vámi (za slovem není žádný zvuk)", "whisperHint": "Přepis používá lokální Whisper: běží na vašem počítači a žádná data neopouštějí zařízení.", @@ -128,13 +128,13 @@ "transcribeNow": "Přepsat nyní", "transcribing": "Přepisování…", "trimSilence": "Oříznutí ticha ({{duration}}s)", - "removeInserted": "Smazat „{{word}}\"", + "removeInserted": "Smazat „{{word}}”", "laneLabel": "Číst přepis z", "noClips": "Zatím žádné klipy", "laneVoiceover": "Komentář", "silence": "[ticho {{duration}}s]", "clipLabel": "Klip {{index}}", - "correctedWord": "Opraveno, přepisovač slyšel „{{original}}\"", + "correctedWord": "Opraveno, přepisovač slyšel „{{original}}”", "noClipTranscript": "Pro tento klip není přepis. Otevřete kartu média a znovu generujte.", "editingHintDev": "Dvojklikem slovo opravte, Backspace ho vystřihne z filmu, psaním mezi dvěma slovy přidáte nové.", "noAudio": "Toto médium nemá zvukovou stopu", @@ -254,7 +254,7 @@ "webcamCropX": "Vodorovné posouvání", "reactiveWebcam": "Zmenšit při přiblížení", "webcamBackground": "Pozadí kamery", - "helpNoWebcam": "Tento projekt nemá kameru, takže ovládání rozvržení je vypnuto a předvolba zobrazuje „Bez webkamery\". Vaše uložené rozvržení je zachováno pro případ přidání kamery.", + "helpNoWebcam": "Tento projekt nemá kameru, takže ovládání rozvržení je vypnuto a předvolba zobrazuje „Bez webkamery”. Vaše uložené rozvržení je zachováno pro případ přidání kamery.", "title": "Rozvržení kamery", "noWebcam": "Bez webkamery" }, diff --git a/src/i18n/locales/cs/shortcuts.json b/src/i18n/locales/cs/shortcuts.json index a2c487361..5332308e2 100644 --- a/src/i18n/locales/cs/shortcuts.json +++ b/src/i18n/locales/cs/shortcuts.json @@ -10,7 +10,7 @@ "resetToDefaults": "Obnovit výchozí nastavení", "alreadyUsedBy": "Již používáno pro {{action}}", "swap": "Prohodit", - "reservedShortcut": "Tato zkratka je vyhrazena pro „{{label}}\" a nelze ji přiřadit.", + "reservedShortcut": "Tato zkratka je vyhrazena pro „{{label}}” a nelze ji přiřadit.", "savedToast": "Klávesové zkratky uloženy", "resetToast": "Výchozí zkratky obnoveny. Klikněte Uložit pro jejich použití.", "registrationFailed": "Nepodařilo se zaregistrovat zkratku. Může být používána jinou aplikací. Zkuste jinou klávesu.", diff --git a/src/i18n/locales/cs/timeline.json b/src/i18n/locales/cs/timeline.json index b3fc4fc5a..0c3457467 100644 --- a/src/i18n/locales/cs/timeline.json +++ b/src/i18n/locales/cs/timeline.json @@ -88,7 +88,7 @@ "smartCutsNoSpeech": "Řeč nebyla detekována", "smartCutsFailed": "Přepisování selhalo, zkuste to znovu z Média", "addAudioTooltip": "Přidat zvuk", - "addedWord": "Přidáno slovo: „{{word}}\", za ním není žádný zvuk" + "addedWord": "Přidáno slovo: „{{word}}”, za ním není žádný zvuk" }, "audio": { "addVoiceover": "Přidat komentář", From cf97efbe07d7eb597968dbc29348f1b2d51af91b Mon Sep 17 00:00:00 2001 From: Adam Pavlat Date: Tue, 8 Sep 2026 22:29:33 +0200 Subject: [PATCH 33/39] fix(cs): address CodeRabbit review: grammar, gender agreement and missing preposition --- src/i18n/locales/cs/dialogs.json | 4 ++-- src/i18n/locales/cs/editor.json | 4 ++-- src/i18n/locales/cs/shortcuts.json | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/i18n/locales/cs/dialogs.json b/src/i18n/locales/cs/dialogs.json index 22a8a474d..850e673c7 100644 --- a/src/i18n/locales/cs/dialogs.json +++ b/src/i18n/locales/cs/dialogs.json @@ -52,8 +52,8 @@ "saveAndClose": "Uložit a zavřít", "discardAndClose": "Zahodit a zavřít", "detailNewProject": "Chcete uložit projekt před vytvořením nového?", - "saveAndNewProject": "Uložit a nový projekt", - "discardAndNewProject": "Zahodit a nový projekt", + "saveAndNewProject": "Uložit a vytvořit nový projekt", + "discardAndNewProject": "Zahodit a vytvořit nový projekt", "detailLoadProject": "Chcete uložit projekt před načtením jiného?", "saveAndLoadProject": "Uložit a načíst projekt", "discardAndLoadProject": "Zahodit a načíst projekt", diff --git a/src/i18n/locales/cs/editor.json b/src/i18n/locales/cs/editor.json index 5f3b8858c..77ebafa32 100644 --- a/src/i18n/locales/cs/editor.json +++ b/src/i18n/locales/cs/editor.json @@ -47,7 +47,7 @@ "cameraDisconnected": "Webkamera odpojena.", "cameraNotFound": "Kamera nenalezena.", "cameraCaptureUnavailable": "Kameru se nepodařilo otevřít. Nahrávání probíhá bez ní.", - "microphoneDefaulted": "Vybraný mikrofon se nepodařilo identifikovat. Nahrávání výchozího vstupu.", + "microphoneDefaulted": "Vybraný mikrofon se nepodařilo identifikovat. Nahrávání bude pokračovat s výchozím vstupem.", "permissionDenied": "Oprávnění k nahrávání odepřeno. Povolte prosím záznam obrazovky.", "accessibilityAllowAndRetry": "Povolte přístup k usnadnění pro OpenScreen a poté znovu stiskněte tlačítko nahrávání pro zahájení odpočítávání.", "selectSource": "Vyberte prosím zdroj pro nahrávání" @@ -75,7 +75,7 @@ "pasted": "Atributy {{region}} vloženy", "nothingToCopy": "Vyberte oblast pro zkopírování jejích atributů", "nothingToPaste": "Zatím nebyly zkopírovány žádné atributy", - "pasteAssetMissing": "Soubor tohoto zvukového stopy není v tomto projektu", + "pasteAssetMissing": "Soubor této zvukové stopy není v tomto projektu", "kinds": { "zoom": "Přiblížení", "speed": "Rychlost", diff --git a/src/i18n/locales/cs/shortcuts.json b/src/i18n/locales/cs/shortcuts.json index 5332308e2..47d6c77a6 100644 --- a/src/i18n/locales/cs/shortcuts.json +++ b/src/i18n/locales/cs/shortcuts.json @@ -12,7 +12,7 @@ "swap": "Prohodit", "reservedShortcut": "Tato zkratka je vyhrazena pro „{{label}}” a nelze ji přiřadit.", "savedToast": "Klávesové zkratky uloženy", - "resetToast": "Výchozí zkratky obnoveny. Klikněte Uložit pro jejich použití.", + "resetToast": "Výchozí zkratky obnoveny. Klikněte na Uložit pro jejich použití.", "registrationFailed": "Nepodařilo se zaregistrovat zkratku. Může být používána jinou aplikací. Zkuste jinou klávesu.", "actions": { "openApp": "Otevřít aplikaci", From 0c9aa77d3e188517f7847d2b607de7fbb189622e Mon Sep 17 00:00:00 2001 From: Adam Pavlat Date: Sun, 13 Sep 2026 21:37:56 +0200 Subject: [PATCH 34/39] fix(cs): add cs to tutorialHelpTranslations static locale map --- src/i18n/__tests__/tutorialHelpTranslations.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/i18n/__tests__/tutorialHelpTranslations.test.ts b/src/i18n/__tests__/tutorialHelpTranslations.test.ts index 0e592a71d..877a9682f 100644 --- a/src/i18n/__tests__/tutorialHelpTranslations.test.ts +++ b/src/i18n/__tests__/tutorialHelpTranslations.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { type Locale, SUPPORTED_LOCALES } from "@/i18n/config"; import arDialogs from "@/i18n/locales/ar/dialogs.json"; +import csDialogs from "@/i18n/locales/cs/dialogs.json"; import enDialogs from "@/i18n/locales/en/dialogs.json"; import esDialogs from "@/i18n/locales/es/dialogs.json"; import frDialogs from "@/i18n/locales/fr/dialogs.json"; @@ -42,6 +43,7 @@ const keysThatMayBeEmpty = new Set<(typeof tutorialHelpKeys)[number]>(["step1Des const dialogsByLocale = { en: enDialogs, ar: arDialogs, + cs: csDialogs, es: esDialogs, fr: frDialogs, it: itDialogs, From b1d57bbf31dfb70081c63f54df3bc0060215d1bd Mon Sep 17 00:00:00 2001 From: Adam Pavlat Date: Sun, 13 Sep 2026 21:40:06 +0200 Subject: [PATCH 35/39] fix(cs): add missing i18n keys added in main merge (autoZoom, cursor) --- src/i18n/locales/cs/editor.json | 2 ++ src/i18n/locales/cs/launch.json | 5 +++++ src/i18n/locales/cs/settings.json | 10 ++++++++-- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/i18n/locales/cs/editor.json b/src/i18n/locales/cs/editor.json index 77ebafa32..90a91314b 100644 --- a/src/i18n/locales/cs/editor.json +++ b/src/i18n/locales/cs/editor.json @@ -110,6 +110,8 @@ "microphone": "Mikrofon", "camera": "Kamera", "cursorHighlight": "Zvýraznění kurzoru", + "autoZoom": "Automatické přiblížení po nahrání", + "autoZoomNeedsEditableCursor": "Automatické přiblížení vyžaduje upravitelný kurzor. Přepněte režim kurzoru, abyste ho povolili.", "on": "Zapnuto", "off": "Vypnuto", "loading": "Načítání…", diff --git a/src/i18n/locales/cs/launch.json b/src/i18n/locales/cs/launch.json index 87dbef6c3..8f97189c5 100644 --- a/src/i18n/locales/cs/launch.json +++ b/src/i18n/locales/cs/launch.json @@ -52,6 +52,11 @@ "camera": "Kamera", "cameraDevice": "Zařízení kamery" }, + "autoZoom": { + "enable": "Zapnout automatické přiblížení po nahrání", + "disable": "Vypnout automatické přiblížení po nahrání", + "needsEditableCursor": "Automatické přiblížení vyžaduje upravitelný kurzor. Přepněte režim kurzoru, abyste ho povolili." + }, "cursor": { "useEditableCursor": "Použít upravitelný kurzor", "useSystemCursor": "Použít systémový kurzor" diff --git a/src/i18n/locales/cs/settings.json b/src/i18n/locales/cs/settings.json index 83e434e78..5014164f9 100644 --- a/src/i18n/locales/cs/settings.json +++ b/src/i18n/locales/cs/settings.json @@ -310,7 +310,12 @@ "level": "Úroveň přiblížení", "previewHold": "Podržte pro náhled efektu přiblížení", "customScale": "Vlastní přiblížení", - "selectRegion": "Vyberte oblast přiblížení pro úpravu" + "selectRegion": "Vyberte oblast přiblížení pro úpravu", + "cursor": { + "title": "Kurzor", + "show": "Viditelný", + "hide": "Skrytý" + } }, "audio": { "outputGain": "Výstupní úroveň", @@ -342,7 +347,8 @@ "themeDefault": "Výchozí", "clipToBounds": "Oříznout na plátno", "motionBlur": "Pohybové rozmazání", - "theme": "Styl kurzoru" + "theme": "Styl kurzoru", + "autoHide": "Automaticky skrýt, když je neaktivní" }, "export": { "gifButton": "Exportovat GIF", From 2fb5691eb3aa9d3e5f2e7a9f09daae8617e7c0f8 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Tue, 15 Sep 2026 09:21:20 +0200 Subject: [PATCH 36/39] fix(cs): register cs in main process and packaging, sync keys with main --- electron-builder.json5 | 3 ++- electron/i18n.ts | 5 +++++ src/i18n/locales/cs/editor.json | 8 +++----- src/i18n/locales/cs/launch.json | 5 ----- 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/electron-builder.json5 b/electron-builder.json5 index dafe8c690..adf69f819 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -405,12 +405,13 @@ // Mirrors SUPPORTED_LOCALES in src/i18n/config.ts. This list is what the Store // shows as "supported languages" on the product page and what lets the listing // surface in each language's Store search — declaring only en-US/fr-FR advertised - // 2 of the 13 languages the app actually ships. Bare tags (ar, es, ...) match every + // 2 of the 14 languages the app actually ships. Bare tags (ar, es, ...) match every // region of that language, which is what the renderer's locale resolution does too. "languages": [ "en-US", "fr-FR", "ar", + "cs", "es", "it", "ja-JP", diff --git a/electron/i18n.ts b/electron/i18n.ts index 00e013ba4..9ea7ca119 100644 --- a/electron/i18n.ts +++ b/electron/i18n.ts @@ -3,6 +3,8 @@ import commonAr from "../src/i18n/locales/ar/common.json"; import dialogsAr from "../src/i18n/locales/ar/dialogs.json"; +import commonCs from "../src/i18n/locales/cs/common.json"; +import dialogsCs from "../src/i18n/locales/cs/dialogs.json"; import commonEn from "../src/i18n/locales/en/common.json"; import dialogsEn from "../src/i18n/locales/en/dialogs.json"; import commonEs from "../src/i18n/locales/es/common.json"; @@ -31,6 +33,7 @@ import dialogsZhTw from "../src/i18n/locales/zh-TW/dialogs.json"; type Locale = | "en" | "ar" + | "cs" | "es" | "fr" | "it" @@ -48,6 +51,7 @@ type MessageMap = Record; const messages: Record> = { en: { common: commonEn, dialogs: dialogsEn }, ar: { common: commonAr, dialogs: dialogsAr }, + cs: { common: commonCs, dialogs: dialogsCs }, es: { common: commonEs, dialogs: dialogsEs }, fr: { common: commonFr, dialogs: dialogsFr }, it: { common: commonIt, dialogs: dialogsIt }, @@ -67,6 +71,7 @@ export function setMainLocale(locale: string) { if ( locale === "en" || locale === "ar" || + locale === "cs" || locale === "es" || locale === "fr" || locale === "it" || diff --git a/src/i18n/locales/cs/editor.json b/src/i18n/locales/cs/editor.json index 90a91314b..81afc8965 100644 --- a/src/i18n/locales/cs/editor.json +++ b/src/i18n/locales/cs/editor.json @@ -110,8 +110,6 @@ "microphone": "Mikrofon", "camera": "Kamera", "cursorHighlight": "Zvýraznění kurzoru", - "autoZoom": "Automatické přiblížení po nahrání", - "autoZoomNeedsEditableCursor": "Automatické přiblížení vyžaduje upravitelný kurzor. Přepněte režim kurzoru, abyste ho povolili.", "on": "Zapnuto", "off": "Vypnuto", "loading": "Načítání…", @@ -314,9 +312,9 @@ "title": "Upravit klip", "adjustStart": "Upravit začátek klipu", "adjustEnd": "Upravit konec klipu", - "start": "Začátek", - "end": "Konec", - "duration": "Délka", + "originalDuration": "Původní délka", + "trimRange": "Rozsah oříznutí", + "duration": "Výsledná délka", "reset": "Obnovit", "apply": "Použít", "pickClipTitle": "Vyberte klip k úpravě", diff --git a/src/i18n/locales/cs/launch.json b/src/i18n/locales/cs/launch.json index 8f97189c5..87dbef6c3 100644 --- a/src/i18n/locales/cs/launch.json +++ b/src/i18n/locales/cs/launch.json @@ -52,11 +52,6 @@ "camera": "Kamera", "cameraDevice": "Zařízení kamery" }, - "autoZoom": { - "enable": "Zapnout automatické přiblížení po nahrání", - "disable": "Vypnout automatické přiblížení po nahrání", - "needsEditableCursor": "Automatické přiblížení vyžaduje upravitelný kurzor. Přepněte režim kurzoru, abyste ho povolili." - }, "cursor": { "useEditableCursor": "Použít upravitelný kurzor", "useSystemCursor": "Použít systémový kurzor" From 2f00a9d085554e88ab79c3396cd6bbfb4dab7c16 Mon Sep 17 00:00:00 2001 From: CyberSparkx Date: Wed, 9 Sep 2026 11:58:50 +0530 Subject: [PATCH 37/39] fix(hud): label recording controls for assistive technology Adds accessible aria-labels to recording state controls (pause, restart, stop/cancel) and window control buttons in the HUD overlay. Fixes #627. --- src/components/launch/HudControls.tsx | 45 ++++++++++++++++++--- src/components/launch/LaunchWindow.test.tsx | 18 +++++++++ 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/src/components/launch/HudControls.tsx b/src/components/launch/HudControls.tsx index 360499c79..a78925ffb 100644 --- a/src/components/launch/HudControls.tsx +++ b/src/components/launch/HudControls.tsx @@ -160,6 +160,7 @@ export const HudSystemAudioButton = memo(function HudSystemAudioButton({ )} - - @@ -493,10 +516,22 @@ export const HudWindowControls = memo(function HudWindowControls({ }) { return (
- -
diff --git a/src/components/launch/LaunchWindow.test.tsx b/src/components/launch/LaunchWindow.test.tsx index 73cc4ea5a..f59ffc054 100644 --- a/src/components/launch/LaunchWindow.test.tsx +++ b/src/components/launch/LaunchWindow.test.tsx @@ -377,6 +377,24 @@ describe("LaunchWindow record button", () => { expect(recorderState.value.toggleRecording).not.toHaveBeenCalled(); }); + it("provides accessible aria-labels on HUD icon controls", async () => { + renderLaunchWindow(); + + const recordButton = await screen.findByTestId("launch-record-button"); + const systemAudioButton = screen.getByTestId("launch-system-audio-button"); + const micButton = screen.getByTestId("launch-microphone-button"); + const webcamButton = screen.getByTestId("launch-webcam-button"); + const cursorButton = screen.getByTestId("launch-cursor-mode-button"); + const studioButton = screen.getByTestId("launch-open-studio-button"); + + expect(recordButton).toHaveAttribute("aria-label"); + expect(systemAudioButton).toHaveAttribute("aria-label"); + expect(micButton).toHaveAttribute("aria-label"); + expect(webcamButton).toHaveAttribute("aria-label"); + expect(cursorButton).toHaveAttribute("aria-label"); + expect(studioButton).toHaveAttribute("aria-label"); + }); + it("clears record-after-selection intent when the source picker closes without a selection", async () => { renderLaunchWindow(); await waitForSourceSelectionSubscription(); From 93f22d3ac52f970841e210c59be41406914ecde6 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Tue, 15 Sep 2026 09:19:06 +0200 Subject: [PATCH 38/39] test(hud): pin the exact accessible name of every HUD control The previous assertions only checked that aria-label existed, so an empty or wrong label passed, and the pause, restart, cancel, hide and close controls the fix labels were never rendered. --- src/components/launch/LaunchWindow.test.tsx | 58 ++++++++++++++++----- 1 file changed, 44 insertions(+), 14 deletions(-) diff --git a/src/components/launch/LaunchWindow.test.tsx b/src/components/launch/LaunchWindow.test.tsx index f59ffc054..201cbfd8d 100644 --- a/src/components/launch/LaunchWindow.test.tsx +++ b/src/components/launch/LaunchWindow.test.tsx @@ -296,6 +296,7 @@ function resetLaunchMocks() { recorderState.value.softwareEncoderFallbackNoticeVisible = false; recorderState.value.dismissSoftwareEncoderFallbackNotice.mockClear(); recorderState.value.recording = false; + recorderState.value.canPauseRecording = false; recorderState.value.microphoneEnabled = false; recorderState.value.setMicrophoneEnabled.mockClear(); recorderState.value.setMicrophoneDeviceId.mockClear(); @@ -377,22 +378,51 @@ describe("LaunchWindow record button", () => { expect(recorderState.value.toggleRecording).not.toHaveBeenCalled(); }); - it("provides accessible aria-labels on HUD icon controls", async () => { + it("names the idle HUD icon controls for assistive technology", async () => { renderLaunchWindow(); + await screen.findByTestId("launch-record-button"); - const recordButton = await screen.findByTestId("launch-record-button"); - const systemAudioButton = screen.getByTestId("launch-system-audio-button"); - const micButton = screen.getByTestId("launch-microphone-button"); - const webcamButton = screen.getByTestId("launch-webcam-button"); - const cursorButton = screen.getByTestId("launch-cursor-mode-button"); - const studioButton = screen.getByTestId("launch-open-studio-button"); - - expect(recordButton).toHaveAttribute("aria-label"); - expect(systemAudioButton).toHaveAttribute("aria-label"); - expect(micButton).toHaveAttribute("aria-label"); - expect(webcamButton).toHaveAttribute("aria-label"); - expect(cursorButton).toHaveAttribute("aria-label"); - expect(studioButton).toHaveAttribute("aria-label"); + expect(screen.getByTestId("launch-system-audio-button")).toHaveAttribute( + "aria-label", + "Enable system audio", + ); + expect(screen.getByTestId("launch-microphone-button")).toHaveAttribute( + "aria-label", + "Enable microphone", + ); + expect(screen.getByTestId("launch-webcam-button")).toHaveAttribute( + "aria-label", + "Enable webcam", + ); + expect(screen.getByTestId("launch-cursor-mode-button")).toHaveAttribute( + "aria-label", + "Use system cursor", + ); + expect(screen.getByTestId("launch-open-studio-button")).toHaveAttribute( + "aria-label", + "Open Studio", + ); + expect(screen.getByTitle("Hide HUD")).toHaveAttribute("aria-label", "Hide HUD"); + expect(screen.getByTitle("Close App")).toHaveAttribute("aria-label", "Close App"); + }); + + it("names the recording-state HUD controls for assistive technology", async () => { + recorderState.value.recording = true; + recorderState.value.canPauseRecording = true; + renderLaunchWindow(); + + expect(await screen.findByTestId("launch-pause-button")).toHaveAttribute( + "aria-label", + "tooltips.pauseRecording", + ); + expect(screen.getByTestId("launch-restart-button")).toHaveAttribute( + "aria-label", + "tooltips.restartRecording", + ); + expect(screen.getByTestId("launch-cancel-button")).toHaveAttribute( + "aria-label", + "tooltips.cancelRecording", + ); }); it("clears record-after-selection intent when the source picker closes without a selection", async () => { From ac36aab440f83fbe733f382b3b9b1a8c8c0c450a Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Tue, 15 Sep 2026 11:22:38 +0200 Subject: [PATCH 39/39] refactor(settings): drop the Settings dialog and new-project appearance defaults Capture settings stay persisted transparently from the HUD and the Rec stage, which write on every change. The dialog added a second writer with its own Save/Reset step for the same store, so it goes. New projects always start from the factory appearance: each project keeps its own style, and reusable styles will come later as presets. Removed: the editor Settings dialog, its app-menu entry and i18n keys; the get-app-settings, reset-recording-setup and project-appearance IPC; the projectAppearance block of recording-settings.json; the new-project defaults hook in DocumentService, with their tests, e2e spec and the Windows validation note. Recording prefs, last-source restore and select-source ownership are unchanged. --- .../document-service.defaults.test.ts | 51 --- electron/ai-edition/document-service.ts | 12 +- electron/app-settings.test.ts | 25 +- electron/app-settings.ts | 57 --- electron/electron-env.d.ts | 6 - electron/ipc/handlers.ts | 16 +- electron/ipc/recordingPrefs.test.ts | 26 +- electron/ipc/recordingPrefs.ts | 15 - electron/ipc/selectSourceOwnership.test.ts | 37 +- electron/ipc/selectSourceOwnership.ts | 9 - electron/preload.ts | 9 - src/App.tsx | 6 - .../ai-edition/AppSettings.test.tsx | 249 -------------- src/components/ai-edition/AppSettings.tsx | 325 ------------------ src/components/ai-edition/NewEditorShell.tsx | 1 - .../ai-edition/ProviderSettings.test.tsx | 1 - .../ai-edition/v4/EditorTopBar.test.tsx | 25 +- src/components/ai-edition/v4/EditorTopBar.tsx | 11 - .../ai-edition/v4/RecStage.test.tsx | 2 +- src/contexts/EditorDialogsContext.tsx | 2 +- src/hooks/useScreenRecorder.ts | 4 +- src/i18n/locales/ar/editor.json | 27 -- src/i18n/locales/en/editor.json | 27 -- src/i18n/locales/es/editor.json | 27 -- src/i18n/locales/fr/editor.json | 27 -- src/i18n/locales/it/editor.json | 27 -- src/i18n/locales/ja-JP/editor.json | 27 -- src/i18n/locales/ko-KR/editor.json | 27 -- src/i18n/locales/pt-BR/editor.json | 27 -- src/i18n/locales/ru/editor.json | 27 -- src/i18n/locales/tr/editor.json | 27 -- src/i18n/locales/vi/editor.json | 27 -- src/i18n/locales/zh-CN/editor.json | 27 -- src/i18n/locales/zh-TW/editor.json | 27 -- src/lib/projectDefaults.test.ts | 63 ---- src/lib/projectDefaults.ts | 145 +------- src/lib/userPreferences.test.ts | 7 - src/lib/userPreferences.ts | 6 +- src/native/browserShim.test.ts | 62 +--- src/native/browserShim.ts | 87 +---- .../recording-defaults-reset-windows.png | Bin 60529 -> 0 bytes .../testing/manual-e2e-checklist.md | 2 - .../testing/recording-defaults-windows.md | 17 - tests/e2e/settings-defaults.spec.ts | 118 ------- 44 files changed, 36 insertions(+), 1711 deletions(-) delete mode 100644 electron/ai-edition/document-service.defaults.test.ts delete mode 100644 src/components/ai-edition/AppSettings.test.tsx delete mode 100644 src/components/ai-edition/AppSettings.tsx delete mode 100644 src/lib/projectDefaults.test.ts delete mode 100644 technical-documentation/testing/images/recording-defaults-reset-windows.png delete mode 100644 technical-documentation/testing/recording-defaults-windows.md delete mode 100644 tests/e2e/settings-defaults.spec.ts diff --git a/electron/ai-edition/document-service.defaults.test.ts b/electron/ai-edition/document-service.defaults.test.ts deleted file mode 100644 index ddd7d208d..000000000 --- a/electron/ai-edition/document-service.defaults.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { getEditorSettings } from "../../src/lib/ai-edition/store/editorSettings"; -import { DEFAULT_PROJECT_APPEARANCE } from "../../src/lib/projectDefaults"; -import { DocumentService } from "./document-service"; - -const dirs: string[] = []; -function temp() { - const root = mkdtempSync(path.join(os.tmpdir(), "openscreen-default-doc-")); - dirs.push(root); - return root; -} -afterEach(() => { - for (const root of dirs.splice(0)) rmSync(root, { recursive: true, force: true }); -}); - -describe("DocumentService project appearance defaults", () => { - it("materializes the current defaults before the new project's first write", async () => { - const root = temp(); - const projects = path.join(root, "projects"); - const service = new DocumentService(projects, path.join(root, "media"), undefined, () => ({ - ...DEFAULT_PROJECT_APPEARANCE, - wallpaper: "#123456", - padding: 22, - })); - const created = await service.createProject("New recording or CLI project"); - const onDisk = JSON.parse( - readFileSync(path.join(projects, `${created.project.id}.openscreen`), "utf8"), - ); - expect(getEditorSettings(onDisk)).toMatchObject({ wallpaper: "#123456", padding: 22 }); - expect(onDisk.assets).toEqual([]); - expect(onDisk.annotations).toEqual([]); - }); - - it("never reapplies changed defaults while opening an existing project", async () => { - const root = temp(); - let padding = 11; - const service = new DocumentService( - path.join(root, "projects"), - path.join(root, "media"), - undefined, - () => ({ ...DEFAULT_PROJECT_APPEARANCE, padding }), - ); - const created = await service.createProject("Existing"); - padding = 44; - const reopened = await service.getProject(created.project.id); - expect(getEditorSettings(reopened).padding).toBe(11); - }); -}); diff --git a/electron/ai-edition/document-service.ts b/electron/ai-edition/document-service.ts index 75d6fb1ab..e78a9c48e 100644 --- a/electron/ai-edition/document-service.ts +++ b/electron/ai-edition/document-service.ts @@ -21,10 +21,6 @@ import { documentSchema, migrateRawDocumentToCurrent, } from "../../src/lib/ai-edition/schema"; -import { - applyProjectAppearanceDefaults, - type ProjectAppearanceDefaults, -} from "../../src/lib/projectDefaults"; import { ensureDocumentExtensions } from "../media/extensionClip"; import { relinkProjectMedia } from "../media/projectMediaRelinker"; @@ -169,18 +165,15 @@ export class DocumentService { * `electron` import. Optional so the tests and the CLI construct it as they always did. */ private readonly onProjectRead?: (document: AxcutDocument) => void; - private readonly loadProjectDefaults?: () => ProjectAppearanceDefaults; constructor( projectsRoot: string, mediaRegistryDir: string, onProjectRead?: (document: AxcutDocument) => void, - loadProjectDefaults?: () => ProjectAppearanceDefaults, ) { this.projectsRoot = projectsRoot; this.mediaRegistryDir = mediaRegistryDir; this.onProjectRead = onProjectRead; - this.loadProjectDefaults = loadProjectDefaults; } async ensureProjectsDir(): Promise { @@ -306,13 +299,10 @@ export class DocumentService { async createProject(title: string): Promise { await this.ensureProjectsDir(); const projectId = createId("proj"); - const empty = createEmptyDocument({ + const doc = createEmptyDocument({ projectId, title: title?.trim() || "Untitled Project", }); - const doc = this.loadProjectDefaults - ? applyProjectAppearanceDefaults(empty, this.loadProjectDefaults()) - : empty; await this.writeProject(doc); return doc; } diff --git a/electron/app-settings.test.ts b/electron/app-settings.test.ts index 2370c9b34..902d7a8a2 100644 --- a/electron/app-settings.test.ts +++ b/electron/app-settings.test.ts @@ -2,7 +2,6 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { DEFAULT_PROJECT_APPEARANCE } from "../src/lib/projectDefaults"; import { AppSettingsStore, DEFAULT_RECORDING_PREFERENCES } from "./app-settings"; const dirs: string[] = []; @@ -41,33 +40,23 @@ describe("app settings store", () => { } }); - it("stores and resets versioned appearance and recording setup", () => { + it("stores the last source beside the recording preferences", () => { const dir = temp(); const store = new AppSettingsStore(dir); - const custom = { ...DEFAULT_PROJECT_APPEARANCE, wallpaper: "#010203", padding: 7 }; - expect(store.setAppearanceDefaults(custom).appearance).toMatchObject({ custom: true }); - store.setLastSource({ + const source = { platform: "win32", kind: "screen", id: "screen:1", name: "Display", displayId: "1", - }); + } as const; store.setRecordingPreferences({ micEnabled: true, micDeviceId: "mic" }); - expect(store.resetRecordingSetup()).toMatchObject({ - recording: DEFAULT_RECORDING_PREFERENCES, - lastSource: null, - }); - expect(store.resetAppearanceDefaults().appearance).toEqual({ - version: 1, - custom: false, - defaults: DEFAULT_PROJECT_APPEARANCE, - }); + expect(store.setLastSource(source).lastSource).toEqual(source); expect( JSON.parse(readFileSync(path.join(dir, "recording-settings.json"), "utf8")), - ).toMatchObject({ - projectAppearance: { version: 1, defaults: null }, - }); + ).toMatchObject({ micEnabled: true, micDeviceId: "mic", lastSource: source }); + expect(store.setLastSource(null).lastSource).toBeNull(); + expect(store.getSnapshot().recording).toMatchObject({ micEnabled: true, micDeviceId: "mic" }); }); it("rejects invalid or failed writes without changing the published durable value", () => { diff --git a/electron/app-settings.ts b/electron/app-settings.ts index 64b31704d..a678a6c02 100644 --- a/electron/app-settings.ts +++ b/electron/app-settings.ts @@ -1,10 +1,5 @@ import { readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import path from "node:path"; -import { - DEFAULT_PROJECT_APPEARANCE, - type ProjectAppearanceDefaults, - parseProjectAppearanceDefaults, -} from "../src/lib/projectDefaults"; import type { CursorCaptureMode } from "../src/lib/recordingSession"; export interface RecordingPreferences { @@ -40,11 +35,6 @@ export interface RecordingSourceDescriptor { export interface AppSettingsSnapshot { recording: RecordingPreferences; lastSource: RecordingSourceDescriptor | null; - appearance: { - version: 1; - custom: boolean; - defaults: ProjectAppearanceDefaults; - }; } type RawSettings = Record; @@ -115,26 +105,6 @@ function parseSource(value: unknown): RecordingSourceDescriptor | null { return candidate as unknown as RecordingSourceDescriptor; } -function parseAppearance(raw: RawSettings): AppSettingsSnapshot["appearance"] { - const value = raw.projectAppearance; - if (!value || typeof value !== "object" || Array.isArray(value)) { - return { version: 1, custom: false, defaults: DEFAULT_PROJECT_APPEARANCE }; - } - const candidate = value as Record; - if (candidate.version !== 1 || candidate.defaults === null) { - return { version: 1, custom: false, defaults: DEFAULT_PROJECT_APPEARANCE }; - } - try { - return { - version: 1, - custom: true, - defaults: parseProjectAppearanceDefaults(candidate.defaults), - }; - } catch { - return { version: 1, custom: false, defaults: DEFAULT_PROJECT_APPEARANCE }; - } -} - function validateRecordingPatch(patch: Partial): void { const allowed = new Set(Object.keys(DEFAULT_RECORDING_PREFERENCES)); for (const [key, value] of Object.entries(patch)) { @@ -164,7 +134,6 @@ export class AppSettingsStore { return { recording: parseRecording(raw), lastSource: parseSource(raw.lastSource), - appearance: parseAppearance(raw), }; } @@ -185,30 +154,4 @@ export class AppSettingsStore { atomicWrite(this.userData, { ...raw, lastSource: source }); return this.getSnapshot(); } - - setAppearanceDefaults(defaults: ProjectAppearanceDefaults): AppSettingsSnapshot { - const parsed = parseProjectAppearanceDefaults(defaults); - const raw = readRaw(this.userData); - atomicWrite(this.userData, { - ...raw, - projectAppearance: { version: 1, defaults: parsed }, - }); - return this.getSnapshot(); - } - - resetAppearanceDefaults(): AppSettingsSnapshot { - const raw = readRaw(this.userData); - atomicWrite(this.userData, { ...raw, projectAppearance: { version: 1, defaults: null } }); - return this.getSnapshot(); - } - - resetRecordingSetup(): AppSettingsSnapshot { - const raw = readRaw(this.userData); - atomicWrite(this.userData, { - ...raw, - ...DEFAULT_RECORDING_PREFERENCES, - lastSource: null, - }); - return this.getSnapshot(); - } } diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 4d1484c1c..f96b0f205 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -63,12 +63,6 @@ interface Window { setRecordingPrefs: ( prefs: Partial, ) => Promise; - getAppSettings: () => Promise; - setProjectAppearanceDefaults: ( - defaults: import("../src/lib/projectDefaults").ProjectAppearanceDefaults, - ) => Promise; - resetProjectAppearanceDefaults: () => Promise; - resetRecordingSetup: () => Promise; onRecordingPrefsChanged: ( callback: (prefs: import("./ipc/handlers").RecordingPrefs) => void, ) => () => void; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 3d6e267af..2b464240f 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -117,11 +117,7 @@ import { registerNativeBridgeHandlers } from "./nativeBridge"; import { createNativeMacMidCaptureErrorWatch } from "./nativeMacMidCaptureErrorWatch"; import { registerRecordingPrefsHandlers } from "./recordingPrefs"; import { RecordingStreamRegistry, registerRecordingStreamHandlers } from "./recordingStream"; -import { - resetSelectSource, - type SelectSourceContext, - selectSourceWithOwnership, -} from "./selectSourceOwnership"; +import { type SelectSourceContext, selectSourceWithOwnership } from "./selectSourceOwnership"; const PROJECT_FILE_EXTENSION = "openscreen"; export const SHORTCUTS_FILE = path.join(app.getPath("userData"), "shortcuts.json"); @@ -2095,13 +2091,8 @@ export function registerIpcHandlers( return selectedSource; }); - registerRecordingPrefsHandlers( - defaultRecordingPrefs, - getMainWindow, - () => { - resetSelectSource(selectSourceContext, broadcastSelectedSource); - }, - () => BrowserWindow.getAllWindows(), + registerRecordingPrefsHandlers(defaultRecordingPrefs, getMainWindow, () => + BrowserWindow.getAllWindows(), ); ipcMain.handle("request-camera-access", async () => { @@ -4607,7 +4598,6 @@ export function registerIpcHandlers( path.join(app.getPath("userData"), "projects"), RECORDINGS_DIR, approveDocumentMedia, - () => appSettings.getSnapshot().appearance.defaults, ); // LlmConfigStore is single-instance for a duller reason — its constructor does diff --git a/electron/ipc/recordingPrefs.test.ts b/electron/ipc/recordingPrefs.test.ts index a6d978b85..0db57c795 100644 --- a/electron/ipc/recordingPrefs.test.ts +++ b/electron/ipc/recordingPrefs.test.ts @@ -32,11 +32,10 @@ afterEach(() => rmSync(dir, { recursive: true, force: true })); function start( getWindow: () => BrowserWindow | null = () => null, - onResetSource: () => void = () => undefined, getAppWindows?: () => BrowserWindow[], ) { electron.handle.mockClear(); - registerRecordingPrefsHandlers(defaults, getWindow, onResetSource, getAppWindows); + registerRecordingPrefsHandlers(defaults, getWindow, getAppWindows); const get = electron.handle.mock.calls.find( ([name]) => name === "get-recording-prefs", )?.[1] as () => RecordingPrefs; @@ -44,13 +43,9 @@ function start( _event: unknown, prefs: Partial, ) => RecordingPrefs; - const reset = electron.handle.mock.calls.find( - ([name]) => name === "reset-recording-setup", - )?.[1] as (_event: unknown) => { recording: RecordingPrefs; lastSource: null }; return { get, set: (prefs: Partial) => set(undefined, prefs), - reset: () => reset(undefined), }; } @@ -96,7 +91,6 @@ describe("recording preferences IPC", () => { } as unknown as BrowserWindow; const session = start( () => first, - undefined, () => [first, second, first, destroyed], ); const updated = session.set({ micEnabled: true }); @@ -106,24 +100,6 @@ describe("recording preferences IPC", () => { expect(destroyedSend).not.toHaveBeenCalled(); }); - it("publishes the reset snapshot and runs the source reset callback", () => { - const send = vi.fn(); - const window = { - isDestroyed: () => false, - webContents: { send }, - } as unknown as BrowserWindow; - const onResetSource = vi.fn(); - const session = start(() => window, onResetSource); - session.set({ micEnabled: true, camEnabled: true, systemAudioEnabled: true }); - send.mockClear(); - - const snapshot = session.reset(); - expect(snapshot.recording).toEqual(defaults); - expect(snapshot.lastSource).toBeNull(); - expect(onResetSource).toHaveBeenCalledTimes(1); - expect(send).toHaveBeenCalledWith("recording-prefs-changed", defaults); - }); - it("does not publish an invalid or failed preference write", () => { const session = start(); expect(() => session.set({ micEnabled: null } as unknown as Partial)).toThrow( diff --git a/electron/ipc/recordingPrefs.ts b/electron/ipc/recordingPrefs.ts index b80b03430..d92171465 100644 --- a/electron/ipc/recordingPrefs.ts +++ b/electron/ipc/recordingPrefs.ts @@ -1,5 +1,4 @@ import { app, type BrowserWindow, ipcMain } from "electron"; -import type { ProjectAppearanceDefaults } from "../../src/lib/projectDefaults"; import { AppSettingsStore } from "../app-settings"; import type { RecordingPrefs } from "./handlers"; @@ -7,7 +6,6 @@ import type { RecordingPrefs } from "./handlers"; export function registerRecordingPrefsHandlers( defaults: RecordingPrefs, getMainWindow: () => BrowserWindow | null, - onResetSource: () => void = () => undefined, getAppWindows: () => BrowserWindow[] = () => { const mainWindow = getMainWindow(); return mainWindow ? [mainWindow] : []; @@ -32,17 +30,4 @@ export function registerRecordingPrefsHandlers( publish(); return recordingPrefs; }); - - ipcMain.handle("get-app-settings", () => settings.getSnapshot()); - ipcMain.handle("set-project-appearance-defaults", (_, value: ProjectAppearanceDefaults) => - settings.setAppearanceDefaults(value), - ); - ipcMain.handle("reset-project-appearance-defaults", () => settings.resetAppearanceDefaults()); - ipcMain.handle("reset-recording-setup", () => { - const snapshot = settings.resetRecordingSetup(); - recordingPrefs = snapshot.recording; - onResetSource(); - publish(); - return snapshot; - }); } diff --git a/electron/ipc/selectSourceOwnership.test.ts b/electron/ipc/selectSourceOwnership.test.ts index 88d839af2..8aaaf9258 100644 --- a/electron/ipc/selectSourceOwnership.test.ts +++ b/electron/ipc/selectSourceOwnership.test.ts @@ -1,9 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { - resetSelectSource, - type SelectSourceContext, - selectSourceWithOwnership, -} from "./selectSourceOwnership"; +import { type SelectSourceContext, selectSourceWithOwnership } from "./selectSourceOwnership"; type Live = { id: string; name: string; display_id: string }; @@ -42,37 +38,6 @@ const sourceA: Live = { id: "screen:a", name: "Display A", display_id: "1" }; const sourceB: Live = { id: "screen:b", name: "Display B", display_id: "2" }; describe("selectSourceWithOwnership", () => { - it("does not restore A after reset while A is still enumerating", async () => { - const harness = createContext(); - let resolveA!: (sources: Live[]) => void; - const persist = vi.fn(); - const broadcast = vi.fn(); - const pendingA = selectSourceWithOwnership( - harness.ctx, - sourceA, - { persist: true }, - { - getSources: () => - new Promise((resolve) => { - resolveA = resolve; - }), - persist, - broadcast, - shouldPersist: () => true, - }, - ); - - resetSelectSource(harness.ctx, broadcast); - expect(harness.selected.source).toBeNull(); - - resolveA([sourceA, sourceB]); - await pendingA; - - expect(harness.selected.source).toBeNull(); - expect(persist).not.toHaveBeenCalled(); - expect(broadcast).toHaveBeenLastCalledWith(null); - }); - it("keeps B when a slower A enumeration finishes later", async () => { const harness = createContext(); harness.seedCache(sourceB); diff --git a/electron/ipc/selectSourceOwnership.ts b/electron/ipc/selectSourceOwnership.ts index 7a3339f84..3777d505a 100644 --- a/electron/ipc/selectSourceOwnership.ts +++ b/electron/ipc/selectSourceOwnership.ts @@ -24,15 +24,6 @@ export function bumpSelectSourceGeneration(generation: { value: number }): numbe return generation.value; } -export function resetSelectSource( - ctx: SelectSourceContext, - broadcast: (source: SelectedSourceSnapshot | null) => void, -): void { - bumpSelectSourceGeneration(ctx.generation); - ctx.setSelected(null, null); - broadcast(null); -} - export async function selectSourceWithOwnership< TLive extends { id: string; name: string; display_id: string }, >( diff --git a/electron/preload.ts b/electron/preload.ts index 35699839e..4ea4cb5f3 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -2,12 +2,10 @@ import { contextBridge, ipcRenderer, webUtils } from "electron"; import type { NativeLinuxRecordingRequest } from "../src/lib/nativeLinuxRecording"; import type { NativeMacRecordingRequest } from "../src/lib/nativeMacRecording"; import type { NativeWindowsRecordingRequest } from "../src/lib/nativeWindowsRecording"; -import type { ProjectAppearanceDefaults } from "../src/lib/projectDefaults"; import type { RecordingSession, StoreRecordedSessionInput } from "../src/lib/recordingSession"; import type { ShortcutBinding } from "../src/lib/shortcuts"; import type { AiEditionChatEvent } from "../src/native/contracts"; import { NATIVE_BRIDGE_CHANNEL, type NativeBridgeRequest } from "../src/native/contracts"; -import type { AppSettingsSnapshot } from "./app-settings"; import type { RecordingPrefs } from "./ipc/handlers"; import type { SttStatusEvent, @@ -136,13 +134,6 @@ contextBridge.exposeInMainWorld("electronAPI", { setRecordingPrefs: (prefs: Partial) => { return ipcRenderer.invoke("set-recording-prefs", prefs); }, - getAppSettings: () => ipcRenderer.invoke("get-app-settings") as Promise, - setProjectAppearanceDefaults: (defaults: ProjectAppearanceDefaults) => - ipcRenderer.invoke("set-project-appearance-defaults", defaults) as Promise, - resetProjectAppearanceDefaults: () => - ipcRenderer.invoke("reset-project-appearance-defaults") as Promise, - resetRecordingSetup: () => - ipcRenderer.invoke("reset-recording-setup") as Promise, onRecordingPrefsChanged: (callback: (prefs: RecordingPrefs) => void) => { const listener = (_event: unknown, prefs: RecordingPrefs) => callback(prefs); ipcRenderer.on("recording-prefs-changed", listener); diff --git a/src/App.tsx b/src/App.tsx index 163632f11..aa7619d26 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -35,11 +35,6 @@ const ProviderSettingsDialog = lazy(() => default: module.ProviderSettingsDialog, })), ); -const AppSettingsDialog = lazy(() => - import("./components/ai-edition/AppSettings").then((module) => ({ - default: module.AppSettingsDialog, - })), -); export default function App() { const [windowType, setWindowType] = useState( @@ -151,7 +146,6 @@ export default function App() { - diff --git a/src/components/ai-edition/AppSettings.test.tsx b/src/components/ai-edition/AppSettings.test.tsx deleted file mode 100644 index e2d6d08da..000000000 --- a/src/components/ai-edition/AppSettings.test.tsx +++ /dev/null @@ -1,249 +0,0 @@ -// @vitest-environment jsdom -import "@testing-library/jest-dom"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { EditorDialogsProvider, useEditorDialogActions } from "@/contexts/EditorDialogsContext"; -import { createEmptyDocument } from "@/lib/ai-edition/schema"; -import { DEFAULT_PROJECT_APPEARANCE } from "@/lib/projectDefaults"; -import { AppSettingsDialog } from "./AppSettings"; - -const project = vi.hoisted(() => ({ - document: null as ReturnType | null, -})); -vi.mock("@/lib/ai-edition/store/projectStore", () => ({ - useProjectStore: (selector: (state: typeof project) => unknown) => selector(project), -})); -const microphoneHook = vi.hoisted(() => ({ - enabled: [] as boolean[], - selectedDeviceId: "mic-live", - devices: [{ deviceId: "mic-live", label: "Live microphone", groupId: "g" }], - isReady: true, -})); -vi.mock("@/hooks/useMicrophoneDevices", () => ({ - useMicrophoneDevices: (enabled: boolean) => { - microphoneHook.enabled.push(enabled); - return { - devices: microphoneHook.devices, - selectedDeviceId: microphoneHook.selectedDeviceId, - setSelectedDeviceId: vi.fn(), - isReady: microphoneHook.isReady, - }; - }, -})); -vi.mock("@/hooks/useCameraDevices", () => ({ - useCameraDevices: () => ({ - devices: [{ deviceId: "cam-live", label: "Live camera", groupId: "g" }], - selectedDeviceId: "cam-live", - setSelectedDeviceId: vi.fn(), - isReady: true, - }), -})); -vi.mock("@/contexts/I18nContext", () => ({ - useScopedT: () => (key: string, vars?: Record) => - vars ? `${key}:${Object.values(vars).join(":")}` : key, -})); - -const recording = { - micEnabled: false, - micDeviceId: null, - micDeviceName: null, - camEnabled: false, - camDeviceId: null, - camDeviceName: null, - systemAudioEnabled: false, - cursorCaptureMode: "editable-overlay" as const, -}; -const snapshot = { - recording, - lastSource: null, - appearance: { version: 1 as const, custom: false, defaults: DEFAULT_PROJECT_APPEARANCE }, -}; - -function OpenSettings() { - const { openDialog, closeDialog } = useEditorDialogActions(); - return ( - <> - - - - ); -} - -function renderSettings() { - render( - - - - , - ); - fireEvent.click(screen.getByText("open settings")); -} - -describe("AppSettings", () => { - beforeEach(() => { - microphoneHook.enabled = []; - microphoneHook.selectedDeviceId = "mic-live"; - microphoneHook.devices = [{ deviceId: "mic-live", label: "Live microphone", groupId: "g" }]; - microphoneHook.isReady = true; - project.document = createEmptyDocument({ projectId: "proj_settings", title: "Settings" }); - window.electronAPI = { - getAppSettings: vi.fn(async () => snapshot), - setRecordingPrefs: vi.fn(async (value) => ({ ...recording, ...value })), - setProjectAppearanceDefaults: vi.fn(async (defaults) => ({ - ...snapshot, - appearance: { version: 1 as const, custom: true, defaults }, - })), - resetProjectAppearanceDefaults: vi.fn(async () => snapshot), - resetRecordingSetup: vi.fn(async () => snapshot), - } as unknown as typeof window.electronAPI; - }); - - it("does not enable microphone enumeration until the microphone is on", async () => { - renderSettings(); - await screen.findByTestId("app-settings-dialog"); - expect(microphoneHook.enabled.at(-1)).toBe(false); - fireEvent.click(screen.getByLabelText("appSettings.microphone")); - expect(microphoneHook.enabled.at(-1)).toBe(true); - }); - - it("loads settings and saves edited recording toggles", async () => { - renderSettings(); - expect(await screen.findByTestId("app-settings-dialog")).toBeInTheDocument(); - fireEvent.click(screen.getByLabelText("appSettings.systemAudio")); - fireEvent.click(screen.getByText("appSettings.saveRecording")); - await waitFor(() => - expect(window.electronAPI.setRecordingPrefs).toHaveBeenCalledWith( - expect.objectContaining({ systemAudioEnabled: true }), - ), - ); - expect(await screen.findByRole("status")).toHaveTextContent("appSettings.saved"); - }); - - it("shows a failed save and never reports Saved", async () => { - vi.mocked(window.electronAPI.setRecordingPrefs).mockRejectedValueOnce(new Error("disk full")); - renderSettings(); - await screen.findByTestId("app-settings-dialog"); - fireEvent.click(screen.getByText("appSettings.saveRecording")); - expect(await screen.findByRole("alert")).toHaveTextContent("disk full"); - expect(screen.queryByRole("status")).not.toBeInTheDocument(); - }); - - it("shows a load failure without rendering stale settings", async () => { - vi.mocked(window.electronAPI.getAppSettings).mockRejectedValueOnce(new Error("read failed")); - renderSettings(); - expect(await screen.findByRole("alert")).toHaveTextContent("read failed"); - expect(screen.queryByText("appSettings.saveRecording")).not.toBeInTheDocument(); - }); - - it("does not let a stale save overwrite a newer local edit", async () => { - let finishSave!: (value: typeof recording) => void; - vi.mocked(window.electronAPI.setRecordingPrefs).mockImplementation( - () => - new Promise((resolve) => { - finishSave = resolve; - }), - ); - renderSettings(); - await screen.findByTestId("app-settings-dialog"); - fireEvent.click(screen.getByLabelText("appSettings.systemAudio")); - fireEvent.click(screen.getByText("appSettings.saveRecording")); - fireEvent.click(screen.getByLabelText("appSettings.systemAudio")); - expect(screen.getByLabelText("appSettings.systemAudio")).not.toBeChecked(); - finishSave({ ...recording, systemAudioEnabled: true }); - await waitFor(() => - expect(window.electronAPI.setRecordingPrefs).toHaveBeenCalledWith( - expect.objectContaining({ systemAudioEnabled: true }), - ), - ); - expect(screen.getByLabelText("appSettings.systemAudio")).not.toBeChecked(); - expect(screen.queryByRole("status")).not.toBeInTheDocument(); - }); - - it("clears loaded settings when a later open fails to load", async () => { - vi.mocked(window.electronAPI.getAppSettings) - .mockResolvedValueOnce(snapshot) - .mockRejectedValueOnce(new Error("read failed")); - renderSettings(); - await screen.findByText("appSettings.saveRecording"); - fireEvent.click(screen.getByText("close settings")); - fireEvent.click(screen.getByText("open settings")); - expect(await screen.findByRole("alert")).toHaveTextContent("read failed"); - expect(screen.queryByText("appSettings.saveRecording")).not.toBeInTheDocument(); - }); - - it("saves the resolved microphone when the stored device has fallen back", async () => { - vi.mocked(window.electronAPI.getAppSettings).mockResolvedValue({ - ...snapshot, - recording: { - ...recording, - micEnabled: true, - micDeviceId: "mic-stale", - micDeviceName: "Unplugged microphone", - }, - }); - renderSettings(); - await screen.findByTestId("app-settings-dialog"); - fireEvent.click(screen.getByText("appSettings.saveRecording")); - await waitFor(() => - expect(window.electronAPI.setRecordingPrefs).toHaveBeenCalledWith( - expect.objectContaining({ - micEnabled: true, - micDeviceId: "mic-live", - micDeviceName: "Live microphone", - }), - ), - ); - }); - - it("does not save while an enabled microphone is still resolving", async () => { - microphoneHook.isReady = false; - vi.mocked(window.electronAPI.getAppSettings).mockResolvedValue({ - ...snapshot, - recording: { ...recording, micEnabled: true }, - }); - renderSettings(); - await screen.findByTestId("app-settings-dialog"); - expect(screen.getByText("appSettings.saveRecording").closest("button")).toBeDisabled(); - fireEvent.click(screen.getByText("appSettings.saveRecording")); - expect(window.electronAPI.setRecordingPrefs).not.toHaveBeenCalled(); - }); - - it("keeps an unsaved recording draft when appearance is saved", async () => { - vi.mocked(window.electronAPI.setProjectAppearanceDefaults).mockImplementation( - async (defaults) => ({ - recording, - lastSource: null, - appearance: { version: 1 as const, custom: true, defaults }, - }), - ); - renderSettings(); - await screen.findByTestId("app-settings-dialog"); - fireEvent.click(screen.getByLabelText("appSettings.systemAudio")); - expect(screen.getByLabelText("appSettings.systemAudio")).toBeChecked(); - fireEvent.click(screen.getByText("appSettings.useCurrentAppearance")); - await waitFor(() => expect(window.electronAPI.setProjectAppearanceDefaults).toHaveBeenCalled()); - expect(screen.getByLabelText("appSettings.systemAudio")).toBeChecked(); - expect(window.electronAPI.setRecordingPrefs).not.toHaveBeenCalled(); - }); - - it("copies the current look and exposes both reset actions", async () => { - renderSettings(); - await screen.findByTestId("app-settings-dialog"); - fireEvent.click(screen.getByText("appSettings.useCurrentAppearance")); - await waitFor(() => - expect(window.electronAPI.setProjectAppearanceDefaults).toHaveBeenCalledWith( - expect.objectContaining({ padding: 50, aspectRatio: "16:9" }), - ), - ); - fireEvent.click(screen.getByText("appSettings.resetAppearance")); - await waitFor(() => - expect(window.electronAPI.resetProjectAppearanceDefaults).toHaveBeenCalled(), - ); - fireEvent.click(screen.getByText("appSettings.resetRecording")); - await waitFor(() => expect(window.electronAPI.resetRecordingSetup).toHaveBeenCalled()); - }); -}); diff --git a/src/components/ai-edition/AppSettings.tsx b/src/components/ai-edition/AppSettings.tsx deleted file mode 100644 index 87ecdac62..000000000 --- a/src/components/ai-edition/AppSettings.tsx +++ /dev/null @@ -1,325 +0,0 @@ -import { Camera, Loader2, Mic, Monitor, MousePointer2, RotateCcw, Save } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; -import { useEditorDialogActions, useEditorDialogSection } from "@/contexts/EditorDialogsContext"; -import { useScopedT } from "@/contexts/I18nContext"; -import { useCameraDevices } from "@/hooks/useCameraDevices"; -import { useMicrophoneDevices } from "@/hooks/useMicrophoneDevices"; -import { getEditorSettings } from "@/lib/ai-edition/store/editorSettings"; -import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; -import { projectAppearanceFromEditorSettings } from "@/lib/projectDefaults"; -import type { AppSettingsSnapshot, RecordingPreferences } from "../../../electron/app-settings"; -import { ModalShell } from "./Modals"; -import styles from "./NewEditorShell.module.css"; - -type Status = "idle" | "loading" | "saving" | "saved" | "error"; - -function AppSettings({ open, onClose }: { open: boolean; onClose: () => void }) { - const t = useScopedT("editor"); - const document = useProjectStore((state) => state.document); - const [snapshot, setSnapshot] = useState(null); - const [recording, setRecording] = useState(null); - const [status, setStatus] = useState("idle"); - const [error, setError] = useState(null); - const generationRef = useRef(0); - - useEffect(() => { - if (!open) { - generationRef.current += 1; - return; - } - const generation = ++generationRef.current; - setStatus("loading"); - setError(null); - setSnapshot(null); - setRecording(null); - void window.electronAPI - .getAppSettings() - .then((value) => { - if (generation !== generationRef.current) return; - setSnapshot(value); - setRecording(value.recording); - setStatus("idle"); - }) - .catch((cause) => { - if (generation !== generationRef.current) return; - setError(cause instanceof Error ? cause.message : String(cause)); - setStatus("error"); - }); - }, [open]); - - const microphones = useMicrophoneDevices( - open && Boolean(recording?.micEnabled), - recording?.micDeviceId ?? undefined, - recording?.micDeviceName ?? undefined, - ); - const cameras = useCameraDevices( - open, - recording?.camDeviceId ?? undefined, - recording?.camDeviceName ?? undefined, - ); - - const run = async ( - work: () => Promise, - options: { replaceRecording?: boolean } = {}, - ) => { - const replaceRecording = options.replaceRecording !== false; - const generation = ++generationRef.current; - setStatus("saving"); - setError(null); - try { - const next = await work(); - if (generation !== generationRef.current) return; - setSnapshot(next); - if (replaceRecording) { - setRecording(next.recording); - } - setStatus("saved"); - } catch (cause) { - if (generation !== generationRef.current) return; - setError(cause instanceof Error ? cause.message : String(cause)); - setStatus("error"); - } - }; - - const recordingDevicesReady = recording - ? (!recording.micEnabled || microphones.isReady) && (!recording.camEnabled || cameras.isReady) - : false; - - const saveRecording = () => { - if (!recording || !recordingDevicesReady) return; - const payload = { ...recording }; - if (recording.micEnabled) { - const mic = microphones.devices.find( - (device) => device.deviceId === microphones.selectedDeviceId, - ); - if (mic) { - payload.micDeviceId = mic.deviceId; - payload.micDeviceName = mic.label; - } - } - if (recording.camEnabled) { - const cam = cameras.devices.find((device) => device.deviceId === cameras.selectedDeviceId); - if (cam) { - payload.camDeviceId = cam.deviceId; - payload.camDeviceName = cam.label; - } - } - void run(async () => { - const saved = await window.electronAPI.setRecordingPrefs(payload); - const next = await window.electronAPI.getAppSettings(); - return { ...next, recording: saved }; - }); - }; - const editRecording = (patch: Partial) => { - generationRef.current += 1; - setRecording((current) => (current ? { ...current, ...patch } : current)); - setStatus("idle"); - setError(null); - }; - - return ( - -
- {status === "loading" ? ( -
- {t("appSettings.loading")} -
- ) : recording && snapshot ? ( - <> -
-

- {t("appSettings.recordingTitle")} -

- editRecording({ systemAudioEnabled: value })} - /> - editRecording({ micEnabled: value })} - /> - - editRecording({ camEnabled: value })} - /> - - - editRecording({ - cursorCaptureMode: value ? "editable-overlay" : "system", - }) - } - /> -

- {t("appSettings.lastSource", { - source: snapshot.lastSource?.name ?? t("appSettings.noSource"), - })} -

-
- - -
-
-
-

- {t("appSettings.appearanceTitle")} -

-

- {snapshot.appearance.custom - ? t("appSettings.customAppearance") - : t("appSettings.factoryAppearance")} -

-

- {t("appSettings.appearanceSummary", { - aspect: snapshot.appearance.defaults.aspectRatio, - padding: snapshot.appearance.defaults.padding, - radius: snapshot.appearance.defaults.borderRadius, - cursor: snapshot.appearance.defaults.cursorTheme, - camera: snapshot.appearance.defaults.webcamLayoutPreset, - })} -

-
- - -
-
- - ) : null} - {status === "saved" ?

{t("appSettings.saved")}

: null} - {error ?

{t("appSettings.saveFailed", { error })}

: null} -
-
- ); -} - -function SettingToggle({ - label, - checked, - disabled, - onChange, -}: { - label: string; - checked: boolean; - disabled?: boolean; - onChange: (value: boolean) => void; -}) { - return ( - - ); -} - -export function AppSettingsDialog() { - const section = useEditorDialogSection(); - const { closeDialog } = useEditorDialogActions(); - return ; -} diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx index 596ea0c67..fabfc3acb 100644 --- a/src/components/ai-edition/NewEditorShell.tsx +++ b/src/components/ai-edition/NewEditorShell.tsx @@ -1547,7 +1547,6 @@ export function NewEditorShell() { save: () => void handleSave(), export: handleExport, openSettings: handleOpenSettings, - openAppSettings: () => openDialog("settings"), renameProject: handleRenameProject, toggleChat: () => setChatOpen((v) => !v), openProviderSettings: () => openDialog("providers"), diff --git a/src/components/ai-edition/ProviderSettings.test.tsx b/src/components/ai-edition/ProviderSettings.test.tsx index 63508dd53..6f20c3ebc 100644 --- a/src/components/ai-edition/ProviderSettings.test.tsx +++ b/src/components/ai-edition/ProviderSettings.test.tsx @@ -54,7 +54,6 @@ function TopBar({ mode }: { mode: EditorMode }) { save: noop, export: noop, openSettings: noop, - openAppSettings: noop, renameProject: noop, toggleChat: noop, openProviderSettings: () => openDialog("providers"), diff --git a/src/components/ai-edition/v4/EditorTopBar.test.tsx b/src/components/ai-edition/v4/EditorTopBar.test.tsx index f904d88ec..087c3cc38 100644 --- a/src/components/ai-edition/v4/EditorTopBar.test.tsx +++ b/src/components/ai-edition/v4/EditorTopBar.test.tsx @@ -19,16 +19,15 @@ import { EditorTopBar } from "./EditorTopBar"; const noop = () => {}; -function renderTopBar(projectTitle: string | null, mode: "media" | "edit" | "rec" = "edit") { +function renderTopBar(projectTitle: string | null) { const onRename = vi.fn(); const onShowAbout = vi.fn(); const onCheckForUpdates = vi.fn(); const onOpenSettings = vi.fn(); - const onOpenAppSettings = vi.fn(); const onOpenProviderSettings = vi.fn(); render( , ); - return { - onRename, - onShowAbout, - onCheckForUpdates, - onOpenSettings, - onOpenAppSettings, - onOpenProviderSettings, - }; + return { onRename, onShowAbout, onCheckForUpdates, onOpenSettings, onOpenProviderSettings }; } /** The menu reads two separate channels, and they answer different questions: `getAppInfo` for @@ -172,25 +163,17 @@ describe("AppMenu", () => { expect(trigger.getAttribute("style") ?? "").not.toMatch(/all\s*:\s*unset/); }); - it("opens on click and offers app settings, shortcuts, AI settings and about", () => { + it("opens on click and offers shortcuts, AI settings and about", () => { renderTopBar("Demo Project"); fireEvent.click(screen.getByRole("button", { name: /OpenScreen/ })); expect(screen.getByRole("menu")).toBeInTheDocument(); // Exact names: the translator echoes keys, and both settings rows are labelled with a // `…title` key, so a /title/ match would hit two items and pin neither. expect(screen.getByRole("menuitem", { name: "title" })).toBeInTheDocument(); - expect(screen.getByRole("menuitem", { name: "appSettings.title" })).toBeInTheDocument(); expect(screen.getByRole("menuitem", { name: "providerSettings.title" })).toBeInTheDocument(); expect(screen.getByRole("menuitem", { name: /actions\.about/ })).toBeInTheDocument(); }); - it.each(["media", "edit", "rec"] as const)("opens app settings from %s mode", (mode) => { - const { onOpenAppSettings } = renderTopBar("Demo Project", mode); - fireEvent.click(screen.getByRole("button", { name: /OpenScreen/ })); - fireEvent.click(screen.getByRole("menuitem", { name: "appSettings.title" })); - expect(onOpenAppSettings).toHaveBeenCalledTimes(1); - }); - // Issue #420: the AI dialog used to be openable only from the chat panel, which mounts in // Edit mode with the panel expanded. The row is unconditional here — its dialog is mounted // in App.tsx, above every mode — so the menu does not lie in Media and Rec. diff --git a/src/components/ai-edition/v4/EditorTopBar.tsx b/src/components/ai-edition/v4/EditorTopBar.tsx index ea00e541d..0fa4ce90e 100644 --- a/src/components/ai-edition/v4/EditorTopBar.tsx +++ b/src/components/ai-edition/v4/EditorTopBar.tsx @@ -10,7 +10,6 @@ import { PanelLeft, RefreshCw, Save, - Settings, Sparkles, Sun, } from "lucide-react"; @@ -29,7 +28,6 @@ export interface TopBarActions { save: () => void; export: () => void; openSettings: () => void; - openAppSettings: () => void; renameProject: (title: string) => void; toggleChat: () => void; openProviderSettings: () => void; @@ -399,15 +397,6 @@ function AppMenu({ actions }: { actions: TopBarActions }) { {open ? (
-