From ce43a1cd7caff28d68462f73d765c7187b13a9d9 Mon Sep 17 00:00:00 2001 From: MinhOmega <49482201+MinhOmega@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:45:28 +0700 Subject: [PATCH 01/40] fix(linux): record on Sway and niri, whose DMA-BUF frames were all dropped On a DMA-BUF stream the CPU path bounded each frame by the producer's `maxsize` and `chunk->size`. xdg-desktop-portal-wlr sends 0 / 9 and niri's portal 1 / 1 for a full frame, so `stride * height` never fit and every frame was refused: the recording came out empty. PipeWire's DMA-BUF docs tell consumers to ignore both and size the buffer from the fd, which is exactly the mapping osc_on_add_buffer already made (lseek-recovered when maxsize is 0). Frames on that path are now bounded by the mapping's own length; shared memory keeps its chunk-size bound. Rebuilt from the upstream PR rather than merged: it conflicts with our shim, and the part that makes recording work is this bound. The PR's (fd, mapoffset) keyed maps, refcounts and drop diagnostics are left out. Upstream-PR: getopenscreen/openscreen#386 --- .../native/pipewire-capture/csrc/pw_shim.c | 34 +++++++++++++++---- .../native/pipewire-capture/csrc/pw_shim.h | 10 ++++++ electron/native/pipewire-capture/src/shim.rs | 23 ++++++++++++- 3 files changed, 60 insertions(+), 7 deletions(-) diff --git a/electron/native/pipewire-capture/csrc/pw_shim.c b/electron/native/pipewire-capture/csrc/pw_shim.c index 45e95fb55..d7ae66df5 100644 --- a/electron/native/pipewire-capture/csrc/pw_shim.c +++ b/electron/native/pipewire-capture/csrc/pw_shim.c @@ -820,18 +820,31 @@ static void *osc_map_dmabuf(int fd, size_t *len, const char **why) return ptr == MAP_FAILED ? NULL : ptr; } -static void *osc_find_dmabuf_map(struct osc_pw_session *session, int fd) +static void *osc_find_dmabuf_map(struct osc_pw_session *session, int fd, size_t *len) { size_t i; for (i = 0; i < OSC_MAX_DMABUF_MAPS; i++) { if (session->dmabuf_maps[i].ptr != NULL && session->dmabuf_maps[i].fd == fd) { + *len = session->dmabuf_maps[i].len; return session->dmabuf_maps[i].ptr; } } return NULL; } +uint32_t osc_pw_frame_readable(int is_dmabuf, size_t avail, uint32_t offset, uint32_t chunk_size) +{ + if (offset > avail) { + return 0; + } + avail -= offset; + if (avail > UINT32_MAX) { + avail = UINT32_MAX; + } + return is_dmabuf ? (uint32_t)avail : SPA_MIN(chunk_size, (uint32_t)avail); +} + /* * CPU access to a dmabuf has to be bracketed by DMA_BUF_IOCTL_SYNC, or the * driver is under no obligation to have flushed the GPU's writes into the @@ -1095,7 +1108,8 @@ static int osc_read_cursor(const struct spa_buffer *buffer, struct osc_pw_cursor * Extracts the pixels of one buffer. Returns 1 when `out` describes a frame, 0 * when this buffer carries none. * - * The offset/size clamping against `maxsize` is the standard PipeWire consumer + * The offset/size clamping against `maxsize` (against our own mapping for a + * DMA-BUF) is the standard PipeWire consumer * idiom and is not paranoia: `chunk` lives in memory the PRODUCER writes, so its * fields are untrusted input from another process. A compositor bug — or a * malicious one — that reports a size past the end of the mapping would @@ -1112,6 +1126,9 @@ static int osc_read_frame(struct osc_pw_session *session, const struct spa_buffe int32_t stride; int32_t height; int is_dmabuf_import = 0; + /* Bytes behind `base`: the producer's `maxsize` for shared memory, our own + * mapping's length for a DMA-BUF (see osc_pw_frame_readable). */ + size_t avail; const uint8_t *base; @@ -1125,6 +1142,7 @@ static int osc_read_frame(struct osc_pw_session *session, const struct spa_buffe if (data->chunk == NULL) { return 0; } + avail = data->maxsize; if (data->type == SPA_DATA_DmaBuf) { /* @@ -1133,7 +1151,7 @@ static int osc_read_frame(struct osc_pw_session *session, const struct spa_buffe * osc_on_add_buffer. A miss means the mmap failed there — reported at * that point — and there is nothing readable here. */ - base = osc_find_dmabuf_map(session, (int)data->fd); + base = osc_find_dmabuf_map(session, (int)data->fd, &avail); if (base == NULL) { if (!session->import_dmabuf) { return 0; @@ -1152,7 +1170,10 @@ static int osc_read_frame(struct osc_pw_session *session, const struct spa_buffe base = data->data; } /* A zero-sized chunk is how a compositor ships a cursor update with no new - * frame attached. Not an error, just not a frame. */ + * frame attached. Not an error, just not a frame. + * ponytail: on DMA-BUF a producer may also leave a real frame's size at 0; + * none seen yet (wlr sends 9, niri 1). If one shows up, tell cursor-only + * buffers apart by `chunk->stride == 0` there instead. */ if (data->chunk->size == 0) { return 0; } @@ -1197,8 +1218,9 @@ static int osc_read_frame(struct osc_pw_session *session, const struct spa_buffe (pd->chunk != NULL && pd->chunk->stride > 0) ? pd->chunk->stride : import_stride; } } else { - offset = SPA_MIN(data->chunk->offset, data->maxsize); - size = SPA_MIN(data->chunk->size, data->maxsize - offset); + offset = data->chunk->offset; + size = osc_pw_frame_readable(data->type == SPA_DATA_DmaBuf, avail, offset, + data->chunk->size); if (stride <= 0) { return 0; } diff --git a/electron/native/pipewire-capture/csrc/pw_shim.h b/electron/native/pipewire-capture/csrc/pw_shim.h index b442fb70c..a45154724 100644 --- a/electron/native/pipewire-capture/csrc/pw_shim.h +++ b/electron/native/pipewire-capture/csrc/pw_shim.h @@ -206,6 +206,16 @@ int osc_pw_cursor_meta_accepts_producer_size(uint32_t width, uint32_t height); */ int osc_pw_enum_format_accepts_dmabuf_producer(int with_modifier, int64_t producer_modifier); +/* + * How many bytes of a frame are readable from `offset` on, given `avail` bytes + * behind the base pointer. A shared-memory chunk is bounded by the producer's + * `chunk_size`; a DMA-BUF one is not, because PipeWire tells consumers to ignore + * both `maxsize` and `chunk->size` on DMA-BUF and size the buffer from the fd + * (xdg-desktop-portal-wlr sends maxsize 0 / size 9, niri's portal 1 / 1, for a + * full 1080p frame). 0 when `offset` lies past `avail`. + */ +uint32_t osc_pw_frame_readable(int is_dmabuf, size_t avail, uint32_t offset, uint32_t chunk_size); + struct osc_pw_session; /* diff --git a/electron/native/pipewire-capture/src/shim.rs b/electron/native/pipewire-capture/src/shim.rs index b24725561..bd6b82592 100644 --- a/electron/native/pipewire-capture/src/shim.rs +++ b/electron/native/pipewire-capture/src/shim.rs @@ -134,6 +134,8 @@ extern "C" { with_modifier: i32, producer_modifier: i64, ) -> i32; + #[cfg(test)] + fn osc_pw_frame_readable(is_dmabuf: i32, avail: usize, offset: u32, chunk_size: u32) -> u32; fn osc_pw_start( fd: i32, node_id: u32, @@ -1103,7 +1105,7 @@ fn on_frame_inner(state: &CallbackState, frame: *const RawFrame) -> i32 { if rows > frame.size { return 0; } - // SAFETY: the shim clamped `size` against the mapping's `maxsize` before + // SAFETY: the shim clamped `size` against the mapping's length before // the callback, `rows <= size` was just checked, and the mapping stays // live until this returns. let pixels = unsafe { std::slice::from_raw_parts(frame.data, rows) }; @@ -1201,6 +1203,25 @@ mod tests { use std::sync::mpsc; use std::time::Duration; + /// A DMA-BUF frame is bounded by our mapping, not by the placeholder sizes + /// wlr / niri portals send, which rejected every 1080p frame (#287 follow-up). + #[test] + fn dmabuf_frames_are_bounded_by_the_mapping_not_the_chunk() { + let frame = 7680 * 1080; + // SAFETY: pure arithmetic on the C side. + let readable = |dmabuf, avail, offset, chunk| unsafe { + osc_pw_frame_readable(dmabuf, avail, offset, chunk) + }; + // xdg-desktop-portal-wlr: chunk size 9 on an 8 MiB mapping. + assert!(readable(1, 8_388_608, 0, 9) as usize >= frame); + // Shared memory keeps trusting the chunk. + assert_eq!(readable(0, 8_388_608, 0, 9), 9); + // Never past the mapping, whatever the producer claims. + assert_eq!(readable(1, 8_388_608, 4096, u32::MAX), 8_388_608 - 4096); + assert_eq!(readable(1, 100, 200, 50), 0); + assert_eq!(readable(0, 100, 200, 50), 0); + } + /// The bound that made Stage 1 produce nothing on the first real run. /// /// Compositors declare SPA_PARAM_META_size for the cursor as a FIXED From 949c730145d5168974f28d1d50381ed008d0abb2 Mon Sep 17 00:00:00 2001 From: MinhOmega <49482201+MinhOmega@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:46:19 +0700 Subject: [PATCH 02/40] perf(build): shrink the installer by keeping node_modules out of app.asar Vite bundles every runtime import into dist/ and dist-electron/ (the main bundle requires only Node builtins and electron), yet electron-builder still packed the production node_modules into app.asar: about 265 MB in the upstream measurement (app.asar 275 MB -> 10 MB), none of which the app loads. It also carried a second copy of the wallpapers, cursors and MediaPipe models that ship as extraResources and are read from there, plus README art. All of it is excluded now, the dead `**/*.node` asarUnpack goes with it (no .node file travels through `files` any more), and the MediaPipe notice points at resources/mediapipe/, where the models live. Rebuilt from the upstream PR, keeping only what its maintainer review confirmed. Left out: `compression: maximum` (a no-op for NSIS, slower AppImage), `electronLanguages` (its hyphenated spellings delete pt_BR, zh_CN and zh_TW on macOS), excluding ffmpeg-shared.exe (the only ffmpeg the Windows build spawns) and `--optimize-for-size` (unmeasured, and it overwrites a user's own --js-flags). Upstream-PR: getopenscreen/openscreen#617 --- THIRD-PARTY-NOTICES.md | 2 +- electron-builder.json5 | 17 +++++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index af8d5175e..5e0bd2b78 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -80,7 +80,7 @@ distributed by their own registries, not redistributed inside our binaries. - **Components**: `selfie_segmentation.tflite`, `selfie_segmentation_landscape.tflite` and the `selfie_segmentation_landscape.onnx` - derived from them, shipped inside `app.asar` under `dist/mediapipe/`. + derived from them, shipped under `resources/mediapipe/`. - **License**: Apache-2.0 — . Copyright The MediaPipe Authors. - The `.onnx` is a **derived work**, generated from the vendored `.tflite` by diff --git a/electron-builder.json5 b/electron-builder.json5 index 054f45188..d98273cd9 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -3,12 +3,6 @@ "$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json", "appId": "com.capturia.app", "asar": true, - // .node binaries can't be dlopen'd from inside an asar — must live unpacked. - // onnxruntime-node distributes as a `.node` shared object that whisper.cpp - // helpers run from process spawn, so they all unpack via the same glob. - "asarUnpack": [ - "**/*.node" - ], "productName": "Capturia", // There is deliberately no `electronVersion` here. electron-builder needs an EXACT // version because it downloads the binaries for one release, and it takes that from @@ -75,6 +69,17 @@ "files": [ "dist", "dist-electron", + // Vite bundles every runtime import into dist/ and dist-electron/, so the + // node_modules electron-builder would add are dead weight (app.asar 275 MB -> 10 MB upstream). + "!node_modules/**", + // Already shipped as extraResources, which is where they are read from + // (ASSET_BASE_DIR / sceneAssetBaseDirs); Vite's public/ copy is a duplicate. + "!dist/wallpapers/**", + "!dist/cursors/**", + "!dist/mediapipe/**", + // README/store art that Vite copies from public/; nothing in the app loads it. + "!dist/demo.gif", + "!dist/preview*.png", "!*.png", "!preview*.png", "!*.md", From 030ce989138c0f6904a5df75ffa2486b55ee82b3 Mon Sep 17 00:00:00 2001 From: MinhOmega <49482201+MinhOmega@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:47:33 +0700 Subject: [PATCH 03/40] fix(macos): raise the Screen Recording prompt on first run A fresh install never saw the macOS Screen Recording prompt. The request was gated on `status === "not-determined"`, but Chromium reads this permission with CGPreflightScreenCaptureAccess(), a bool, so a never-asked app reports "denied" and the gate never opened. The user went straight to our "Open System Settings" dialog, where Capturia was not even listed yet because it had never attempted a capture. The capture attempt that raises the prompt now runs for any status short of granted. macOS draws the prompt once per app and answers silently after that, so a user who already refused still gets the dialog at once, as before; on the very first try the prompt and the dialog both appear, and both lead to the same pane. The dialog now says to reopen Capturia after granting, because the preflight answer is cached for the life of the process. Rebuilt from the upstream PR's diagnosis, not its 1.1k-line rework (fresh process status probe, persisted first-run marker, prompt/mic gating), which is still in review upstream. Upstream-PR: getopenscreen/openscreen#302 --- electron/ipc/handlers.ts | 35 ++++++++++++++++++----------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 595c30a67..9e3a58bdf 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -1913,24 +1913,25 @@ export function registerIpcHandlers( } // Screen recording has no askForMediaAccess equivalent, so trigger the - // TCC prompt without opening OpenScreen's source selector above it. - if (status === "not-determined") { - const mainWin = getMainWindow(); - if (mainWin && !mainWin.isDestroyed()) { - if (!mainWin.isVisible()) { - mainWin.show(); - } - mainWin.focus(); + // TCC prompt without opening Capturia's source selector above it. + // Not gated on "not-determined": Chromium reads this permission with + // CGPreflightScreenCaptureAccess(), a bool, so a never-asked app reports + // "denied" too and that gate never let the prompt be raised. macOS draws + // the prompt once per app; after that this capture attempt is answered + // silently and the caller's Settings dialog is what the user sees. + const mainWin = getMainWindow(); + if (mainWin && !mainWin.isDestroyed()) { + if (!mainWin.isVisible()) { + mainWin.show(); } - app.focus({ steal: true }); - desktopCapturer - .getSources({ types: ["screen"], thumbnailSize: { width: 1, height: 1 } }) - .catch(() => { - // Permission probing failure is reported by the explicit status check below. - }); - return { success: true, granted: false, status: "not-determined" }; + mainWin.focus(); } - + app.focus({ steal: true }); + desktopCapturer + .getSources({ types: ["screen"], thumbnailSize: { width: 1, height: 1 } }) + .catch(() => { + // Permission probing failure is reported by the explicit status check below. + }); return { success: true, granted: false, status }; } catch (error) { console.error("Failed to request screen access:", error); @@ -2186,7 +2187,7 @@ export function registerIpcHandlers( cancelId: 1, message: "Screen Recording permission is required", detail: - "Allow Capturia in macOS System Settings, then come back and choose a screen or window.", + "Allow Capturia in macOS System Settings, then quit and reopen Capturia: macOS reports the change to Capturia only after a fresh launch.", } satisfies Electron.MessageBoxOptions; const result = mainWin && !mainWin.isDestroyed() From 55870e28e05a45094f6350e532074b11a96ba53e Mon Sep 17 00:00:00 2001 From: MinhOmega <49482201+MinhOmega@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:49:35 +0700 Subject: [PATCH 04/40] fix(capture): stop a helper that dies at stop time from crashing the app On macOS and Linux the app writes "stop\n" (and pause/resume, arm) to the capture helper's stdin with no 'error' listener on the pipe. When the helper has already died, or closed its stdin, that write raises EPIPE asynchronously, past the surrounding try/catch, and an 'error' with no listener is an uncaught exception in the main process: the app went down at the exact moment the stop path was about to report the helper's exit. Verified with a child that closes stdin: the write returns, then EPIPE arrives as an uncaught exception. The macOS drain now sinks errors on all three pipes, as the Windows drain already did, and the Linux capture and cursor sessions sink stdin errors. Taken from the upstream PR's maintainer fix. The PR's MP4 salvage is left out: its predicate requires a non-zero duration and sample table, which a fragmented file left by a killed helper never has, so it rejects exactly the recordings it exists to rescue, and a helper that exits 0 without the ack is already accepted here. Upstream-PR: getopenscreen/openscreen#571 --- electron/ipc/handlers.ts | 9 +++++++++ .../capture/linuxNativeCaptureSession.test.ts | 9 +++++++++ .../native-bridge/capture/linuxNativeCaptureSession.ts | 6 ++++++ .../cursor/recording/pipeWireCursorRecordingSession.ts | 5 +++++ 4 files changed, 29 insertions(+) diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 9e3a58bdf..9ce04caaa 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -1578,6 +1578,15 @@ function attachNativeMacCaptureOutputDrain( proc.stderr.on("data", drain); proc.once("close", cleanup); proc.once("error", cleanup); + // Same reason as attachNativeWindowsCaptureOutputDrain: writing "stop\n" to a + // helper that already died raises EPIPE on stdin, and an 'error' with no + // listener is an uncaught exception in the main process. The stop wait then + // never gets to report the helper's exit. + for (const stream of [proc.stdin, proc.stdout, proc.stderr]) { + stream.on("error", (error) => { + console.warn("[native-sck] helper pipe error:", error); + }); + } proc.once("exit", (code, signal) => { const detail = `code=${code ?? "null"} signal=${signal ?? "null"}`; notifyNativeCaptureHelperExit("darwin", recordingId, detail); diff --git a/electron/native-bridge/capture/linuxNativeCaptureSession.test.ts b/electron/native-bridge/capture/linuxNativeCaptureSession.test.ts index f97db3cd8..59467a29c 100644 --- a/electron/native-bridge/capture/linuxNativeCaptureSession.test.ts +++ b/electron/native-bridge/capture/linuxNativeCaptureSession.test.ts @@ -235,6 +235,15 @@ describe("LinuxNativeCaptureSession", () => { expect(helper.stdinWrites).toContain("record\n"); }); + it("survives EPIPE from a helper that died before its command was written", async () => { + await startReady(newSession()); + // An 'error' with no listener throws out of emit(), which in the main + // process is an uncaught exception that takes the app down. + expect(() => + helper.stdin.emit("error", Object.assign(new Error("write EPIPE"), { code: "EPIPE" })), + ).not.toThrow(); + }); + it("arms at most once, so a caller need not track whether it prepared", async () => { const session = newSession(true); await startReady(session); diff --git a/electron/native-bridge/capture/linuxNativeCaptureSession.ts b/electron/native-bridge/capture/linuxNativeCaptureSession.ts index 809184456..8afcae4a6 100644 --- a/electron/native-bridge/capture/linuxNativeCaptureSession.ts +++ b/electron/native-bridge/capture/linuxNativeCaptureSession.ts @@ -148,6 +148,12 @@ export class LinuxNativeCaptureSession { stdio: ["pipe", "pipe", "pipe"], }); this.process = child; + // Writing to a helper that already died raises EPIPE asynchronously, past the + // try/catch in write(); with no listener that is an uncaught exception in + // the main process. Exit reporting already covers the helper being gone. + child.stdin.on("error", (error) => { + console.warn("[capture-linux] helper stdin error:", error); + }); child.stdout.setEncoding("utf8"); child.stdout.on("data", (chunk: string) => this.handleStdout(chunk)); diff --git a/electron/native-bridge/cursor/recording/pipeWireCursorRecordingSession.ts b/electron/native-bridge/cursor/recording/pipeWireCursorRecordingSession.ts index 47ff34a6b..2c5a9fa43 100644 --- a/electron/native-bridge/cursor/recording/pipeWireCursorRecordingSession.ts +++ b/electron/native-bridge/cursor/recording/pipeWireCursorRecordingSession.ts @@ -111,6 +111,11 @@ export class PipeWireCursorRecordingSession implements CursorRecordingSession { { stdio: ["pipe", "pipe", "pipe"] }, ); this.process = child; + // See linuxNativeCaptureSession: EPIPE from a dead helper arrives past the + // try/catch around the stop write and would crash the main process. + child.stdin.on("error", (error) => { + console.warn("[cursor-linux] helper stdin error:", error); + }); child.stdout.setEncoding("utf8"); child.stdout.on("data", (chunk: string) => this.handleStdoutChunk(chunk)); From 8ad382692a87dfe9ed1db55fd7fc461ce85890f6 Mon Sep 17 00:00:00 2001 From: MinhOmega <49482201+MinhOmega@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:51:03 +0700 Subject: [PATCH 05/40] fix(windows): stop 96/192 kHz system audio from aliasing into the recording When loopback runs at an integer multiple of the 48 kHz AAC rate (a USB DAC set to 96, 192 or 384 kHz), each group of source frames was averaged and kept. A box average barely filters: at 96 -> 48 kHz a 36 kHz tone passed at about -8 dB and folded onto 12 kHz, audible in the recording. Every frame now goes through a Kaiser-windowed sinc low-pass (80*factor+1 taps, cutoff 22 kHz at a 48 kHz output, unity at DC) before frames are dropped, with the filter history kept per stream across packets and reset where the old byte remainder was. Output frame counts are unchanged. Costs, as measured upstream: 20-24 kHz is attenuated, decimated streams lag by about 0.82 ms, and full-scale square content can overshoot and clip where the box average could not. Upstream's hardware A/B on a USB DAC at 96/192/384 kHz shows the folds gone; not buildable on Linux, so this is carried as-is (the files were identical to upstream's). Upstream-PR: getopenscreen/openscreen#644 --- .../wgc-capture/src/audio_sample_utils.cpp | 236 +++- .../wgc-capture/src/audio_sample_utils.h | 36 +- .../src/audio_sample_utils_test.cpp | 1043 ++++++++++++++++- .../testing/manual-e2e-checklist.md | 2 + 4 files changed, 1239 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) { diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index 439c703d2..402713483 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,3 +622,4 @@ 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. | From e922de08b97a5f148e2d729c67a1086db2eef895 Mon Sep 17 00:00:00 2001 From: MinhOmega <49482201+MinhOmega@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:51:41 +0700 Subject: [PATCH 06/40] feat(editor): show what a trim keeps while the handles are still moving The Edit clip dialog printed Start / End / Duration and then the same range again inside the selection bar, with nothing saying how long the source was. It now reads Original duration / Trim range / Final duration, all live while a handle is dragged (an em dash when the asset carries no probed length, rather than passing the out-point off as the source length). Each clip card on the timeline also shows the length it contributes, when the card is wide enough for that clip's own timecode. Also fixes a trap in the same dialog: the dimmed tail was painted above the end handle, so once the range got narrower than the handle it swallowed the grab and a range dragged to the 0.05 s minimum could only be recovered with Reset. The dimmed head and tail no longer take pointer events. Upstream-PR: getopenscreen/openscreen#640 --- .../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 | 33 ++++ src/components/ai-edition/v4/V4Timeline.tsx | 22 +++ 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, 292 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 1f545bf72..28ddfed53 100644 --- a/src/components/ai-edition/v4/EditorShellV4.module.css +++ b/src/components/ai-edition/v4/EditorShellV4.module.css @@ -2000,6 +2000,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 be94d6909..9a321b83d 100644 --- a/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx +++ b/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx @@ -407,6 +407,39 @@ 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("asks for the room this card's own timecode needs, not the shortest one", () => { + // 600s of 3965s is a ~130px card. `0:12.0` would fit there; `10:00.0` is a + // character wider and does not, and `formatSec` has no hour field to stop + // the string growing — a clip past a hundred minutes reads `100:00.0`. A + // single fixed width would have let those through onto the delete button. + renderTimeline([clip(0, 600), clip(600, 3965)]); + + expect(screen.queryByText("10:00.0")).not.toBeInTheDocument(); + expect(screen.getByText("56:05.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 381310ed6..4053c5969 100644 --- a/src/components/ai-edition/v4/V4Timeline.tsx +++ b/src/components/ai-edition/v4/V4Timeline.tsx @@ -160,6 +160,22 @@ 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; +// Whether a card can also carry its edited duration. The label pill is capped at +// `calc(100% - 50px)` so it clears the delete button, and everything inside it +// but the name is incompressible: 15px of padding, the 16px pencil, two 8px +// gaps, and the timecode. The timecode is the part that varies — `formatSec` +// never prints an hour field, so a clip past ten minutes reads `16:40.0` and one +// past a hundred `100:00.0` — so the room is measured against THIS card's own +// text rather than a single number that only ever fitted the short form. +// Measured in the running window: 6.0px per character at 10px in the mono face, +// and a 6-character code overlapping the delete button at a 121px card, clear at +// 131px. +const CLIP_LABEL_RESERVE_PX = 50; +const CLIP_LABEL_FIXED_PX = 47; +const CLIP_LABEL_CHAR_PX = 6; +function cardFitsDuration(cardPx: number, text: string): boolean { + return cardPx >= CLIP_LABEL_RESERVE_PX + CLIP_LABEL_FIXED_PX + text.length * CLIP_LABEL_CHAR_PX; +} const CLIP_GUTTER_PX = 6; /** @@ -2472,6 +2488,9 @@ export function V4Timeline({ // there is no arrangement that fits a button inside that — so while // it is selected the controls step outside the box instead. const narrow = boxLen * pxPerSec < NARROW_CLIP_PX; + // The gutter is taken out of the card's own width below, so the + // room the label actually has is that much less than the span. + const durText = formatSec(dur); return (
{tl.assets.find((a) => a.id === c.assetId)?.label ?? c.assetId} + {cardFitsDuration(boxLen * pxPerSec - CLIP_GUTTER_PX, durText) ? ( + {durText} + ) : null}
{selected ? ( )} - - From 94580679caac51d986f1d97321127a4d3a0b3c8d Mon Sep 17 00:00:00 2001 From: MinhOmega <49482201+MinhOmega@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:54:50 +0700 Subject: [PATCH 10/40] ci(whisper-stt): drop the Windows MSVC setup step nothing uses ilammy/msvc-dev-cmd is the last node20 action in the workflow and has had no upstream release since 2024, so every Windows run logs a deprecation warning and the job will stop at that step once GitHub drops the node20 shim. Nothing reads what it sets up: scripts/build-whisper-stt.sh runs cmake without -G, so Windows gets the Visual Studio generator, which finds MSVC through the VS installer and builds with MSBuild; vcpkg finds the compiler on its own too. A comment at the spot says when a vcvars step would be needed again (a move to Ninja). Upstream-PR: getopenscreen/openscreen#641 --- .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 6a4d592600291ecad82c7276fae8e83888d74fb4 Mon Sep 17 00:00:00 2001 From: MinhOmega <49482201+MinhOmega@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:55:17 +0700 Subject: [PATCH 11/40] docs(install): correct the platform table on Linux export and webcam capture The table said Linux MP4 export is software-encoded and pointed at a roadmap item for hardware encode. H.264 export on Linux already goes through h264_vaapi from a dmabuf when the driver and the Vulkan device allow it, and falls back to software otherwise; H.265 is always software (libkvazaar). The webcam row said macOS captures the camera natively: it records it in the app, like Linux, and on every platform the camera is saved as a separate file (Windows captures it in the helper). Each claim was checked against our pipeline_linux.rs, d3d_linux.rs, the macOS helper and the Windows helper. Upstream-PR: getopenscreen/openscreen#642 --- 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 de7027c22..372eae876 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, 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 | ✅ | ✅ | ✅ (software encode) | +| 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. 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/MinhOmega/Capturia/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 *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 7f02d5ba88fc7517e0cceacffd5c29abf185ce00 Mon Sep 17 00:00:00 2001 From: MinhOmega Date: Fri, 11 Sep 2026 23:10:01 +0700 Subject: [PATCH 12/40] fixup! perf(timeline): re-render at most once per frame while scrubbing --- .../ai-edition/v4/V4Timeline.geometry.test.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx b/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx index f4bf736e3..fc7ba132f 100644 --- a/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx +++ b/src/components/ai-edition/v4/V4Timeline.geometry.test.tsx @@ -172,11 +172,20 @@ describe("V4Timeline scrubbing", () => { frames.delete(frameId); }); const onRender = vi.fn(); + // Render passes WE schedule. Every commit of the timeline is followed by a + // "nested-update": Radix Slot (react-slot 1.2.3, `asChild` under each + // Tooltip) builds a fresh composeRefs callback per render, so React re-runs + // the trigger ref with null then the node, and TooltipRoot's setTrigger + // re-renders that root in the commit phase (~0.1 ms, measured). Mocking + // Tooltip removes it. Scrub code cannot schedule a commit-phase update, so + // the frame budget is counted on the "update" phase. + const updates = () => onRender.mock.calls.filter(([, phase]) => phase === "update").length; const { setCurrentTime } = renderTimeline(undefined, undefined, undefined, onRender); const ruler = document.querySelector("[class*=tlRulerRow]") as HTMLElement; fireEvent.pointerDown(ruler, { button: 0, clientX: 90 }); const commitsAfterPointerDown = onRender.mock.calls.length; + const updatesAfterPointerDown = updates(); setCurrentTime.mockClear(); // Three pointer moves inside one frame: the playhead follows each in the DOM, @@ -191,7 +200,7 @@ describe("V4Timeline scrubbing", () => { const [[frameId, frame]] = frames; frames.delete(frameId); act(() => frame(0)); - expect(onRender).toHaveBeenCalledTimes(commitsAfterPointerDown + 1); + expect(updates()).toBe(updatesAfterPointerDown + 1); expect(setCurrentTime).toHaveBeenCalledTimes(1); expect(setCurrentTime).toHaveBeenCalledWith(720); // 360 of 900 px over 1800 s fireEvent.pointerUp(window); From 12bd014c96155a1ee61a08c652323a244836f109 Mon Sep 17 00:00:00 2001 From: MinhOmega <49482201+MinhOmega@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:00:33 +0700 Subject: [PATCH 13/40] feat(editor): save the look as a preset and seed new projects from a default The style panes (Composition, Camera, Cursor, Captions) gain a "Saved looks" menu: save the current appearance under a name, apply, rename, delete, and star one as the default for new projects. A look is the appearance subset of the editor settings plus the caption style, stored as the same patch shapes the panes already write, so applying one goes through patchEditorSettings/patchCaptionSettings and a single history-recording saveDocument: one undo step, and regions, trims, zooms, crop, clips, camera framing, audio gain and the transcript are never touched. The canvas format is included only when the user opts in at save time. Presets persist in localStorage beside userPreferences under their own key, so a large inline custom background hitting the quota cannot break the other preferences; a failed write is reported instead of lost. Loading rebuilds each preset through the document readers and clamps to the slider ranges, so a hand-edited value lands in range and fields from a newer build are ignored. At apply time a referenced image that no longer loads, or an unknown cursor theme, falls back to the default for that field only, with one toast. The default look is seeded in projectStore.createProject, because the presets live in the renderer and the main process cannot read them. --- src/components/ai-edition/CaptionsPane.tsx | 27 +- src/components/ai-edition/LookPresetsMenu.tsx | 247 ++++++++++++++++++ src/components/ai-edition/RightPanes.tsx | 10 +- src/i18n/locales/ar/settings.json | 11 + src/i18n/locales/en/settings.json | 11 + src/i18n/locales/es/settings.json | 11 + src/i18n/locales/fr/settings.json | 11 + src/i18n/locales/it/settings.json | 11 + src/i18n/locales/ja-JP/settings.json | 11 + src/i18n/locales/ko-KR/settings.json | 11 + src/i18n/locales/pt-BR/settings.json | 11 + src/i18n/locales/ru/settings.json | 11 + src/i18n/locales/tr/settings.json | 11 + src/i18n/locales/vi/settings.json | 11 + src/i18n/locales/zh-CN/settings.json | 11 + src/i18n/locales/zh-TW/settings.json | 11 + src/lib/ai-edition/store/lookPresets.test.ts | 107 ++++++++ src/lib/ai-edition/store/lookPresets.ts | 226 ++++++++++++++++ src/lib/ai-edition/store/projectStore.ts | 14 +- src/lib/userPreferences.ts | 2 +- 20 files changed, 761 insertions(+), 15 deletions(-) create mode 100644 src/components/ai-edition/LookPresetsMenu.tsx create mode 100644 src/lib/ai-edition/store/lookPresets.test.ts create mode 100644 src/lib/ai-edition/store/lookPresets.ts diff --git a/src/components/ai-edition/CaptionsPane.tsx b/src/components/ai-edition/CaptionsPane.tsx index afdafdd35..b2c233a95 100644 --- a/src/components/ai-edition/CaptionsPane.tsx +++ b/src/components/ai-edition/CaptionsPane.tsx @@ -28,6 +28,7 @@ import { firstTimelineBusyView } from "@/lib/ai-edition/transcription/status"; import { nativeBridgeClient } from "@/native"; import { ColorField } from "./ColorField"; import { FontFamilyField } from "./FontFamilyField"; +import { LookPresetsMenu } from "./LookPresetsMenu"; import styles from "./NewEditorShell.module.css"; import { SliderCell, Toggle } from "./RightPanes"; import { useTranscriptionLabel } from "./TranscriptionStatus"; @@ -189,18 +190,20 @@ export function CaptionsPane({ onClose }: { onClose?: () => void } = {}) {

{t("facets.captions")}

- {onClose ? ( - - ) : null} + + + {onClose ? ( + + ) : null} +
s.document !== null); + const [open, setOpen] = useState(false); + const [state, setState] = useState({ presets: [], defaultId: null }); + const [name, setName] = useState(""); + const [withAspectRatio, setWithAspectRatio] = useState(false); + const [renaming, setRenaming] = useState<{ id: string; name: string } | null>(null); + + const persist = (next: LookPresetState) => { + if (saveLookPresets(next)) setState(next); + else toast.error(ts("looks.saveFailed")); + }; + + const saveCurrent = () => { + const doc = useProjectStore.getState().document; + const trimmed = name.trim(); + if (!doc || !trimmed) return; + const preset: LookPreset = { + id: createId("look"), + name: trimmed, + ...lookFromDocument(doc, withAspectRatio), + }; + persist({ ...state, presets: [...state.presets, preset] }); + setName(""); + }; + + const apply = async (preset: LookPreset) => { + setOpen(false); + const look = await withAvailableAssets(preset); + // Read after the await: the document the look lands on is the one on screen now. + const doc = useProjectStore.getState().document; + if (doc) await useProjectStore.getState().saveDocument(applyLook(doc, look), { history: true }); + }; + + const commitRename = () => { + if (!renaming) return; + const trimmed = renaming.name.trim(); + setRenaming(null); + if (!trimmed) return; + persist({ + ...state, + presets: state.presets.map((p) => (p.id === renaming.id ? { ...p, name: trimmed } : p)), + }); + }; + + return ( + { + // Re-read on every open: another pane's menu may have changed the list. + if (next) setState(loadLookPresets()); + setRenaming(null); + setOpen(next); + }} + > + + + + +
+
{ts("looks.title")}
+ {state.presets.length === 0 ? ( +

+ {ts("looks.empty")} +

+ ) : null} + {state.presets.map((preset) => { + const isDefault = state.defaultId === preset.id; + return ( +
+ {renaming?.id === preset.id ? ( + setRenaming({ id: preset.id, name: e.target.value })} + onBlur={commitRename} + onKeyDown={(e) => { + if (e.key === "Enter") commitRename(); + }} + /> + ) : ( + + )} + + + +
+ ); + })} +
{ts("looks.saveAs")}
+
{ + e.preventDefault(); + saveCurrent(); + }} + > + setName(e.target.value)} + /> + + +
+
+
+
+ ); +} diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx index 2c0548f81..8f7f050de 100644 --- a/src/components/ai-edition/RightPanes.tsx +++ b/src/components/ai-edition/RightPanes.tsx @@ -108,6 +108,7 @@ import { import { useCanSegmentCamera } from "../../native/hooks/useSegmentationSupport"; import { CaptionsPane } from "./CaptionsPane"; import { insertionsEnabled } from "./insertionsEnabled"; +import { LookPresetsMenu } from "./LookPresetsMenu"; import styles from "./NewEditorShell.module.css"; import { useTranscriptionLabel } from "./TranscriptionStatus"; import { transcriptionBusyLabel } from "./transcriptionBusyLabel"; @@ -2383,6 +2384,7 @@ export function VideoEffectsPane() { } + actions={} // Two complete sentences, one per merged half, rather than a third string to // translate 13 times — both already exist in every locale and neither is a // fragment of the other, so joining them survives translation and RTL alike. @@ -2774,7 +2776,12 @@ export function LayoutPane() { setLive({ webcamCropPan: pan, webcamCropRegion: cropRegionFor(webcamCrop.width, pan) }); }; return ( - } helpText={helpText}> + } + helpText={helpText} + actions={} + >
{ts("layout.preset")}
@@ -3295,6 +3302,7 @@ export function CursorPane() { } + actions={} helpText={ts("cursor.help")} >
diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json index b44c7d4d0..9a385e23e 100644 --- a/src/i18n/locales/ar/settings.json +++ b/src/i18n/locales/ar/settings.json @@ -241,6 +241,17 @@ "title": "تخطيط الكاميرا", "noWebcam": "بدون كاميرا" }, + "looks": { + "title": "المظاهر المحفوظة", + "empty": "لا توجد مظاهر محفوظة بعد.", + "saveAs": "حفظ المظهر كإعداد مسبق…", + "namePlaceholder": "اسم الإعداد المسبق", + "includeAspectRatio": "تضمين التنسيق (نسبة العرض إلى الارتفاع)", + "defaultForNew": "افتراضي للمشاريع الجديدة", + "rename": "إعادة التسمية", + "missingAsset": "لم تعد صورة أو مؤشر في هذا المظهر متاحًا، لذا أُعيد تعيينه إلى الوضع الافتراضي.", + "saveFailed": "تعذّر حفظ المظهر. قد تكون صورة الخلفية المخصصة أكبر من أن تُخزَّن." + }, "textAnimation": { "slideLeft": "انزلاق لليسار", "pulse": "نبض", diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index ba75b00c1..257636adb 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -241,6 +241,17 @@ "title": "Camera layout", "noWebcam": "No Webcam" }, + "looks": { + "title": "Saved looks", + "empty": "No saved looks yet.", + "saveAs": "Save look as preset…", + "namePlaceholder": "Preset name", + "includeAspectRatio": "Include format (aspect ratio)", + "defaultForNew": "Default for new projects", + "rename": "Rename", + "missingAsset": "An image or cursor in this look is no longer available, so it was reset to the default.", + "saveFailed": "Couldn't save the look. A custom background image may be too large to store." + }, "textAnimation": { "slideLeft": "Slide Left", "pulse": "Pulse", diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index 156e4b0eb..42e9a168d 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -241,6 +241,17 @@ "title": "Disposición de cámara", "noWebcam": "Sin cámara" }, + "looks": { + "title": "Estilos guardados", + "empty": "Aún no hay estilos guardados.", + "saveAs": "Guardar estilo como predefinido…", + "namePlaceholder": "Nombre del predefinido", + "includeAspectRatio": "Incluir formato (relación de aspecto)", + "defaultForNew": "Predeterminado para proyectos nuevos", + "rename": "Cambiar nombre", + "missingAsset": "Una imagen o un cursor de este estilo ya no está disponible, así que se restableció el valor predeterminado.", + "saveFailed": "No se pudo guardar el estilo. Puede que una imagen de fondo personalizada sea demasiado grande para almacenarla." + }, "textAnimation": { "slideLeft": "Deslizar izquierda", "pulse": "Pulso", diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index d7690bf05..2fe39247b 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -241,6 +241,17 @@ "title": "Disposition caméra", "noWebcam": "Sans webcam" }, + "looks": { + "title": "Styles enregistrés", + "empty": "Aucun style enregistré pour l'instant.", + "saveAs": "Enregistrer le style comme préréglage…", + "namePlaceholder": "Nom du préréglage", + "includeAspectRatio": "Inclure le format (rapport d'aspect)", + "defaultForNew": "Par défaut pour les nouveaux projets", + "rename": "Renommer", + "missingAsset": "Une image ou un curseur de ce style n'est plus disponible ; la valeur par défaut a été rétablie.", + "saveFailed": "Impossible d'enregistrer le style. Une image d'arrière-plan personnalisée est peut-être trop volumineuse pour être stockée." + }, "textAnimation": { "slideLeft": "Glisser à gauche", "pulse": "Pulsation", diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json index 9f8fefb87..9cd5f700b 100644 --- a/src/i18n/locales/it/settings.json +++ b/src/i18n/locales/it/settings.json @@ -241,6 +241,17 @@ "title": "Disposizione camera", "noWebcam": "Nessuna webcam" }, + "looks": { + "title": "Stili salvati", + "empty": "Nessuno stile salvato.", + "saveAs": "Salva lo stile come preset…", + "namePlaceholder": "Nome del preset", + "includeAspectRatio": "Includi formato (proporzioni)", + "defaultForNew": "Predefinito per i nuovi progetti", + "rename": "Rinomina", + "missingAsset": "Un'immagine o un cursore di questo stile non è più disponibile, quindi è stato ripristinato il valore predefinito.", + "saveFailed": "Impossibile salvare lo stile. Un'immagine di sfondo personalizzata potrebbe essere troppo grande da archiviare." + }, "textAnimation": { "slideLeft": "Scivola a sinistra", "pulse": "Pulsazione", diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index 26f3a44b3..57dafd313 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -241,6 +241,17 @@ "title": "カメラレイアウト", "noWebcam": "Webカメラなし" }, + "looks": { + "title": "保存したルック", + "empty": "保存したルックはまだありません。", + "saveAs": "ルックをプリセットとして保存…", + "namePlaceholder": "プリセット名", + "includeAspectRatio": "フォーマット(アスペクト比)を含める", + "defaultForNew": "新規プロジェクトのデフォルト", + "rename": "名前を変更", + "missingAsset": "このルックの画像またはカーソルが見つからないため、デフォルトに戻しました。", + "saveFailed": "ルックを保存できませんでした。カスタム背景画像が大きすぎて保存できない可能性があります。" + }, "textAnimation": { "slideLeft": "左へスライド", "pulse": "パルス", diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json index 9e70678ab..e6eab64d0 100644 --- a/src/i18n/locales/ko-KR/settings.json +++ b/src/i18n/locales/ko-KR/settings.json @@ -241,6 +241,17 @@ "title": "카메라 레이아웃", "noWebcam": "웹캠 없음" }, + "looks": { + "title": "저장된 스타일", + "empty": "저장된 스타일이 아직 없습니다.", + "saveAs": "스타일을 프리셋으로 저장…", + "namePlaceholder": "프리셋 이름", + "includeAspectRatio": "형식(화면 비율) 포함", + "defaultForNew": "새 프로젝트 기본값", + "rename": "이름 바꾸기", + "missingAsset": "이 스타일의 이미지 또는 커서를 더 이상 사용할 수 없어 기본값으로 재설정했습니다.", + "saveFailed": "스타일을 저장할 수 없습니다. 사용자 지정 배경 이미지가 너무 커서 저장하지 못했을 수 있습니다." + }, "textAnimation": { "slideLeft": "왼쪽 슬라이드", "pulse": "펄스", diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json index 3361c9833..e56234ed7 100644 --- a/src/i18n/locales/pt-BR/settings.json +++ b/src/i18n/locales/pt-BR/settings.json @@ -241,6 +241,17 @@ "title": "Layout da câmera", "noWebcam": "Sem Webcam" }, + "looks": { + "title": "Estilos salvos", + "empty": "Nenhum estilo salvo ainda.", + "saveAs": "Salvar estilo como predefinição…", + "namePlaceholder": "Nome da predefinição", + "includeAspectRatio": "Incluir formato (proporção)", + "defaultForNew": "Padrão para novos projetos", + "rename": "Renomear", + "missingAsset": "Uma imagem ou um cursor deste estilo não está mais disponível, então foi redefinido para o padrão.", + "saveFailed": "Não foi possível salvar o estilo. Uma imagem de fundo personalizada pode ser grande demais para ser armazenada." + }, "textAnimation": { "slideLeft": "Deslizar à Esquerda", "pulse": "Pulsar", diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index 10e517ae2..6a7c36c3e 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -241,6 +241,17 @@ "title": "Расположение камеры", "noWebcam": "Без веб-камеры" }, + "looks": { + "title": "Сохранённые стили", + "empty": "Сохранённых стилей пока нет.", + "saveAs": "Сохранить стиль как пресет…", + "namePlaceholder": "Название пресета", + "includeAspectRatio": "Включить формат (соотношение сторон)", + "defaultForNew": "По умолчанию для новых проектов", + "rename": "Переименовать", + "missingAsset": "Изображение или курсор из этого стиля больше недоступны, поэтому восстановлено значение по умолчанию.", + "saveFailed": "Не удалось сохранить стиль. Возможно, собственное фоновое изображение слишком велико для хранения." + }, "textAnimation": { "slideLeft": "Скольжение влево", "pulse": "Импульс", diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index 3aadcdd1f..0f5a1b167 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -241,6 +241,17 @@ "title": "Kamera düzeni", "noWebcam": "Web kamerası yok" }, + "looks": { + "title": "Kayıtlı görünümler", + "empty": "Henüz kayıtlı görünüm yok.", + "saveAs": "Görünümü ön ayar olarak kaydet…", + "namePlaceholder": "Ön ayar adı", + "includeAspectRatio": "Biçimi dahil et (en boy oranı)", + "defaultForNew": "Yeni projeler için varsayılan", + "rename": "Yeniden adlandır", + "missingAsset": "Bu görünümdeki bir görsel veya imleç artık kullanılamıyor, bu yüzden varsayılana sıfırlandı.", + "saveFailed": "Görünüm kaydedilemedi. Özel arka plan görseli saklanamayacak kadar büyük olabilir." + }, "textAnimation": { "slideLeft": "Sola Kaydırma", "pulse": "Nabız", diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json index 4a5bf6cfa..f5205e2e4 100644 --- a/src/i18n/locales/vi/settings.json +++ b/src/i18n/locales/vi/settings.json @@ -241,6 +241,17 @@ "title": "Bố cục camera", "noWebcam": "Không có webcam" }, + "looks": { + "title": "Giao diện đã lưu", + "empty": "Chưa có giao diện nào được lưu.", + "saveAs": "Lưu giao diện thành cài đặt sẵn…", + "namePlaceholder": "Tên cài đặt sẵn", + "includeAspectRatio": "Bao gồm định dạng (tỷ lệ khung hình)", + "defaultForNew": "Mặc định cho dự án mới", + "rename": "Đổi tên", + "missingAsset": "Một hình ảnh hoặc con trỏ trong giao diện này không còn khả dụng nên đã được đặt lại về mặc định.", + "saveFailed": "Không thể lưu giao diện. Có thể ảnh nền tùy chỉnh quá lớn để lưu trữ." + }, "textAnimation": { "slideLeft": "Trượt sang trái", "pulse": "Nhấp nháy", diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 5603923d9..510c26e39 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -241,6 +241,17 @@ "title": "摄像头布局", "noWebcam": "无摄像头" }, + "looks": { + "title": "已保存的外观", + "empty": "还没有保存的外观。", + "saveAs": "将外观保存为预设…", + "namePlaceholder": "预设名称", + "includeAspectRatio": "包含格式(宽高比)", + "defaultForNew": "新项目的默认外观", + "rename": "重命名", + "missingAsset": "此外观中的图片或光标已不可用,已重置为默认值。", + "saveFailed": "无法保存外观。自定义背景图片可能太大,无法存储。" + }, "textAnimation": { "slideLeft": "向左滑动", "pulse": "脉动", diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index d806000c2..62ca02566 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -241,6 +241,17 @@ "title": "攝影機版面", "noWebcam": "無網路攝影機" }, + "looks": { + "title": "已儲存的外觀", + "empty": "尚未儲存任何外觀。", + "saveAs": "將外觀儲存為預設集…", + "namePlaceholder": "預設集名稱", + "includeAspectRatio": "包含格式(長寬比)", + "defaultForNew": "新專案的預設外觀", + "rename": "重新命名", + "missingAsset": "此外觀中的圖片或游標已無法使用,已重設為預設值。", + "saveFailed": "無法儲存外觀。自訂背景圖片可能太大而無法儲存。" + }, "textAnimation": { "slideLeft": "向左滑動", "pulse": "脈動", diff --git a/src/lib/ai-edition/store/lookPresets.test.ts b/src/lib/ai-edition/store/lookPresets.test.ts new file mode 100644 index 000000000..f3021b436 --- /dev/null +++ b/src/lib/ai-edition/store/lookPresets.test.ts @@ -0,0 +1,107 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { getCaptionSettings } from "../captions/settings"; +import { type AxcutDocument, createEmptyDocument } from "../schema"; +import { getEditorSettings, patchEditorSettings } from "./editorSettings"; +import { applyLook, loadLookPresets, lookFromDocument } from "./lookPresets"; + +// In-memory stand-in, as in `transport.test.ts`: a string-keyed store is the only browser +// API the loader touches, and a jsdom costs seconds per file. +const store = new Map(); +globalThis.localStorage = { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => void store.set(key, value), + removeItem: (key: string) => void store.delete(key), + clear: () => store.clear(), + key: (index: number) => [...store.keys()][index] ?? null, + get length() { + return store.size; + }, +} satisfies Storage; + +const empty = createEmptyDocument({ projectId: "p1", title: "Test" }); + +/** A project with content in every place a look must not reach. */ +const target: AxcutDocument = { + ...empty, + timeline: { + ...empty.timeline, + trimRanges: [{ id: "t1", assetId: "a1", startSec: 1, endSec: 2, reason: "", origin: "user" }], + }, + zoomRanges: [{ id: "z1", startMs: 0, endMs: 1000, depth: 2, focus: { cx: 0.3, cy: 0.7 } }], + legacyEditor: { + cropRegion: { x: 0.1, y: 0.1, width: 0.5, height: 0.5 }, + speedRegions: [{ id: "s1", startMs: 0, endMs: 500, speed: 2 }], + webcamCropRegion: { x: 0, y: 0, width: 0.5, height: 0.5 }, + audioGainDb: -6, + padding: 10, + captions: { enabled: true, language: "fr", fontSize: 30 }, + }, +}; + +afterEach(() => localStorage.clear()); + +describe("applyLook", () => { + it("changes appearance and leaves regions, trims, zooms and crop untouched", () => { + const source = patchEditorSettings(empty, { + padding: 80, + borderRadius: 12, + wallpaper: "#123456", + aspectRatio: "9:16", + cursor: { size: 5, theme: "default" }, + }); + const look = lookFromDocument( + { ...source, legacyEditor: { ...source.legacyEditor, captions: { fontSize: 90 } } }, + false, + ); + + const next = applyLook(target, look); + const settings = getEditorSettings(next); + expect(settings.padding).toBe(80); + expect(settings.borderRadius).toBe(12); + expect(settings.wallpaper).toBe("#123456"); + expect(settings.cursor.size).toBe(5); + expect(getCaptionSettings(next).fontSize).toBe(90); + + // Untouched by construction: the same objects, not merely equal ones. + expect(next.timeline).toBe(target.timeline); + expect(next.zoomRanges).toBe(target.zoomRanges); + const before = target.legacyEditor as Record; + const after = next.legacyEditor as Record; + for (const key of ["cropRegion", "speedRegions", "webcamCropRegion", "audioGainDb"]) { + expect(after[key]).toBe(before[key]); + } + // The opt-in was off, so the canvas keeps its shape; captions stay on, in French. + expect(settings.aspectRatio).toBe("16:9"); + expect(getCaptionSettings(next).enabled).toBe(true); + expect(getCaptionSettings(next).language).toBe("fr"); + }); + + it("loads a hand-edited preset clamped, and ignores fields it does not know", () => { + localStorage.setItem( + "capturia_look_presets", + JSON.stringify({ + presets: [ + { + id: "l1", + name: " Mine ", + settings: { padding: 5000, borderRadius: -3, cropRegion: { x: 0.9 }, futureField: 1 }, + captions: { fontSize: 9999 }, + }, + { id: "broken" }, + ], + defaultId: "l1", + }), + ); + const { presets, defaultId } = loadLookPresets(); + expect(presets).toHaveLength(1); + expect(defaultId).toBe("l1"); + const [preset] = presets; + expect(preset.name).toBe("Mine"); + expect(preset.settings.padding).toBe(100); + expect(preset.settings.borderRadius).toBe(0); + expect(preset.settings).not.toHaveProperty("cropRegion"); + expect(preset.settings).not.toHaveProperty("futureField"); + expect(preset.settings).not.toHaveProperty("aspectRatio"); + expect(preset.captions?.fontSize).toBe(200); + }); +}); diff --git a/src/lib/ai-edition/store/lookPresets.ts b/src/lib/ai-edition/store/lookPresets.ts new file mode 100644 index 000000000..744105d37 --- /dev/null +++ b/src/lib/ai-edition/store/lookPresets.ts @@ -0,0 +1,226 @@ +// Saved looks: a named bundle of APPEARANCE settings that can be applied to any +// project, and one of which can seed every new project. +// +// A look is stored as the same patch shapes the panes already write +// (`EditorSettingsPatch`, `CaptionSettings`), so applying one is the same two +// patch functions every other appearance edit goes through. What a look never +// carries is anything tied to the footage: regions, trims, zooms, crop, clips, +// the transcript, the camera framing, the audio gain. +// +// Persisted beside `userPreferences` with the same localStorage mechanism, under +// its own key: a custom background is an inline `data:` URL that can run to +// megabytes, and a quota failure here must not take the user's other +// preferences down with it. + +import { toast } from "sonner"; +import type { WebcamMaskShape } from "@/components/video-editor/types"; +import { toastText } from "@/i18n/toastText"; +import { WEBCAM_LAYOUT_PRESETS } from "@/lib/compositeLayout"; +import { CURSOR_THEME_IDS, DEFAULT_CURSOR_THEME_ID } from "@/lib/cursor/cursorThemes"; +import { safeJsonParse } from "@/lib/userPreferences"; +import { classifyWallpaper, resolveImageWallpaperUrl } from "@/lib/wallpaper"; +import { isAspectRatio } from "@/utils/aspectRatioUtils"; +import { clamp, clamp01 } from "@/utils/math"; +import { + type CaptionSettings, + getCaptionSettings, + patchCaptionSettings, +} from "../captions/settings"; +import { type AxcutDocument, createEmptyDocument } from "../schema"; +import { + DEFAULT_EDITOR_SETTINGS, + type EditorSettingsPatch, + getEditorSettings, + patchEditorSettings, +} from "./editorSettings"; + +const LOOK_PRESETS_KEY = "capturia_look_presets"; + +/** Caption appearance only. Whether captions show, their language and their lane depend + * on the project's transcript, so a look leaves them alone. */ +export type CaptionStyle = Omit; + +export interface Look { + /** `aspectRatio` is present only when the user opted in when saving. */ + settings: EditorSettingsPatch; + /** `null` when the source project never set a caption style: its insets would be the + * aspect-derived defaults, and freezing a landscape default into a 9:16 project is the + * exact failure `getCaptionSettings` takes an aspect to avoid. */ + captions: CaptionStyle | null; +} + +export interface LookPreset extends Look { + id: string; + name: string; +} + +export interface LookPresetState { + presets: LookPreset[]; + defaultId: string | null; +} + +const MASK_SHAPES: readonly WebcamMaskShape[] = ["rectangle", "circle", "square", "rounded"]; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * The look a document currently wears. + * + * Read through the document's own readers, so a preset gets exactly the type guards and + * clamps a project file does — and then clamped once more to the ranges the sliders + * offer, which the readers leave open for a handful of fields. + */ +export function lookFromDocument(doc: AxcutDocument, includeAspectRatio: boolean): Look { + const s = getEditorSettings(doc); + const legacy = isRecord(doc.legacyEditor) ? doc.legacyEditor : null; + let captions: CaptionStyle | null = null; + if (isRecord(legacy?.captions)) { + const { + enabled: _enabled, + language: _language, + captionLane: _lane, + ...style + } = getCaptionSettings(doc); + captions = style; + } + return { + settings: { + wallpaper: s.wallpaper, + shadowIntensity: clamp01(s.shadowIntensity), + showBlur: s.showBlur, + motionBlurAmount: clamp01(s.motionBlurAmount), + borderRadius: clamp(s.borderRadius, 0, 64), + padding: clamp(s.padding, 0, 100), + webcamLayoutPreset: WEBCAM_LAYOUT_PRESETS.some((p) => p.value === s.webcamLayoutPreset) + ? s.webcamLayoutPreset + : DEFAULT_EDITOR_SETTINGS.webcamLayoutPreset, + webcamMaskShape: MASK_SHAPES.includes(s.webcamMaskShape) + ? s.webcamMaskShape + : DEFAULT_EDITOR_SETTINGS.webcamMaskShape, + webcamMirrored: s.webcamMirrored, + webcamReactiveZoom: s.webcamReactiveZoom, + webcamSizePreset: clamp(s.webcamSizePreset, 10, 50), + webcamPosition: s.webcamPosition, + webcamBackgroundMode: s.webcamBackgroundMode, + webcamWallpaper: s.webcamWallpaper, + webcamBlurIntensity: s.webcamBlurIntensity, + cursor: { + size: clamp(s.cursor.size, 0.5, 10), + smoothing: clamp01(s.cursor.smoothing), + motionBlur: clamp01(s.cursor.motionBlur), + clickBounce: clamp(s.cursor.clickBounce, 0, 5), + clipToBounds: s.cursor.clipToBounds, + // Unknown ids are kept here and caught in `withAvailableAssets`, which is where + // the user gets told a theme went missing. + theme: s.cursorTheme, + show: s.cursorShow, + }, + ...(includeAspectRatio && isAspectRatio(s.aspectRatio) ? { aspectRatio: s.aspectRatio } : {}), + }, + captions, + }; +} + +/** Applies the look's appearance fields and nothing else. Pure. */ +export function applyLook(doc: AxcutDocument, look: Look): AxcutDocument { + const next = patchEditorSettings(doc, look.settings); + // Every inset is in the style, so the aspect `patchCaptionSettings` would use for + // first-write defaults has nothing left to decide. + return look.captions ? patchCaptionSettings(next, look.captions) : next; +} + +const PROBE_DOC = createEmptyDocument({ projectId: "look-preset", title: "" }); + +/** A stored preset, rebuilt from known fields only — so a field a newer build wrote is + * ignored, and a hand-edited value lands in range — or `null` when it is not a preset. */ +function parsePreset(raw: unknown): LookPreset | null { + if (!isRecord(raw) || typeof raw.id !== "string" || typeof raw.name !== "string") return null; + const name = raw.name.trim(); + if (!name) return null; + const settings = isRecord(raw.settings) ? raw.settings : {}; + // Lay the raw values out as a document and read them back: the readers ARE the schema. + const probe = patchEditorSettings( + { ...PROBE_DOC, legacyEditor: isRecord(raw.captions) ? { captions: raw.captions } : null }, + settings as EditorSettingsPatch, + ); + return { id: raw.id, name, ...lookFromDocument(probe, settings.aspectRatio !== undefined) }; +} + +export function loadLookPresets(): LookPresetState { + let raw: Record | null = null; + try { + raw = safeJsonParse(localStorage.getItem(LOOK_PRESETS_KEY)); + } catch { + return { presets: [], defaultId: null }; + } + const list = raw?.presets; + const presets = Array.isArray(list) ? list.flatMap((p) => parsePreset(p) ?? []) : []; + const defaultId = presets.some((p) => p.id === raw?.defaultId) + ? (raw?.defaultId as string) + : null; + return { presets, defaultId }; +} + +/** Returns false when the write failed — most likely a custom background too large for + * the storage quota — so the caller can say so instead of losing the preset silently. */ +export function saveLookPresets(state: LookPresetState): boolean { + try { + localStorage.setItem(LOOK_PRESETS_KEY, JSON.stringify(state)); + return true; + } catch { + return false; + } +} + +export function defaultLookPreset(): LookPreset | null { + const { presets, defaultId } = loadLookPresets(); + return presets.find((p) => p.id === defaultId) ?? null; +} + +/** Whether an image wallpaper still resolves. Colours, gradients and inline `data:` images + * carry their own content, so only a referenced file can have gone missing. */ +function imageAvailable(value: string | undefined): Promise { + if (value === undefined) return Promise.resolve(true); + const classified = classifyWallpaper(value); + if (classified.kind !== "image" || classified.path.startsWith("data:")) { + return Promise.resolve(true); + } + let url: string; + try { + url = resolveImageWallpaperUrl(classified.path); + } catch { + return Promise.resolve(false); + } + return new Promise((resolve) => { + const img = new Image(); + img.onload = () => resolve(true); + img.onerror = () => resolve(false); + img.src = url; + }); +} + +/** + * The look with every referenced image or cursor theme that no longer exists reset to + * its default — that field only — and one toast when anything was. + */ +export async function withAvailableAssets(look: Look): Promise { + const s = look.settings; + const [wallpaperOk, webcamWallpaperOk] = await Promise.all([ + imageAvailable(s.wallpaper), + imageAvailable(s.webcamWallpaper), + ]); + const themeOk = s.cursor?.theme === undefined || CURSOR_THEME_IDS.has(s.cursor.theme); + if (wallpaperOk && webcamWallpaperOk && themeOk) return look; + toast.warning(toastText("settings", "looks.missingAsset")); + return { + ...look, + settings: { + ...s, + ...(wallpaperOk ? {} : { wallpaper: DEFAULT_EDITOR_SETTINGS.wallpaper }), + ...(webcamWallpaperOk ? {} : { webcamWallpaper: DEFAULT_EDITOR_SETTINGS.webcamWallpaper }), + cursor: { ...s.cursor, ...(themeOk ? {} : { theme: DEFAULT_CURSOR_THEME_ID }) }, + }, + }; +} diff --git a/src/lib/ai-edition/store/projectStore.ts b/src/lib/ai-edition/store/projectStore.ts index b47210d33..fb7e46911 100644 --- a/src/lib/ai-edition/store/projectStore.ts +++ b/src/lib/ai-edition/store/projectStore.ts @@ -9,6 +9,7 @@ import { type Interval, replaceTimeline as replaceTimelineOp } from "../document import { type AxcutAsset, type AxcutDocument, createAudioTrack, documentSchema } from "../schema"; import { probeAudioDuration, probeVideoDimensions } from "../timeline/duration"; import { DEFAULT_PREVIEW_RATE } from "../timeline/transport"; +import { applyLook, defaultLookPreset, withAvailableAssets } from "./lookPresets"; import { clearHistory, currentWriteEpoch, pushHistory } from "./undoStack"; let documentSavesInFlight = 0; @@ -321,7 +322,18 @@ export const useProjectStore = create((set, get) => ({ if (!result.success || !result.document) { throw new Error(result.error ?? "Failed to create project"); } - const document = parseDocument(result.document); + let document = parseDocument(result.document); + // Seeded here rather than in the main process because the presets live in this + // renderer's localStorage. Best effort: a failed write leaves the project on the + // shipped defaults, which is what it was before looks existed. + const look = defaultLookPreset(); + if (look) { + const seeded = await nativeBridgeClient.aiEdition.save( + applyLook(document, await withAvailableAssets(look)), + ); + if (seeded.success && seeded.document) document = parseDocument(seeded.document); + else console.warn("[project] could not apply the default look:", seeded.error); + } set({ projectId: document.project.id, document, diff --git a/src/lib/userPreferences.ts b/src/lib/userPreferences.ts index 20df2e0c9..e13b52f9d 100644 --- a/src/lib/userPreferences.ts +++ b/src/lib/userPreferences.ts @@ -66,7 +66,7 @@ export const DEFAULT_PREFS: UserPreferences = { }; /** Parses stored preferences without throwing on malformed JSON. */ -function safeJsonParse(text: string | null): Record | null { +export function safeJsonParse(text: string | null): Record | null { if (!text) return null; try { return JSON.parse(text); From 9efa5d305450ca8ee1da79c1988a534064c80909 Mon Sep 17 00:00:00 2001 From: MinhOmega <49482201+MinhOmega@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:10:57 +0700 Subject: [PATCH 14/40] fix(editor): keep the look probe document valid at import time createEmptyDocument rejects an empty title, and the probe is built when the module loads, so importing projectStore threw before the editor could open. Also declares the look apply in the document-write audit. --- src/lib/ai-edition/store/documentWriteAudit.test.ts | 3 +++ src/lib/ai-edition/store/lookPresets.ts | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/lib/ai-edition/store/documentWriteAudit.test.ts b/src/lib/ai-edition/store/documentWriteAudit.test.ts index c5bccc515..e208555d7 100644 --- a/src/lib/ai-edition/store/documentWriteAudit.test.ts +++ b/src/lib/ai-edition/store/documentWriteAudit.test.ts @@ -119,6 +119,9 @@ const DECLARED: WritePath[] = [ "gesture", ), + // Applying a saved look is a click in the "Saved looks" menu: one undo step. + w("src/components/ai-edition/LookPresetsMenu.tsx", "apply", "save", "gesture"), + // The persist that follows an undo. Recording it would undo the undo. w("src/components/ai-edition/NewEditorShell.tsx", "NewEditorShell", "save", "automatic"), // "Save" on the unsaved-changes prompt. diff --git a/src/lib/ai-edition/store/lookPresets.ts b/src/lib/ai-edition/store/lookPresets.ts index 744105d37..ebf5592d6 100644 --- a/src/lib/ai-edition/store/lookPresets.ts +++ b/src/lib/ai-edition/store/lookPresets.ts @@ -131,7 +131,9 @@ export function applyLook(doc: AxcutDocument, look: Look): AxcutDocument { return look.captions ? patchCaptionSettings(next, look.captions) : next; } -const PROBE_DOC = createEmptyDocument({ projectId: "look-preset", title: "" }); +// The schema rejects an empty title, and this runs at import time: an invalid probe +// would take projectStore (which imports this module) down with it. +const PROBE_DOC = createEmptyDocument({ projectId: "look-preset", title: "Look preset" }); /** A stored preset, rebuilt from known fields only — so a field a newer build wrote is * ignored, and a hand-edited value lands in range — or `null` when it is not a preset. */ From e69e35dad9007736871d8e6e83d42a02ac7414db Mon Sep 17 00:00:00 2001 From: MinhOmega <49482201+MinhOmega@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:08:23 +0700 Subject: [PATCH 15/40] fix(ci): look the Store package up by the name its manifest declares verify-appx-native.ps1 hardcoded Get-AppxPackage -Name "EtienneLescot.OpenScreen". The Capturia rename changed appx.identityName to MinhOmega.Capturia and updated the script's -AppId copy but not the name copy, so the v2.0.0-rc.3 Store job failed with "the package registered but cannot be found by name" before probing any binary. Read Identity Name and Application Id from the extracted AppxManifest.xml, which electron-builder generates from electron-builder.json5, instead of keeping copies. --- scripts/verify-appx-native.ps1 | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/scripts/verify-appx-native.ps1 b/scripts/verify-appx-native.ps1 index 211dbee07..c62ee787e 100644 --- a/scripts/verify-appx-native.ps1 +++ b/scripts/verify-appx-native.ps1 @@ -195,6 +195,17 @@ try { $manifest = Join-Path $extracted "AppxManifest.xml" if (-not (Test-Path $manifest)) { throw "no AppxManifest.xml in $appxPath, is it really an appx?" } + # The package name and application id are read back from the manifest that + # electron-builder wrote from `appx.identityName` and `appx.applicationId`, not + # copied in here. A copy is what broke this: the Capturia rename updated the id + # and left the name saying EtienneLescot.OpenScreen, so the lookup below found + # nothing and the check never reached a single binary. XmlDocument.Load honours + # the manifest's own encoding declaration; Get-Content in 5.1 would guess ANSI. + $manifestXml = New-Object System.Xml.XmlDocument + $manifestXml.Load($manifest) + $identityName = $manifestXml.Package.Identity.Name + $applicationId = $manifestXml.Package.Applications.Application.Id + Write-Host "Registering the package" try { Add-AppxPackage -Register $manifest -ErrorAction Stop @@ -203,8 +214,8 @@ try { throw "Add-AppxPackage -Register failed: $($_.Exception.Message)`n`nLoose registration needs Developer Mode (Settings > System > For developers)." } - $pkg = Get-AppxPackage -Name "EtienneLescot.OpenScreen" - if (-not $pkg) { throw "the package registered but cannot be found by name" } + $pkg = Get-AppxPackage -Name $identityName + if (-not $pkg) { throw "the package registered but cannot be found by name $identityName" } $registered = $pkg.PackageFullName Write-Host "Registered $($pkg.PackageFullName)" @@ -214,7 +225,7 @@ try { $childArgs = "-NoProfile -ExecutionPolicy Bypass -File `"$PSCommandPath`" -InPackage -PackageRoot `"$extracted`" -ReportPath `"$report`"" Invoke-CommandInDesktopPackage ` -PackageFamilyName $pkg.PackageFamilyName ` - -AppId "Capturia" ` + -AppId $applicationId ` -Command "powershell.exe" ` -Args $childArgs ` -ErrorAction Stop From 7da7476ff91c41d4a487622f79e4e4571f2b43f3 Mon Sep 17 00:00:00 2001 From: MinhOmega <49482201+MinhOmega@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:17:08 +0700 Subject: [PATCH 16/40] feat(timeline): add a zoom at every moment flagged while recording A new Auto-enhance entry turns the recording's flags into zooms. Each one starts just before its flag, is held like a click zoom, is focused where the pointer was at that instant, and uses the default depth. The placement is the auto-zoom's own, not a second copy of it. `buildAutoZoomSuggestions` is split into detection and `placeZoomCandidates` (spacing, sizing, clamping, overlap with reserved spans), and the per-clip source-to-ruler shift in `buildAutoZoomSuggestionsForClips` moves into `placeInClip`. Flags feed candidates into both, so a flag and a detected click follow the same rules. The collection follows the wand's path in `apply-auto-zooms.ts`. `collectFlagZoomSuggestionsForLatestDocument` reads telemetry through the same `AutoZoomTelemetryReader`. It shares the wand's stale-clips retry, which moved into a private `collectForLatestDocument` so that there is one copy of the rule. It also resolves the stored source-time flags against the document the zooms are built from, not the render that was current at click time. V4Timeline passes both passes one `readRecordingTelemetry` reader. `primaryVideoAsset` moves from the marker hook into `recordingMarkers.ts` so this pure module can use it, and the hook also returns the raw `markersMs`. Flags inside a trim are skipped. Flags whose zoom would land on an existing zoom, or on the zoom of an earlier flag, are skipped too. The toast counts both kinds. The pass writes through `addZoomsBulk`, so it is one save and one undo step. With no flags the entry is disabled, and its hint names the recorder's flag button, read from the launch namespace so the two never disagree. There is no hotkey for flagging, only that button. `interpolateCursorAt` now accepts anything with timeMs/cx/cy, so it can read the samples the suggester already holds. --- src/components/ai-edition/v4/V4Timeline.tsx | 93 +++++++-- src/i18n/locales/ar/timeline.json | 5 + src/i18n/locales/en/timeline.json | 5 + src/i18n/locales/es/timeline.json | 5 + src/i18n/locales/fr/timeline.json | 5 + src/i18n/locales/it/timeline.json | 5 + src/i18n/locales/ja-JP/timeline.json | 5 + src/i18n/locales/ko-KR/timeline.json | 5 + src/i18n/locales/pt-BR/timeline.json | 5 + src/i18n/locales/ru/timeline.json | 5 + src/i18n/locales/tr/timeline.json | 5 + src/i18n/locales/vi/timeline.json | 5 + src/i18n/locales/zh-CN/timeline.json | 5 + src/i18n/locales/zh-TW/timeline.json | 5 + .../ai-edition/store/useRecordingMarkers.ts | 22 +-- .../timeline/apply-auto-zooms.test.ts | 50 +++++ .../ai-edition/timeline/apply-auto-zooms.ts | 68 +++++-- .../ai-edition/timeline/recordingMarkers.ts | 13 ++ .../ai-edition/timeline/zoom-suggestions.ts | 177 ++++++++++++++---- src/lib/zoomMath/cursorFollowUtils.ts | 2 +- 20 files changed, 409 insertions(+), 81 deletions(-) diff --git a/src/components/ai-edition/v4/V4Timeline.tsx b/src/components/ai-edition/v4/V4Timeline.tsx index 173013709..1344f85c2 100644 --- a/src/components/ai-edition/v4/V4Timeline.tsx +++ b/src/components/ai-edition/v4/V4Timeline.tsx @@ -3,6 +3,7 @@ import { Clock, Crosshair, EyeOff, + Flag, Loader2, Maximize2, MessageSquare, @@ -56,7 +57,11 @@ import { useChatPromptBus } from "@/lib/ai-edition/store/useChatPromptBus"; import { useEditorSettings } from "@/lib/ai-edition/store/useEditorSettings"; import { useRecordingMarkers } from "@/lib/ai-edition/store/useRecordingMarkers"; import type { useTimeline } from "@/lib/ai-edition/store/useTimeline"; -import { collectAutoZoomSuggestionsForLatestDocument } from "@/lib/ai-edition/timeline/apply-auto-zooms"; +import { + type AutoZoomTelemetryReader, + collectAutoZoomSuggestionsForLatestDocument, + collectFlagZoomSuggestionsForLatestDocument, +} from "@/lib/ai-edition/timeline/apply-auto-zooms"; import { hasAnyClipWithCamera } from "@/lib/ai-edition/timeline/camera"; import { formatSec } from "@/lib/ai-edition/timeline/format"; import { @@ -100,6 +105,16 @@ const AI_ENHANCE_PROMPT = type TimelineApi = ReturnType; +// The telemetry both Auto-enhance zoom passes read. `getRecordingData`, not +// `getTelemetry`: the latter is a projection that keeps positions and DROPS +// `interactionType` (see `readCursorTelemetryFile`), so every click the recorder +// captured was thrown away one call before the detector that wants it. That +// projection is right for the timeline overlay it was written for and wrong here — +// it left the suggester guessing from stillness while the ground truth sat in the +// same sidecar. +const readRecordingTelemetry: AutoZoomTelemetryReader = async (videoPath) => + (await nativeBridgeClient.cursor.getRecordingData(videoPath))?.samples ?? []; + const ASSET_MIME = "application/x-axcut-asset"; type ToolId = "cut" | "comment" | "speed" | "blur"; @@ -643,6 +658,8 @@ export function V4Timeline({ // The camera lane borrows the Layout pane's "No Webcam" wording when there is no // camera to grow, so the two surfaces say the same thing about the same project. const ts = useScopedT("settings"); + // The recorder's own name for its flag button, so the hint that points at it cannot drift. + const tLaunch = useScopedT("launch"); // Wheel zoom/pan listens on the whole pane (toolbar down through the nav bar), // not just the lanes — a user scrolling over the ruler or the hint labels // expects the same zoom/pan the lanes give, not silence. @@ -933,7 +950,7 @@ export function V4Timeline({ // Moments the user flagged while recording. Positions are derived from source // time on every render of the document, so a marker follows the clip that // carries it through cuts, reorders and retimes instead of going stale. - const recordingMarkers = useRecordingMarkers(); + const { markers: recordingMarkers, markersMs: recordingMarkersMs } = useRecordingMarkers(); // Live scrub position. The store write behind it is rAF-throttled (see // seekToClientX), so this keeps the playhead and the timecode pinned to the @@ -1700,15 +1717,7 @@ export function V4Timeline({ // different media. const collected = await collectAutoZoomSuggestionsForLatestDocument( () => useProjectStore.getState().document, - // `getRecordingData`, not `getTelemetry`: the latter is a projection - // that keeps positions and DROPS `interactionType` (see - // `readCursorTelemetryFile`), so every click the recorder captured - // was thrown away one call before the detector that wants it. That - // projection is right for the timeline overlay it was written for - // and wrong here — it left the suggester guessing from stillness - // while the ground truth sat in the same sidecar. - async (videoPath) => - (await nativeBridgeClient.cursor.getRecordingData(videoPath))?.samples ?? [], + readRecordingTelemetry, ); const suggestions = collected?.suggestions ?? []; if (suggestions.length === 0) { @@ -1734,6 +1743,49 @@ export function V4Timeline({ } }, [tl, t]); + // Auto-enhance: one zoom per moment flagged while recording. Collected through the + // same reader and stale-clips retry as the wand above, and placed by the same rules + // (see `buildFlagZoomSuggestions`); the telemetry is only asked where the pointer was + // at each flag. + const runFlagZooms = useCallback(async () => { + setAutoEnhanceOpen(false); + setAutoBusy(true); + try { + const collected = await collectFlagZoomSuggestionsForLatestDocument( + () => useProjectStore.getState().document, + readRecordingTelemetry, + recordingMarkersMs, + ); + if (!collected) return; + const { suggestions, covered, trimmed } = collected; + const skipped = + [ + covered > 0 ? t("toolbar.flagZoomsCovered", { count: covered }) : null, + trimmed > 0 ? t("toolbar.flagZoomsTrimmed", { count: trimmed }) : null, + ] + .filter(Boolean) + .join(" · ") || undefined; + if (suggestions.length === 0) { + toast.info(t("toolbar.noAutoZoomMoments"), { description: skipped }); + return; + } + // One save, so the whole pass is one undo step. + const added = await tl.addZoomsBulk(suggestions); + if (added === 0) return; + toast.success( + t(added === 1 ? "toolbar.addedAutoZoom" : "toolbar.addedAutoZoomPlural", { count: added }), + { description: skipped }, + ); + } catch (err) { + toast.error(t("toolbar.autoZoomFailed"), { + description: err instanceof Error ? err.message : String(err), + }); + } finally { + setAutoBusy(false); + } + }, [recordingMarkersMs, tl, t]); + const noFlags = recordingMarkers.length === 0; + // Auto-enhance option 2 — hand a generic prompt to the AI agent (smart // zooms + cuts) via the chat prompt-bus. The chat panel owns the outcome // toast: submitting is not the same as being accepted (no usable provider @@ -2036,6 +2088,25 @@ export function V4Timeline({ +
) : null} diff --git a/src/components/ai-edition/v4/EditorShellV4.module.css b/src/components/ai-edition/v4/EditorShellV4.module.css index 28ddfed53..338b58e93 100644 --- a/src/components/ai-edition/v4/EditorShellV4.module.css +++ b/src/components/ai-edition/v4/EditorShellV4.module.css @@ -1182,6 +1182,16 @@ background: var(--accent-soft); color: var(--accent); } +/* The same rows as items of a Radix menu (the timeline's right-click menu), which are not + buttons: keyboard focus shows like hover, and a disabled item reads as one. */ +.recMenuRow[data-highlighted] { + background: var(--surface-2); + outline: none; +} +.recMenuRow[data-disabled] { + opacity: 0.55; + pointer-events: none; +} /* ─── REC-MODE SOURCE PICKER MODAL ──────────────────────────────────── */ .sourceModalOverlay { diff --git a/src/components/ai-edition/v4/RegionContextMenu.tsx b/src/components/ai-edition/v4/RegionContextMenu.tsx new file mode 100644 index 000000000..1b3d07a5a --- /dev/null +++ b/src/components/ai-edition/v4/RegionContextMenu.tsx @@ -0,0 +1,151 @@ +// The timeline's right-click menu: Copy, Paste at playhead and Delete on a region pill, Split at +// playhead on a clip. The Menu key and Shift+F10 open it too, for the selected pill. +// +// It owns no editing logic. Every entry calls the function its keyboard shortcut calls — +// handed down from the editor shell, or `tl.splitAtPlayhead` — and shows that shortcut's +// live binding, so the menu and the keys cannot come to disagree. + +import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; +import { useEffect } from "react"; +import { useScopedT } from "@/contexts/I18nContext"; +import { useShortcuts } from "@/contexts/ShortcutsContext"; +import { isModalOpen } from "@/lib/ai-edition/modalGuard"; +import { useRegionClipboard } from "@/lib/ai-edition/store/regionClipboard"; +import type { useTimeline } from "@/lib/ai-edition/store/useTimeline"; +import { formatBinding, isTextEditingTarget, type ShortcutBinding } from "@/lib/shortcuts"; +import styles from "./EditorShellV4.module.css"; + +export interface RegionMenuTarget { + kind: "zoom" | "trim" | "annotation" | "speed" | "cameraFullscreen" | "audio" | "clip"; + id: string; + /** Viewport point the menu opens at. */ + x: number; + y: number; +} + +export function RegionContextMenu({ + target, + onTargetChange, + tl, + onCopy, + onPaste, + onDelete, +}: { + target: RegionMenuTarget | null; + onTargetChange: (target: RegionMenuTarget | null) => void; + tl: ReturnType; + onCopy?: () => void; + onPaste?: () => void; + onDelete?: () => void; +}) { + const t = useScopedT("timeline"); + const tc = useScopedT("common"); + const { shortcuts, isMac } = useShortcuts(); + const clipboard = useRegionClipboard(); + + // The keyboard way in. Pills are selected by pointerdown, which does not focus them, so + // this keys off the SELECTION rather than off focus, and opens the menu under that pill. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key !== "ContextMenu" && !(e.shiftKey && e.key === "F10")) return; + if (isTextEditingTarget(e.target) || isModalOpen()) return; + const selected = + tl.selection ?? + (tl.selectedAudioTrackId ? { kind: "audio" as const, id: tl.selectedAudioTrackId } : null); + const pill = + selected && document.querySelector(`[data-pill-id="${CSS.escape(selected.id)}"]`); + if (!selected || !pill) return; + e.preventDefault(); + const rect = pill.getBoundingClientRect(); + onTargetChange({ kind: selected.kind, id: selected.id, x: rect.left, y: rect.bottom }); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [tl, onTargetChange]); + + const items: Array<{ + label: string; + binding: ShortcutBinding; + onSelect: () => void; + disabled?: boolean; + }> = + target?.kind === "clip" + ? [ + { + label: t("buttons.splitAtPlayhead"), + binding: shortcuts.splitAtPlayhead, + onSelect: () => void tl.splitAtPlayhead(), + }, + ] + : [ + { + label: tc("actions.copy"), + binding: shortcuts.copySelected, + onSelect: () => onCopy?.(), + // Copy reads the FOCUSED selection, like Ctrl+C. Right-clicking a pill that + // is only a passenger in a multi-selection would copy a different pill. + disabled: + target?.kind === "audio" + ? tl.selectedAudioTrackId !== target.id + : tl.selection?.id !== target?.id, + }, + { + // Paste makes a NEW region at the playhead, like Ctrl+V; it never writes + // onto the pill that was clicked, so the label says where it lands and + // the clicked pill's kind does not gate it. + label: t("buttons.pasteAtPlayhead"), + binding: shortcuts.paste, + onSelect: () => onPaste?.(), + disabled: !clipboard.hasContent, + }, + { + label: tc("actions.delete"), + binding: shortcuts.deleteSelected, + onSelect: () => onDelete?.(), + }, + ]; + + return ( + !open && onTargetChange(null)} + > + + + + + e.nativeEvent.stopPropagation()} + > + {items.map((item) => ( + + {item.label} + {formatBinding(item.binding, isMac)} + + ))} + + + + ); +} diff --git a/src/components/ai-edition/v4/V4Timeline.tsx b/src/components/ai-edition/v4/V4Timeline.tsx index 1344f85c2..7865cfbc1 100644 --- a/src/components/ai-edition/v4/V4Timeline.tsx +++ b/src/components/ai-edition/v4/V4Timeline.tsx @@ -85,6 +85,7 @@ import { nativeBridgeClient } from "@/native/client"; import { TransportBar } from "../TransportBar"; import type { VideoSource } from "../VirtualPreview"; import styles from "./EditorShellV4.module.css"; +import { RegionContextMenu, type RegionMenuTarget } from "./RegionContextMenu"; // The AI option's prompt — sent straight to the chat agent via the prompt-bus. // @@ -469,6 +470,7 @@ const AudioLanePill = memo(function AudioLanePill({ selected, onStartDrag, onSelect, + onContextMenu, label, slipHint, slipArmed, @@ -495,6 +497,7 @@ const AudioLanePill = memo(function AudioLanePill({ selected: boolean; onStartDrag: (e: ReactPointerEvent, track: AxcutAudioTrack, mode: "move" | "l" | "r") => void; onSelect: (id: string) => void; + onContextMenu: (e: React.MouseEvent, kind: "audio", id: string) => void; label: string; /** Appended to the pill's tooltip. A modifier is never discoverable on its own — * you either read it somewhere or you never find it — and the tooltip is where a @@ -545,6 +548,7 @@ const AudioLanePill = memo(function AudioLanePill({
onStartDrag(e, track, "move")} + onContextMenu={(e) => onContextMenu(e, "audio", track.id)} onKeyDown={(e) => { if (e.key !== "Enter" && e.key !== " ") return; e.preventDefault(); @@ -634,6 +639,9 @@ export function V4Timeline({ onNextClip, onEditClip, onAddVoiceover, + onCopyRegion, + onPasteRegion, + onDeleteSelection, }: { tl: TimelineApi; setCurrentTime: (sec: number) => void; @@ -650,6 +658,11 @@ export function V4Timeline({ /** Opens the voiceover recorder. Shell-level like the clip editor: the * dialog owns the microphone and the shell owns the transport. */ onAddVoiceover: () => void; + /** The shell's own copy / paste / delete — what its shortcuts call — for the + * right-click menu, so the menu cannot do anything the keys would not. */ + onCopyRegion?: () => void; + onPasteRegion?: () => void; + onDeleteSelection?: () => void; }) { const t = useScopedT("timeline"); // The live bindings, not the defaults: these keys are remappable, and a menu @@ -1077,6 +1090,9 @@ export function V4Timeline({ const startPillDrag = useCallback( (e: ReactPointerEvent, pill: LanePill, dragMode: "move" | "l" | "r") => { + // A right-button press is the context menu's, and must not collapse a + // multi-selection or arm a drag the menu would then leave running. + if (e.button !== 0) return; e.preventDefault(); e.stopPropagation(); selectPill(pill, e.shiftKey); @@ -1264,6 +1280,7 @@ export function V4Timeline({ // once, on pointerup. const startAudioDrag = useCallback( (e: ReactPointerEvent, track: AxcutAudioTrack, mode: "move" | "l" | "r") => { + if (e.button !== 0) return; e.preventDefault(); e.stopPropagation(); tl.selectAudioTrack(track.id); @@ -1843,6 +1860,23 @@ export function V4Timeline({ const isPillSelected = (id: string) => tl.selection?.id === id || tl.multiSelection.some((m) => m.id === id); + + // Right-click: select what was clicked unless it is already part of the selection — a + // multi-selection has to survive the click to be deleted as one — then open the menu at + // the pointer. + const [regionMenu, setRegionMenu] = useState(null); + const openRegionMenu = useCallback( + (e: React.MouseEvent, kind: RegionMenuTarget["kind"], id: string) => { + e.preventDefault(); + e.stopPropagation(); + if (kind === "clip") tl.selectClip(id); + else if (kind === "audio") tl.selectAudioTrack(id); + else if (tl.selection?.id !== id && !tl.multiSelection.some((m) => m.id === id)) + tl.selectRegion(kind, id); + setRegionMenu({ kind, id, x: e.clientX, y: e.clientY }); + }, + [tl], + ); // Optimistic preview: during a clip-reorder drag, slide each region pill by // the same amount as the clip it sits on — mirroring the clip transforms so // zoom/speed/annotation/trim pills travel with their content in real time, @@ -1889,6 +1923,7 @@ export function V4Timeline({ key={seg.key} role={seg.interactive ? "button" : undefined} tabIndex={seg.interactive ? 0 : undefined} + data-pill-id={seg.interactive ? p.id : undefined} className={`${styles.lanePill} ${laneOf(p.kind)}${ compact ? ` ${styles.lanePillCompact}` : "" }${seg.interactive && isPillSelected(p.id) ? ` ${styles.lanePillSel}` : ""}`} @@ -1911,6 +1946,7 @@ export function V4Timeline({ : {}), }} onPointerDown={seg.interactive ? (e) => startPillDrag(e, p, "move") : undefined} + onContextMenu={seg.interactive ? (e) => openRegionMenu(e, p.kind, p.id) : undefined} // A pill is focusable and announced as a button, so Enter and Space have to // activate it — without this a keyboard user could tab to a region and then // reach nothing that acts on a selection: Delete, copy/paste, the inspector. @@ -2483,6 +2519,7 @@ export function V4Timeline({ selected={tl.selectedAudioTrackId === track.id} onStartDrag={startAudioDrag} onSelect={tl.selectAudioTrack} + onContextMenu={openRegionMenu} label={track.label || asset?.label || ts("audioTrack.defaultLabel")} slipHint={ts("audioTrack.slipHint")} slipArmed={slipArmed} @@ -2585,6 +2622,8 @@ export function V4Timeline({ transform: clipTransform, }} onPointerDown={(e) => startClipDrag(e, c)} + // Split needs a playhead, which only the Edit surface has. + onContextMenu={showLanes ? (e) => openRegionMenu(e, "clip", c.id) : undefined} onClick={(e) => { e.stopPropagation(); // A completed reorder-drag also fires a click; don't let it @@ -2700,6 +2739,14 @@ export function V4Timeline({
) : null} + {/* The crop readout, at the component ROOT rather than in the lane: the lane sits inside the zoomed canvas transform, which would scale a chip placed there. `in -> out / length` — 0:00.0 and out = length are the boundary diff --git a/src/i18n/locales/ar/timeline.json b/src/i18n/locales/ar/timeline.json index 48e594731..1fcf6a675 100644 --- a/src/i18n/locales/ar/timeline.json +++ b/src/i18n/locales/ar/timeline.json @@ -10,7 +10,8 @@ "addAnnotation": "إضافة شرح (A)", "addSpeed": "إضافة سرعة (S)", "addCameraFullscreen": "إضافة كاميرا كاملة الشاشة (C)", - "splitAtPlayhead": "تقسيم المقطع عند رأس التشغيل" + "splitAtPlayhead": "تقسيم المقطع عند رأس التشغيل", + "pasteAtPlayhead": "لصق عند رأس التشغيل" }, "hints": { "pressZoom": "اضغط Z لإضافة تكبير", diff --git a/src/i18n/locales/en/timeline.json b/src/i18n/locales/en/timeline.json index 73fe42eb6..c49720930 100644 --- a/src/i18n/locales/en/timeline.json +++ b/src/i18n/locales/en/timeline.json @@ -10,7 +10,8 @@ "addAnnotation": "Add Annotation (A)", "addSpeed": "Add Speed (S)", "addCameraFullscreen": "Add Full Camera (C)", - "splitAtPlayhead": "Split Clip at Playhead" + "splitAtPlayhead": "Split Clip at Playhead", + "pasteAtPlayhead": "Paste at Playhead" }, "hints": { "pressZoom": "Press Z to add zoom", diff --git a/src/i18n/locales/es/timeline.json b/src/i18n/locales/es/timeline.json index 9d3be7ebf..eeca499d0 100644 --- a/src/i18n/locales/es/timeline.json +++ b/src/i18n/locales/es/timeline.json @@ -10,7 +10,8 @@ "addAnnotation": "Agregar anotación (A)", "addSpeed": "Agregar velocidad (S)", "addCameraFullscreen": "Agregar cámara a pantalla completa (C)", - "splitAtPlayhead": "Dividir el clip en el cursor de reproducción" + "splitAtPlayhead": "Dividir el clip en el cursor de reproducción", + "pasteAtPlayhead": "Pegar en el cursor de reproducción" }, "hints": { "pressZoom": "Presiona Z para agregar zoom", diff --git a/src/i18n/locales/fr/timeline.json b/src/i18n/locales/fr/timeline.json index b49417ac6..cb79881c5 100644 --- a/src/i18n/locales/fr/timeline.json +++ b/src/i18n/locales/fr/timeline.json @@ -10,7 +10,8 @@ "addAnnotation": "Ajouter une annotation (A)", "addSpeed": "Ajouter une vitesse (S)", "addCameraFullscreen": "Ajouter Caméra plein écran (C)", - "splitAtPlayhead": "Couper le clip à la tête de lecture" + "splitAtPlayhead": "Couper le clip à la tête de lecture", + "pasteAtPlayhead": "Coller à la tête de lecture" }, "hints": { "pressZoom": "Appuyez sur Z pour ajouter un zoom", diff --git a/src/i18n/locales/it/timeline.json b/src/i18n/locales/it/timeline.json index 2c00e5cdd..6c3e03d9e 100644 --- a/src/i18n/locales/it/timeline.json +++ b/src/i18n/locales/it/timeline.json @@ -10,7 +10,8 @@ "addAnnotation": "Aggiungi annotazione (A)", "addSpeed": "Aggiungi velocità (S)", "addCameraFullscreen": "Aggiungi Camera a schermo intero (C)", - "splitAtPlayhead": "Dividi la clip alla testina" + "splitAtPlayhead": "Dividi la clip alla testina", + "pasteAtPlayhead": "Incolla alla testina" }, "hints": { "pressZoom": "Premi Z per aggiungere zoom", diff --git a/src/i18n/locales/ja-JP/timeline.json b/src/i18n/locales/ja-JP/timeline.json index b0922e8f1..797ec0f7e 100644 --- a/src/i18n/locales/ja-JP/timeline.json +++ b/src/i18n/locales/ja-JP/timeline.json @@ -10,7 +10,8 @@ "addAnnotation": "注釈を追加 (A)", "addSpeed": "再生速度を追加 (S)", "addCameraFullscreen": "フルスクリーンカメラを追加 (C)", - "splitAtPlayhead": "再生ヘッドでクリップを分割" + "splitAtPlayhead": "再生ヘッドでクリップを分割", + "pasteAtPlayhead": "再生ヘッド位置に貼り付け" }, "hints": { "pressZoom": "Zキーを押してズームを追加", diff --git a/src/i18n/locales/ko-KR/timeline.json b/src/i18n/locales/ko-KR/timeline.json index 89535f458..770ad5dad 100644 --- a/src/i18n/locales/ko-KR/timeline.json +++ b/src/i18n/locales/ko-KR/timeline.json @@ -10,7 +10,8 @@ "addAnnotation": "주석 추가 (A)", "addSpeed": "속도 추가 (S)", "addCameraFullscreen": "전체 화면 카메라 추가 (C)", - "splitAtPlayhead": "재생 헤드에서 클립 분할" + "splitAtPlayhead": "재생 헤드에서 클립 분할", + "pasteAtPlayhead": "재생 헤드 위치에 붙여넣기" }, "hints": { "pressZoom": "Z를 눌러 줌 추가", diff --git a/src/i18n/locales/pt-BR/timeline.json b/src/i18n/locales/pt-BR/timeline.json index 8312d2764..08853e3ec 100644 --- a/src/i18n/locales/pt-BR/timeline.json +++ b/src/i18n/locales/pt-BR/timeline.json @@ -10,7 +10,8 @@ "addAnnotation": "Adicionar Anotação (A)", "addSpeed": "Adicionar Velocidade (S)", "addCameraFullscreen": "Adicionar Câmera em Tela Cheia (C)", - "splitAtPlayhead": "Dividir o clipe na cabeça de reprodução" + "splitAtPlayhead": "Dividir o clipe na cabeça de reprodução", + "pasteAtPlayhead": "Colar na cabeça de reprodução" }, "hints": { "pressZoom": "Pressione Z para adicionar zoom", diff --git a/src/i18n/locales/ru/timeline.json b/src/i18n/locales/ru/timeline.json index 3ac37bc85..9415d3ace 100644 --- a/src/i18n/locales/ru/timeline.json +++ b/src/i18n/locales/ru/timeline.json @@ -10,7 +10,8 @@ "addAnnotation": "Добавить аннотацию (A)", "addSpeed": "Изменить скорость (S)", "addCameraFullscreen": "Добавить камеру на весь экран (C)", - "splitAtPlayhead": "Разрезать клип по позиции воспроизведения" + "splitAtPlayhead": "Разрезать клип по позиции воспроизведения", + "pasteAtPlayhead": "Вставить в позицию воспроизведения" }, "hints": { "pressZoom": "Нажмите Z для добавления масштабирования", diff --git a/src/i18n/locales/tr/timeline.json b/src/i18n/locales/tr/timeline.json index ff847956e..b0d8caf2f 100644 --- a/src/i18n/locales/tr/timeline.json +++ b/src/i18n/locales/tr/timeline.json @@ -10,7 +10,8 @@ "addAnnotation": "Açıklama Ekle (A)", "addSpeed": "Hız Ekle (S)", "addCameraFullscreen": "Tam Ekran Kamera Ekle (C)", - "splitAtPlayhead": "Klibi oynatma çizgisinde böl" + "splitAtPlayhead": "Klibi oynatma çizgisinde böl", + "pasteAtPlayhead": "Oynatma çizgisine yapıştır" }, "hints": { "pressZoom": "Yakınlaştırma eklemek için Z tuşuna basın", diff --git a/src/i18n/locales/vi/timeline.json b/src/i18n/locales/vi/timeline.json index 3f6215458..d8fd3420d 100644 --- a/src/i18n/locales/vi/timeline.json +++ b/src/i18n/locales/vi/timeline.json @@ -10,7 +10,8 @@ "addAnnotation": "Thêm Chú thích (A)", "addSpeed": "Thêm Tốc độ (S)", "addCameraFullscreen": "Thêm Camera Toàn màn hình (C)", - "splitAtPlayhead": "Cắt clip tại đầu phát" + "splitAtPlayhead": "Cắt clip tại đầu phát", + "pasteAtPlayhead": "Dán tại đầu phát" }, "hints": { "pressZoom": "Nhấn Z để thêm thu phóng", diff --git a/src/i18n/locales/zh-CN/timeline.json b/src/i18n/locales/zh-CN/timeline.json index c17cf63f8..6ebb48539 100644 --- a/src/i18n/locales/zh-CN/timeline.json +++ b/src/i18n/locales/zh-CN/timeline.json @@ -10,7 +10,8 @@ "addAnnotation": "添加标注 (A)", "addSpeed": "添加速度 (S)", "addCameraFullscreen": "添加全屏摄像头 (C)", - "splitAtPlayhead": "在播放头处分割片段" + "splitAtPlayhead": "在播放头处分割片段", + "pasteAtPlayhead": "粘贴到播放头处" }, "hints": { "pressZoom": "按 Z 添加缩放", diff --git a/src/i18n/locales/zh-TW/timeline.json b/src/i18n/locales/zh-TW/timeline.json index aab736f74..07686cd8b 100644 --- a/src/i18n/locales/zh-TW/timeline.json +++ b/src/i18n/locales/zh-TW/timeline.json @@ -10,7 +10,8 @@ "addAnnotation": "新增標註 (A)", "addSpeed": "新增速度 (S)", "addCameraFullscreen": "新增全螢幕攝影機 (C)", - "splitAtPlayhead": "在播放磁頭處分割片段" + "splitAtPlayhead": "在播放磁頭處分割片段", + "pasteAtPlayhead": "貼上至播放磁頭處" }, "hints": { "pressZoom": "按 Z 新增縮放", From 99b0425b7db79d50e70efea394f649e91a8d84bc Mon Sep 17 00:00:00 2001 From: MinhOmega <49482201+MinhOmega@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:05:19 +0700 Subject: [PATCH 18/40] docs(roadmap): tick the right-click menu and correct three stale items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Upstream #24's right-click menu is ticked. The copy/paste line now says what actually shipped: copy a region, then paste a new one at the playhead. "Apply copied attributes onto an existing region" goes back in as its own unticked item. The old line was ticked for that behaviour, which never existed. - One-click cleanup is split in two. The silence and filler-word pass has shipped (`rough-cut.ts` `FILLER_WORDS`, behind Auto-enhance → Remove dead air), so that half is ticked. Voice enhancement is still open. - Upstream #19 (WebGL context loss) is retired. The preview draws the native compositor's frames into a 2D canvas, and nothing in src uses WebGL or Pixi any more. - Upstream #22 (macOS single-window cursor offset) stays open, marked "fix landed, awaiting Mac verification". `getSelectedSourceBounds` uses the window frame the capture helper reports, but no one has confirmed it on a Mac. --- ROADMAP.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 25874aadd..05fe5ac11 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -32,7 +32,8 @@ What ships today (each one opt-in, each one toggleable independently): Still open on this axis: -- [ ] **One-click cleanup** — silence trimming ships in the transcript pane, but there's no dedicated filler-word pass (only the agent names a word a filler today), and voice enhancement ("Studio Sound") isn't started. +- [x] **One-click cleanup: silences and filler words** — Auto-enhance → *Remove dead air* cuts long pauses and hesitation sounds ("um", "uh", …) from the transcript's own word timings, on this device, with no provider involved. It runs as one undo step and does not stack a second cut on a pause that is already cut. +- [ ] **One-click cleanup: voice enhancement** — cleaning up the recorded voice itself isn't started. - [ ] **Sanctioned ChatGPT / GitHub Copilot sign-in** — both were removed in 1.8.0: reaching a user's subscription meant shipping GitHub's and OpenAI's own client IDs and an editor `User-Agent` against endpoints reserved for first-party clients, from inside a signed installer. They come back on the vendors' sanctioned surfaces — GitHub's Copilot SDK (we register our own OAuth App) and `codex app-server` (drives the user's own `codex login`, no client ID shipped at all). Separate integrations, not a header swap. ## 🖥️ Rendering & platform parity @@ -54,10 +55,11 @@ Pulled from real user bug reports on upstream's tracker, [getopenscreen/openscre - [ ] **Fix:** video disappears from editor after export — [upstream #8](https://github.com/getopenscreen/openscreen/issues/8) (Linux, Manjaro). Renderer regression after export. - [ ] **Fix:** crash after stopping macOS recording — [upstream #21](https://github.com/getopenscreen/openscreen/issues/21) (macOS 26.4.1, Apple Silicon). Crash is in the Electron / Node async fs shutdown path; recording artifacts are written correctly. -- [ ] **Fix:** macOS cursor offset in single-window capture — [upstream #22](https://github.com/getopenscreen/openscreen/issues/22). -- [ ] **Fix:** recover preview from WebGL context loss on Linux / Wayland — [upstream #19](https://github.com/getopenscreen/openscreen/issues/19). -- [x] **Feature:** copy / paste attributes in the timeline — [upstream #24](https://github.com/getopenscreen/openscreen/issues/24). `Ctrl/Cmd + C` / `Ctrl/Cmd + V` copy a selected region's attributes onto another region of the same kind. -- [ ] **Feature:** right-click context menu for the copy / paste above — the other half of [upstream #24](https://github.com/getopenscreen/openscreen/issues/24), still open. +- [ ] **Fix:** macOS cursor offset in single-window capture — [upstream #22](https://github.com/getopenscreen/openscreen/issues/22). Fix landed, awaiting Mac verification: the capture helper reports the captured window's frame, and the cursor is normalised against it instead of against the display. +- ~~**Fix:** recover preview from WebGL context loss on Linux / Wayland — [upstream #19](https://github.com/getopenscreen/openscreen/issues/19).~~ Retired: the preview no longer draws through WebGL (it shows the native compositor's frames in a 2D canvas), so there is no WebGL context left to lose. +- [x] **Feature:** copy / paste regions in the timeline — [upstream #24](https://github.com/getopenscreen/openscreen/issues/24). `Ctrl/Cmd + C` copies the selected region; `Ctrl/Cmd + V` pastes a new one with the same attributes and length at the playhead. +- [x] **Feature:** right-click context menu for the copy / paste above — [upstream #24](https://github.com/getopenscreen/openscreen/issues/24). Right-click a region pill for Copy, Paste at playhead and Delete, or a clip for Split at playhead; the Menu key and `Shift + F10` open it for the selected pill. Each entry runs the same action as its shortcut and shows that shortcut. +- [ ] **Feature:** apply copied attributes onto an existing region of the same kind — [upstream #24](https://github.com/getopenscreen/openscreen/issues/24). Not built: paste only ever creates a new region at the playhead. This line was previously ticked for behaviour that never shipped. - [x] **Feature:** restore blur regions — [upstream #76](https://github.com/getopenscreen/openscreen/issues/76). Shipped as an annotation **type** rather than its own region kind: Gaussian or mosaic, rectangle or oval, composited natively in both preview and export. Freehand is deliberately not offered when creating one — its input was broken and the renderer only ever masked the bounding box, and a half-reliable privacy tool is worse than no tool, because people trust it. Existing freehand shapes still render as their bounding box, with the inspector saying so. ## 📚 Site & documentation @@ -84,3 +86,4 @@ Entries dated before 2026-09-10 are inherited from upstream's roadmap document a - **2026-07-27** — reconciled the roadmap with the code. The AI Edition tier moved from "a direction, not a sprint plan" to shipped: on-device transcription, transcript-driven editing, captions as a derived layer with translation, the chat agent, and the non-destructive project document are all in. Provider list corrected — ChatGPT and GitHub Copilot were removed in 1.8.0 and are now blocked on the vendors' sanctioned surfaces, and MiniMax was missing. New "Rendering & platform parity" tier: preview and MP4 export share one native D3D11 compositor, and porting it off Windows is now the biggest open item; #18 moved there since it's an encoder concern. Blur (#76) marked shipped — as an annotation type, not a region kind, so the old note pointing at `src/lib/exporter/videoExporter.ts` was doubly stale (that file was deleted with the web export pipeline). Copy/paste (#24) split: the shortcuts shipped, the right-click menu didn't. Docusaurus site marked shipped. - **2026-08-01** — the platform-parity tier was the stalest thing on this page: it still described the compositor as Direct3D 11 and listed MP4 export on macOS and Linux as unstarted, while v1.8.0-rc.5 was already publishing DMGs and Linux packages built on the Metal and WGSL backends. #18 (software encoder fallback) shipped with them, as an automatically-selected CPU backend rather than an encoder flag. Two real gaps replace them: Linux export is software-encoded, and every performance number on record still comes from one passive-iGPU laptop. Also corrected the framing that produced this drift — the tier was written as "porting it off Windows is the biggest open item", which stayed true in the text long after it stopped being true in the tree. - **2026-09-10** — rebranded this document to Capturia and published it on the site at `/roadmap`. Upstream's issue numbers are now linked as `upstream #N` against getopenscreen/openscreen, where they actually live: relative `../../issues/N` links resolved to *this* repo, where those numbers are unrelated PRs. Corrected the project file extension (`.capturia`; `.openscreen` and `.axcut` still open as legacy) and the site URL. Dropped the Discord section — that invite pointed at upstream's server, and Capturia does not run one; GitHub issues are the channel. +- **2026-09-11** — shipped the right-click menu for upstream #24, and corrected the copy/paste line, which had been ticked for applying attributes onto another region. Paste has only ever created a new region at the playhead, so applying attributes onto an existing region is now listed separately, unticked. Split one-click cleanup: the silence and filler-word pass has shipped as *Remove dead air*, and only voice enhancement is still open. Retired upstream #19, because nothing in the preview uses WebGL any more. Marked upstream #22 as fixed in code but unverified, because no one has confirmed it on a Mac. From b238c7b93e026c139f9030e3780e895711458ba5 Mon Sep 17 00:00:00 2001 From: MinhOmega <49482201+MinhOmega@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:59:10 +0700 Subject: [PATCH 19/40] feat(media): show real poster frames in the project list and media cards The project list drew a folder tile and a truncated id, and media cards a gradient and a film icon. Both now show a frame of the media, and each project row shows the length of its edit. Frames are grabbed by ffmpeg in the main process (electron/media/posterFrames.ts), the binary and cache-key scheme the waveform peaks already use: one small JPEG per file under userData/posters, keyed on path + size + mtime plus the frame time, so a replaced file gets a new poster. Grabs run one at a time in the background; the dialog renders at once and each poster fills in when it lands. No ffmpeg, a missing file or an undecodable one keeps today's placeholder. Media cards go through the same approved-path gate as every other media read. Project rows ask by project id instead: those projects are not open, their media was never approved, and the path is read from the project's own file under the same extension gate approveDocumentMedia applies, pinned to ffmpeg's file protocol so a URL-shaped path is never fetched. --- electron/ai-edition/document-service.ts | 22 +++ electron/electron-env.d.ts | 2 + electron/ipc/handlers.ts | 32 +++++ electron/media/audioPeaks.ts | 5 +- electron/media/posterFrames.test.ts | 71 ++++++++++ electron/media/posterFrames.ts | 144 ++++++++++++++++++++ electron/preload.ts | 7 + src/components/ai-edition/Modals.tsx | 45 ++++-- src/components/ai-edition/v4/MediaStage.tsx | 42 +++++- src/hooks/usePosterFrame.ts | 32 +++++ src/native/contracts.ts | 2 + 11 files changed, 381 insertions(+), 23 deletions(-) create mode 100644 electron/media/posterFrames.test.ts create mode 100644 electron/media/posterFrames.ts create mode 100644 src/hooks/usePosterFrame.ts diff --git a/electron/ai-edition/document-service.ts b/electron/ai-edition/document-service.ts index 40f29541a..cf743bb2a 100644 --- a/electron/ai-edition/document-service.ts +++ b/electron/ai-edition/document-service.ts @@ -21,6 +21,7 @@ import { documentSchema, migrateRawDocumentToCurrent, } from "../../src/lib/ai-edition/schema"; +import { totalVirtualDuration } from "../../src/lib/ai-edition/timeline/virtual-preview"; import { LEGACY_PROJECT_FILE_EXTENSIONS, PROJECT_FILE_EXTENSION, @@ -33,6 +34,8 @@ export interface ProjectSummary { title: string; updatedAt: string; assetCount: number; + /** Length of the edit; absent while the timeline is empty. */ + durationSec?: number; } export interface AddAssetInput { @@ -250,6 +253,7 @@ export class DocumentService { title: parsed.project.title, updatedAt: parsed.project.updatedAt, assetCount: parsed.assets.length, + durationSec: totalVirtualDuration(parsed.timeline.clips) || undefined, }); } catch (error) { // ponytail: skip unreadable files rather than failing the whole list. @@ -261,6 +265,24 @@ export class DocumentService { return summaries; } + /** + * The frame a project's poster shows: where its first clip starts, or the start + * of its first video when the timeline is empty. Null for a project with no + * video at all. + * + * Read straight from the stored JSON, without `getProject`'s relink: this runs + * once per row of the project list, and media that moved just keeps the + * placeholder until the project is opened and relinked. + */ + async posterSource(projectId: string): Promise<{ path: string; atSec: number } | null> { + const document = parseLoadedDocument(await this.readProjectFile(projectId)); + const clip = document.timeline.clips[0]; + const clipAsset = document.assets.find((a) => a.id === clip?.assetId && a.kind === "video"); + if (clipAsset) return { path: clipAsset.originalPath, atSec: clip.sourceStartSec }; + const firstVideo = document.assets.find((a) => a.kind === "video"); + return firstVideo ? { path: firstVideo.originalPath, atSec: 0 } : null; + } + /** * Read the project's JSON, preferring the canonical `.capturia` file and * falling back through the legacy spellings (newest first) so a project diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index d43e7c4bd..4f98bbe7a 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -385,6 +385,8 @@ interface Window { filePath: string, durationSec: number, ) => Promise; + getMediaPoster: (filePath: string, atSec: number) => Promise; + getProjectPoster: (projectId: string) => Promise; readFileChunk: ( filePath: string, offset: number, diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 9ce04caaa..c63057bfd 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -73,6 +73,7 @@ import { readCursorTelemetryFile as readCursorTelemetryFileFrom, } from "../media/cursorSidecar"; import { findMediaLinksByFingerprint, registerMediaLinks } from "../media/mediaLinksRegistry"; +import { getPosterFrame } from "../media/posterFrames"; import { relinkProjectMedia } from "../media/projectMediaRelinker"; import { readRecordingMarkers, writeRecordingMarkers } from "../media/recordingMarkers"; import { @@ -4150,6 +4151,18 @@ export function registerIpcHandlers( }, ); + // Poster frame for a media card (see media/posterFrames). Null keeps the + // card's placeholder: no ffmpeg, a missing file, or nothing decodable. + ipcMain.handle( + "get-media-poster", + async (_, filePath: string, atSec: number): Promise => { + // Same approval gate as every other read of a renderer-supplied path. + const normalizedPath = readableApprovedPath(filePath); + if (!normalizedPath) return null; + return getPosterFrame(normalizedPath, Number.isFinite(atSec) ? atSec : 0).catch(() => null); + }, + ); + // Cap renderer-requested chunk sizes so a buggy or compromised renderer // cannot make the main process allocate an arbitrarily large buffer. const MAX_IPC_CHUNK_BYTES = 64 * 1024 * 1024; @@ -4598,6 +4611,25 @@ export function registerIpcHandlers( approveDocumentMedia, ); + // Poster frame for a row of the project list. Keyed on the project id, not a + // path: the list shows projects that are not open, whose media was never + // approved, and approving it all just to draw thumbnails would widen every + // generic read. The path comes from the project's own file instead — the same + // trust `approveDocumentMedia` extends to a loaded document, and under the same + // extension gate — and only a thumbnail of it ever leaves this handler. + ipcMain.handle("get-project-poster", async (_, projectId: string): Promise => { + try { + const source = await aiEditionDocuments.posterSource(projectId); + const media = normalizeVideoSourcePath(source?.path); + if (!source || !media || !path.isAbsolute(media) || !hasAllowedImportVideoExtension(media)) { + return null; + } + return await getPosterFrame(media, source.atSec); + } catch { + return null; + } + }); + // LlmConfigStore is single-instance for a duller reason — its constructor does // two sync readFileSync plus a safeStorage decrypt, and it was running on every // chat message. But it must also stay UNBUILT until something actually needs it: diff --git a/electron/media/audioPeaks.ts b/electron/media/audioPeaks.ts index 4fd154138..be93406c4 100644 --- a/electron/media/audioPeaks.ts +++ b/electron/media/audioPeaks.ts @@ -264,9 +264,10 @@ async function decodePeaks( /** * Cache key: path plus size plus mtime. A recording is immutable in practice, * but keying on identity alone would serve stale peaks for a re-encoded or - * replaced file, and that failure is silent and confusing. + * replaced file, and that failure is silent and confusing. Shared with the poster + * cache (`posterFrames.ts`), which has the same staleness problem. */ -async function cacheKey(filePath: string): Promise { +export async function cacheKey(filePath: string): Promise { const info = await stat(filePath); return createHash("sha1") .update(`${filePath}:${info.size}:${info.mtimeMs}`) diff --git a/electron/media/posterFrames.test.ts b/electron/media/posterFrames.test.ts new file mode 100644 index 000000000..7505aaf79 --- /dev/null +++ b/electron/media/posterFrames.test.ts @@ -0,0 +1,71 @@ +// @vitest-environment node +import { EventEmitter } from "node:events"; +import { mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const hoisted = vi.hoisted(() => ({ + userData: "", + spawn: vi.fn(), +})); + +vi.mock("electron", () => ({ app: { getPath: () => hoisted.userData } })); +vi.mock("node:child_process", () => ({ spawn: hoisted.spawn })); +vi.mock("./audioPeaks", async (importOriginal) => ({ + ...(await importOriginal()), + resolveFfmpeg: () => "/fake/ffmpeg", +})); + +import { getPosterFrame } from "./posterFrames"; + +/** Stands in for ffmpeg: writes one "JPEG" to stdout and exits 0. */ +function fakeFfmpeg(bytes: string) { + const child = Object.assign(new EventEmitter(), { + stdout: new EventEmitter(), + stderr: new EventEmitter(), + kill: vi.fn(), + }); + setImmediate(() => { + child.stdout.emit("data", Buffer.from(bytes)); + child.emit("close", 0); + }); + return child; +} + +describe("getPosterFrame cache", () => { + const root = mkdtempSync(path.join(tmpdir(), "capturia-posters-")); + const video = path.join(root, "clip.mp4"); + + beforeEach(() => { + hoisted.userData = path.join(root, "userData"); + hoisted.spawn.mockReset(); + }); + + afterAll(() => rmSync(root, { recursive: true, force: true })); + + it("serves the cached poster until the file's size or mtime changes", async () => { + writeFileSync(video, "first encode"); + hoisted.spawn.mockImplementation(() => fakeFfmpeg("poster-1")); + const first = await getPosterFrame(video, 1); + expect(first).toBe(`data:image/jpeg;base64,${Buffer.from("poster-1").toString("base64")}`); + + // Unchanged file: straight from disk, no decode. + expect(await getPosterFrame(video, 1)).toBe(first); + expect(hoisted.spawn).toHaveBeenCalledTimes(1); + + // Same size, new mtime — a file replaced in place must not keep the old poster. + writeFileSync(video, "second encod"); + utimesSync(video, new Date(), new Date(Date.now() + 5_000)); + hoisted.spawn.mockImplementation(() => fakeFfmpeg("poster-2")); + const second = await getPosterFrame(video, 1); + expect(second).toBe(`data:image/jpeg;base64,${Buffer.from("poster-2").toString("base64")}`); + expect(hoisted.spawn).toHaveBeenCalledTimes(2); + + // A different size invalidates it too. + writeFileSync(video, "a longer third encode"); + hoisted.spawn.mockImplementation(() => fakeFfmpeg("poster-3")); + expect(await getPosterFrame(video, 1)).not.toBe(second); + expect(hoisted.spawn).toHaveBeenCalledTimes(3); + }); +}); diff --git a/electron/media/posterFrames.ts b/electron/media/posterFrames.ts new file mode 100644 index 000000000..6e70ff2cf --- /dev/null +++ b/electron/media/posterFrames.ts @@ -0,0 +1,144 @@ +import { spawn } from "node:child_process"; +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { app } from "electron"; +import { cacheKey, resolveFfmpeg } from "./audioPeaks"; + +/** + * Poster frames for the project list and the media cards: one small JPEG per + * file, grabbed by ffmpeg in the main process and cached on disk beside the + * waveform peaks. + * + * ffmpeg rather than a DOM `
+ ); +} + export async function addSelectedAssetToTimeline( selected: Pick | null, onAddToTimeline: (assetId: string) => Promise, @@ -165,12 +200,7 @@ export function MediaStage({ }} onClick={() => openDetail(asset)} > -
- -
+
{asset.id === selectedId ? ( (null); + useEffect(() => { + setPoster(null); + const api = window.electronAPI; + if (!id || !api?.getProjectPoster || !api.getMediaPoster) return; + let live = true; + const request = source === "project" ? api.getProjectPoster(id) : api.getMediaPoster(id, atSec); + request.then( + (url) => { + if (live) setPoster(url); + }, + () => undefined, + ); + return () => { + live = false; + }; + }, [source, id, atSec]); + return poster; +} diff --git a/src/native/contracts.ts b/src/native/contracts.ts index f00754b59..6b2fa6ad2 100644 --- a/src/native/contracts.ts +++ b/src/native/contracts.ts @@ -233,6 +233,8 @@ export interface AiEditionProjectSummary { title: string; updatedAt: string; assetCount: number; + /** Length of the edit; absent while the timeline is empty. */ + durationSec?: number; } export interface AiEditionAssetResult { From e20089a6b3049b365eb00dd012f3f06abcb9a3de Mon Sep 17 00:00:00 2001 From: MinhOmega <49482201+MinhOmega@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:15:27 +0700 Subject: [PATCH 20/40] feat(stt): choose the speech model in AI settings AI settings gets a Speech model section with three whisper models, each listing its download size and a speed note: Fast ggml-base-q8_0.bin 81,768,585 B Balanced ggml-small-q8_0.bin 264,464,607 B (today's model, default) Accurate ggml-large-v3-turbo-q5_0.bin 574,041,195 B All three come from the same pinned HuggingFace revision and are verified by SHA-256; the two new digests were computed by downloading the files and hashing them. Accurate shows a warning when no GPU backend is known to bind. Switching downloads and verifies the model with progress, and only then writes it to stt-models/active-model.json; a failed or interrupted download throws with the previous model still active, and an interrupted stream now deletes its .partial instead of stranding it. Models other than the active one can be deleted. Existing transcripts are untouched; the next run (so Regenerate) re-prepares, and prepare() stops the helper when the model path changed, because start() is a no-op while a helper holding the old model is up. The helper needed a matching change: DTW alignment heads are per model family, and the hard-coded WHISPER_AHEADS_SMALL makes whisper_init fail outright on a base file ("tried to set alignment head on head 10, but model only has 8 heads"). whisper-stt-server takes --dtw-preset base|small| large-v3-turbo, defaulting to small, and the Node side passes the preset from the model descriptor. A helper built before this commit ignores the flag, so Fast and Accurate need the rebuilt binary; Balanced is unaffected. --- electron/electron-env.d.ts | 6 + electron/native/whisper-stt/src/main.cpp | 21 ++- electron/preload.ts | 12 ++ electron/stt/index.test.ts | 50 +++++- electron/stt/index.ts | 111 +++++++++++-- electron/stt/modelManager.test.ts | 136 ++++++++-------- electron/stt/modelManager.ts | 142 ++++++++++++---- electron/stt/transcriptionContract.ts | 22 ++- electron/stt/whisperServer.ts | 5 +- .../ai-edition/ProviderSettings.tsx | 14 +- .../ai-edition/SpeechModelSettings.tsx | 153 ++++++++++++++++++ src/i18n/locales/ar/editor.json | 17 ++ src/i18n/locales/en/editor.json | 17 ++ src/i18n/locales/es/editor.json | 17 ++ src/i18n/locales/fr/editor.json | 17 ++ src/i18n/locales/it/editor.json | 17 ++ src/i18n/locales/ja-JP/editor.json | 17 ++ src/i18n/locales/ko-KR/editor.json | 17 ++ src/i18n/locales/pt-BR/editor.json | 17 ++ src/i18n/locales/ru/editor.json | 17 ++ src/i18n/locales/tr/editor.json | 17 ++ src/i18n/locales/vi/editor.json | 17 ++ src/i18n/locales/zh-CN/editor.json | 17 ++ src/i18n/locales/zh-TW/editor.json | 17 ++ .../transcription-and-captions.md | 19 ++- 25 files changed, 784 insertions(+), 128 deletions(-) create mode 100644 src/components/ai-edition/SpeechModelSettings.tsx diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 4f98bbe7a..14afea2ad 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -502,6 +502,12 @@ interface Window { onStatus: ( callback: (event: import("./stt/transcriptionContract").SttStatusEvent) => void, ) => () => void; + listModels: () => Promise; + setModel: (id: import("./stt/transcriptionContract").SttModelId) => Promise; + deleteModel: (id: import("./stt/transcriptionContract").SttModelId) => Promise; + onModelProgress: ( + callback: (event: import("./stt/transcriptionContract").SttModelProgressEvent) => void, + ) => () => void; }; // CLI mode (hidden runner windows; see electron/cli/) cliGetRequest: () => Promise; diff --git a/electron/native/whisper-stt/src/main.cpp b/electron/native/whisper-stt/src/main.cpp index 805178d4f..f7b213c64 100644 --- a/electron/native/whisper-stt/src/main.cpp +++ b/electron/native/whisper-stt/src/main.cpp @@ -225,6 +225,9 @@ int main(int argc, char** argv) { std::string host = "127.0.0.1"; bool host_from_flag = false; bool force_cpu = false; + // Which model family's alignment heads DTW reads. Defaults to the model this + // helper shipped with, so an older Node side that never passes it still works. + std::string dtw_preset = "small"; int port = 0; int threads = std::max(1u, std::thread::hardware_concurrency()); @@ -235,6 +238,7 @@ int main(int argc, char** argv) { else if (a == "--port" && i + 1 < argc) port = std::atoi(argv[++i]); else if (a == "--threads" && i + 1 < argc) threads = std::atoi(argv[++i]); else if (a == "--cpu") force_cpu = true; + else if (a == "--dtw-preset" && i + 1 < argc) dtw_preset = argv[++i]; } // ponytail: prefer env var (matches the prior native STT model env var // shape; the Node wrapper passes both ways). @@ -264,7 +268,20 @@ int main(int argc, char** argv) { "CAPTURIA_WHISPER_MODEL is required" << std::endl; return 2; } - log("boot: model=" + model_path + " host=" + host + + // Alignment heads are per model family, so the preset has to match the file. + // A mismatch is not a quality loss: whisper_init fails outright when the + // preset names a layer or head the model does not have (`small` on a `base` + // file: "tried to set alignment head on head 10, but model only has 8 heads"). + whisper_alignment_heads_preset aheads_preset; + if (dtw_preset == "base") aheads_preset = WHISPER_AHEADS_BASE; + else if (dtw_preset == "small") aheads_preset = WHISPER_AHEADS_SMALL; + else if (dtw_preset == "large-v3-turbo") aheads_preset = WHISPER_AHEADS_LARGE_V3_TURBO; + else { + std::cerr << "FATAL: unknown --dtw-preset " << dtw_preset + << " (expected base | small | large-v3-turbo)" << std::endl; + return 2; + } + log("boot: model=" + model_path + " dtw-preset=" + dtw_preset + " host=" + host + " port=" + (port > 0 ? std::to_string(port) : "(any)") + " threads=" + std::to_string(threads)); @@ -276,7 +293,7 @@ int main(int argc, char** argv) { // /inference handler still runs, but skipping // the request is wasted work. cparams.dtw_token_timestamps = true; - cparams.dtw_aheads_preset = WHISPER_AHEADS_SMALL; + cparams.dtw_aheads_preset = aheads_preset; whisper_context* ctx = whisper_init_from_file_with_params(model_path.c_str(), cparams); if (!ctx && cparams.use_gpu) { // Metal/Vulkan allocation can fail transiently when the editor or another diff --git a/electron/preload.ts b/electron/preload.ts index 98988da56..a6d02e223 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -8,6 +8,9 @@ import type { AiEditionChatEvent } from "../src/native/contracts"; import { NATIVE_BRIDGE_CHANNEL, type NativeBridgeRequest } from "../src/native/contracts"; import type { RecordingPrefs } from "./ipc/handlers"; import type { + SttModelId, + SttModelProgressEvent, + SttModelsSnapshot, SttStatusEvent, SttTranscribeRequest, SttTranscribeResponse, @@ -522,6 +525,15 @@ contextBridge.exposeInMainWorld("electronAPI", { ipcRenderer.on("stt:status", listener); return () => ipcRenderer.removeListener("stt:status", listener); }, + /** Speech-model settings: see `SttManager.listModels / setModel / deleteModel`. */ + listModels: (): Promise => ipcRenderer.invoke("stt:models"), + setModel: (id: SttModelId): Promise => ipcRenderer.invoke("stt:set-model", id), + deleteModel: (id: SttModelId): Promise => ipcRenderer.invoke("stt:delete-model", id), + onModelProgress: (callback: (event: SttModelProgressEvent) => void) => { + const listener = (_event: unknown, payload: SttModelProgressEvent) => callback(payload); + ipcRenderer.on("stt:model-progress", listener); + return () => ipcRenderer.removeListener("stt:model-progress", listener); + }, }, // --- CLI mode (hidden runner windows; see electron/cli/) --- cliGetRequest: (): Promise => { diff --git a/electron/stt/index.test.ts b/electron/stt/index.test.ts index 69ef1723b..a14995699 100644 --- a/electron/stt/index.test.ts +++ b/electron/stt/index.test.ts @@ -1,3 +1,6 @@ +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest"; import { planChunks } from "./chunking"; @@ -32,11 +35,11 @@ vi.mock("./whisperServer", () => { return { WhisperServerManager: FakeWhisperServerManager }; }); -vi.mock("./modelManager", () => ({ +// The real module, minus the download: a test that wants one opts back in with +// `mockImplementationOnce(actual.ensureModels)`. +vi.mock("./modelManager", async (importOriginal) => ({ + ...(await importOriginal()), ensureModels: vi.fn(async () => undefined), - modelPaths: (base: string) => ({ - whisper: `${base}/whisper-ggml/ggml-small-q8_0.bin`, - }), })); vi.mock("./gpuDetector", () => ({ @@ -470,6 +473,45 @@ describe("SttManager", () => { expect(fakeWhisperServer.start).toHaveBeenCalledOnce(); }); + it("keeps the previous model active when switching models fails mid-download", async () => { + const actual = await vi.importActual("./modelManager"); + const { ensureModels } = await import("./modelManager"); + const dir = mkdtempSync(path.join(tmpdir(), "capturia-stt-switch-")); + // The connection drops after the first KiB of the new model. + let sent = false; + const fetchStub = vi.fn( + async () => + new Response( + new ReadableStream({ + pull(controller) { + if (sent) controller.error(new Error("connection reset")); + else controller.enqueue(new Uint8Array(1024)); + sent = true; + }, + }), + ), + ); + vi.stubGlobal("fetch", fetchStub); + try { + const mgr = new SttManager(); + await mgr.init({ modelsBaseDir: dir }); + // Only the switch downloads for real; the init above kept the no-op. + vi.mocked(ensureModels).mockImplementationOnce(actual.ensureModels); + + await expect(mgr.setModel("accurate")).rejects.toThrow("connection reset"); + + expect(fetchStub).toHaveBeenCalledOnce(); + expect((await mgr.listModels()).active).toBe("balanced"); + // Nothing half-written survives, under either name, to pass for the model later. + const target = actual.modelPath(dir, "accurate"); + expect(existsSync(target)).toBe(false); + expect(existsSync(`${target}.partial`)).toBe(false); + } finally { + vi.unstubAllGlobals(); + rmSync(dir, { recursive: true, force: true }); + } + }); + it("fans status out to every sink, and detaching one leaves the others", async () => { const mgr = new SttManager(); const a = vi.fn<(e: SttStatusEvent) => void>(); diff --git a/electron/stt/index.ts b/electron/stt/index.ts index a20f4e577..bb19cacea 100644 --- a/electron/stt/index.ts +++ b/electron/stt/index.ts @@ -1,9 +1,23 @@ +import { rm } from "node:fs/promises"; import path from "node:path"; import { app, type IpcMain } from "electron"; import { planChunks } from "./chunking"; import { extractMono16kPcm } from "./extractAudio"; -import { ensureModels, modelPaths } from "./modelManager"; +import { detectGpuBackend } from "./gpuDetector"; +import { + ensureModels, + isModelPresent, + isSttModelId, + modelPath, + readActiveModel, + STT_MODELS, + type SttModelId, + writeActiveModel, +} from "./modelManager"; import type { + SttBackend, + SttModelProgressEvent, + SttModelsSnapshot, SttPhraseSegment, SttStatusEvent, SttTiming, @@ -11,7 +25,7 @@ import type { SttTranscribeResponse, SttWordSegment, } from "./transcriptionContract"; -import { WhisperServerManager } from "./whisperServer"; +import { WhisperServerManager, type WhisperServerStartOptions } from "./whisperServer"; /** * Owner of the long-lived STT pipeline. One instance per Electron app. @@ -96,7 +110,9 @@ export class SttManager { private readonly statusSinks = new Set<(event: SttStatusEvent) => void>(); private initPromise: Promise | null = null; /** Kept from `prepare()` so a chunk retry can respawn a helper that died mid-run. */ - private modelPath: string | null = null; + private model: WhisperServerStartOptions | null = null; + /** What the last run actually bound; the settings' CPU warning trusts it over a guess. */ + private lastBackend: SttBackend | null = null; /** * Bumped by `cancel()`. The chunk loop compares it against the value it * captured on entry, so a cancel that lands after a new run started cannot @@ -178,9 +194,11 @@ export class SttManager { private async prepare(): Promise { const modelsDir = this.getModelsDir(); - this.emit({ phase: "model", model: "whisper", downloadedBytes: 0, totalBytes: 0 }); + const id = await readActiveModel(modelsDir); + this.emit({ phase: "model", model: id, downloadedBytes: 0, totalBytes: 0 }); await ensureModels({ baseDir: modelsDir, + only: [id], onProgress: (event) => { this.emit({ phase: "model", @@ -192,10 +210,13 @@ export class SttManager { }); if (this.shuttingDown) throw cancelledError(); - const paths = modelPaths(modelsDir); - this.modelPath = paths.whisper; + const model = { modelPath: modelPath(modelsDir, id), dtwPreset: STT_MODELS[id].dtwPreset }; + // `start()` is a no-op while a helper is up, and that helper holds the model + // it was spawned with — after a switch it has to go before the new one loads. + if (this.model && this.model.modelPath !== model.modelPath) await this.server.stop(); + this.model = model; try { - await this.server.start({ modelPath: paths.whisper }); + await this.server.start(model); } catch (error) { if (this.shuttingDown) throw cancelledError(); throw error; @@ -235,8 +256,8 @@ export class SttManager { lastError = error; if (this.shuttingDown) throw cancelledError(); if (attempt === CHUNK_ATTEMPTS) break; - if (this.modelPath) { - await this.server.start({ modelPath: this.modelPath }).catch(() => undefined); + if (this.model) { + await this.server.start(this.model).catch(() => undefined); } if (this.shuttingDown) throw cancelledError(); await new Promise((resolve) => setTimeout(resolve, 500 * attempt)); @@ -405,6 +426,7 @@ export class SttManager { // // The per-chunk `rtf` emitted above is deliberately not held to this: it is // a RATIO, not a total, and stays honest over whatever subset reported. + this.lastBackend = backend; const timing: SttTiming | undefined = timedChunks > 0 && untimedChunks === 0 && audioSec > 0 ? { elapsedSec, audioSec, rtf: elapsedSec / audioSec } @@ -426,6 +448,64 @@ export class SttManager { }; } + /** The speech-model settings: the active model, what is downloaded, and the CPU verdict. */ + async listModels(): Promise { + const modelsDir = this.getModelsDir(); + const ids = Object.keys(STT_MODELS) as SttModelId[]; + const models = await Promise.all( + ids.map(async (id) => ({ + id, + bytes: STT_MODELS[id].files[0].approximateBytes, + downloaded: await isModelPresent(modelsDir, id), + })), + ); + // Before any run this is only the platform's guess (a Vulkan binary on + // Windows/Linux may still fall back to CPU); after one it is what bound. + const backend = this.lastBackend ?? (await detectGpuBackend()).backend; + return { + active: await readActiveModel(modelsDir), + models, + cpuOnly: backend === "whispercpp-cpu", + }; + } + + /** + * Download and verify `id`, then make it the model the next run loads. + * + * The switch is written only once the file is on disk under its final name, + * which `ensureModels` does only after the digest matched — so a failed, + * cancelled or interrupted download throws out of here with the previous model + * still active and nothing half-written left to pass for the new one. + * + * A run already in flight finishes on the model it started with; clearing + * `initPromise` makes the next one re-prepare, and `prepare()` swaps the helper. + */ + async setModel( + id: SttModelId, + onProgress?: (event: SttModelProgressEvent) => void, + ): Promise { + const modelsDir = this.getModelsDir(); + await ensureModels({ + baseDir: modelsDir, + only: [id], + onProgress: (event) => + onProgress?.({ id, downloadedBytes: event.downloadedBytes, totalBytes: event.totalBytes }), + }); + await writeActiveModel(modelsDir, id); + this.initPromise = null; + } + + /** Remove a downloaded model other than the active one. */ + async deleteModel(id: SttModelId): Promise { + const modelsDir = this.getModelsDir(); + if (id === (await readActiveModel(modelsDir))) { + throw new Error("The active speech model cannot be deleted"); + } + const file = modelPath(modelsDir, id); + await rm(file, { force: true }); + await rm(`${file}.partial`, { force: true }); + } + /** Best-effort shutdown; safe to call from `before-quit` hooks. */ async shutdown(): Promise { if (this.shuttingDown) return; @@ -494,4 +574,17 @@ export function registerSttIpc(ipcMain: IpcMain): void { ipcMain.handle("stt:cancel", () => { manager.cancel(); }); + ipcMain.handle("stt:models", () => manager.listModels()); + // Progress on its own channel: `stt:status` is read by a transcription in + // flight, which would take a settings download for its own. + ipcMain.handle("stt:set-model", async (event, id: unknown) => { + if (!isSttModelId(id)) throw new Error(`Unknown speech model: ${String(id)}`); + await manager.setModel(id, (progress) => { + if (!event.sender.isDestroyed()) event.sender.send("stt:model-progress", progress); + }); + }); + ipcMain.handle("stt:delete-model", async (_event, id: unknown) => { + if (!isSttModelId(id)) throw new Error(`Unknown speech model: ${String(id)}`); + await manager.deleteModel(id); + }); } diff --git a/electron/stt/modelManager.test.ts b/electron/stt/modelManager.test.ts index e5f186fbd..dcd137fcd 100644 --- a/electron/stt/modelManager.test.ts +++ b/electron/stt/modelManager.test.ts @@ -4,7 +4,7 @@ import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises" import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { areModelsPresent, ensureModels, modelPaths, STT_MODELS } from "./modelManager"; +import { ensureModels, isModelPresent, modelPath, STT_MODELS } from "./modelManager"; describe("modelManager", () => { let dir: string; @@ -15,45 +15,47 @@ describe("modelManager", () => { await rm(dir, { recursive: true, force: true }); }); - it("exposes the whisper model descriptor with a single GGML file", () => { - expect(STT_MODELS.whisper.cacheDir).toBe("whisper-ggml"); - expect(STT_MODELS.whisper.repoId).toBe("ggerganov/whisper.cpp"); - expect(STT_MODELS.whisper.files.length).toBe(1); - expect(STT_MODELS.whisper.files[0].name).toBe("ggml-small-q8_0.bin"); - expect(STT_MODELS.whisper.files[0].expectedSha256).not.toBeNull(); - for (const f of STT_MODELS.whisper.files) { - expect(f.approximateBytes).toBeGreaterThan(0); - expect(f.url).toContain("huggingface.co"); - // Pinned to an immutable commit: resolving through `main` would let a - // re-upload invalidate every cached model in the field at once. - expect(f.url).toMatch(/\/resolve\/[0-9a-f]{40}\//); + it("pins every model to one GGML file with a SHA-256, and keeps small as balanced", () => { + expect(STT_MODELS.balanced.files[0].name).toBe("ggml-small-q8_0.bin"); + for (const model of Object.values(STT_MODELS)) { + expect(model.cacheDir).toBe("whisper-ggml"); + expect(model.repoId).toBe("ggerganov/whisper.cpp"); + expect(model.files.length).toBe(1); + for (const f of model.files) { + expect(f.expectedSha256).toMatch(/^[0-9a-f]{64}$/i); + expect(f.approximateBytes).toBeGreaterThan(0); + expect(f.url).toContain("huggingface.co"); + // Pinned to an immutable commit: resolving through `main` would let a + // re-upload invalidate every cached model in the field at once. + expect(f.url).toMatch(/\/resolve\/[0-9a-f]{40}\//); + } } }); - it("modelPaths places the GGML file under the cache directory", () => { - const paths = modelPaths(dir); - expect(paths.whisper).toBe(path.join(dir, "whisper-ggml", "ggml-small-q8_0.bin")); + it("modelPath places the GGML file under the cache directory", () => { + const file = modelPath(dir, "balanced"); + expect(file).toBe(path.join(dir, "whisper-ggml", "ggml-small-q8_0.bin")); }); - it("areModelsPresent returns false when the model file is missing", async () => { - expect(await areModelsPresent(dir)).toBe(false); + it("isModelPresent returns false when the model file is missing", async () => { + expect(await isModelPresent(dir, "balanced")).toBe(false); }); - it("areModelsPresent returns true once the GGML file is present", async () => { - const paths = modelPaths(dir); - await mkdir(path.dirname(paths.whisper), { recursive: true }); - expect(await areModelsPresent(dir)).toBe(false); - await writeFile(paths.whisper, "dummy-ggml"); - expect(await areModelsPresent(dir)).toBe(true); + it("isModelPresent returns true once the GGML file is present", async () => { + const file = modelPath(dir, "balanced"); + await mkdir(path.dirname(file), { recursive: true }); + expect(await isModelPresent(dir, "balanced")).toBe(false); + await writeFile(file, "dummy-ggml"); + expect(await isModelPresent(dir, "balanced")).toBe(true); }); it("ensureModels succeeds when the file is already present (cache hit)", async () => { - const paths = modelPaths(dir); - await mkdir(path.dirname(paths.whisper), { recursive: true }); + const file = modelPath(dir, "balanced"); + await mkdir(path.dirname(file), { recursive: true }); const cached = Buffer.from("dummy-ggml"); - await writeFile(paths.whisper, cached); - const originalSha = STT_MODELS.whisper.files[0].expectedSha256; - STT_MODELS.whisper.files[0].expectedSha256 = createHash("sha256").update(cached).digest("hex"); + await writeFile(file, cached); + const originalSha = STT_MODELS.balanced.files[0].expectedSha256; + STT_MODELS.balanced.files[0].expectedSha256 = createHash("sha256").update(cached).digest("hex"); let fetches = 0; const fetcher: typeof fetch = async () => { fetches++; @@ -62,23 +64,23 @@ describe("modelManager", () => { try { await ensureModels({ baseDir: dir, - only: ["whisper"], + only: ["balanced"], fetcher, onProgress: () => undefined, }); expect(fetches).toBe(0); } finally { - STT_MODELS.whisper.files[0].expectedSha256 = originalSha; + STT_MODELS.balanced.files[0].expectedSha256 = originalSha; } }); it("re-downloads a non-empty cached model when its checksum is wrong", async () => { - const paths = modelPaths(dir); - await mkdir(path.dirname(paths.whisper), { recursive: true }); - await writeFile(paths.whisper, "corrupt-cache"); + const file = modelPath(dir, "balanced"); + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, "corrupt-cache"); const replacement = Buffer.from("verified-ggml-weights"); - const originalSha = STT_MODELS.whisper.files[0].expectedSha256; - STT_MODELS.whisper.files[0].expectedSha256 = createHash("sha256") + const originalSha = STT_MODELS.balanced.files[0].expectedSha256; + STT_MODELS.balanced.files[0].expectedSha256 = createHash("sha256") .update(replacement) .digest("hex"); let fetches = 0; @@ -88,63 +90,63 @@ describe("modelManager", () => { }; try { - await ensureModels({ baseDir: dir, only: ["whisper"], fetcher }); + await ensureModels({ baseDir: dir, only: ["balanced"], fetcher }); expect(fetches).toBe(1); - expect(await readFile(paths.whisper)).toEqual(replacement); + expect(await readFile(file)).toEqual(replacement); // The stale copy is displaced by the atomic rename, not quarantined // beside it: a `.bad` sibling would strand 264 MB nothing ever reaps. - expect(existsSync(`${paths.whisper}.bad`)).toBe(false); - expect(existsSync(`${paths.whisper}.partial`)).toBe(false); + expect(existsSync(`${file}.bad`)).toBe(false); + expect(existsSync(`${file}.partial`)).toBe(false); } finally { - STT_MODELS.whisper.files[0].expectedSha256 = originalSha; + STT_MODELS.balanced.files[0].expectedSha256 = originalSha; } }); it("never lets a mismatching download occupy the live model path", async () => { - const paths = modelPaths(dir); - const originalSha = STT_MODELS.whisper.files[0].expectedSha256; - STT_MODELS.whisper.files[0].expectedSha256 = createHash("sha256") + const file = modelPath(dir, "balanced"); + const originalSha = STT_MODELS.balanced.files[0].expectedSha256; + STT_MODELS.balanced.files[0].expectedSha256 = createHash("sha256") .update("the-weights-we-asked-for") .digest("hex"); const served = Buffer.from("truncated-or-tampered-weights"); const fetcher: typeof fetch = async () => new Response(served, { status: 200 }); try { - await expect(ensureModels({ baseDir: dir, only: ["whisper"], fetcher })).rejects.toThrow( + await expect(ensureModels({ baseDir: dir, only: ["balanced"], fetcher })).rejects.toThrow( /SHA-256 mismatch/, ); - expect(existsSync(paths.whisper)).toBe(false); - expect(existsSync(`${paths.whisper}.partial`)).toBe(false); + expect(existsSync(file)).toBe(false); + expect(existsSync(`${file}.partial`)).toBe(false); } finally { - STT_MODELS.whisper.files[0].expectedSha256 = originalSha; + STT_MODELS.balanced.files[0].expectedSha256 = originalSha; } }); it("keeps the cached model when the replacement download also mismatches", async () => { - const paths = modelPaths(dir); - await mkdir(path.dirname(paths.whisper), { recursive: true }); - await writeFile(paths.whisper, "the-only-copy-the-user-has"); - const originalSha = STT_MODELS.whisper.files[0].expectedSha256; - STT_MODELS.whisper.files[0].expectedSha256 = createHash("sha256") + const file = modelPath(dir, "balanced"); + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, "the-only-copy-the-user-has"); + const originalSha = STT_MODELS.balanced.files[0].expectedSha256; + STT_MODELS.balanced.files[0].expectedSha256 = createHash("sha256") .update("the-weights-we-asked-for") .digest("hex"); const fetcher: typeof fetch = async () => new Response(Buffer.from("also-wrong"), { status: 200 }); try { - await expect(ensureModels({ baseDir: dir, only: ["whisper"], fetcher })).rejects.toThrow( + await expect(ensureModels({ baseDir: dir, only: ["balanced"], fetcher })).rejects.toThrow( /SHA-256 mismatch/, ); - expect(await readFile(paths.whisper, "utf8")).toBe("the-only-copy-the-user-has"); + expect(await readFile(file, "utf8")).toBe("the-only-copy-the-user-has"); } finally { - STT_MODELS.whisper.files[0].expectedSha256 = originalSha; + STT_MODELS.balanced.files[0].expectedSha256 = originalSha; } }); it("ensureModels downloads the missing GGML file with progress", async () => { - const paths = modelPaths(dir); - const originalSha = STT_MODELS.whisper.files[0].expectedSha256; - STT_MODELS.whisper.files[0].expectedSha256 = null; + const file = modelPath(dir, "balanced"); + const originalSha = STT_MODELS.balanced.files[0].expectedSha256; + STT_MODELS.balanced.files[0].expectedSha256 = null; const progressCalls: Array<{ id: string; @@ -165,7 +167,7 @@ describe("modelManager", () => { try { await ensureModels({ baseDir: dir, - only: ["whisper"], + only: ["balanced"], fetcher, onProgress: (ev) => { progressCalls.push({ @@ -177,12 +179,12 @@ describe("modelManager", () => { }); expect(fetches).toBe(1); - const s = await stat(paths.whisper); + const s = await stat(file); expect(s.size).toBeGreaterThan(0); expect(progressCalls.length).toBeGreaterThanOrEqual(1); expect(progressCalls[0].file).toBe("ggml-small-q8_0.bin"); } finally { - STT_MODELS.whisper.files[0].expectedSha256 = originalSha; + STT_MODELS.balanced.files[0].expectedSha256 = originalSha; } }); @@ -199,7 +201,7 @@ describe("modelManager", () => { await expect( ensureModels({ baseDir: dir, - only: ["whisper"], + only: ["balanced"], fetcher, onProgress: () => undefined, }), @@ -209,8 +211,8 @@ describe("modelManager", () => { }); it("ensureModels retries transient 5xx errors with bounded backoff", async () => { - const originalSha = STT_MODELS.whisper.files[0].expectedSha256; - STT_MODELS.whisper.files[0].expectedSha256 = null; + const originalSha = STT_MODELS.balanced.files[0].expectedSha256; + STT_MODELS.balanced.files[0].expectedSha256 = null; const attempts: number[] = []; const fetcher: typeof fetch = async () => { attempts.push(attempts.length + 1); @@ -226,13 +228,13 @@ describe("modelManager", () => { try { await ensureModels({ baseDir: dir, - only: ["whisper"], + only: ["balanced"], fetcher, onProgress: () => undefined, }); expect(attempts).toHaveLength(2); } finally { - STT_MODELS.whisper.files[0].expectedSha256 = originalSha; + STT_MODELS.balanced.files[0].expectedSha256 = originalSha; } }); }); diff --git a/electron/stt/modelManager.ts b/electron/stt/modelManager.ts index cb13fd31f..cc39ec315 100644 --- a/electron/stt/modelManager.ts +++ b/electron/stt/modelManager.ts @@ -1,31 +1,33 @@ import { createHash } from "node:crypto"; import { createReadStream, existsSync } from "node:fs"; -import { mkdir, rename, rm, stat } from "node:fs/promises"; +import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; +import type { SttModelId } from "./transcriptionContract"; + +export type { SttModelId } from "./transcriptionContract"; /** - * Manages the lifetime of the on-disk model artifact used by the STT stack. + * Manages the lifetime of the on-disk model artifacts used by the STT stack. * - * The model is a single GGML file downloaded from HuggingFace + * Each model is a single GGML file downloaded from HuggingFace * (`ggerganov/whisper.cpp` — the model-file repo predates and is separate * from the `ggml-org` GitHub org the engine itself now lives under; * `ggml-org/whisper.cpp` on HuggingFace is a different, access-gated repo * and returns 401 on every file including README.md — confirmed by curl). - * whisper.cpp bakes precision into the file, so - * there is no runtime `--int8` flag; OpenScreen ships the q8_0 quantized - * `small` multilingual model by default. + * whisper.cpp bakes precision into the file, so there is no runtime `--int8` + * flag. The user picks one of three in AI settings; `balanced`, the q8_0 + * quantized `small` multilingual model, is the default and what every install + * before the choice existed was running. * - * The file is verified by SHA-256 and written atomically (via .partial rename) + * Every file is verified by SHA-256 and written atomically (via .partial rename) * to prevent partial downloads from being treated as complete. * * Word timestamps come from whisper.cpp's native DTW token timestamps, so no * separate VAD model is required. See `technical-documentation/architecture/transcription-and-captions.md`. */ -export type SttModelId = "whisper"; - export interface SttModelFile { /** Relative path within the model directory (e.g. "ggml-small-q8_0.bin"). */ name: string; @@ -42,6 +44,11 @@ export interface SttModelDescriptor { cacheDir: string; /** HuggingFace repo identifier (e.g. "ggerganov/whisper.cpp"). */ repoId: string; + /** + * The helper's `--dtw-preset`: which model family's alignment heads DTW reads. + * It has to match the file — a mismatched preset makes whisper_init fail. + */ + dtwPreset: "base" | "small" | "large-v3-turbo"; /** List of model files to download (currently a single GGML file). */ files: SttModelFile[]; } @@ -54,48 +61,111 @@ const MODEL_BASE = "https://huggingface.co"; // long-standing public model-file repo that never moved when the engine's // GitHub org was renamed. const MODEL_REPO = "ggerganov/whisper.cpp"; -const MODEL_FILE = "ggml-small-q8_0.bin"; // Pinned to a commit rather than `main` so `expectedSha256` is an invariant and // not a bet: `main` is a mutable branch pointer, and a re-upload under it would // now invalidate every cache in the field at once instead of merely breaking new -// installs. This revision was checked against HuggingFace's paths-info API — its -// LFS oid for MODEL_FILE is exactly the digest below. +// installs. Every digest below was computed by downloading the file from this +// revision and hashing it, and matches the LFS oid HuggingFace's paths-info API +// reports for it. const MODEL_REVISION = "5359861c739e955e79d9a303bcbc70fb988958b1"; +function ggmlFile(name: string, expectedSha256: string, bytes: number): SttModelFile { + return { + name, + url: `${MODEL_BASE}/${MODEL_REPO}/resolve/${MODEL_REVISION}/${name}`, + expectedSha256, + approximateBytes: bytes, + }; +} + +// Sizes are the exact byte counts of the pinned files. q8_0 for the two small +// models, where the saving from a coarser quantization is tens of MB and the +// accuracy cost is not; q5_0 for turbo, a large model that tolerates it and +// where q8_0 would add 300 MB. export const STT_MODELS: Record = { - whisper: { + fast: { + cacheDir: "whisper-ggml", + repoId: MODEL_REPO, + dtwPreset: "base", + files: [ + ggmlFile( + "ggml-base-q8_0.bin", + "c577b9a86e7e048a0b7eada054f4dd79a56bbfa911fbdacf900ac5b567cbb7d9", + 81_768_585, + ), + ], + }, + balanced: { + cacheDir: "whisper-ggml", + repoId: MODEL_REPO, + dtwPreset: "small", + files: [ + ggmlFile( + "ggml-small-q8_0.bin", + "49C8FB02B65E6049D5FA6C04F81F53B867B5EC9540406812C643F177317F779F", + 264_464_607, + ), + ], + }, + accurate: { cacheDir: "whisper-ggml", repoId: MODEL_REPO, + dtwPreset: "large-v3-turbo", files: [ - { - name: MODEL_FILE, - url: `${MODEL_BASE}/${MODEL_REPO}/resolve/${MODEL_REVISION}/${MODEL_FILE}`, - expectedSha256: "49C8FB02B65E6049D5FA6C04F81F53B867B5EC9540406812C643F177317F779F", - approximateBytes: 264_000_000, - }, + ggmlFile( + "ggml-large-v3-turbo-q5_0.bin", + "394221709cd5ad1f40c46e6031ca61bce88931e6e088c188294c6d5a55ffa7e2", + 574_041_195, + ), ], }, }; -export function modelPaths(baseDir: string): Record { - return { - whisper: path.join(baseDir, STT_MODELS.whisper.cacheDir, MODEL_FILE), - }; +/** The model a fresh install runs, and the one every install ran before the choice existed. */ +export const DEFAULT_STT_MODEL: SttModelId = "balanced"; + +export function isSttModelId(value: unknown): value is SttModelId { + return typeof value === "string" && (Object.keys(STT_MODELS) as string[]).includes(value); +} + +export function modelPath(baseDir: string, id: SttModelId): string { + const { cacheDir, files } = STT_MODELS[id]; + return path.join(baseDir, cacheDir, files[0].name); } /** - * True when the GGML model file exists and is non-empty. + * True when the model's GGML file exists and is non-empty. Only ever a final, + * verified file can sit at that path (see `ensureFile`), and `ensureModels` + * re-verifies it before any run loads it. */ -export async function areModelsPresent(baseDir: string): Promise { - const paths = modelPaths(baseDir); +export async function isModelPresent(baseDir: string, id: SttModelId): Promise { try { - const s = await stat(paths.whisper); + const s = await stat(modelPath(baseDir, id)); return s.isFile() && s.size > 0; } catch { return false; } } +const ACTIVE_MODEL_FILE = "active-model.json"; + +/** The model transcription loads. Anything unreadable falls back to the default. */ +export async function readActiveModel(baseDir: string): Promise { + try { + const raw = JSON.parse(await readFile(path.join(baseDir, ACTIVE_MODEL_FILE), "utf8")) as { + id?: unknown; + }; + return isSttModelId(raw.id) ? raw.id : DEFAULT_STT_MODEL; + } catch { + return DEFAULT_STT_MODEL; + } +} + +export async function writeActiveModel(baseDir: string, id: SttModelId): Promise { + await mkdir(baseDir, { recursive: true }); + await writeFile(path.join(baseDir, ACTIVE_MODEL_FILE), `${JSON.stringify({ id })}\n`, "utf8"); +} + /** Verify SHA-256 of a file in 64 KiB chunks; resolves to the lowercase hex digest. */ export async function sha256OfFile(filePath: string): Promise { const hash = createHash("sha256"); @@ -196,7 +266,15 @@ async function ensureFile( options.onProgress?.(downloaded); }); const { createWriteStream } = await import("node:fs"); - await pipeline(source, createWriteStream(tmp)); + try { + await pipeline(source, createWriteStream(tmp)); + } catch (error) { + // A dropped connection must not strand hundreds of MB of `.partial`. It + // could never pass as the model anyway — only the rename below creates + // that name, and only after the digest matched. + await rm(tmp, { force: true }).catch(() => undefined); + throw error; + } if (expectedSha256) { const actual = await sha256OfFile(tmp); @@ -217,7 +295,7 @@ async function ensureFile( export interface EnsureModelsOptions { baseDir: string; - /** Models to ensure; defaults to all (currently just `whisper`). */ + /** Models to ensure; defaults to `DEFAULT_STT_MODEL`. */ only?: SttModelId[]; onProgress?: (event: { id: SttModelId; @@ -228,12 +306,12 @@ export interface EnsureModelsOptions { fetcher?: typeof fetch; } -/** Ensure the GGML model file is present locally; downloads with progress + retry. */ +/** Ensure the GGML model files are present locally; downloads with progress + retry. */ export async function ensureModels(opts: EnsureModelsOptions): Promise { - const targets = (opts.only ?? (["whisper"] as SttModelId[])).map((id) => ({ + const targets = (opts.only ?? [DEFAULT_STT_MODEL]).map((id) => ({ id, descriptor: STT_MODELS[id], - filePath: modelPaths(opts.baseDir)[id], + filePath: modelPath(opts.baseDir, id), })); for (const { id, descriptor, filePath } of targets) { diff --git a/electron/stt/transcriptionContract.ts b/electron/stt/transcriptionContract.ts index d2e67ae11..fcc4bcf7b 100644 --- a/electron/stt/transcriptionContract.ts +++ b/electron/stt/transcriptionContract.ts @@ -11,7 +11,7 @@ */ /** A word-level segment with timestamps from whisper.cpp's native DTW token - * timestamps (`t_dtw`, computed with the SMALL aheads preset — see + * timestamps (`t_dtw`, computed with the aheads preset of the loaded model — see * technical-documentation/architecture/transcription-and-captions.md § Decision rationale). Absolute seconds * in the source recording. */ export interface SttWordSegment { @@ -60,6 +60,24 @@ export interface SttTiming { rtf: number; } +/** Speech model choice, in the order the settings list them (see `STT_MODELS`). */ +export type SttModelId = "fast" | "balanced" | "accurate"; + +/** What the speech-model settings show: which model runs, and what is on disk. */ +export interface SttModelsSnapshot { + active: SttModelId; + models: { id: SttModelId; bytes: number; downloaded: boolean }[]; + /** No GPU backend is known to bind, so the bigger models run slowly. */ + cpuOnly: boolean; +} + +/** Progress of a speech-model download started from the settings (`stt:set-model`). */ +export interface SttModelProgressEvent { + id: SttModelId; + downloadedBytes: number; + totalBytes: number; +} + /** Status phase the renderer surfaces over `onStatus("model" | "transcribe")`. */ export type SttStatusPhase = "model" | "transcribe"; @@ -71,7 +89,7 @@ export interface SttStatusEvent { /** Total bytes for the in-flight download. */ totalBytes?: number; /** Which model is downloading. */ - model?: "whisper"; + model?: SttModelId; /** * Seconds of audio transcribed so far, and the total for this request. Only * when `phase === "transcribe"`. Progress is reported per CHUNK (see diff --git a/electron/stt/whisperServer.ts b/electron/stt/whisperServer.ts index 950dc879c..9f9fe9f60 100644 --- a/electron/stt/whisperServer.ts +++ b/electron/stt/whisperServer.ts @@ -47,7 +47,7 @@ const REQUEST_TIMEOUT_MS = 280_000; * and the renderer doesn't move. * * Word timestamps come from whisper.cpp's native DTW token timestamps - * (`t_dtw`, SMALL aheads preset, `flash_attn = false` so DTW is actually + * (`t_dtw`, the loaded model's aheads preset, `flash_attn = false` so DTW is actually * computed). The helper returns them already absolute, so no segment-offset * arithmetic is required. * @@ -59,6 +59,8 @@ const REQUEST_TIMEOUT_MS = 280_000; export interface WhisperServerStartOptions { /** Absolute path to the GGML model file (e.g. ggml-small-q8_0.bin). */ modelPath: string; + /** The model family's alignment heads (`STT_MODELS[id].dtwPreset`); the helper assumes `small`. */ + dtwPreset?: string; /** Externally-resolved binary path (skips gpuDetector on startup); null = auto. */ binaryPath?: string | null; /** Externally-resolved backend (logs only); null = auto. */ @@ -271,6 +273,7 @@ export class WhisperServerManager { "--threads", String(Math.max(1, os.cpus().length)), ]; + if (options.dtwPreset) args.push("--dtw-preset", options.dtwPreset); if (forceCpu) args.push("--cpu"); const child = spawn(binaryPath, args, { stdio: ["ignore", "pipe", "pipe"] }); const activeBackend: SttBackend = forceCpu ? "whispercpp-cpu" : resolved.backend; diff --git a/src/components/ai-edition/ProviderSettings.tsx b/src/components/ai-edition/ProviderSettings.tsx index 8d66681c7..f9ea7d729 100644 --- a/src/components/ai-edition/ProviderSettings.tsx +++ b/src/components/ai-edition/ProviderSettings.tsx @@ -31,6 +31,7 @@ import { } from "../../../electron/ai-edition/provider-registry"; import { ModalShell } from "./Modals"; import styles from "./NewEditorShell.module.css"; +import { SpeechModelSettings } from "./SpeechModelSettings"; type Mode = "list" | "form"; @@ -177,11 +178,14 @@ function ProviderSettings({ open, onClose }: ProviderSettingsProps) { wide > {mode === "list" ? ( - + <> + + + ) : active ? ( window.electronAPI?.stt; + +export function SpeechModelSettings() { + const te = useScopedT("editor"); + const [snapshot, setSnapshot] = useState(null); + /** The model being downloaded, and how far along it is (0-1). */ + const [pending, setPending] = useState<{ id: SttModelId; fraction: number } | null>(null); + + const refresh = useCallback(async () => { + const stt = sttBridge(); + if (stt?.listModels) setSnapshot(await stt.listModels().catch(() => null)); + }, []); + useEffect(() => { + void refresh(); + }, [refresh]); + useEffect( + () => + sttBridge()?.onModelProgress?.(({ id, downloadedBytes, totalBytes }) => + setPending({ id, fraction: totalBytes > 0 ? downloadedBytes / totalBytes : 0 }), + ), + [], + ); + + const stt = sttBridge(); + // No STT bridge (browser preview, tests): nothing to choose between. + if (!stt || !snapshot) return null; + + const use = async (id: SttModelId) => { + setPending({ id, fraction: 0 }); + try { + await stt.setModel(id); + } catch (err) { + toast.error(te("speechModel.switchFailed"), { + description: err instanceof Error ? err.message : String(err), + }); + } finally { + setPending(null); + await refresh(); + } + }; + + const remove = async (id: SttModelId) => { + try { + await stt.deleteModel(id); + } catch (err) { + toast.error(te("speechModel.deleteFailed"), { + description: err instanceof Error ? err.message : String(err), + }); + } + await refresh(); + }; + + return ( +
+

+ {te("speechModel.title")} +

+

+ {te("speechModel.hint")} +

+
+ {snapshot.models.map((model) => { + const isActive = model.id === snapshot.active; + const isPending = pending?.id === model.id; + return ( +
+
+ {te(`speechModel.${model.id}`)} + {isActive ? ( + + + {te("speechModel.active")} + + ) : isPending ? ( + + {Math.round((pending?.fraction ?? 0) * 100)}% + + ) : model.downloaded ? ( + + {te("speechModel.downloaded")} + + ) : null} +
+ + {formatBytes(model.bytes)} + + + {te(`speechModel.${model.id}Note`)} + + {model.id === "accurate" && snapshot.cpuOnly ? ( + + + {te("speechModel.cpuWarning")} + + ) : null} + {isActive ? null : ( +
+ + {model.downloaded ? ( + + ) : null} +
+ )} +
+ ); + })} +
+
+ ); +} diff --git a/src/i18n/locales/ar/editor.json b/src/i18n/locales/ar/editor.json index 601138020..b6eb5acba 100644 --- a/src/i18n/locales/ar/editor.json +++ b/src/i18n/locales/ar/editor.json @@ -417,5 +417,22 @@ "cpuCompositor": { "notice": "لا توجد بطاقة رسومات متوافقة — تتم المعالجة على المعالج، لذا يكون التشغيل أبطأ.", "exportWarning": "لا توجد بطاقة رسومات متوافقة: يتم هذا التصدير على المعالج وسيستغرق وقتًا أطول بكثير من المعتاد." + }, + "speechModel": { + "title": "نموذج الكلام", + "hint": "يحوّل الكلام إلى نص للنصوص المفرّغة والترجمات. تُحفظ النصوص الحالية؛ أعد إنشاء أحدها لاستخدام النموذج الجديد.", + "fast": "سريع", + "fastNote": "الأسرع، لكنه يفوّت كلمات أكثر في الكلام المشوَّش أو ذي اللكنة", + "balanced": "متوازن", + "balancedNote": "دقة جيدة بسرعة معتدلة", + "accurate": "دقيق", + "accurateNote": "أفضل دقة، لكنه الأبطأ", + "cpuWarning": "لا يُستخدم معالج رسوميات: سيكون النموذج الدقيق بطيئًا جدًا على هذا الجهاز", + "active": "نشط", + "downloaded": "تم التنزيل", + "use": "استخدام", + "delete": "حذف النموذج", + "switchFailed": "تعذّر تبديل نموذج الكلام؛ لا يزال النموذج السابق قيد الاستخدام", + "deleteFailed": "تعذّر حذف نموذج الكلام" } } diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json index 53ba2064f..b14099e24 100644 --- a/src/i18n/locales/en/editor.json +++ b/src/i18n/locales/en/editor.json @@ -417,5 +417,22 @@ "cpuCompositor": { "notice": "Running without a compatible GPU — rendering on CPU, so playback is slower.", "exportWarning": "No compatible GPU: this export runs on CPU and will take much longer than usual." + }, + "speechModel": { + "title": "Speech model", + "hint": "Transcribes speech for transcripts and captions. Existing transcripts are kept; regenerate one to use a new model.", + "fast": "Fast", + "fastNote": "Quickest, but misses more words in noisy or accented speech", + "balanced": "Balanced", + "balancedNote": "Good accuracy at a moderate speed", + "accurate": "Accurate", + "accurateNote": "Best accuracy, and the slowest", + "cpuWarning": "No GPU in use: Accurate will be very slow on this machine", + "active": "Active", + "downloaded": "Downloaded", + "use": "Use", + "delete": "Delete model", + "switchFailed": "Could not switch the speech model; the previous one is still in use", + "deleteFailed": "Could not delete the speech model" } } diff --git a/src/i18n/locales/es/editor.json b/src/i18n/locales/es/editor.json index 4f0b3b1bc..4446af0c5 100644 --- a/src/i18n/locales/es/editor.json +++ b/src/i18n/locales/es/editor.json @@ -417,5 +417,22 @@ "cpuCompositor": { "notice": "Sin GPU compatible: el renderizado se hace en la CPU, por lo que la reproducción es más lenta.", "exportWarning": "Sin GPU compatible: esta exportación se ejecuta en la CPU y tardará mucho más de lo normal." + }, + "speechModel": { + "title": "Modelo de voz", + "hint": "Transcribe la voz para transcripciones y subtítulos. Las transcripciones existentes se conservan; regenera una para usar el nuevo modelo.", + "fast": "Rápido", + "fastNote": "El más rápido, pero omite más palabras con ruido o acentos marcados", + "balanced": "Equilibrado", + "balancedNote": "Buena precisión a velocidad moderada", + "accurate": "Preciso", + "accurateNote": "La mejor precisión, y el más lento", + "cpuWarning": "No se usa GPU: Preciso será muy lento en este equipo", + "active": "Activo", + "downloaded": "Descargado", + "use": "Usar", + "delete": "Eliminar modelo", + "switchFailed": "No se pudo cambiar el modelo de voz; se sigue usando el anterior", + "deleteFailed": "No se pudo eliminar el modelo de voz" } } diff --git a/src/i18n/locales/fr/editor.json b/src/i18n/locales/fr/editor.json index 1f6715cdd..1195c4619 100644 --- a/src/i18n/locales/fr/editor.json +++ b/src/i18n/locales/fr/editor.json @@ -417,5 +417,22 @@ "cpuCompositor": { "notice": "Aucun GPU compatible — rendu sur CPU, la lecture est donc plus lente.", "exportWarning": "Aucun GPU compatible : cet export tourne sur CPU et sera beaucoup plus long que d'habitude." + }, + "speechModel": { + "title": "Modèle vocal", + "hint": "Transcrit la parole pour les transcriptions et les sous-titres. Les transcriptions existantes sont conservées ; régénérez-en une pour utiliser le nouveau modèle.", + "fast": "Rapide", + "fastNote": "Le plus rapide, mais manque plus de mots si la voix est bruitée ou accentuée", + "balanced": "Équilibré", + "balancedNote": "Bonne précision à vitesse modérée", + "accurate": "Précis", + "accurateNote": "Meilleure précision, mais le plus lent", + "cpuWarning": "Aucun GPU utilisé : Précis sera très lent sur cette machine", + "active": "Actif", + "downloaded": "Téléchargé", + "use": "Utiliser", + "delete": "Supprimer le modèle", + "switchFailed": "Impossible de changer de modèle vocal ; le précédent reste utilisé", + "deleteFailed": "Impossible de supprimer le modèle vocal" } } diff --git a/src/i18n/locales/it/editor.json b/src/i18n/locales/it/editor.json index 9dc59d2bf..5d9296a4d 100644 --- a/src/i18n/locales/it/editor.json +++ b/src/i18n/locales/it/editor.json @@ -417,5 +417,22 @@ "cpuCompositor": { "notice": "Nessuna GPU compatibile: il rendering avviene sulla CPU, quindi la riproduzione è più lenta.", "exportWarning": "Nessuna GPU compatibile: questa esportazione viene eseguita sulla CPU e richiederà molto più tempo del solito." + }, + "speechModel": { + "title": "Modello vocale", + "hint": "Trascrive il parlato per trascrizioni e sottotitoli. Le trascrizioni esistenti vengono mantenute; rigenerane una per usare il nuovo modello.", + "fast": "Veloce", + "fastNote": "Il più rapido, ma perde più parole con rumore o accenti marcati", + "balanced": "Bilanciato", + "balancedNote": "Buona precisione a velocità moderata", + "accurate": "Preciso", + "accurateNote": "La massima precisione, ma il più lento", + "cpuWarning": "Nessuna GPU in uso: Preciso sarà molto lento su questo computer", + "active": "Attivo", + "downloaded": "Scaricato", + "use": "Usa", + "delete": "Elimina modello", + "switchFailed": "Impossibile cambiare modello vocale; resta in uso quello precedente", + "deleteFailed": "Impossibile eliminare il modello vocale" } } diff --git a/src/i18n/locales/ja-JP/editor.json b/src/i18n/locales/ja-JP/editor.json index ec77269bc..f0048c982 100644 --- a/src/i18n/locales/ja-JP/editor.json +++ b/src/i18n/locales/ja-JP/editor.json @@ -417,5 +417,22 @@ "cpuCompositor": { "notice": "対応 GPU がないため CPU で描画しています。再生が遅くなります。", "exportWarning": "対応 GPU がありません。この書き出しは CPU で実行されるため、通常よりはるかに時間がかかります。" + }, + "speechModel": { + "title": "音声モデル", + "hint": "文字起こしと字幕のために音声を認識します。既存の文字起こしはそのまま残ります。新しいモデルを使うには文字起こしを再生成してください。", + "fast": "高速", + "fastNote": "最も速いものの、雑音やなまりのある音声では聞き漏らしが増えます", + "balanced": "バランス", + "balancedNote": "適度な速度で十分な精度", + "accurate": "高精度", + "accurateNote": "精度は最高ですが、最も低速です", + "cpuWarning": "GPU を使用していません: このマシンでは高精度モデルは非常に低速です", + "active": "使用中", + "downloaded": "ダウンロード済み", + "use": "使用する", + "delete": "モデルを削除", + "switchFailed": "音声モデルを切り替えられませんでした。以前のモデルを引き続き使用します", + "deleteFailed": "音声モデルを削除できませんでした" } } diff --git a/src/i18n/locales/ko-KR/editor.json b/src/i18n/locales/ko-KR/editor.json index 44fd0b8da..3b3beffef 100644 --- a/src/i18n/locales/ko-KR/editor.json +++ b/src/i18n/locales/ko-KR/editor.json @@ -417,5 +417,22 @@ "cpuCompositor": { "notice": "호환되는 GPU가 없어 CPU로 렌더링합니다. 재생이 느려집니다.", "exportWarning": "호환되는 GPU가 없습니다. 이 내보내기는 CPU에서 실행되며 평소보다 훨씬 오래 걸립니다." + }, + "speechModel": { + "title": "음성 모델", + "hint": "대본과 자막을 위해 음성을 텍스트로 변환합니다. 기존 대본은 유지되며, 새 모델을 사용하려면 대본을 재생성하세요.", + "fast": "빠름", + "fastNote": "가장 빠르지만 소음이나 억양이 있는 음성에서 놓치는 단어가 더 많습니다", + "balanced": "균형", + "balancedNote": "적당한 속도에 좋은 정확도", + "accurate": "정확", + "accurateNote": "정확도는 가장 높지만 가장 느립니다", + "cpuWarning": "GPU를 사용하지 않음: 이 컴퓨터에서는 정확 모델이 매우 느립니다", + "active": "사용 중", + "downloaded": "다운로드됨", + "use": "사용", + "delete": "모델 삭제", + "switchFailed": "음성 모델을 바꾸지 못했습니다. 이전 모델을 계속 사용합니다", + "deleteFailed": "음성 모델을 삭제하지 못했습니다" } } diff --git a/src/i18n/locales/pt-BR/editor.json b/src/i18n/locales/pt-BR/editor.json index 571340156..e1001cf6f 100644 --- a/src/i18n/locales/pt-BR/editor.json +++ b/src/i18n/locales/pt-BR/editor.json @@ -417,5 +417,22 @@ "cpuCompositor": { "notice": "Sem GPU compatível — a renderização ocorre na CPU, então a reprodução fica mais lenta.", "exportWarning": "Sem GPU compatível: esta exportação roda na CPU e vai demorar muito mais que o normal." + }, + "speechModel": { + "title": "Modelo de voz", + "hint": "Transcreve a fala para transcrições e legendas. As transcrições existentes são mantidas; gere uma novamente para usar o novo modelo.", + "fast": "Rápido", + "fastNote": "O mais rápido, mas perde mais palavras com ruído ou sotaque forte", + "balanced": "Equilibrado", + "balancedNote": "Boa precisão com velocidade moderada", + "accurate": "Preciso", + "accurateNote": "A melhor precisão, e o mais lento", + "cpuWarning": "Nenhuma GPU em uso: Preciso ficará muito lento nesta máquina", + "active": "Ativo", + "downloaded": "Baixado", + "use": "Usar", + "delete": "Excluir modelo", + "switchFailed": "Não foi possível trocar o modelo de voz; o anterior continua em uso", + "deleteFailed": "Não foi possível excluir o modelo de voz" } } diff --git a/src/i18n/locales/ru/editor.json b/src/i18n/locales/ru/editor.json index 53be56166..7d08e2f76 100644 --- a/src/i18n/locales/ru/editor.json +++ b/src/i18n/locales/ru/editor.json @@ -417,5 +417,22 @@ "cpuCompositor": { "notice": "Совместимый GPU не найден — отрисовка на CPU, поэтому воспроизведение медленнее.", "exportWarning": "Совместимый GPU не найден: этот экспорт выполняется на CPU и займёт намного больше времени." + }, + "speechModel": { + "title": "Речевая модель", + "hint": "Распознаёт речь для расшифровок и субтитров. Существующие расшифровки сохраняются; пересоздайте расшифровку, чтобы использовать новую модель.", + "fast": "Быстрая", + "fastNote": "Самая быстрая, но чаще пропускает слова при шуме или акценте", + "balanced": "Сбалансированная", + "balancedNote": "Хорошая точность при умеренной скорости", + "accurate": "Точная", + "accurateNote": "Лучшая точность, но самая медленная", + "cpuWarning": "GPU не используется: «Точная» будет работать очень медленно на этом компьютере", + "active": "Активна", + "downloaded": "Загружена", + "use": "Использовать", + "delete": "Удалить модель", + "switchFailed": "Не удалось сменить речевую модель; используется прежняя", + "deleteFailed": "Не удалось удалить речевую модель" } } diff --git a/src/i18n/locales/tr/editor.json b/src/i18n/locales/tr/editor.json index 84c270004..d2061f8b1 100644 --- a/src/i18n/locales/tr/editor.json +++ b/src/i18n/locales/tr/editor.json @@ -417,5 +417,22 @@ "cpuCompositor": { "notice": "Uyumlu GPU yok — işleme CPU üzerinde yapılıyor, bu yüzden oynatma daha yavaş.", "exportWarning": "Uyumlu GPU yok: bu dışa aktarma CPU üzerinde çalışır ve normalden çok daha uzun sürer." + }, + "speechModel": { + "title": "Konuşma modeli", + "hint": "Dökümler ve altyazılar için konuşmayı yazıya döker. Mevcut dökümler korunur; yeni modeli kullanmak için bir dökümü yeniden oluşturun.", + "fast": "Hızlı", + "fastNote": "En hızlısı, ancak gürültülü veya aksanlı konuşmada daha çok kelime kaçırır", + "balanced": "Dengeli", + "balancedNote": "Orta hızda iyi doğruluk", + "accurate": "Hassas", + "accurateNote": "En iyi doğruluk, ama en yavaşı", + "cpuWarning": "GPU kullanılmıyor: Hassas bu bilgisayarda çok yavaş çalışacak", + "active": "Etkin", + "downloaded": "İndirildi", + "use": "Kullan", + "delete": "Modeli sil", + "switchFailed": "Konuşma modeli değiştirilemedi; önceki model kullanılmaya devam ediyor", + "deleteFailed": "Konuşma modeli silinemedi" } } diff --git a/src/i18n/locales/vi/editor.json b/src/i18n/locales/vi/editor.json index cc41d643e..17a6e179e 100644 --- a/src/i18n/locales/vi/editor.json +++ b/src/i18n/locales/vi/editor.json @@ -417,5 +417,22 @@ "cpuCompositor": { "notice": "Không có GPU tương thích — kết xuất trên CPU nên phát lại chậm hơn.", "exportWarning": "Không có GPU tương thích: lần xuất này chạy trên CPU và sẽ lâu hơn bình thường rất nhiều." + }, + "speechModel": { + "title": "Mô hình giọng nói", + "hint": "Nhận dạng giọng nói cho bản phiên âm và phụ đề. Các bản phiên âm hiện có được giữ nguyên; hãy tạo lại một bản để dùng mô hình mới.", + "fast": "Nhanh", + "fastNote": "Nhanh nhất, nhưng bỏ sót nhiều từ hơn khi có tiếng ồn hoặc giọng địa phương", + "balanced": "Cân bằng", + "balancedNote": "Độ chính xác tốt với tốc độ vừa phải", + "accurate": "Chính xác", + "accurateNote": "Chính xác nhất, nhưng chậm nhất", + "cpuWarning": "Không dùng GPU: Chính xác sẽ chạy rất chậm trên máy này", + "active": "Đang dùng", + "downloaded": "Đã tải", + "use": "Dùng", + "delete": "Xóa mô hình", + "switchFailed": "Không thể đổi mô hình giọng nói; mô hình trước vẫn đang được dùng", + "deleteFailed": "Không thể xóa mô hình giọng nói" } } diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json index 574628f07..d730ef204 100644 --- a/src/i18n/locales/zh-CN/editor.json +++ b/src/i18n/locales/zh-CN/editor.json @@ -417,5 +417,22 @@ "cpuCompositor": { "notice": "没有兼容的 GPU,正在使用 CPU 渲染,因此播放较慢。", "exportWarning": "没有兼容的 GPU:本次导出将在 CPU 上运行,耗时会比平常长很多。" + }, + "speechModel": { + "title": "语音模型", + "hint": "用于转录和字幕的语音识别。现有转录会保留;重新生成转录即可使用新模型。", + "fast": "快速", + "fastNote": "速度最快,但在嘈杂或有口音的语音中漏词更多", + "balanced": "均衡", + "balancedNote": "速度适中,准确度良好", + "accurate": "精确", + "accurateNote": "准确度最高,但速度最慢", + "cpuWarning": "未使用 GPU:在此设备上精确模型会非常慢", + "active": "使用中", + "downloaded": "已下载", + "use": "使用", + "delete": "删除模型", + "switchFailed": "无法切换语音模型,仍在使用之前的模型", + "deleteFailed": "无法删除语音模型" } } diff --git a/src/i18n/locales/zh-TW/editor.json b/src/i18n/locales/zh-TW/editor.json index 4810c0167..77b7467f6 100644 --- a/src/i18n/locales/zh-TW/editor.json +++ b/src/i18n/locales/zh-TW/editor.json @@ -417,5 +417,22 @@ "cpuCompositor": { "notice": "沒有相容的 GPU,正在使用 CPU 算繪,因此播放較慢。", "exportWarning": "沒有相容的 GPU:這次匯出將在 CPU 上執行,耗時會比平常長很多。" + }, + "speechModel": { + "title": "語音模型", + "hint": "用於逐字稿與字幕的語音辨識。現有逐字稿會保留;重新產生逐字稿即可使用新模型。", + "fast": "快速", + "fastNote": "速度最快,但在嘈雜或帶口音的語音中漏字較多", + "balanced": "平衡", + "balancedNote": "速度適中,準確度良好", + "accurate": "精確", + "accurateNote": "準確度最高,但速度最慢", + "cpuWarning": "未使用 GPU:在這台裝置上精確模型會非常慢", + "active": "使用中", + "downloaded": "已下載", + "use": "使用", + "delete": "刪除模型", + "switchFailed": "無法切換語音模型,仍在使用先前的模型", + "deleteFailed": "無法刪除語音模型" } } diff --git a/technical-documentation/architecture/transcription-and-captions.md b/technical-documentation/architecture/transcription-and-captions.md index 3e68f1f41..4f33a2ac4 100644 --- a/technical-documentation/architecture/transcription-and-captions.md +++ b/technical-documentation/architecture/transcription-and-captions.md @@ -191,7 +191,9 @@ it verbatim in the response. array. 2. **DTW timestamp** — every non-special token carries `t_dtw` in centiseconds from whisper.cpp's native DTW - (`dtw_token_timestamps=true`, `dtw_aheads_preset=WHISPER_AHEADS_SMALL`, + (`dtw_token_timestamps=true`, `dtw_aheads_preset` matching the loaded + model's family — passed as `--dtw-preset base|small|large-v3-turbo`, + because a mismatched preset makes `whisper_init` fail outright — `flash_attn=false`, which together are the prerequisites for DTW to actually run). `t_dtw == -1` is the DTW-inactive guardrail: the helper fails the request rather than emit zero-quality timestamps. @@ -239,11 +241,16 @@ linked above). ### Model -The single shipped artifact is `ggml-small-q8_0.bin` from -`ggerganov/whisper.cpp` on HuggingFace: Whisper `small`, multilingual (~99 -languages), q8_0 quantised, ~264 MB. Precision is baked into the GGML file — -there is no runtime `--int8` flag. `electron/stt/modelManager.ts` downloads -the file once into the user-data cache and writes it through an atomic +AI settings → **Speech model** offers three files from `ggerganov/whisper.cpp` +on HuggingFace, all multilingual (~99 languages) and pinned by SHA-256 at one +commit: **Fast** `ggml-base-q8_0.bin` (81.8 MB), **Balanced** +`ggml-small-q8_0.bin` (264.5 MB, the default and the only model before the +choice existed) and **Accurate** `ggml-large-v3-turbo-q5_0.bin` (574.0 MB, +several times slower than Balanced without a GPU). The choice lives in +`stt-models/active-model.json` and only changes once the new file is verified +on disk, so a failed switch leaves the previous model running. Precision is +baked into the GGML file — there is no runtime `--int8` flag. +`electron/stt/modelManager.ts` downloads each file once into the user-data cache and writes it through an atomic `.partial` rename, so a half-downloaded file can never be picked up as a usable model. The SHA-256 is checked on the cached copy too, not only on a fresh download, so a model corrupted after the fact is re-fetched rather than From 2da29698ade3eced6a0c9f425b2f1a22cd6c4b7d Mon Sep 17 00:00:00 2001 From: MinhOmega <49482201+MinhOmega@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:09:45 +0700 Subject: [PATCH 21/40] feat(recording): choose where new recordings are saved Settings gets "Save recordings to" with Change... and Reset. New takes are written to the chosen folder; the default folder under userData stays an allowed root, so every earlier recording keeps working where it is. - The choice is persisted in recording-settings.json (next to auto-zoom), set only from the OS folder picker, and re-validated in main on every use (absolute, exists, is a directory; writable for a new take). Anything else falls back to the default folder. - Confinement: renderer-supplied paths are accepted in the default folder exactly as before. In the chosen folder only files named like a take are accepted, and only when still inside after symlinks are resolved, so a user's own files and links pointing out of the folder are refused. resolveRecordingOutputPath now also refuses names Capturia does not write. - Session manifests are written next to the take they describe. - The low-disk check measures the drive the next take will be written to. - Right before a take starts, an unavailable chosen folder raises a dialog offering the default folder instead; declining cancels the take. - Cleanup still sweeps only the default folder: in a user's folder, name patterns cannot tell Capturia's files from another tool's, and the age and size passes would delete takes the user chose to keep. --- electron/electron-env.d.ts | 17 ++ electron/ipc/handlers.ts | 166 ++++++++++++++---- electron/main.ts | 2 + electron/preload.ts | 12 ++ electron/recording-settings.test.ts | 21 ++- electron/recording-settings.ts | 37 ++-- electron/recordingsCleanup.ts | 7 + electron/recordingsFolder.test.ts | 183 ++++++++++++++++++++ electron/recordingsFolder.ts | 84 +++++++++ src/components/launch/HudDeviceSettings.tsx | 61 +++++++ src/components/launch/LaunchWindow.tsx | 52 +++++- src/hooks/useScreenRecorder.ts | 21 ++- src/i18n/locales/ar/dialogs.json | 7 +- src/i18n/locales/ar/launch.json | 7 +- src/i18n/locales/en/dialogs.json | 7 +- src/i18n/locales/en/launch.json | 7 +- src/i18n/locales/es/dialogs.json | 7 +- src/i18n/locales/es/launch.json | 7 +- src/i18n/locales/fr/dialogs.json | 7 +- src/i18n/locales/fr/launch.json | 7 +- src/i18n/locales/it/dialogs.json | 7 +- src/i18n/locales/it/launch.json | 7 +- src/i18n/locales/ja-JP/dialogs.json | 7 +- src/i18n/locales/ja-JP/launch.json | 7 +- src/i18n/locales/ko-KR/dialogs.json | 7 +- src/i18n/locales/ko-KR/launch.json | 7 +- src/i18n/locales/pt-BR/dialogs.json | 7 +- src/i18n/locales/pt-BR/launch.json | 7 +- src/i18n/locales/ru/dialogs.json | 7 +- src/i18n/locales/ru/launch.json | 7 +- src/i18n/locales/tr/dialogs.json | 7 +- src/i18n/locales/tr/launch.json | 7 +- src/i18n/locales/vi/dialogs.json | 7 +- src/i18n/locales/vi/launch.json | 7 +- src/i18n/locales/zh-CN/dialogs.json | 7 +- src/i18n/locales/zh-CN/launch.json | 7 +- src/i18n/locales/zh-TW/dialogs.json | 7 +- src/i18n/locales/zh-TW/launch.json | 7 +- 38 files changed, 773 insertions(+), 72 deletions(-) create mode 100644 electron/recordingsFolder.test.ts create mode 100644 electron/recordingsFolder.ts diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 14afea2ad..8237616f0 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -144,6 +144,23 @@ interface Window { getRecordingsDiskSpace: () => Promise< import("../src/lib/recordingDiskSpace").RecordingDiskSpaceSnapshot >; + /** Settings → "Save recordings to". `folder` is the default one when `isDefault`, and + * `available` is false while the chosen folder cannot take a new recording. */ + getRecordingsFolder: () => Promise<{ folder: string; isDefault: boolean; available: boolean }>; + /** Opens the OS folder picker and resolves with the state after the user's answer. */ + chooseRecordingsFolder: () => Promise<{ + folder: string; + isDefault: boolean; + available: boolean; + }>; + resetRecordingsFolder: () => Promise<{ + folder: string; + isDefault: boolean; + available: boolean; + }>; + /** Right before a take: false when the chosen folder is unavailable and the user declined + * to record into the default folder instead. */ + confirmRecordingsFolder: () => Promise; setRecordingState: ( recording: boolean, recordingId?: number, diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index c63057bfd..acf80a514 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -41,6 +41,7 @@ import { type RecordingSession, type StoreRecordedSessionInput, } from "../../src/lib/recordingSession"; +import { recordingGroupKeyFromFileName } from "../../src/lib/recordingsCleanupPolicy"; import type { CursorRecordingData, CursorRecordingSample, @@ -109,6 +110,12 @@ import { } from "../recording/nativeWindowsCaptureStop"; import { patchWebmDurationOnDisk } from "../recording/webm-duration"; import { reindexRecordingOnDisk } from "../recording/webm-seek-index"; +import { loadRecordingsFolder, saveRecordingsFolder } from "../recording-settings"; +import { + isPathWithinDir, + isPathWithinRecordingRoots, + validRecordingsFolder, +} from "../recordingsFolder"; import { settingsPaneUrl } from "../windowPermissions"; import { registerNativeBridgeHandlers } from "./nativeBridge"; import { registerRecordingPrefsHandlers } from "./recordingPrefs"; @@ -166,20 +173,28 @@ function approveFilePath(filePath: string): void { approvedPaths.add(path.resolve(filePath)); } -function getAllowedReadDirs(): string[] { - return [RECORDINGS_DIR]; +// Settings → "Save recordings to", as saved; null means the default folder. Only ever set from +// the OS folder picker (see `choose-recordings-folder`), and re-validated on every use below. +let chosenRecordingsFolder = loadRecordingsFolder(app.getPath("userData")); + +/** Where a new take is written: the chosen folder while it can take one, else the default. */ +function newTakeDir(): string { + return validRecordingsFolder(chosenRecordingsFolder, { writable: true }) ?? RECORDINGS_DIR; } -function isPathWithinDir(filePath: string, dirPath: string): boolean { - const resolved = path.resolve(filePath); - const resolvedDir = path.resolve(dirPath); - return resolved === resolvedDir || resolved.startsWith(resolvedDir + path.sep); +/** The default folder, plus the chosen one for files Capturia named — see recordingsFolder.ts. */ +function isWithinRecordingRoots(filePath: string): boolean { + return isPathWithinRecordingRoots( + filePath, + RECORDINGS_DIR, + validRecordingsFolder(chosenRecordingsFolder), + ); } function isPathAllowed(filePath: string): boolean { const resolved = path.resolve(filePath); if (approvedPaths.has(resolved)) return true; - return getAllowedReadDirs().some((dir) => isPathWithinDir(resolved, dir)); + return isWithinRecordingRoots(resolved); } function resolveApprovedVideoPath(videoPath?: string | null): string | null { @@ -402,7 +417,7 @@ function approveReadableAudioPath( * the renderer could name any media file on the machine and have its bytes handed back, * which is a capability no generic handler should carry (CWE-200). * - * Approval is granted in exactly three places now: the recordings directory, a file the user + * Approval is granted in exactly three places now: the recordings folders, a file the user * picked, and the assets a loaded project declares (`approveDocumentMedia`). Everything else * spends one. */ @@ -443,8 +458,14 @@ function resolveRecordingOutputPath(fileName: string): string { if (hasTraversalSegments || isNestedPath || parsedPath.base !== trimmed) { throw new Error("Recording file name must not contain path segments"); } + // The renderer names this file and main then creates, overwrites or deletes it (the stream + // handlers, the empty-take unlink). In a folder the user picked, that must never reach a + // file Capturia did not name itself. + if (recordingGroupKeyFromFileName(parsedPath.base) === null) { + throw new Error("Recording file name is not one Capturia writes"); + } - return path.join(RECORDINGS_DIR, parsedPath.base); + return path.join(newTakeDir(), parsedPath.base); } function isValidDurationMs(value: number | undefined): value is number { @@ -790,7 +811,7 @@ async function removeNativeWindowsCaptureOutputs( ]; for (const target of targets) { - if (!target || !isPathWithinDir(target, RECORDINGS_DIR)) { + if (!target || !isWithinRecordingRoots(target)) { continue; } try { @@ -2363,11 +2384,12 @@ export function registerIpcHandlers( typeof request?.recordingId === "number" && Number.isFinite(request.recordingId) ? request.recordingId : Date.now(); - const outputPath = path.join(RECORDINGS_DIR, `${RECORDING_FILE_PREFIX}${recordingId}.mp4`); + const takeDir = newTakeDir(); + const outputPath = path.join(takeDir, `${RECORDING_FILE_PREFIX}${recordingId}.mp4`); const cursorCaptureMode = normalizeCursorCaptureMode(request?.cursor?.mode) ?? "editable-overlay"; - await fs.mkdir(RECORDINGS_DIR, { recursive: true }); + await fs.mkdir(takeDir, { recursive: true }); const session = new LinuxNativeCaptureSession({ outputPath, @@ -2441,11 +2463,12 @@ export function registerIpcHandlers( typeof request?.recordingId === "number" && Number.isFinite(request.recordingId) ? request.recordingId : Date.now(); - const outputPath = path.join(RECORDINGS_DIR, `${RECORDING_FILE_PREFIX}${recordingId}.mp4`); + const takeDir = newTakeDir(); + const outputPath = path.join(takeDir, `${RECORDING_FILE_PREFIX}${recordingId}.mp4`); const cursorCaptureMode = normalizeCursorCaptureMode(request?.cursor?.mode) ?? "editable-overlay"; - await fs.mkdir(RECORDINGS_DIR, { recursive: true }); + await fs.mkdir(takeDir, { recursive: true }); // A session prepared before the countdown, if there was one. Taking // it here rather than requiring it is what keeps every caller @@ -2545,7 +2568,9 @@ export function registerIpcHandlers( try { if (discard) { session.discard(); - const discarded = path.join(RECORDINGS_DIR, `${RECORDING_FILE_PREFIX}${recordingId}.mp4`); + // The folder the take started in: the HUD locks the setting while recording, and a + // folder that vanished mid-take took the take with it. + const discarded = path.join(newTakeDir(), `${RECORDING_FILE_PREFIX}${recordingId}.mp4`); await Promise.all([ fs.rm(discarded, { force: true }), fs.rm(`${discarded}.cursor.json`, { force: true }), @@ -2575,7 +2600,7 @@ export function registerIpcHandlers( currentProjectPath = null; const sessionManifestPath = path.join( - RECORDINGS_DIR, + path.dirname(result.path), `${path.parse(result.path).name}${RECORDING_SESSION_SUFFIX}`, ); await fs.writeFile(sessionManifestPath, JSON.stringify(session_, null, 2), "utf-8"); @@ -2641,9 +2666,10 @@ export function registerIpcHandlers( typeof request.recordingId === "number" && Number.isFinite(request.recordingId) ? request.recordingId : Date.now(); - const outputPath = path.join(RECORDINGS_DIR, `${RECORDING_FILE_PREFIX}${recordingId}.mp4`); + const takeDir = newTakeDir(); + const outputPath = path.join(takeDir, `${RECORDING_FILE_PREFIX}${recordingId}.mp4`); const webcamOutputPath = path.join( - RECORDINGS_DIR, + takeDir, `${RECORDING_FILE_PREFIX}${recordingId}-webcam.mp4`, ); const sourceDisplay = @@ -2739,7 +2765,7 @@ export function registerIpcHandlers( outputPath, }); - await fs.mkdir(RECORDINGS_DIR, { recursive: true }); + await fs.mkdir(takeDir, { recursive: true }); nativeWindowsCaptureOutput = ""; nativeWindowsCaptureTargetPath = outputPath; nativeWindowsCaptureWebcamTargetPath = request.webcam.enabled ? webcamOutputPath : null; @@ -2866,7 +2892,8 @@ export function registerIpcHandlers( typeof request.recordingId === "number" && Number.isFinite(request.recordingId) ? request.recordingId : Date.now(); - const outputPath = path.join(RECORDINGS_DIR, `${RECORDING_FILE_PREFIX}${recordingId}.mp4`); + const takeDir = newTakeDir(); + const outputPath = path.join(takeDir, `${RECORDING_FILE_PREFIX}${recordingId}.mp4`); const cursorCaptureMode = normalizeCursorCaptureMode(request.cursor?.mode) ?? "editable-overlay"; try { @@ -2920,7 +2947,7 @@ export function registerIpcHandlers( outputs: { screenPath: outputPath, manifestPath: path.join( - RECORDINGS_DIR, + takeDir, `${RECORDING_FILE_PREFIX}${recordingId}${RECORDING_SESSION_SUFFIX}`, ), }, @@ -2936,7 +2963,7 @@ export function registerIpcHandlers( outputPath, }); - await fs.mkdir(RECORDINGS_DIR, { recursive: true }); + await fs.mkdir(takeDir, { recursive: true }); nativeMacCaptureOutput = ""; nativeMacCaptureTargetPath = outputPath; nativeMacCaptureRecordingId = recordingId; @@ -3268,7 +3295,7 @@ export function registerIpcHandlers( currentProjectPath = null; const sessionManifestPath = path.join( - RECORDINGS_DIR, + path.dirname(screenVideoPath), `${path.parse(screenVideoPath).name}${RECORDING_SESSION_SUFFIX}`, ); await fs.writeFile(sessionManifestPath, JSON.stringify(session, null, 2), "utf-8"); @@ -3357,7 +3384,7 @@ export function registerIpcHandlers( currentProjectPath = null; const sessionManifestPath = path.join( - RECORDINGS_DIR, + path.dirname(screenVideoPath), `${path.parse(screenVideoPath).name}${RECORDING_SESSION_SUFFIX}`, ); await fs.writeFile(sessionManifestPath, JSON.stringify(session, null, 2), "utf-8"); @@ -3418,7 +3445,7 @@ export function registerIpcHandlers( try { { const screenVideoPath = normalizeVideoSourcePath(payload.screenVideoPath); - if (!screenVideoPath || !isPathWithinDir(screenVideoPath, RECORDINGS_DIR)) { + if (!screenVideoPath || !isWithinRecordingRoots(screenVideoPath)) { return { success: false, error: `Native ${platformLabel} webcam attachment requires a recording output path.`, @@ -3480,7 +3507,7 @@ export function registerIpcHandlers( currentProjectPath = null; const sessionManifestPath = path.join( - RECORDINGS_DIR, + path.dirname(screenVideoPath), `${path.parse(screenVideoPath).name}${RECORDING_SESSION_SUFFIX}`, ); await fs.writeFile(sessionManifestPath, JSON.stringify(session, null, 2), "utf-8"); @@ -3633,7 +3660,7 @@ export function registerIpcHandlers( currentProjectPath = null; const sessionManifestPath = path.join( - RECORDINGS_DIR, + path.dirname(screenVideoPath), `${path.parse(payload.screen.fileName).name}${RECORDING_SESSION_SUFFIX}`, ); await fs.writeFile(sessionManifestPath, JSON.stringify(session, null, 2), "utf-8"); @@ -3701,7 +3728,7 @@ export function registerIpcHandlers( // native paths, and a marker that disagreed with the file's own clock would // send the user to the wrong frame. ipcMain.handle("write-recording-markers", async (_, videoPath: unknown, markers: unknown) => { - if (typeof videoPath !== "string" || !isPathWithinDir(videoPath, RECORDINGS_DIR)) { + if (typeof videoPath !== "string" || !isWithinRecordingRoots(videoPath)) { return { success: false, error: "Refusing to write markers outside the recordings dir." }; } try { @@ -3726,8 +3753,9 @@ export function registerIpcHandlers( return { success: true, markers: await readRecordingMarkers(approved) }; }); - // Free space on the recordings volume, asked for right before a recording - // starts. None of the three capture helpers watches for a full disk: they + // Free space on the volume the next take will be written to (the chosen folder's + // drive when there is one), asked for right before a recording starts. None of the + // three capture helpers watches for a full disk: they // hand frames to their muxer and never inspect a write error, so a volume // that fills mid-take leaves a file truncated wherever the writer was — on // the native path an MP4 with no `moov` box, which opens nowhere. The HUD @@ -3735,7 +3763,7 @@ export function registerIpcHandlers( // left to make (`src/lib/recordingDiskSpace.ts` holds the thresholds). ipcMain.handle("get-recordings-disk-space", async () => { try { - const stats = await fs.statfs(RECORDINGS_DIR); + const stats = await fs.statfs(newTakeDir()); const blockSize = Number(stats.bsize); // `bavail` is what an unprivileged process may use, which is what // matters here: `bfree` includes the root reserve the recorder can @@ -3752,6 +3780,80 @@ export function registerIpcHandlers( } }); + // Settings → "Save recordings to". `available` is false when the chosen folder cannot take a + // new recording right now, in which case `newTakeDir` is already falling back to the default. + const recordingsFolderState = () => ({ + folder: chosenRecordingsFolder ?? RECORDINGS_DIR, + isDefault: chosenRecordingsFolder === null, + available: + chosenRecordingsFolder === null || + validRecordingsFolder(chosenRecordingsFolder, { writable: true }) !== null, + }); + + const persistRecordingsFolder = (folder: string | null) => { + try { + saveRecordingsFolder(app.getPath("userData"), folder); + chosenRecordingsFolder = folder; + } catch (error) { + console.error("Failed to save the recordings folder:", error); + } + return recordingsFolderState(); + }; + + const showRecordingsFolderMessage = (options: Electron.MessageBoxOptions) => { + const mainWin = getMainWindow(); + return mainWin && !mainWin.isDestroyed() + ? dialog.showMessageBox(mainWin, options) + : dialog.showMessageBox(options); + }; + + ipcMain.handle("get-recordings-folder", () => recordingsFolderState()); + + // The path comes from the OS folder picker, never from the renderer. + ipcMain.handle("choose-recordings-folder", async () => { + const result = await dialog.showOpenDialog( + buildDialogOptions( + { + title: mainT("dialogs", "fileDialogs.selectRecordingsFolder"), + defaultPath: newTakeDir(), + properties: ["openDirectory", "createDirectory"], + }, + getMainWindow(), + ), + ); + const picked = result.canceled ? undefined : result.filePaths[0]; + if (!picked) return recordingsFolderState(); + const folder = validRecordingsFolder(picked, { writable: true }); + if (!folder) { + await showRecordingsFolderMessage({ + type: "error", + message: mainT("dialogs", "recordingsFolder.unavailable", { folder: picked }), + }); + return recordingsFolderState(); + } + return persistRecordingsFolder(folder); + }); + + ipcMain.handle("reset-recordings-folder", () => persistRecordingsFolder(null)); + + // Asked by the recorder right before a take starts, beside the disk check: never mid-take. + // A folder that was fine when picked can be gone by now (a drive unplugged, a permission + // revoked), and the take would then land in the default folder without a word. This is + // where the user hears about it, while nothing has been recorded yet. + ipcMain.handle("confirm-recordings-folder", async () => { + if (recordingsFolderState().available) return true; + const result = await showRecordingsFolderMessage({ + type: "warning", + buttons: [mainT("dialogs", "recordingsFolder.useDefault"), mainT("common", "actions.cancel")], + defaultId: 0, + cancelId: 1, + message: mainT("dialogs", "recordingsFolder.unavailable", { + folder: chosenRecordingsFolder ?? "", + }), + }); + return result.response === 0; + }); + ipcMain.handle( "set-recording-state", async (_, recording: boolean, recordingId?: number, cursorCaptureMode?: CursorCaptureMode) => { @@ -3927,7 +4029,7 @@ export function registerIpcHandlers( const dialogOptions = buildDialogOptions( { title: mainT("dialogs", "fileDialogs.selectVideo"), - defaultPath: RECORDINGS_DIR, + defaultPath: newTakeDir(), filters: [ { name: mainT("dialogs", "fileDialogs.videoFiles"), diff --git a/electron/main.ts b/electron/main.ts index be11e5d4a..7faac84b0 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1318,6 +1318,8 @@ appReady?.then(async () => { // repair scratch all accumulate until the disk is full — and a full disk is // how a recording is lost. Deliberately not awaited: startup must not wait on // a stat of every file in the folder, and a sweep that fails changes nothing. + // The default folder only, even when the user chose another in Settings: see the + // header of recordingsCleanup.ts for why a user's folder is never swept. scheduleRecordingsCleanup({ recordingsDir: RECORDINGS_DIR, userDataDir: app.getPath("userData"), diff --git a/electron/preload.ts b/electron/preload.ts index a6d02e223..45bd0f230 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -189,6 +189,18 @@ contextBridge.exposeInMainWorld("electronAPI", { getRecordingsDiskSpace: () => { return ipcRenderer.invoke("get-recordings-disk-space"); }, + getRecordingsFolder: () => { + return ipcRenderer.invoke("get-recordings-folder"); + }, + chooseRecordingsFolder: () => { + return ipcRenderer.invoke("choose-recordings-folder"); + }, + resetRecordingsFolder: () => { + return ipcRenderer.invoke("reset-recordings-folder"); + }, + confirmRecordingsFolder: () => { + return ipcRenderer.invoke("confirm-recordings-folder"); + }, writeRecordingMarkers: (videoPath: string, markers: number[]) => { return ipcRenderer.invoke("write-recording-markers", videoPath, markers); }, diff --git a/electron/recording-settings.test.ts b/electron/recording-settings.test.ts index 49a1202f1..fc8c30f53 100644 --- a/electron/recording-settings.test.ts +++ b/electron/recording-settings.test.ts @@ -4,7 +4,12 @@ import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; -import { loadAutoZoomEnabled, saveAutoZoomEnabled } from "./recording-settings"; +import { + loadAutoZoomEnabled, + loadRecordingsFolder, + saveAutoZoomEnabled, + saveRecordingsFolder, +} from "./recording-settings"; const temps: string[] = []; const tmp = () => { @@ -60,6 +65,20 @@ describe("recording settings", () => { expect(result.stdout).toBe("false"); }); + it("round-trips the recordings folder and its reset beside auto-zoom", () => { + const dir = tmp(); + expect(loadRecordingsFolder(dir)).toBeNull(); + saveAutoZoomEnabled(dir, false); + saveRecordingsFolder(dir, "/media/usb/Recordings"); + expect(loadRecordingsFolder(dir)).toBe("/media/usb/Recordings"); + expect(loadAutoZoomEnabled(dir)).toBe(false); + saveRecordingsFolder(dir, null); + expect(loadRecordingsFolder(dir)).toBeNull(); + expect(loadAutoZoomEnabled(dir)).toBe(false); + writeFileSync(path.join(dir, "recording-settings.json"), '{"recordingsFolder":42}'); + expect(loadRecordingsFolder(dir)).toBeNull(); + }); + it("rejects invalid writes and reports a failed disk write", () => { const dir = tmp(); saveAutoZoomEnabled(dir, false); diff --git a/electron/recording-settings.ts b/electron/recording-settings.ts index 25370cdab..5a7dbc3e0 100644 --- a/electron/recording-settings.ts +++ b/electron/recording-settings.ts @@ -14,21 +14,15 @@ function readSettings(userData: string): Record { } } -/** Default on for new users; a saved false must survive an app restart. */ -export function loadAutoZoomEnabled(userData: string): boolean { - const value = readSettings(userData).autoZoomEnabled; - return typeof value === "boolean" ? value : true; -} - -/** Save only this durable preference; device selection remains session-only. */ -export function saveAutoZoomEnabled(userData: string, enabled: boolean): void { - if (typeof enabled !== "boolean") throw new TypeError("autoZoomEnabled must be a boolean"); +/** Sets one key, keeping every other one. Throws on a failed write, so no caller reports a + * preference that will not survive a restart. */ +function writeSetting(userData: string, key: string, value: unknown): void { const destination = path.join(userData, "recording-settings.json"); const temporary = `${destination}.${process.pid}.tmp`; try { writeFileSync( temporary, - `${JSON.stringify({ ...readSettings(userData), autoZoomEnabled: enabled })}\n`, + `${JSON.stringify({ ...readSettings(userData), [key]: value })}\n`, "utf8", ); renameSync(temporary, destination); @@ -36,3 +30,26 @@ export function saveAutoZoomEnabled(userData: string, enabled: boolean): void { rmSync(temporary, { force: true }); } } + +/** Default on for new users; a saved false must survive an app restart. */ +export function loadAutoZoomEnabled(userData: string): boolean { + const value = readSettings(userData).autoZoomEnabled; + return typeof value === "boolean" ? value : true; +} + +/** Save only this durable preference; device selection remains session-only. */ +export function saveAutoZoomEnabled(userData: string, enabled: boolean): void { + if (typeof enabled !== "boolean") throw new TypeError("autoZoomEnabled must be a boolean"); + writeSetting(userData, "autoZoomEnabled", enabled); +} + +/** Settings → "Save recordings to" as saved, or null for the default folder. Unvalidated: + * every use goes through `validRecordingsFolder` (recordingsFolder.ts). */ +export function loadRecordingsFolder(userData: string): string | null { + const value = readSettings(userData).recordingsFolder; + return typeof value === "string" ? value : null; +} + +export function saveRecordingsFolder(userData: string, folder: string | null): void { + writeSetting(userData, "recordingsFolder", folder); +} diff --git a/electron/recordingsCleanup.ts b/electron/recordingsCleanup.ts index e3b097fc3..3f638249c 100644 --- a/electron/recordingsCleanup.ts +++ b/electron/recordingsCleanup.ts @@ -10,6 +10,13 @@ // This file does two things that need a filesystem: work out which recordings a // saved project still needs, and delete. // +// ONLY THE DEFAULT FOLDER IS EVER SWEPT, never one chosen in Settings (see +// `recordingsFolder.ts`). Files are recognised by name (`recordingsCleanupPolicy.ts`), +// and that is enough in a folder only Capturia writes to. It is not in `~/Videos`: +// another tool's `recording-20240101.mp4` fits the same pattern, and even Capturia's +// own takes there are ones the user moved out of app storage on purpose — the age and +// size passes below would delete finished recordings the user chose to keep. +// // THE ONE RULE THAT MATTERS: if the protected set cannot be computed in full, // nothing is deleted. A partial answer is worse than no cleanup, because it // looks like a successful run while removing exactly the media whose project diff --git a/electron/recordingsFolder.test.ts b/electron/recordingsFolder.test.ts new file mode 100644 index 000000000..139ffa672 --- /dev/null +++ b/electron/recordingsFolder.test.ts @@ -0,0 +1,183 @@ +import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { isPathWithinRecordingRoots, validRecordingsFolder } from "./recordingsFolder"; + +const temps: string[] = []; + +afterEach(() => { + for (const dir of temps) rmSync(dir, { recursive: true, force: true }); + temps.length = 0; +}); + +/** `/default`, `/chosen` and `/outside`, all real directories. */ +function roots() { + const base = mkdtempSync(path.join(os.tmpdir(), "capturia-recordings-folder-")); + temps.push(base); + const dirs = { + base, + defaultDir: path.join(base, "default"), + chosen: path.join(base, "chosen"), + outside: path.join(base, "outside"), + }; + for (const dir of [dirs.defaultDir, dirs.chosen, dirs.outside]) mkdirSync(dir); + return dirs; +} + +// Links need a privilege on Windows that CI and most dev machines do not hold. +const itWithSymlinks = it.skipIf(process.platform === "win32"); + +describe("isPathWithinRecordingRoots", () => { + it("allows the default folder exactly as before, whatever the file is called", () => { + const { defaultDir, chosen } = roots(); + expect( + isPathWithinRecordingRoots(path.join(defaultDir, "recording-1.webm"), defaultDir, null), + ).toBe(true); + expect( + isPathWithinRecordingRoots(path.join(defaultDir, "voiceover-x.webm"), defaultDir, chosen), + ).toBe(true); + }); + + it("allows the chosen folder for Capturia's own files, existing or about to be written", () => { + const { defaultDir, chosen } = roots(); + writeFileSync(path.join(chosen, "recording-1.mp4"), ""); + expect( + isPathWithinRecordingRoots(path.join(chosen, "recording-1.mp4"), defaultDir, chosen), + ).toBe(true); + expect( + isPathWithinRecordingRoots(path.join(chosen, "recording-2-webcam.webm"), defaultDir, chosen), + ).toBe(true); + expect( + isPathWithinRecordingRoots(path.join(chosen, "recording-1.session.json"), defaultDir, chosen), + ).toBe(true); + expect( + isPathWithinRecordingRoots( + path.join(chosen, "recording-1.mp4.cursor.json"), + defaultDir, + chosen, + ), + ).toBe(true); + }); + + it("refuses the user's own files in the chosen folder", () => { + const { defaultDir, chosen } = roots(); + writeFileSync(path.join(chosen, "holiday.mp4"), ""); + expect(isPathWithinRecordingRoots(path.join(chosen, "holiday.mp4"), defaultDir, chosen)).toBe( + false, + ); + expect( + isPathWithinRecordingRoots(path.join(chosen, "notes.session.json"), defaultDir, chosen), + ).toBe(false); + expect(isPathWithinRecordingRoots(chosen, defaultDir, chosen)).toBe(false); + }); + + it("refuses the chosen folder once it is no longer the chosen one", () => { + const { defaultDir, chosen } = roots(); + expect(isPathWithinRecordingRoots(path.join(chosen, "recording-1.mp4"), defaultDir, null)).toBe( + false, + ); + }); + + it("rejects `..` traversal and sibling prefixes out of either root", () => { + const { defaultDir, chosen, outside, base } = roots(); + for (const root of [defaultDir, chosen]) { + expect( + isPathWithinRecordingRoots( + path.join(root, "..", "outside", "recording-1.mp4"), + defaultDir, + chosen, + ), + ).toBe(false); + expect( + isPathWithinRecordingRoots( + `${root}${path.sep}..${path.sep}recording-1.mp4`, + defaultDir, + chosen, + ), + ).toBe(false); + } + mkdirSync(`${chosen}-evil`); + expect( + isPathWithinRecordingRoots( + path.join(`${chosen}-evil`, "recording-1.mp4"), + defaultDir, + chosen, + ), + ).toBe(false); + expect( + isPathWithinRecordingRoots(path.join(outside, "recording-1.mp4"), defaultDir, chosen), + ).toBe(false); + expect(isPathWithinRecordingRoots(path.join(base, "recording-1.mp4"), defaultDir, chosen)).toBe( + false, + ); + }); + + itWithSymlinks("rejects a symlink in the chosen folder that points out of it", () => { + const { defaultDir, chosen, outside } = roots(); + writeFileSync(path.join(outside, "recording-1.mp4"), ""); + symlinkSync(outside, path.join(chosen, "escape"), "dir"); + symlinkSync(path.join(outside, "recording-1.mp4"), path.join(chosen, "recording-2.mp4")); + symlinkSync(path.join(outside, "missing.mp4"), path.join(chosen, "recording-3.mp4")); + + // Through a linked directory, a linked file, and a dangling link that a write would follow. + expect( + isPathWithinRecordingRoots( + path.join(chosen, "escape", "recording-1.mp4"), + defaultDir, + chosen, + ), + ).toBe(false); + expect( + isPathWithinRecordingRoots( + path.join(chosen, "escape", "recording-9.mp4"), + defaultDir, + chosen, + ), + ).toBe(false); + expect( + isPathWithinRecordingRoots(path.join(chosen, "recording-2.mp4"), defaultDir, chosen), + ).toBe(false); + expect( + isPathWithinRecordingRoots(path.join(chosen, "recording-3.mp4"), defaultDir, chosen), + ).toBe(false); + }); + + itWithSymlinks("still allows a chosen folder that is itself reached through a link", () => { + const { defaultDir, chosen, base } = roots(); + const linked = path.join(base, "linked-chosen"); + symlinkSync(chosen, linked, "dir"); + writeFileSync(path.join(chosen, "recording-1.mp4"), ""); + expect( + isPathWithinRecordingRoots(path.join(linked, "recording-1.mp4"), defaultDir, linked), + ).toBe(true); + }); +}); + +describe("validRecordingsFolder", () => { + it("accepts an existing absolute directory and nothing else", () => { + const { chosen, base } = roots(); + const file = path.join(base, "file.txt"); + writeFileSync(file, ""); + expect(validRecordingsFolder(chosen)).toBe(chosen); + expect(validRecordingsFolder(null)).toBeNull(); + expect(validRecordingsFolder("relative/folder")).toBeNull(); + expect(validRecordingsFolder(path.join(base, "unplugged"))).toBeNull(); + expect(validRecordingsFolder(file)).toBeNull(); + }); + + // root ignores the permission bits, so the check cannot fail there. + it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + "refuses a folder it can no longer write to only when asked about writing", + () => { + const { chosen } = roots(); + chmodSync(chosen, 0o500); + try { + expect(validRecordingsFolder(chosen)).toBe(chosen); + expect(validRecordingsFolder(chosen, { writable: true })).toBeNull(); + } finally { + chmodSync(chosen, 0o700); + } + }, + ); +}); diff --git a/electron/recordingsFolder.ts b/electron/recordingsFolder.ts new file mode 100644 index 000000000..924077109 --- /dev/null +++ b/electron/recordingsFolder.ts @@ -0,0 +1,84 @@ +// Settings → "Save recordings to": whether the saved folder is usable, and which folders a +// path from the renderer may name. The choice itself is persisted in recording-settings.ts. +// +// Choosing a folder decides where NEW takes are written. It does not move anything, and the +// default folder (`RECORDINGS_DIR`, under userData) never stops being a root, so every take +// recorded before the choice keeps working where it is. +// +// Node-pure, like `recordingsCleanup.ts`: no `electron` import, every directory is injected. + +import { accessSync, constants, lstatSync, realpathSync, statSync } from "node:fs"; +import path from "node:path"; +import { recordingGroupKeyFromFileName } from "../src/lib/recordingsCleanupPolicy"; + +/** + * `folder` when it is an absolute path to an existing directory — and, with `writable`, one this + * process may create files in — else null, which means "use the default folder". + * + * Asked on every use rather than once: a drive can be unplugged, or a permission revoked, + * between two takes, and the saved file can say anything. + */ +export function validRecordingsFolder( + folder: string | null, + options: { writable?: boolean } = {}, +): string | null { + if (!folder || !path.isAbsolute(folder)) return null; + try { + if (!statSync(folder).isDirectory()) return null; + if (options.writable) accessSync(folder, constants.W_OK); + return path.resolve(folder); + } catch { + return null; + } +} + +/** Lexical containment: rejects `..` traversal and sibling prefixes, does not look at links. */ +export function isPathWithinDir(filePath: string, dirPath: string): boolean { + const resolved = path.resolve(filePath); + const resolvedDir = path.resolve(dirPath); + return resolved === resolvedDir || resolved.startsWith(resolvedDir + path.sep); +} + +/** `realpath`, except that a path with nothing behind it yet is judged by its parent directory. */ +function realpathAllowingMissing(filePath: string): string { + try { + return realpathSync(filePath); + } catch (error) { + // A dangling link reports ENOENT too, and can still be written through, so only a path + // that lstat cannot find either gets the parent's answer. + if ( + (error as NodeJS.ErrnoException).code !== "ENOENT" || + lstatSync(filePath, { throwIfNoEntry: false }) + ) { + throw error; + } + return path.join(realpathSync(path.dirname(filePath)), path.basename(filePath)); + } +} + +/** + * Whether a renderer-supplied path may be read or written as a recording. + * + * The default folder is app-private and keeps exactly the lexical check it always had. The + * chosen folder is the user's — `~/Videos`, a whole drive — so inside it only a file named like + * a take counts (`recordingGroupKeyFromFileName`: Capturia's own names, nothing else in there), + * and the path must still be inside once links are resolved: a symlink in that folder pointing + * out of it is not a way out. + */ +export function isPathWithinRecordingRoots( + filePath: string, + defaultDir: string, + chosenDir: string | null, +): boolean { + if (isPathWithinDir(filePath, defaultDir)) return true; + if (!chosenDir || !isPathWithinDir(filePath, chosenDir)) return false; + if (recordingGroupKeyFromFileName(path.basename(filePath)) === null) return false; + try { + return isPathWithinDir( + realpathAllowingMissing(path.resolve(filePath)), + realpathSync(chosenDir), + ); + } catch { + return false; + } +} diff --git a/src/components/launch/HudDeviceSettings.tsx b/src/components/launch/HudDeviceSettings.tsx index 67996993d..4651c5af2 100644 --- a/src/components/launch/HudDeviceSettings.tsx +++ b/src/components/launch/HudDeviceSettings.tsx @@ -43,6 +43,18 @@ export interface HudDeviceSettingsLabels { about: string; checkForUpdates: string; checkingForUpdates: string; + saveTo: string; + changeFolder: string; + resetFolder: string; + folderUnavailable: string; + folderHint: string; +} + +/** What main reports for Settings → "Save recordings to" (`get-recordings-folder`). */ +export interface RecordingsFolderState { + folder: string; + isDefault: boolean; + available: boolean; } /** Segmented input-level bar, driven by the live analyser. */ @@ -140,6 +152,8 @@ export const HudDeviceSettings = memo(function HudDeviceSettings({ versionLabel, canCheckForUpdates, checkingForUpdates, + recordingsFolder, + recordingsFolderLocked, onSelectMic, onSelectCamera, onSelectFrameRate, @@ -147,6 +161,8 @@ export const HudDeviceSettings = memo(function HudDeviceSettings({ onSelectCountdown, onSelectMicGain, onCheckForUpdates, + onChooseRecordingsFolder, + onResetRecordingsFolder, onClose, panelRef, }: { @@ -166,6 +182,10 @@ export const HudDeviceSettings = memo(function HudDeviceSettings({ versionLabel: string | null; canCheckForUpdates: boolean; checkingForUpdates: boolean; + /** Null until main answers; the row stays out rather than showing a blank path. */ + recordingsFolder: RecordingsFolderState | null; + /** Mid-take: the take already has its folder, and its stop paths expect it unchanged. */ + recordingsFolderLocked: boolean; onSelectMic: (device: MicrophoneDevice) => void; onSelectCamera: (device: CameraDevice) => void; onSelectFrameRate: (fps: CaptureFrameRate) => void; @@ -173,6 +193,8 @@ export const HudDeviceSettings = memo(function HudDeviceSettings({ onSelectCountdown: (seconds: CountdownSeconds) => void; onSelectMicGain: (gain: MicrophoneGain) => void; onCheckForUpdates: () => void; + onChooseRecordingsFolder: () => void; + onResetRecordingsFolder: () => void; onClose: () => void; panelRef: (el: HTMLDivElement | null) => void; }) { @@ -334,6 +356,45 @@ export const HudDeviceSettings = memo(function HudDeviceSettings({
{labels.captureHint}
+ {/* Where the next take is written — the same kind of choice as the rows above. Main + owns the path: Change opens the OS picker there, and nothing here types one in. */} + {recordingsFolder ? ( + <> +
{labels.saveTo}
+
+ + {recordingsFolder.folder} + + + + {recordingsFolder.isDefault ? null : ( + + )} + +
+
+ {recordingsFolder.available ? labels.folderHint : labels.folderUnavailable} +
+ + ) : null} + {/* This panel is the app's only settings surface, so the permission list lives here rather than behind a window of its own: it is the same question as the device rows above — will the next recording actually diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index ffb3b73e1..32315ccac 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -28,7 +28,11 @@ import { HudTrayLayoutButton, HudWindowControls, } from "./HudControls"; -import { HudDeviceSettings, type HudDeviceSettingsLabels } from "./HudDeviceSettings"; +import { + HudDeviceSettings, + type HudDeviceSettingsLabels, + type RecordingsFolderState, +} from "./HudDeviceSettings"; import { computeHudBarMaxHeight, computeHudModalMaxHeight, @@ -143,6 +147,7 @@ export function LaunchWindow() { null, ); const [isCheckingForUpdates, setIsCheckingForUpdates] = useState(false); + const [recordingsFolder, setRecordingsFolder] = useState(null); /** * Narrower than [`isLinuxHud`] on purpose: without the helper the recorder * falls back to Chromium's capture, which DOES take a source id, so the @@ -286,6 +291,42 @@ export function LaunchWindow() { }); }, []); + // Asked each time the panel opens rather than once: the chosen folder can go away (a drive + // unplugged) while the HUD sits there, and the row should say so when the user looks. + useEffect(() => { + const getRecordingsFolder = window.electronAPI?.getRecordingsFolder; + if (!isDeviceSettingsOpen || !getRecordingsFolder) return; + let cancelled = false; + getRecordingsFolder() + .then((state) => { + if (!cancelled) setRecordingsFolder(state); + }) + .catch((error) => { + console.warn("Failed to read the recordings folder:", error); + }); + return () => { + cancelled = true; + }; + }, [isDeviceSettingsOpen]); + + const handleChooseRecordingsFolder = useCallback(() => { + window.electronAPI + ?.chooseRecordingsFolder?.() + .then(setRecordingsFolder) + .catch((error) => { + console.error("Failed to choose a recordings folder:", error); + }); + }, []); + + const handleResetRecordingsFolder = useCallback(() => { + window.electronAPI + ?.resetRecordingsFolder?.() + .then(setRecordingsFolder) + .catch((error) => { + console.error("Failed to reset the recordings folder:", error); + }); + }, []); + useEffect(() => { if (!import.meta.env.DEV) { return; @@ -936,6 +977,11 @@ export function LaunchWindow() { about: t("deviceSettings.about"), checkForUpdates: tCommon("actions.checkForUpdates"), checkingForUpdates: t("deviceSettings.checkingForUpdates"), + saveTo: t("deviceSettings.saveTo"), + changeFolder: t("deviceSettings.changeFolder"), + resetFolder: t("deviceSettings.resetFolder"), + folderUnavailable: t("deviceSettings.folderUnavailable"), + folderHint: t("deviceSettings.folderHint"), }), [t, tCommon], ); @@ -1164,6 +1210,8 @@ export function LaunchWindow() { // main process refuses the check then — an offered button would be dead. canCheckForUpdates={(appInfo?.canCheckForUpdates ?? false) && !recording} checkingForUpdates={isCheckingForUpdates} + recordingsFolder={recordingsFolder} + recordingsFolderLocked={controlsLocked} onSelectMic={handleSelectMicDevice} onSelectCamera={handleSelectCameraDevice} onSelectFrameRate={setCaptureFrameRate} @@ -1171,6 +1219,8 @@ export function LaunchWindow() { onSelectCountdown={setCountdownSeconds} onSelectMicGain={setMicrophoneGain} onCheckForUpdates={handleCheckForUpdates} + onChooseRecordingsFolder={handleChooseRecordingsFolder} + onResetRecordingsFolder={handleResetRecordingsFolder} onClose={closeDeviceSettings} panelRef={setPopoverEl} /> diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index fc883f7e0..0c4e4e69c 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -1920,6 +1920,21 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return true; }; + /** + * False when the folder chosen in Settings cannot take this recording and the + * user declined the default folder instead (main asks; see + * `confirm-recordings-folder`). Like the disk check, a check that could not + * run answers true. + */ + const recordingsFolderConfirmed = async (): Promise => { + try { + return (await window.electronAPI?.confirmRecordingsFolder?.()) !== false; + } catch (error) { + console.warn("Failed to check the recordings folder before recording:", error); + return true; + } + }; + const startRecording = async ( countdownRunToken?: number, preparedRecordingId?: number | null, @@ -1935,8 +1950,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn { // The one choke point every start path funnels through — the countdown, // the tray, the CLI runner and `restartRecording` all land here — so the - // disk gate lives here rather than in `startRecordCountdown`. - if (!(await hasRoomToRecord())) { + // disk gate lives here rather than in `startRecordCountdown`. The folder + // gate goes first: the disk check measures wherever the take will land, + // which is the default folder once the user accepts it instead. + if (!(await recordingsFolderConfirmed()) || !(await hasRoomToRecord())) { teardownMedia(); return; } diff --git a/src/i18n/locales/ar/dialogs.json b/src/i18n/locales/ar/dialogs.json index 44bb800c2..419fbbbfe 100644 --- a/src/i18n/locales/ar/dialogs.json +++ b/src/i18n/locales/ar/dialogs.json @@ -87,6 +87,11 @@ "videoFiles": "ملفات فيديو", "audioFiles": "ملفات الصوت", "openscreenProject": "مشروع Capturia", - "allFiles": "جميع الملفات" + "allFiles": "جميع الملفات", + "selectRecordingsFolder": "اختيار مجلد التسجيلات" + }, + "recordingsFolder": { + "unavailable": "لا يمكن لـ Capturia حفظ التسجيلات في {{folder}}. ربما تم فصل القرص، أو لا يملك Capturia إذن الكتابة هناك.", + "useDefault": "التسجيل في المجلد الافتراضي" } } diff --git a/src/i18n/locales/ar/launch.json b/src/i18n/locales/ar/launch.json index cea4a8d7c..7f5d8455f 100644 --- a/src/i18n/locales/ar/launch.json +++ b/src/i18n/locales/ar/launch.json @@ -107,7 +107,12 @@ "previewUnavailable": "المعاينة غير متاحة", "about": "حول", "version": "الإصدار {{version}}", - "checkingForUpdates": "جارٍ التحقق…" + "checkingForUpdates": "جارٍ التحقق…", + "saveTo": "حفظ التسجيلات في", + "changeFolder": "تغيير…", + "resetFolder": "إعادة تعيين", + "folderUnavailable": "غير متاح حاليًا — سيُقترح المجلد الافتراضي عند بدء التسجيل.", + "folderHint": "تبقى التسجيلات السابقة في المكان الذي حُفظت فيه." }, "permissions": { "title": "الأذونات", diff --git a/src/i18n/locales/en/dialogs.json b/src/i18n/locales/en/dialogs.json index 64027c5d6..3db33b20a 100644 --- a/src/i18n/locales/en/dialogs.json +++ b/src/i18n/locales/en/dialogs.json @@ -87,6 +87,11 @@ "videoFiles": "Video Files", "audioFiles": "Audio Files", "openscreenProject": "Capturia Project", - "allFiles": "All Files" + "allFiles": "All Files", + "selectRecordingsFolder": "Select Recordings Folder" + }, + "recordingsFolder": { + "unavailable": "Capturia can't save recordings to {{folder}}. The drive may be disconnected, or Capturia may not have permission to write there.", + "useDefault": "Record to Default Folder" } } diff --git a/src/i18n/locales/en/launch.json b/src/i18n/locales/en/launch.json index c105b7f60..75715a503 100644 --- a/src/i18n/locales/en/launch.json +++ b/src/i18n/locales/en/launch.json @@ -107,7 +107,12 @@ "previewUnavailable": "Preview unavailable", "about": "About", "version": "Version {{version}}", - "checkingForUpdates": "Checking…" + "checkingForUpdates": "Checking…", + "saveTo": "Save recordings to", + "changeFolder": "Change…", + "resetFolder": "Reset", + "folderUnavailable": "Unavailable right now — the default folder will be offered when you record.", + "folderHint": "Earlier recordings stay where they were saved." }, "permissions": { "title": "Permissions", diff --git a/src/i18n/locales/es/dialogs.json b/src/i18n/locales/es/dialogs.json index 127189d62..9f385db78 100644 --- a/src/i18n/locales/es/dialogs.json +++ b/src/i18n/locales/es/dialogs.json @@ -87,6 +87,11 @@ "videoFiles": "Archivos de video", "audioFiles": "Archivos de audio", "openscreenProject": "Proyecto Capturia", - "allFiles": "Todos los archivos" + "allFiles": "Todos los archivos", + "selectRecordingsFolder": "Seleccionar carpeta de grabaciones" + }, + "recordingsFolder": { + "unavailable": "Capturia no puede guardar grabaciones en {{folder}}. Puede que la unidad esté desconectada o que Capturia no tenga permiso para escribir ahí.", + "useDefault": "Grabar en la carpeta predeterminada" } } diff --git a/src/i18n/locales/es/launch.json b/src/i18n/locales/es/launch.json index a404b55aa..49bca1652 100644 --- a/src/i18n/locales/es/launch.json +++ b/src/i18n/locales/es/launch.json @@ -107,7 +107,12 @@ "previewUnavailable": "Vista previa no disponible", "about": "Acerca de", "version": "Versión {{version}}", - "checkingForUpdates": "Buscando…" + "checkingForUpdates": "Buscando…", + "saveTo": "Guardar grabaciones en", + "changeFolder": "Cambiar…", + "resetFolder": "Restablecer", + "folderUnavailable": "No disponible ahora mismo: se te ofrecerá la carpeta predeterminada al grabar.", + "folderHint": "Las grabaciones anteriores se quedan donde se guardaron." }, "permissions": { "title": "Permisos", diff --git a/src/i18n/locales/fr/dialogs.json b/src/i18n/locales/fr/dialogs.json index 950916434..c1782db3d 100644 --- a/src/i18n/locales/fr/dialogs.json +++ b/src/i18n/locales/fr/dialogs.json @@ -87,6 +87,11 @@ "videoFiles": "Fichiers vidéo", "audioFiles": "Fichiers audio", "openscreenProject": "Projet Capturia", - "allFiles": "Tous les fichiers" + "allFiles": "Tous les fichiers", + "selectRecordingsFolder": "Choisir le dossier des enregistrements" + }, + "recordingsFolder": { + "unavailable": "Capturia ne peut pas enregistrer dans {{folder}}. Le disque est peut-être déconnecté, ou Capturia n'a pas l'autorisation d'y écrire.", + "useDefault": "Enregistrer dans le dossier par défaut" } } diff --git a/src/i18n/locales/fr/launch.json b/src/i18n/locales/fr/launch.json index 1c628d968..6796158e8 100644 --- a/src/i18n/locales/fr/launch.json +++ b/src/i18n/locales/fr/launch.json @@ -107,7 +107,12 @@ "previewUnavailable": "Aperçu indisponible", "about": "À propos", "version": "Version {{version}}", - "checkingForUpdates": "Recherche…" + "checkingForUpdates": "Recherche…", + "saveTo": "Emplacement des enregistrements", + "changeFolder": "Modifier…", + "resetFolder": "Réinitialiser", + "folderUnavailable": "Indisponible pour le moment — le dossier par défaut vous sera proposé à l'enregistrement.", + "folderHint": "Les enregistrements précédents restent à leur emplacement actuel." }, "permissions": { "title": "Autorisations", diff --git a/src/i18n/locales/it/dialogs.json b/src/i18n/locales/it/dialogs.json index 448eb3b3f..f70988ebf 100644 --- a/src/i18n/locales/it/dialogs.json +++ b/src/i18n/locales/it/dialogs.json @@ -87,6 +87,11 @@ "videoFiles": "File video", "audioFiles": "File audio", "openscreenProject": "Progetto Capturia", - "allFiles": "Tutti i file" + "allFiles": "Tutti i file", + "selectRecordingsFolder": "Seleziona la cartella delle registrazioni" + }, + "recordingsFolder": { + "unavailable": "Capturia non può salvare le registrazioni in {{folder}}. L'unità potrebbe essere scollegata, oppure Capturia non ha il permesso di scrivere lì.", + "useDefault": "Registra nella cartella predefinita" } } diff --git a/src/i18n/locales/it/launch.json b/src/i18n/locales/it/launch.json index 17c934585..751417cce 100644 --- a/src/i18n/locales/it/launch.json +++ b/src/i18n/locales/it/launch.json @@ -107,7 +107,12 @@ "previewUnavailable": "Anteprima non disponibile", "about": "Info", "version": "Versione {{version}}", - "checkingForUpdates": "Controllo…" + "checkingForUpdates": "Controllo…", + "saveTo": "Salva le registrazioni in", + "changeFolder": "Cambia…", + "resetFolder": "Ripristina", + "folderUnavailable": "Non disponibile al momento: all'avvio della registrazione verrà proposta la cartella predefinita.", + "folderHint": "Le registrazioni precedenti restano dove sono state salvate." }, "permissions": { "title": "Autorizzazioni", diff --git a/src/i18n/locales/ja-JP/dialogs.json b/src/i18n/locales/ja-JP/dialogs.json index 8f772fff1..d8572b9c4 100644 --- a/src/i18n/locales/ja-JP/dialogs.json +++ b/src/i18n/locales/ja-JP/dialogs.json @@ -87,6 +87,11 @@ "videoFiles": "動画ファイル", "audioFiles": "オーディオファイル", "openscreenProject": "Capturia プロジェクト", - "allFiles": "すべてのファイル" + "allFiles": "すべてのファイル", + "selectRecordingsFolder": "録画の保存フォルダーを選択" + }, + "recordingsFolder": { + "unavailable": "Capturia は {{folder}} に録画を保存できません。ドライブが取り外されているか、書き込み権限がない可能性があります。", + "useDefault": "デフォルトのフォルダーに録画" } } diff --git a/src/i18n/locales/ja-JP/launch.json b/src/i18n/locales/ja-JP/launch.json index bad8b4f04..7a56e564c 100644 --- a/src/i18n/locales/ja-JP/launch.json +++ b/src/i18n/locales/ja-JP/launch.json @@ -107,7 +107,12 @@ "previewUnavailable": "プレビューを利用できません", "about": "情報", "version": "バージョン {{version}}", - "checkingForUpdates": "確認中…" + "checkingForUpdates": "確認中…", + "saveTo": "録画の保存先", + "changeFolder": "変更…", + "resetFolder": "リセット", + "folderUnavailable": "現在利用できません。録画時にデフォルトのフォルダーを提案します。", + "folderHint": "以前の録画は保存された場所にそのまま残ります。" }, "permissions": { "title": "アクセス権限", diff --git a/src/i18n/locales/ko-KR/dialogs.json b/src/i18n/locales/ko-KR/dialogs.json index 59a30d41d..e37ef210c 100644 --- a/src/i18n/locales/ko-KR/dialogs.json +++ b/src/i18n/locales/ko-KR/dialogs.json @@ -87,6 +87,11 @@ "videoFiles": "비디오 파일", "audioFiles": "오디오 파일", "openscreenProject": "Capturia 프로젝트", - "allFiles": "모든 파일" + "allFiles": "모든 파일", + "selectRecordingsFolder": "녹화 폴더 선택" + }, + "recordingsFolder": { + "unavailable": "Capturia가 {{folder}}에 녹화를 저장할 수 없습니다. 드라이브가 분리되었거나 해당 위치에 쓸 권한이 없을 수 있습니다.", + "useDefault": "기본 폴더에 녹화" } } diff --git a/src/i18n/locales/ko-KR/launch.json b/src/i18n/locales/ko-KR/launch.json index cf34d1dab..35ffc9d01 100644 --- a/src/i18n/locales/ko-KR/launch.json +++ b/src/i18n/locales/ko-KR/launch.json @@ -107,7 +107,12 @@ "previewUnavailable": "미리 보기를 사용할 수 없습니다", "about": "정보", "version": "버전 {{version}}", - "checkingForUpdates": "확인 중…" + "checkingForUpdates": "확인 중…", + "saveTo": "녹화 저장 위치", + "changeFolder": "변경…", + "resetFolder": "초기화", + "folderUnavailable": "지금은 사용할 수 없습니다. 녹화할 때 기본 폴더를 제안합니다.", + "folderHint": "이전 녹화는 저장된 위치에 그대로 남습니다." }, "permissions": { "title": "권한", diff --git a/src/i18n/locales/pt-BR/dialogs.json b/src/i18n/locales/pt-BR/dialogs.json index 5fe81c8d2..35725575a 100644 --- a/src/i18n/locales/pt-BR/dialogs.json +++ b/src/i18n/locales/pt-BR/dialogs.json @@ -87,6 +87,11 @@ "videoFiles": "Arquivos de Vídeo", "audioFiles": "Arquivos de áudio", "openscreenProject": "Projeto Capturia", - "allFiles": "Todos os Arquivos" + "allFiles": "Todos os Arquivos", + "selectRecordingsFolder": "Selecionar pasta de gravações" + }, + "recordingsFolder": { + "unavailable": "O Capturia não consegue salvar gravações em {{folder}}. A unidade pode estar desconectada ou o Capturia pode não ter permissão para gravar lá.", + "useDefault": "Gravar na pasta padrão" } } diff --git a/src/i18n/locales/pt-BR/launch.json b/src/i18n/locales/pt-BR/launch.json index db1f46b37..22d5b8e96 100644 --- a/src/i18n/locales/pt-BR/launch.json +++ b/src/i18n/locales/pt-BR/launch.json @@ -107,7 +107,12 @@ "previewUnavailable": "Pré-visualização indisponível", "about": "Sobre", "version": "Versão {{version}}", - "checkingForUpdates": "Verificando…" + "checkingForUpdates": "Verificando…", + "saveTo": "Salvar gravações em", + "changeFolder": "Alterar…", + "resetFolder": "Redefinir", + "folderUnavailable": "Indisponível no momento — a pasta padrão será oferecida ao gravar.", + "folderHint": "As gravações anteriores continuam onde foram salvas." }, "permissions": { "title": "Permissões", diff --git a/src/i18n/locales/ru/dialogs.json b/src/i18n/locales/ru/dialogs.json index 50cbb09c1..68fe40348 100644 --- a/src/i18n/locales/ru/dialogs.json +++ b/src/i18n/locales/ru/dialogs.json @@ -87,6 +87,11 @@ "videoFiles": "Видеофайлы", "audioFiles": "Аудиофайлы", "openscreenProject": "Проект Capturia", - "allFiles": "Все файлы" + "allFiles": "Все файлы", + "selectRecordingsFolder": "Выбрать папку для записей" + }, + "recordingsFolder": { + "unavailable": "Capturia не может сохранять записи в {{folder}}. Возможно, диск отключён или у Capturia нет прав на запись в эту папку.", + "useDefault": "Записать в папку по умолчанию" } } diff --git a/src/i18n/locales/ru/launch.json b/src/i18n/locales/ru/launch.json index 2ce44149d..3b8c819a8 100644 --- a/src/i18n/locales/ru/launch.json +++ b/src/i18n/locales/ru/launch.json @@ -107,7 +107,12 @@ "previewUnavailable": "Предпросмотр недоступен", "about": "О программе", "version": "Версия {{version}}", - "checkingForUpdates": "Проверка…" + "checkingForUpdates": "Проверка…", + "saveTo": "Сохранять записи в", + "changeFolder": "Изменить…", + "resetFolder": "Сбросить", + "folderUnavailable": "Сейчас недоступна — при записи будет предложена папка по умолчанию.", + "folderHint": "Прежние записи остаются там, где были сохранены." }, "permissions": { "title": "Разрешения", diff --git a/src/i18n/locales/tr/dialogs.json b/src/i18n/locales/tr/dialogs.json index 6ec0b04e0..ba110c472 100644 --- a/src/i18n/locales/tr/dialogs.json +++ b/src/i18n/locales/tr/dialogs.json @@ -87,6 +87,11 @@ "videoFiles": "Video Dosyaları", "audioFiles": "Ses dosyaları", "openscreenProject": "Capturia Projesi", - "allFiles": "Tüm Dosyalar" + "allFiles": "Tüm Dosyalar", + "selectRecordingsFolder": "Kayıt Klasörünü Seç" + }, + "recordingsFolder": { + "unavailable": "Capturia kayıtları {{folder}} konumuna kaydedemiyor. Sürücünün bağlantısı kesilmiş olabilir veya Capturia'nın oraya yazma izni olmayabilir.", + "useDefault": "Varsayılan Klasöre Kaydet" } } diff --git a/src/i18n/locales/tr/launch.json b/src/i18n/locales/tr/launch.json index a0192c260..5ba4c49f8 100644 --- a/src/i18n/locales/tr/launch.json +++ b/src/i18n/locales/tr/launch.json @@ -107,7 +107,12 @@ "previewUnavailable": "Önizleme kullanılamıyor", "about": "Hakkında", "version": "Sürüm {{version}}", - "checkingForUpdates": "Denetleniyor…" + "checkingForUpdates": "Denetleniyor…", + "saveTo": "Kayıtları şuraya kaydet", + "changeFolder": "Değiştir…", + "resetFolder": "Sıfırla", + "folderUnavailable": "Şu anda kullanılamıyor — kayıt başlatırken varsayılan klasör önerilecek.", + "folderHint": "Önceki kayıtlar kaydedildikleri yerde kalır." }, "permissions": { "title": "İzinler", diff --git a/src/i18n/locales/vi/dialogs.json b/src/i18n/locales/vi/dialogs.json index 30479828c..bd14acb27 100644 --- a/src/i18n/locales/vi/dialogs.json +++ b/src/i18n/locales/vi/dialogs.json @@ -87,6 +87,11 @@ "videoFiles": "Tệp Video", "audioFiles": "Tệp âm thanh", "openscreenProject": "Dự án Capturia", - "allFiles": "Tất cả các tệp" + "allFiles": "Tất cả các tệp", + "selectRecordingsFolder": "Chọn thư mục lưu bản ghi" + }, + "recordingsFolder": { + "unavailable": "Capturia không thể lưu bản ghi vào {{folder}}. Có thể ổ đĩa đã bị ngắt kết nối hoặc Capturia không có quyền ghi vào đó.", + "useDefault": "Ghi vào thư mục mặc định" } } diff --git a/src/i18n/locales/vi/launch.json b/src/i18n/locales/vi/launch.json index bb2825814..d6dd0d34b 100644 --- a/src/i18n/locales/vi/launch.json +++ b/src/i18n/locales/vi/launch.json @@ -107,7 +107,12 @@ "previewUnavailable": "Không thể xem trước", "about": "Giới thiệu", "version": "Phiên bản {{version}}", - "checkingForUpdates": "Đang kiểm tra…" + "checkingForUpdates": "Đang kiểm tra…", + "saveTo": "Lưu bản ghi vào", + "changeFolder": "Thay đổi…", + "resetFolder": "Đặt lại", + "folderUnavailable": "Hiện không khả dụng — thư mục mặc định sẽ được đề xuất khi bạn ghi.", + "folderHint": "Các bản ghi trước vẫn nằm ở nơi đã được lưu." }, "permissions": { "title": "Quyền truy cập", diff --git a/src/i18n/locales/zh-CN/dialogs.json b/src/i18n/locales/zh-CN/dialogs.json index 6947c1ded..3d676ce9b 100644 --- a/src/i18n/locales/zh-CN/dialogs.json +++ b/src/i18n/locales/zh-CN/dialogs.json @@ -87,6 +87,11 @@ "videoFiles": "视频文件", "audioFiles": "音频文件", "openscreenProject": "Capturia 项目", - "allFiles": "所有文件" + "allFiles": "所有文件", + "selectRecordingsFolder": "选择录制文件夹" + }, + "recordingsFolder": { + "unavailable": "Capturia 无法将录制内容保存到 {{folder}}。驱动器可能已断开连接,或者 Capturia 没有写入权限。", + "useDefault": "录制到默认文件夹" } } diff --git a/src/i18n/locales/zh-CN/launch.json b/src/i18n/locales/zh-CN/launch.json index 72e62d816..b81b05a47 100644 --- a/src/i18n/locales/zh-CN/launch.json +++ b/src/i18n/locales/zh-CN/launch.json @@ -107,7 +107,12 @@ "previewUnavailable": "预览不可用", "about": "关于", "version": "版本 {{version}}", - "checkingForUpdates": "正在检查…" + "checkingForUpdates": "正在检查…", + "saveTo": "录制保存位置", + "changeFolder": "更改…", + "resetFolder": "重置", + "folderUnavailable": "当前不可用——录制时将建议使用默认文件夹。", + "folderHint": "之前的录制内容仍保留在原来的保存位置。" }, "permissions": { "title": "权限", diff --git a/src/i18n/locales/zh-TW/dialogs.json b/src/i18n/locales/zh-TW/dialogs.json index 14fa297ff..6a2f234b9 100644 --- a/src/i18n/locales/zh-TW/dialogs.json +++ b/src/i18n/locales/zh-TW/dialogs.json @@ -87,6 +87,11 @@ "videoFiles": "影片檔案", "audioFiles": "音訊檔案", "openscreenProject": "Capturia 專案", - "allFiles": "所有檔案" + "allFiles": "所有檔案", + "selectRecordingsFolder": "選擇錄製資料夾" + }, + "recordingsFolder": { + "unavailable": "Capturia 無法將錄製內容儲存到 {{folder}}。磁碟機可能已中斷連線,或 Capturia 沒有寫入權限。", + "useDefault": "錄製到預設資料夾" } } diff --git a/src/i18n/locales/zh-TW/launch.json b/src/i18n/locales/zh-TW/launch.json index f353c6a75..4ebc54c0c 100644 --- a/src/i18n/locales/zh-TW/launch.json +++ b/src/i18n/locales/zh-TW/launch.json @@ -107,7 +107,12 @@ "previewUnavailable": "預覽無法使用", "about": "關於", "version": "版本 {{version}}", - "checkingForUpdates": "檢查中…" + "checkingForUpdates": "檢查中…", + "saveTo": "錄製儲存位置", + "changeFolder": "變更…", + "resetFolder": "重設", + "folderUnavailable": "目前無法使用——錄製時將建議改用預設資料夾。", + "folderHint": "先前的錄製內容仍保留在原本的儲存位置。" }, "permissions": { "title": "權限", From ea5bb227373e11fe22f0fa5d0484ae1c6c3491f3 Mon Sep 17 00:00:00 2001 From: MinhOmega <49482201+MinhOmega@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:13:23 +0700 Subject: [PATCH 22/40] feat(updates): opt-in pre-release update channel Settings gets "Get pre-release builds", off by default. When it is on, the update check also considers prereleases, so beta testers are offered the RCs published on MinhOmega/Capturia. - The release check reads the repository's release list instead of /releases/latest (which never returns a prerelease) and takes the highest published version; drafts and non-version tags are skipped, and the release URL still has to be this repository's tag page. With the setting off, the request and the checks are exactly what they were. - Every release URL is now built from one REPO constant, so the API and the trust anchor cannot name different repositories. - electron-updater's allowPrerelease follows the setting on every check. Left at its default it followed the running version, so an RC install could download an RC that the release check never offered. - The setting lives in update-settings.json beside the update mode; saving either keeps the other. --- electron/auto-updater.test.ts | 16 +++ electron/auto-updater.ts | 13 ++- electron/electron-env.d.ts | 8 +- electron/main.ts | 25 +++- electron/preload.ts | 4 + electron/update-checker.test.ts | 122 ++++++++++++++++++++ electron/update-checker.ts | 92 ++++++++++++--- electron/update-settings.test.ts | 27 ++++- electron/update-settings.ts | 40 +++++-- src/components/launch/HudDeviceSettings.tsx | 18 +++ src/components/launch/LaunchWindow.test.tsx | 6 +- src/components/launch/LaunchWindow.tsx | 24 +++- src/i18n/locales/ar/launch.json | 3 +- src/i18n/locales/en/launch.json | 3 +- src/i18n/locales/es/launch.json | 3 +- src/i18n/locales/fr/launch.json | 3 +- src/i18n/locales/it/launch.json | 3 +- src/i18n/locales/ja-JP/launch.json | 3 +- src/i18n/locales/ko-KR/launch.json | 3 +- src/i18n/locales/pt-BR/launch.json | 3 +- src/i18n/locales/ru/launch.json | 3 +- src/i18n/locales/tr/launch.json | 3 +- src/i18n/locales/vi/launch.json | 3 +- src/i18n/locales/zh-CN/launch.json | 3 +- src/i18n/locales/zh-TW/launch.json | 3 +- 25 files changed, 382 insertions(+), 52 deletions(-) diff --git a/electron/auto-updater.test.ts b/electron/auto-updater.test.ts index 3f60a2ec2..831e3ac3b 100644 --- a/electron/auto-updater.test.ts +++ b/electron/auto-updater.test.ts @@ -13,6 +13,8 @@ const mocks = vi.hoisted(() => ({ autoUpdater: { autoDownload: true, autoInstallOnAppQuit: true, + // electron-updater's default on an RC build, which follows the running version. + allowPrerelease: true, logger: {} as unknown, checkForUpdates: vi.fn(), downloadUpdate: vi.fn(), @@ -114,6 +116,20 @@ describe("self-update flow", () => { expect(settingsWhenChecked).toEqual([false, false]); }); + it("follows the pre-release setting on every check, not the running version", async () => { + const allowedWhenChecked: boolean[] = []; + mocks.autoUpdater.checkForUpdates.mockImplementation(() => { + allowedWhenChecked.push(mocks.autoUpdater.allowPrerelease); + return Promise.resolve({ updateInfo: { version: "1.9.2" } }); + }); + + await checkForSelfUpdate("nsis"); + await checkForSelfUpdate("nsis", true); + await checkForSelfUpdate("nsis", false); + + expect(allowedWhenChecked).toEqual([false, true, false]); + }); + it("reports current when the feed offers the running version", async () => { mocks.autoUpdater.checkForUpdates.mockResolvedValue({ updateInfo: { version: "1.9.2" } }); await expect(checkForSelfUpdate("appimage")).resolves.toEqual({ kind: "current" }); diff --git a/electron/auto-updater.ts b/electron/auto-updater.ts index fdee80d2b..e7f9dfbfa 100644 --- a/electron/auto-updater.ts +++ b/electron/auto-updater.ts @@ -54,11 +54,20 @@ async function getUpdater() { return autoUpdater; } -/** Is an update available, and can this install apply it itself? */ -export async function checkForSelfUpdate(channel: InstallChannel): Promise { +/** Is an update available, and can this install apply it itself? + * + * `allowPrerelease` is the "Get pre-release builds" setting, set on every check. Left to + * electron-updater it would follow the running version instead — an RC install would chase + * RCs the release check (update-checker.ts) never offered, and download one of those while + * the dialog named a stable. */ +export async function checkForSelfUpdate( + channel: InstallChannel, + allowPrerelease = false, +): Promise { if (!ownsItsUpdates(channel) || !app.isPackaged) return { kind: "unsupported" }; try { const autoUpdater = await getUpdater(); + autoUpdater.allowPrerelease = allowPrerelease; const result = await autoUpdater.checkForUpdates(); // null when no feed resolved; equal versions come back with no downloadPromise. const version = result?.updateInfo?.version; diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 8237616f0..b5925ccc0 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -470,7 +470,13 @@ interface Window { quitApp: () => void; setTitleBarOverlay: (color: string, symbolColor: string) => void; getPlatform: () => string; - getAppInfo: () => Promise<{ version: string; canCheckForUpdates: boolean }>; + getAppInfo: () => Promise<{ + version: string; + canCheckForUpdates: boolean; + includePrereleases: boolean; + }>; + /** Settings → "Get pre-release builds". Resolves with the value main now holds. */ + setIncludePrereleases: (value: boolean) => Promise; checkForUpdates: () => Promise; showAbout: () => Promise; canCheckForUpdatesNow: () => Promise; diff --git a/electron/main.ts b/electron/main.ts index 7faac84b0..aafe00c64 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -69,7 +69,12 @@ import { scheduleRecordingsCleanup } from "./recordingsCleanup"; import { installNavigationPolicy, installPermissionPolicy } from "./securityPolicy"; import { registerSttIpc, shutdownStt } from "./stt"; import { checkLatestRelease } from "./update-checker"; -import { loadUpdateMode, saveUpdateMode } from "./update-settings"; +import { + loadIncludePrereleases, + loadUpdateMode, + saveIncludePrereleases, + saveUpdateMode, +} from "./update-settings"; import { createCountdownOverlayWindow, createEditorWindow, @@ -633,6 +638,8 @@ function runSaveDiagnostics() { * install directory and NSIS cannot overwrite a running .exe. */ let isRecording = false; let currentUpdateMode: UpdateMode = "notify"; +/** Settings → "Get pre-release builds": both the release check and the updater follow it. */ +let includePrereleases = false; let backgroundUpdateTimer: ReturnType | null = null; function showUpdateSettingsMenu(): boolean { @@ -789,7 +796,10 @@ async function probeSelfUpdate(): Promise { timer.unref?.(); }); try { - return await Promise.race([checkForSelfUpdate(getInstallChannel()), timeout]); + return await Promise.race([ + checkForSelfUpdate(getInstallChannel(), includePrereleases), + timeout, + ]); } finally { if (timer) clearTimeout(timer); } @@ -809,6 +819,7 @@ async function checkForUpdates(onVerdict?: () => void) { currentVersion: app.getVersion(), fetchLatest: (url, init) => net.fetch(url, init), signal, + includePrereleases, }); if (result.kind === "current") { await showMessageBox({ @@ -1260,8 +1271,17 @@ appReady?.then(async () => { ipcMain.handle("get-app-info", () => ({ version: app.getVersion(), canCheckForUpdates: channelAllowsUpdateCheck(), + includePrereleases, })); + ipcMain.handle("set-include-prereleases", (_, value: unknown) => { + if (typeof value === "boolean") { + includePrereleases = value; + saveIncludePrereleases(app.getPath("userData"), value); + } + return includePrereleases; + }); + // The FULL veto, permanent and transient, for a caller that can ask again at the moment it // needs the answer. `get-app-info` deliberately carries only the permanent half because the // HUD reads it once per mount (see 33e19d6e); the editor's app menu has no such excuse — it @@ -1307,6 +1327,7 @@ appReady?.then(async () => { // all (see auto-updater.ts getUpdater) — every real update path applies // its settings lazily on first use. currentUpdateMode = loadUpdateMode(app.getPath("userData")); + includePrereleases = loadIncludePrereleases(app.getPath("userData")); createTray(); updateTrayMenu(); startBackgroundUpdateTimer(); diff --git a/electron/preload.ts b/electron/preload.ts index 45bd0f230..7aa206bd7 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -437,7 +437,11 @@ contextBridge.exposeInMainWorld("electronAPI", { ipcRenderer.invoke("get-app-info") as Promise<{ version: string; canCheckForUpdates: boolean; + includePrereleases: boolean; }>, + /** Settings → "Get pre-release builds". Resolves with the value main now holds. */ + setIncludePrereleases: (value: boolean) => + ipcRenderer.invoke("set-include-prereleases", value) as Promise, /** Resolves once the check has a verdict. The dialogs that verdict leads to — download, * restart — are the main process's conversation, not the caller's. */ checkForUpdates: () => ipcRenderer.invoke("check-for-updates") as Promise, diff --git a/electron/update-checker.test.ts b/electron/update-checker.test.ts index 617bd1078..aaa37464c 100644 --- a/electron/update-checker.test.ts +++ b/electron/update-checker.test.ts @@ -197,3 +197,125 @@ describe("checkLatestRelease", () => { ); }); }); + +describe("checkLatestRelease with pre-release builds", () => { + const release = (tag: string, extra: Record = {}) => ({ + tag_name: tag, + html_url: `https://github.com/MinhOmega/Capturia/releases/tag/${tag}`, + draft: false, + prerelease: tag.includes("-"), + ...extra, + }); + + it("reads this repository's release list and offers a newer RC", async () => { + const fetchLatest = vi + .fn() + .mockResolvedValue( + releaseResponse([release("v2.0.0-rc.3"), release("v2.0.0-rc.2"), release("v1.9.6")]), + ); + + await expect( + checkLatestRelease({ currentVersion: "1.9.6", fetchLatest, includePrereleases: true }), + ).resolves.toEqual({ + kind: "available", + currentVersion: "1.9.6", + latestVersion: "2.0.0-rc.3", + releaseUrl: "https://github.com/MinhOmega/Capturia/releases/tag/v2.0.0-rc.3", + }); + expect(fetchLatest).toHaveBeenCalledWith( + "https://api.github.com/repos/MinhOmega/Capturia/releases?per_page=30", + expect.anything(), + ); + }); + + it("asks only for the latest stable when the setting is off", async () => { + const fetchLatest = vi.fn().mockResolvedValue(releaseResponse(release("v1.9.6"))); + await checkLatestRelease({ currentVersion: "1.9.6", fetchLatest, includePrereleases: false }); + expect(fetchLatest).toHaveBeenCalledWith( + "https://api.github.com/repos/MinhOmega/Capturia/releases/latest", + expect.anything(), + ); + }); + + it("moves an RC install on to the next RC, then to the stable", async () => { + const nextRc = vi.fn().mockResolvedValue(releaseResponse([release("v2.0.0-rc.3")])); + await expect( + checkLatestRelease({ + currentVersion: "2.0.0-rc.2", + fetchLatest: nextRc, + includePrereleases: true, + }), + ).resolves.toMatchObject({ kind: "available", latestVersion: "2.0.0-rc.3" }); + + const stable = vi + .fn() + .mockResolvedValue(releaseResponse([release("v2.0.0"), release("v2.0.0-rc.3")])); + await expect( + checkLatestRelease({ + currentVersion: "2.0.0-rc.3", + fetchLatest: stable, + includePrereleases: true, + }), + ).resolves.toMatchObject({ kind: "available", latestVersion: "2.0.0" }); + }); + + it("picks by version rather than list order, skipping drafts and non-version tags", async () => { + const fetchLatest = vi + .fn() + .mockResolvedValue( + releaseResponse([ + release("v1.9.7-rc.1"), + release("v3.0.0", { draft: true }), + release("nightly"), + { tag_name: "v4.0.0" }, + release("v2.0.1"), + ]), + ); + + await expect( + checkLatestRelease({ currentVersion: "2.0.0", fetchLatest, includePrereleases: true }), + ).resolves.toMatchObject({ kind: "available", latestVersion: "2.0.1" }); + }); + + it("reports current when nothing newer, or nothing at all, is published", async () => { + const olderRc = vi.fn().mockResolvedValue(releaseResponse([release("v2.0.0-rc.3")])); + await expect( + checkLatestRelease({ + currentVersion: "2.0.0", + fetchLatest: olderRc, + includePrereleases: true, + }), + ).resolves.toEqual({ kind: "current", currentVersion: "2.0.0", latestVersion: "2.0.0-rc.3" }); + + const empty = vi.fn().mockResolvedValue(releaseResponse([])); + await expect( + checkLatestRelease({ currentVersion: "2.0.0", fetchLatest: empty, includePrereleases: true }), + ).resolves.toEqual({ kind: "current", currentVersion: "2.0.0", latestVersion: "2.0.0" }); + }); + + it("still refuses a malformed list or a release URL outside this repository", async () => { + const notAList = vi.fn().mockResolvedValue(releaseResponse(release("v9.9.9"))); + await expect( + checkLatestRelease({ + currentVersion: "1.9.0", + fetchLatest: notAList, + includePrereleases: true, + }), + ).rejects.toThrow("invalid GitHub release response"); + + const foreign = vi.fn().mockResolvedValue( + releaseResponse([ + release("v9.9.9-rc.1", { + html_url: "https://github.com/someone-else/fork/releases/tag/v9.9.9-rc.1", + }), + ]), + ); + await expect( + checkLatestRelease({ + currentVersion: "1.9.0", + fetchLatest: foreign, + includePrereleases: true, + }), + ).rejects.toThrow("untrusted release URL"); + }); +}); diff --git a/electron/update-checker.ts b/electron/update-checker.ts index d328de39a..6c467ba39 100644 --- a/electron/update-checker.ts +++ b/electron/update-checker.ts @@ -1,9 +1,13 @@ // This fork's own repo, not upstream's: asking getopenscreen/openscreen whether // Capturia is out of date offered the user an OpenScreen release as "the update" // and linked them to upstream's tag page. `OFFICIAL_RELEASE_PREFIX` is the trust -// anchor for `officialReleaseUrl` below, so the two must name the same repo. -const LATEST_RELEASE_API = "https://api.github.com/repos/MinhOmega/Capturia/releases/latest"; -const OFFICIAL_RELEASE_PREFIX = "/MinhOmega/Capturia/releases/tag/"; +// anchor for `officialReleaseUrl` below, so every URL here is built from one `REPO`. +const REPO = "MinhOmega/Capturia"; +const LATEST_RELEASE_API = `https://api.github.com/repos/${REPO}/releases/latest`; +// `/releases/latest` never returns a prerelease, so the opt-in channel reads the list. +// Newest first; one page is far more than the handful of RCs ahead of any stable. +const RELEASES_API = `https://api.github.com/repos/${REPO}/releases?per_page=30`; +const OFFICIAL_RELEASE_PREFIX = `/${REPO}/releases/tag/`; interface ReleaseResponse { ok: boolean; @@ -122,18 +126,55 @@ export type UpdateCheckResult = latestVersion: string; }; +type Release = { tag_name: string; html_url: string }; + +/** + * The highest-versioned published release in a `/releases` page, prereleases included, or + * null when there is none. Drafts, and tags that are not a version, are skipped rather than + * failing the page: one odd entry must not hide the release beside it. + */ +function newestRelease(payload: unknown): Release | null { + if (!Array.isArray(payload)) throw new Error("invalid GitHub release response"); + let newest: (Release & { version: string }) | null = null; + for (const entry of payload) { + const release = entry as Record | null; + if ( + typeof release?.tag_name !== "string" || + typeof release.html_url !== "string" || + release.draft !== false + ) { + continue; + } + let version: string; + try { + version = parseVersion(release.tag_name).normalized; + } catch { + continue; + } + if (!newest || compareVersions(version, newest.version) > 0) { + newest = { tag_name: release.tag_name, html_url: release.html_url, version }; + } + } + return newest && { tag_name: newest.tag_name, html_url: newest.html_url }; +} + export async function checkLatestRelease(options: { currentVersion: string; fetchLatest: FetchLatestRelease; signal?: AbortSignal; + /** Settings → "Get pre-release builds": RCs count as updates too. */ + includePrereleases?: boolean; }): Promise { - const response = await options.fetchLatest(LATEST_RELEASE_API, { - headers: { - Accept: "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", + const response = await options.fetchLatest( + options.includePrereleases ? RELEASES_API : LATEST_RELEASE_API, + { + headers: { + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + ...(options.signal ? { signal: options.signal } : {}), }, - ...(options.signal ? { signal: options.signal } : {}), - }); + ); // GitHub answers 404 — not an empty body — when a repo has published no releases // at all, which is this fork's state today. That is "nothing newer exists", not a // failure: letting it fall through to the throw below put a modal ERROR dialog @@ -151,18 +192,31 @@ export async function checkLatestRelease(options: { if (!response.ok) throw new Error(`GitHub release check failed (${response.status})`); const payload = await response.json(); - if ( - typeof payload !== "object" || - payload === null || - typeof (payload as Record).tag_name !== "string" || - typeof (payload as Record).html_url !== "string" || - (payload as Record).draft !== false || - (payload as Record).prerelease !== false - ) { - throw new Error("invalid GitHub release response"); + let release: Release | null; + if (options.includePrereleases) { + release = newestRelease(payload); + } else { + if ( + typeof payload !== "object" || + payload === null || + typeof (payload as Record).tag_name !== "string" || + typeof (payload as Record).html_url !== "string" || + (payload as Record).draft !== false || + (payload as Record).prerelease !== false + ) { + throw new Error("invalid GitHub release response"); + } + release = payload as Release; } - const release = payload as { tag_name: string; html_url: string }; const current = parseVersion(options.currentVersion); + // An empty list means nothing is published yet: the same answer as the 404 above. + if (!release) { + return { + kind: "current", + currentVersion: current.normalized, + latestVersion: current.normalized, + }; + } const latest = parseVersion(release.tag_name); const comparison = compareVersions(latest.normalized, current.normalized); if (comparison <= 0) { diff --git a/electron/update-settings.test.ts b/electron/update-settings.test.ts index 4050987c6..9e0526442 100644 --- a/electron/update-settings.test.ts +++ b/electron/update-settings.test.ts @@ -3,7 +3,13 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { parseUpdateMode } from "./background-update"; -import { loadUpdateMode, saveUpdateMode, updateSettingsPath } from "./update-settings"; +import { + loadIncludePrereleases, + loadUpdateMode, + saveIncludePrereleases, + saveUpdateMode, + updateSettingsPath, +} from "./update-settings"; const temps: string[] = []; @@ -35,6 +41,25 @@ describe("update settings", () => { expect(loadUpdateMode(dir)).toBe("notify"); }); + it("keeps pre-release builds off unless they were turned on", () => { + const dir = tmp(); + expect(loadIncludePrereleases(dir)).toBe(false); + writeFileSync(updateSettingsPath(dir), '{"prereleases": "yes"}'); + expect(loadIncludePrereleases(dir)).toBe(false); + saveIncludePrereleases(dir, true); + expect(loadIncludePrereleases(dir)).toBe(true); + }); + + it("saves each setting without dropping the other", () => { + const dir = tmp(); + saveIncludePrereleases(dir, true); + saveUpdateMode(dir, "download"); + expect(loadIncludePrereleases(dir)).toBe(true); + saveIncludePrereleases(dir, false); + expect(loadUpdateMode(dir)).toBe("download"); + expect(loadIncludePrereleases(dir)).toBe(false); + }); + it("refuses garbage mode values rather than trusting the file", () => { for (const garbage of ["install-silently", "", 42, null, { mode: "download" }]) { expect(parseUpdateMode(garbage)).toBe("notify"); diff --git a/electron/update-settings.ts b/electron/update-settings.ts index 9e78b2c30..ee859b002 100644 --- a/electron/update-settings.ts +++ b/electron/update-settings.ts @@ -1,26 +1,50 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; -import { DEFAULT_UPDATE_MODE, parseUpdateMode, type UpdateMode } from "./background-update"; +import { parseUpdateMode, type UpdateMode } from "./background-update"; export function updateSettingsPath(userData: string): string { return path.join(userData, "update-settings.json"); } -export function loadUpdateMode(userData: string): UpdateMode { +function readSettings(userData: string): { mode?: unknown; prereleases?: unknown } { const file = updateSettingsPath(userData); - if (!existsSync(file)) return DEFAULT_UPDATE_MODE; + if (!existsSync(file)) return {}; try { - const raw = JSON.parse(readFileSync(file, "utf8")) as { mode?: unknown }; - return parseUpdateMode(raw.mode); + return ( + (JSON.parse(readFileSync(file, "utf8")) as { mode?: unknown; prereleases?: unknown }) ?? {} + ); } catch { - return DEFAULT_UPDATE_MODE; + return {}; } } -export function saveUpdateMode(userData: string, mode: UpdateMode): void { +/** Both settings share one file, so each save carries the other one over unchanged. */ +function writeSettings(userData: string, next: { mode?: UpdateMode; prereleases?: boolean }): void { + const settings = { + mode: loadUpdateMode(userData), + prereleases: loadIncludePrereleases(userData), + ...next, + }; try { - writeFileSync(updateSettingsPath(userData), `${JSON.stringify({ mode })}\n`, "utf8"); + writeFileSync(updateSettingsPath(userData), `${JSON.stringify(settings)}\n`, "utf8"); } catch { // Best-effort; a failed write must not block the tray click. } } + +export function loadUpdateMode(userData: string): UpdateMode { + return parseUpdateMode(readSettings(userData).mode); +} + +export function saveUpdateMode(userData: string, mode: UpdateMode): void { + writeSettings(userData, { mode }); +} + +/** Settings → "Get pre-release builds". Off unless the file says exactly `true`. */ +export function loadIncludePrereleases(userData: string): boolean { + return readSettings(userData).prereleases === true; +} + +export function saveIncludePrereleases(userData: string, prereleases: boolean): void { + writeSettings(userData, { prereleases }); +} diff --git a/src/components/launch/HudDeviceSettings.tsx b/src/components/launch/HudDeviceSettings.tsx index 4651c5af2..2f7b77875 100644 --- a/src/components/launch/HudDeviceSettings.tsx +++ b/src/components/launch/HudDeviceSettings.tsx @@ -43,6 +43,7 @@ export interface HudDeviceSettingsLabels { about: string; checkForUpdates: string; checkingForUpdates: string; + prereleases: string; saveTo: string; changeFolder: string; resetFolder: string; @@ -152,6 +153,7 @@ export const HudDeviceSettings = memo(function HudDeviceSettings({ versionLabel, canCheckForUpdates, checkingForUpdates, + includePrereleases, recordingsFolder, recordingsFolderLocked, onSelectMic, @@ -161,6 +163,7 @@ export const HudDeviceSettings = memo(function HudDeviceSettings({ onSelectCountdown, onSelectMicGain, onCheckForUpdates, + onToggleIncludePrereleases, onChooseRecordingsFolder, onResetRecordingsFolder, onClose, @@ -182,6 +185,7 @@ export const HudDeviceSettings = memo(function HudDeviceSettings({ versionLabel: string | null; canCheckForUpdates: boolean; checkingForUpdates: boolean; + includePrereleases: boolean; /** Null until main answers; the row stays out rather than showing a blank path. */ recordingsFolder: RecordingsFolderState | null; /** Mid-take: the take already has its folder, and its stop paths expect it unchanged. */ @@ -193,6 +197,7 @@ export const HudDeviceSettings = memo(function HudDeviceSettings({ onSelectCountdown: (seconds: CountdownSeconds) => void; onSelectMicGain: (gain: MicrophoneGain) => void; onCheckForUpdates: () => void; + onToggleIncludePrereleases: () => void; onChooseRecordingsFolder: () => void; onResetRecordingsFolder: () => void; onClose: () => void; @@ -423,6 +428,19 @@ export const HudDeviceSettings = memo(function HudDeviceSettings({ ) : null}
+ {/* Beside the check it changes, and gone wherever that check is. */} + {canCheckForUpdates ? ( + + ) : null} ) : null}
diff --git a/src/components/launch/LaunchWindow.test.tsx b/src/components/launch/LaunchWindow.test.tsx index 94e980401..570952244 100644 --- a/src/components/launch/LaunchWindow.test.tsx +++ b/src/components/launch/LaunchWindow.test.tsx @@ -119,7 +119,7 @@ vi.mock("@/native", () => ({ })); const appInfoState = vi.hoisted(() => ({ - value: { version: "1.9.6", canCheckForUpdates: true }, + value: { version: "1.9.6", canCheckForUpdates: true, includePrereleases: false }, })); const updateCheckMock = vi.hoisted(() => vi.fn(async () => undefined)); @@ -321,7 +321,7 @@ function resetLaunchMocks() { vi.mocked(nativeBridgeClient.system.getPlatform).mockImplementation( async () => platformState.value, ); - appInfoState.value = { version: "1.9.6", canCheckForUpdates: true }; + appInfoState.value = { version: "1.9.6", canCheckForUpdates: true, includePrereleases: false }; updateCheckMock.mockReset(); updateCheckMock.mockResolvedValue(undefined); stubElectronAPI(vi.fn(async () => null)); @@ -1168,7 +1168,7 @@ describe("LaunchWindow device settings", () => { // pointing its user at a GitHub download starts a second, parallel install that then drifts // forever. The version still shows — it is the answer to "what am I running?", not an offer. it("offers no update check where a package manager owns the update", async () => { - appInfoState.value = { version: "1.9.6", canCheckForUpdates: false }; + appInfoState.value = { version: "1.9.6", canCheckForUpdates: false, includePrereleases: false }; renderLaunchWindow(); diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 32315ccac..c81bea7eb 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -143,9 +143,11 @@ export function LaunchWindow() { // Store/Flathub/Snap/Nix install is kept current by its package manager and is offered // nothing (electron/install-channel.ts). Asked once: neither answer changes while the app // runs, and the HUD is rebuilt for every recording anyway. - const [appInfo, setAppInfo] = useState<{ version: string; canCheckForUpdates: boolean } | null>( - null, - ); + const [appInfo, setAppInfo] = useState<{ + version: string; + canCheckForUpdates: boolean; + includePrereleases?: boolean; + } | null>(null); const [isCheckingForUpdates, setIsCheckingForUpdates] = useState(false); const [recordingsFolder, setRecordingsFolder] = useState(null); /** @@ -291,6 +293,19 @@ export function LaunchWindow() { }); }, []); + // Main is the only writer of this setting and the HUD its only editor, so the value main + // answers with is simply adopted. + const handleToggleIncludePrereleases = useCallback(() => { + window.electronAPI + ?.setIncludePrereleases?.(!appInfo?.includePrereleases) + .then((includePrereleases) => { + setAppInfo((info) => (info ? { ...info, includePrereleases } : info)); + }) + .catch((error) => { + console.error("Failed to change the pre-release setting:", error); + }); + }, [appInfo?.includePrereleases]); + // Asked each time the panel opens rather than once: the chosen folder can go away (a drive // unplugged) while the HUD sits there, and the row should say so when the user looks. useEffect(() => { @@ -977,6 +992,7 @@ export function LaunchWindow() { about: t("deviceSettings.about"), checkForUpdates: tCommon("actions.checkForUpdates"), checkingForUpdates: t("deviceSettings.checkingForUpdates"), + prereleases: t("deviceSettings.prereleases"), saveTo: t("deviceSettings.saveTo"), changeFolder: t("deviceSettings.changeFolder"), resetFolder: t("deviceSettings.resetFolder"), @@ -1210,6 +1226,7 @@ export function LaunchWindow() { // main process refuses the check then — an offered button would be dead. canCheckForUpdates={(appInfo?.canCheckForUpdates ?? false) && !recording} checkingForUpdates={isCheckingForUpdates} + includePrereleases={appInfo?.includePrereleases ?? false} recordingsFolder={recordingsFolder} recordingsFolderLocked={controlsLocked} onSelectMic={handleSelectMicDevice} @@ -1219,6 +1236,7 @@ export function LaunchWindow() { onSelectCountdown={setCountdownSeconds} onSelectMicGain={setMicrophoneGain} onCheckForUpdates={handleCheckForUpdates} + onToggleIncludePrereleases={handleToggleIncludePrereleases} onChooseRecordingsFolder={handleChooseRecordingsFolder} onResetRecordingsFolder={handleResetRecordingsFolder} onClose={closeDeviceSettings} diff --git a/src/i18n/locales/ar/launch.json b/src/i18n/locales/ar/launch.json index 7f5d8455f..d54ff5fcd 100644 --- a/src/i18n/locales/ar/launch.json +++ b/src/i18n/locales/ar/launch.json @@ -112,7 +112,8 @@ "changeFolder": "تغيير…", "resetFolder": "إعادة تعيين", "folderUnavailable": "غير متاح حاليًا — سيُقترح المجلد الافتراضي عند بدء التسجيل.", - "folderHint": "تبقى التسجيلات السابقة في المكان الذي حُفظت فيه." + "folderHint": "تبقى التسجيلات السابقة في المكان الذي حُفظت فيه.", + "prereleases": "الحصول على الإصدارات التجريبية" }, "permissions": { "title": "الأذونات", diff --git a/src/i18n/locales/en/launch.json b/src/i18n/locales/en/launch.json index 75715a503..1b9b61c4a 100644 --- a/src/i18n/locales/en/launch.json +++ b/src/i18n/locales/en/launch.json @@ -112,7 +112,8 @@ "changeFolder": "Change…", "resetFolder": "Reset", "folderUnavailable": "Unavailable right now — the default folder will be offered when you record.", - "folderHint": "Earlier recordings stay where they were saved." + "folderHint": "Earlier recordings stay where they were saved.", + "prereleases": "Get pre-release builds" }, "permissions": { "title": "Permissions", diff --git a/src/i18n/locales/es/launch.json b/src/i18n/locales/es/launch.json index 49bca1652..9812a3137 100644 --- a/src/i18n/locales/es/launch.json +++ b/src/i18n/locales/es/launch.json @@ -112,7 +112,8 @@ "changeFolder": "Cambiar…", "resetFolder": "Restablecer", "folderUnavailable": "No disponible ahora mismo: se te ofrecerá la carpeta predeterminada al grabar.", - "folderHint": "Las grabaciones anteriores se quedan donde se guardaron." + "folderHint": "Las grabaciones anteriores se quedan donde se guardaron.", + "prereleases": "Recibir versiones preliminares" }, "permissions": { "title": "Permisos", diff --git a/src/i18n/locales/fr/launch.json b/src/i18n/locales/fr/launch.json index 6796158e8..a54073bf1 100644 --- a/src/i18n/locales/fr/launch.json +++ b/src/i18n/locales/fr/launch.json @@ -112,7 +112,8 @@ "changeFolder": "Modifier…", "resetFolder": "Réinitialiser", "folderUnavailable": "Indisponible pour le moment — le dossier par défaut vous sera proposé à l'enregistrement.", - "folderHint": "Les enregistrements précédents restent à leur emplacement actuel." + "folderHint": "Les enregistrements précédents restent à leur emplacement actuel.", + "prereleases": "Recevoir les préversions" }, "permissions": { "title": "Autorisations", diff --git a/src/i18n/locales/it/launch.json b/src/i18n/locales/it/launch.json index 751417cce..e51a6b989 100644 --- a/src/i18n/locales/it/launch.json +++ b/src/i18n/locales/it/launch.json @@ -112,7 +112,8 @@ "changeFolder": "Cambia…", "resetFolder": "Ripristina", "folderUnavailable": "Non disponibile al momento: all'avvio della registrazione verrà proposta la cartella predefinita.", - "folderHint": "Le registrazioni precedenti restano dove sono state salvate." + "folderHint": "Le registrazioni precedenti restano dove sono state salvate.", + "prereleases": "Ricevi le versioni preliminari" }, "permissions": { "title": "Autorizzazioni", diff --git a/src/i18n/locales/ja-JP/launch.json b/src/i18n/locales/ja-JP/launch.json index 7a56e564c..d88193869 100644 --- a/src/i18n/locales/ja-JP/launch.json +++ b/src/i18n/locales/ja-JP/launch.json @@ -112,7 +112,8 @@ "changeFolder": "変更…", "resetFolder": "リセット", "folderUnavailable": "現在利用できません。録画時にデフォルトのフォルダーを提案します。", - "folderHint": "以前の録画は保存された場所にそのまま残ります。" + "folderHint": "以前の録画は保存された場所にそのまま残ります。", + "prereleases": "プレリリース版を受け取る" }, "permissions": { "title": "アクセス権限", diff --git a/src/i18n/locales/ko-KR/launch.json b/src/i18n/locales/ko-KR/launch.json index 35ffc9d01..5935272b9 100644 --- a/src/i18n/locales/ko-KR/launch.json +++ b/src/i18n/locales/ko-KR/launch.json @@ -112,7 +112,8 @@ "changeFolder": "변경…", "resetFolder": "초기화", "folderUnavailable": "지금은 사용할 수 없습니다. 녹화할 때 기본 폴더를 제안합니다.", - "folderHint": "이전 녹화는 저장된 위치에 그대로 남습니다." + "folderHint": "이전 녹화는 저장된 위치에 그대로 남습니다.", + "prereleases": "사전 릴리스 빌드 받기" }, "permissions": { "title": "권한", diff --git a/src/i18n/locales/pt-BR/launch.json b/src/i18n/locales/pt-BR/launch.json index 22d5b8e96..ddca65db6 100644 --- a/src/i18n/locales/pt-BR/launch.json +++ b/src/i18n/locales/pt-BR/launch.json @@ -112,7 +112,8 @@ "changeFolder": "Alterar…", "resetFolder": "Redefinir", "folderUnavailable": "Indisponível no momento — a pasta padrão será oferecida ao gravar.", - "folderHint": "As gravações anteriores continuam onde foram salvas." + "folderHint": "As gravações anteriores continuam onde foram salvas.", + "prereleases": "Receber versões de pré-lançamento" }, "permissions": { "title": "Permissões", diff --git a/src/i18n/locales/ru/launch.json b/src/i18n/locales/ru/launch.json index 3b8c819a8..1529b4246 100644 --- a/src/i18n/locales/ru/launch.json +++ b/src/i18n/locales/ru/launch.json @@ -112,7 +112,8 @@ "changeFolder": "Изменить…", "resetFolder": "Сбросить", "folderUnavailable": "Сейчас недоступна — при записи будет предложена папка по умолчанию.", - "folderHint": "Прежние записи остаются там, где были сохранены." + "folderHint": "Прежние записи остаются там, где были сохранены.", + "prereleases": "Получать предварительные версии" }, "permissions": { "title": "Разрешения", diff --git a/src/i18n/locales/tr/launch.json b/src/i18n/locales/tr/launch.json index 5ba4c49f8..a4a60c358 100644 --- a/src/i18n/locales/tr/launch.json +++ b/src/i18n/locales/tr/launch.json @@ -112,7 +112,8 @@ "changeFolder": "Değiştir…", "resetFolder": "Sıfırla", "folderUnavailable": "Şu anda kullanılamıyor — kayıt başlatırken varsayılan klasör önerilecek.", - "folderHint": "Önceki kayıtlar kaydedildikleri yerde kalır." + "folderHint": "Önceki kayıtlar kaydedildikleri yerde kalır.", + "prereleases": "Ön sürüm yapılarını al" }, "permissions": { "title": "İzinler", diff --git a/src/i18n/locales/vi/launch.json b/src/i18n/locales/vi/launch.json index d6dd0d34b..979d8fe3a 100644 --- a/src/i18n/locales/vi/launch.json +++ b/src/i18n/locales/vi/launch.json @@ -112,7 +112,8 @@ "changeFolder": "Thay đổi…", "resetFolder": "Đặt lại", "folderUnavailable": "Hiện không khả dụng — thư mục mặc định sẽ được đề xuất khi bạn ghi.", - "folderHint": "Các bản ghi trước vẫn nằm ở nơi đã được lưu." + "folderHint": "Các bản ghi trước vẫn nằm ở nơi đã được lưu.", + "prereleases": "Nhận các bản phát hành thử nghiệm" }, "permissions": { "title": "Quyền truy cập", diff --git a/src/i18n/locales/zh-CN/launch.json b/src/i18n/locales/zh-CN/launch.json index b81b05a47..00f6dea73 100644 --- a/src/i18n/locales/zh-CN/launch.json +++ b/src/i18n/locales/zh-CN/launch.json @@ -112,7 +112,8 @@ "changeFolder": "更改…", "resetFolder": "重置", "folderUnavailable": "当前不可用——录制时将建议使用默认文件夹。", - "folderHint": "之前的录制内容仍保留在原来的保存位置。" + "folderHint": "之前的录制内容仍保留在原来的保存位置。", + "prereleases": "获取预发布版本" }, "permissions": { "title": "权限", diff --git a/src/i18n/locales/zh-TW/launch.json b/src/i18n/locales/zh-TW/launch.json index 4ebc54c0c..86b4278a1 100644 --- a/src/i18n/locales/zh-TW/launch.json +++ b/src/i18n/locales/zh-TW/launch.json @@ -112,7 +112,8 @@ "changeFolder": "變更…", "resetFolder": "重設", "folderUnavailable": "目前無法使用——錄製時將建議改用預設資料夾。", - "folderHint": "先前的錄製內容仍保留在原本的儲存位置。" + "folderHint": "先前的錄製內容仍保留在原本的儲存位置。", + "prereleases": "取得預先發行版本" }, "permissions": { "title": "權限", From 7f954a912774a390b912011d16296b26584b959b Mon Sep 17 00:00:00 2001 From: MinhOmega <49482201+MinhOmega@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:13:16 +0700 Subject: [PATCH 23/40] feat(recording): record an area of the screen The source picker (HUD selector and the editor's Rec stage) gets an Area tab beside Screens and Windows. Picking a screen there opens a transparent overlay on that display: drag a rectangle, move or resize it by its handles, read its size in physical pixels, Enter to confirm, Esc to go back to the picker. The last area is remembered per display. The display is still recorded whole. The main process validates the rectangle (finite, positive, clamped to the display in whole physical pixels, at least the crop dialog's minimum), turns it into a crop, and attaches it to the recording at the hand-off to the editor, which opens the clip already cropped. The full picture stays in the file, so the crop can be widened later. Capture-time cropping is left as the upgrade path. - New `area-selector` window type with no permissions. - Auto-zoom suggestions respect a clip's crop: moments outside it get no zoom, and focus is expressed in the crop's own fractions. - Where the ScreenCast portal picks the monitor (Linux), no overlay can be placed before recording. The Rec stage offers "Draw an area after recording" instead, which opens the new clip's crop dialog on the recording. --- electron/electron-env.d.ts | 10 ++ electron/ipc/handlers.ts | 103 ++++++++++- electron/ipc/recordingPrefs.test.ts | 1 + electron/preload.ts | 6 + electron/windowPermissions.test.ts | 9 +- electron/windowPermissions.ts | 5 +- electron/windows.ts | 52 ++++++ src/App.tsx | 10 +- src/components/ai-edition/Modals.tsx | 2 +- src/components/ai-edition/NewEditorShell.tsx | 16 ++ src/components/ai-edition/cropDraft.ts | 4 + .../ai-edition/recordingImport.test.ts | 59 +++++- src/components/ai-edition/recordingImport.ts | 36 +++- src/components/ai-edition/v4/RecStage.tsx | 66 ++++++- src/components/launch/AreaSelector.tsx | 169 ++++++++++++++++++ src/components/launch/SourceSelector.tsx | 29 ++- src/i18n/locales/ar/common.json | 1 + src/i18n/locales/ar/editor.json | 2 + src/i18n/locales/ar/launch.json | 4 + src/i18n/locales/en/common.json | 1 + src/i18n/locales/en/editor.json | 2 + src/i18n/locales/en/launch.json | 4 + src/i18n/locales/es/common.json | 1 + src/i18n/locales/es/editor.json | 2 + src/i18n/locales/es/launch.json | 4 + src/i18n/locales/fr/common.json | 1 + src/i18n/locales/fr/editor.json | 2 + src/i18n/locales/fr/launch.json | 4 + src/i18n/locales/it/common.json | 1 + src/i18n/locales/it/editor.json | 2 + src/i18n/locales/it/launch.json | 4 + src/i18n/locales/ja-JP/common.json | 1 + src/i18n/locales/ja-JP/editor.json | 2 + src/i18n/locales/ja-JP/launch.json | 4 + src/i18n/locales/ko-KR/common.json | 1 + src/i18n/locales/ko-KR/editor.json | 2 + src/i18n/locales/ko-KR/launch.json | 4 + src/i18n/locales/pt-BR/common.json | 1 + src/i18n/locales/pt-BR/editor.json | 2 + src/i18n/locales/pt-BR/launch.json | 4 + src/i18n/locales/ru/common.json | 1 + src/i18n/locales/ru/editor.json | 2 + src/i18n/locales/ru/launch.json | 4 + src/i18n/locales/tr/common.json | 1 + src/i18n/locales/tr/editor.json | 2 + src/i18n/locales/tr/launch.json | 4 + src/i18n/locales/vi/common.json | 1 + src/i18n/locales/vi/editor.json | 2 + src/i18n/locales/vi/launch.json | 4 + src/i18n/locales/zh-CN/common.json | 1 + src/i18n/locales/zh-CN/editor.json | 2 + src/i18n/locales/zh-CN/launch.json | 4 + src/i18n/locales/zh-TW/common.json | 1 + src/i18n/locales/zh-TW/editor.json | 2 + src/i18n/locales/zh-TW/launch.json | 4 + .../store/documentWriteAudit.test.ts | 3 + .../timeline/apply-auto-zooms.test.ts | 29 +++ .../timeline/zoom-suggestions.test.ts | 28 +++ .../ai-edition/timeline/zoom-suggestions.ts | 40 ++++- src/lib/recordingArea.test.ts | 60 +++++++ src/lib/recordingArea.ts | 78 ++++++++ src/lib/recordingSession.ts | 6 + src/main.tsx | 1 + .../architecture/overview.md | 5 +- 64 files changed, 884 insertions(+), 34 deletions(-) create mode 100644 src/components/launch/AreaSelector.tsx create mode 100644 src/lib/recordingArea.test.ts create mode 100644 src/lib/recordingArea.ts diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index b5925ccc0..77134593c 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -58,6 +58,16 @@ interface Window { }>; selectSource: (source: ProcessedDesktopSource) => Promise; getSelectedSource: () => Promise; + /** Opens the area overlay on this screen source's display; the selected source once + * the user confirms a rectangle, null when they dismiss it. */ + selectArea: (source: ProcessedDesktopSource) => Promise; + /** The area overlay's answer, in its own CSS pixels. */ + finishAreaSelection: (rect: { + x: number; + y: number; + width: number; + height: number; + }) => Promise; onSelectedSourceChanged: (callback: (source: ProcessedDesktopSource) => void) => () => void; getRecordingPrefs: () => Promise; setRecordingPrefs: ( diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index acf80a514..d7e3ce21d 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -31,6 +31,7 @@ import { PROJECT_FILE_EXTENSION, PROJECT_FILE_EXTENSIONS, } from "../../src/lib/projectFileExtension"; +import { type AreaRect, areaToCropRegion, toPhysicalArea } from "../../src/lib/recordingArea"; import { type CursorCaptureMode, normalizeCursorCaptureMode, @@ -117,6 +118,7 @@ import { validRecordingsFolder, } from "../recordingsFolder"; import { settingsPaneUrl } from "../windowPermissions"; +import { createAreaSelectorWindow } from "../windows"; import { registerNativeBridgeHandlers } from "./nativeBridge"; import { registerRecordingPrefsHandlers } from "./recordingPrefs"; import { RecordingStreamRegistry, registerRecordingStreamHandlers } from "./recordingStream"; @@ -616,6 +618,17 @@ type AttachNativeMacWebcamRecordingInput = { }; let selectedSource: SelectedSource | null = null; +// The crop an area pick seeds into the next recording, in fractions of the display's +// frame. Set only from a rectangle this process validated (`finish-area-selection`) and +// cleared by every plain pick, so nothing a renderer puts in `select-source` can reach it. +let selectedAreaCrop: AreaRect | null = null; +// The area overlay on screen, and whoever is waiting for its answer. +let areaSelection: { + win: BrowserWindow; + display: Electron.Display; + source: SelectedSource; + resolve: (source: SelectedSource | null) => void; +} | null = null; let selectedDesktopSource: DesktopCapturerSource | null = null; let lastEnumeratedSources = new Map(); let currentProjectPath: string | null = null; @@ -649,6 +662,12 @@ export interface RecordingPrefs { cursorCaptureMode: CursorCaptureMode; /** After a take, suggest cursor-dwell zooms. Default on, matching 1.5. */ autoZoomEnabled: boolean; + /** + * "Record an area" where the ScreenCast portal picks the monitor: nothing can be + * drawn on it before recording, so the editor opens the new clip's crop dialog + * instead. Everywhere else the area is picked up front (`select-area`). + */ + drawAreaAfterRecording: boolean; } const defaultRecordingPrefs: RecordingPrefs = { micEnabled: false, @@ -659,6 +678,7 @@ const defaultRecordingPrefs: RecordingPrefs = { systemAudioEnabled: false, cursorCaptureMode: "editable-overlay", autoZoomEnabled: true, + drawAreaAfterRecording: false, }; // Cached source from the user's pick. Used by setDisplayMediaRequestHandler in main.ts for cursor-free capture. @@ -2023,7 +2043,7 @@ export function registerIpcHandlers( })); }); - ipcMain.handle("select-source", async (_, source: SelectedSource) => { + async function applySelectedSource(source: SelectedSource) { selectedSource = source; // Reuse the exact source object returned during enumeration to avoid // Windows window-source id mismatches across separate getSources() calls. @@ -2052,6 +2072,74 @@ export function registerIpcHandlers( sourceSelectorWin.close(); } return selectedSource; + } + + ipcMain.handle("select-source", (_, source: SelectedSource) => { + selectedAreaCrop = null; + return applySelectedSource(source); + }); + + // "Record an area": the picked screen is recorded whole, exactly as a plain pick, + // and the rectangle only seeds the clip's crop at import (`set-current-recording-session`). + // Resolves with the selected source once the overlay confirms, or null when it is + // dismissed, so a picker can stay open on Esc. + ipcMain.handle("select-area", (_, source: SelectedSource) => { + const display = + typeof source?.id === "string" && source.id.startsWith("screen:") + ? screen.getAllDisplays().find((each) => String(each.id) === String(source.display_id)) + : undefined; + if (!display) return null; + // One overlay at a time, and the one already up keeps its caller. + if (areaSelection) { + areaSelection.win.focus(); + return null; + } + + return new Promise((resolve) => { + const win = createAreaSelectorWindow(display); + const selection = { win, display, source, resolve }; + areaSelection = selection; + win.on("closed", () => { + if (areaSelection !== selection) return; + areaSelection = null; + resolve(null); + }); + }); + }); + + ipcMain.handle("finish-area-selection", async (event, rect: AreaRect) => { + const selection = areaSelection; + if (!selection || selection.win.isDestroyed() || event.sender !== selection.win.webContents) { + return; + } + areaSelection = null; + const { display } = selection; + // The rectangle is in the overlay's CSS pixels. Re-based on the display rather than + // on the overlay, which the OS may have placed a little off its origin, then + // validated and clamped to whole physical pixels of that display. + const content = selection.win.getContentBounds(); + selection.win.close(); + const area = toPhysicalArea( + { + x: content.x - display.bounds.x + rect?.x, + y: content.y - display.bounds.y + rect?.y, + width: rect?.width, + height: rect?.height, + }, + display.size, + display.scaleFactor, + ); + if (!area) { + selection.resolve(null); + return; + } + selectedAreaCrop = areaToCropRegion(area, display.size, display.scaleFactor); + selection.resolve( + await applySelectedSource({ + ...selection.source, + name: mainT("common", "recordingSource.area", { width: area.width, height: area.height }), + }), + ); }); ipcMain.handle("get-selected-source", () => { @@ -4575,7 +4663,18 @@ export function registerIpcHandlers( ipcMain.handle("set-current-recording-session", (_, session: RecordingSession | null) => { const normalizedSession = normalizeRecordingSession(session); - setCurrentRecordingSessionState(normalizedSession); + // The recorder's hand-off to the editor, so this is where an area pick joins the take + // it framed: the editor opens the clip already cropped to it. + // + // ponytail: the whole display is recorded and the area is only a crop, so the file + // stays full-size (which is also what lets the crop be widened later). Cropping at + // capture time -- a source rect for WGC / ScreenCaptureKit / the PipeWire helper -- + // is the upgrade path if file size matters. + setCurrentRecordingSessionState( + normalizedSession && selectedAreaCrop + ? { ...normalizedSession, cropRegion: selectedAreaCrop } + : normalizedSession, + ); currentVideoPath = normalizedSession?.screenVideoPath ?? null; currentProjectPath = null; return { success: true, session: currentRecordingSession }; diff --git a/electron/ipc/recordingPrefs.test.ts b/electron/ipc/recordingPrefs.test.ts index 115a3c356..c574d4d37 100644 --- a/electron/ipc/recordingPrefs.test.ts +++ b/electron/ipc/recordingPrefs.test.ts @@ -21,6 +21,7 @@ const defaults: RecordingPrefs = { systemAudioEnabled: false, cursorCaptureMode: "editable-overlay", autoZoomEnabled: true, + drawAreaAfterRecording: false, }; let dir: string; beforeEach(() => { diff --git a/electron/preload.ts b/electron/preload.ts index 7aa206bd7..ba4a5f5f1 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -126,6 +126,12 @@ contextBridge.exposeInMainWorld("electronAPI", { getSelectedSource: () => { return ipcRenderer.invoke("get-selected-source"); }, + selectArea: (source: ProcessedDesktopSource) => { + return ipcRenderer.invoke("select-area", source); + }, + finishAreaSelection: (rect: { x: number; y: number; width: number; height: number }) => { + return ipcRenderer.invoke("finish-area-selection", rect); + }, getRecordingPrefs: () => { return ipcRenderer.invoke("get-recording-prefs"); }, diff --git a/electron/windowPermissions.test.ts b/electron/windowPermissions.test.ts index 32c376e29..880a62d14 100644 --- a/electron/windowPermissions.test.ts +++ b/electron/windowPermissions.test.ts @@ -43,6 +43,7 @@ const CAPTURE_CALL_SITES: Record< "cli-export": { kinds: [], why: "renders frames from files; no device access" }, "cli-captions": { kinds: [], why: "transcribes an existing file; no device access" }, "source-selector": { kinds: [], why: "desktopCapturer runs in the main process, not here" }, + "area-selector": { kinds: [], why: "draws a rectangle over the live screen; captures nothing" }, "countdown-overlay": { kinds: [], why: "draws a countdown" }, notes: { kinds: [], why: "text only" }, bench: { kinds: [], why: "renders App's default placeholder, not the editor shell" }, @@ -136,7 +137,13 @@ describe("isPermissionAllowed", () => { }); it("denies every capture kind to windows that only display", () => { - for (const windowType of ["source-selector", "countdown-overlay", "notes", "bench"] as const) { + for (const windowType of [ + "source-selector", + "area-selector", + "countdown-overlay", + "notes", + "bench", + ] as const) { for (const permission of ["screen", "display-capture", "camera", "microphone"]) { expect(allowed(permission, windowType)).toBe(false); } diff --git a/electron/windowPermissions.ts b/electron/windowPermissions.ts index 719dddc9c..d6dea2cd6 100644 --- a/electron/windowPermissions.ts +++ b/electron/windowPermissions.ts @@ -20,8 +20,8 @@ * audio layers (`AddAudioLayerDialog`) and meters the mic in the Rec stage — * and to the CLI sources runner, which needs a short-lived grant to read * device labels; - * - the source selector, the countdown overlay, the notes window and the bench - * window never capture anything. The bench renders App's default placeholder, + * - the source selector, the area selector, the countdown overlay, the notes + * window and the bench window never capture anything. The bench renders App's default placeholder, * not the editor shell. * * Refusing a kind a window does use is not a quiet failure: `getUserMedia` @@ -49,6 +49,7 @@ const WINDOW_TYPE_LIST = [ "editor", "bench", "source-selector", + "area-selector", "countdown-overlay", "notes", "cli-record", diff --git a/electron/windows.ts b/electron/windows.ts index ca5a0a079..689e6b4f9 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -541,6 +541,58 @@ export function createSourceSelectorWindow(): BrowserWindow { return win; } +/** + * Transparent overlay covering one display, on which the user drags the area to record. + * Above every other window (the HUD and the source selector are always-on-top too), and + * closed before recording starts, so it never appears in a take. + */ +export function createAreaSelectorWindow(display: Electron.Display): BrowserWindow { + const win = new BrowserWindow({ + ...display.bounds, + frame: false, + resizable: false, + movable: false, + alwaysOnTop: true, + skipTaskbar: true, + transparent: true, + backgroundColor: "#00000000", + hasShadow: false, + // macOS otherwise keeps the window below the menu bar. + enableLargerThanScreen: true, + show: false, + webPreferences: { + preload: path.join(__dirname, "preload.mjs"), + nodeIntegration: false, + contextIsolation: true, + }, + }); + + win.setAlwaysOnTop(true, "screen-saver"); + if (process.platform === "darwin") { + win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); + } + + win.once("ready-to-show", () => { + // Again, once it exists: a window created for a display whose scale factor differs + // from the one it was born on can come out sized by the wrong one. + win.setBounds(display.bounds); + if (!HEADLESS) win.show(); + win.focus(); + }); + // Esc lives in the page. Without one, this is a screen-sized window nobody can dismiss. + win.webContents.once("did-fail-load", () => win.close()); + win.webContents.once("render-process-gone", () => win.close()); + + const query = { windowType: "area-selector", displayId: String(display.id) }; + if (VITE_DEV_SERVER_URL) { + win.loadURL(`${VITE_DEV_SERVER_URL}?${new URLSearchParams(query).toString()}`); + } else { + win.loadFile(path.join(RENDERER_DIST, "index.html"), { query }); + } + + return win; +} + /** * Centered transparent countdown overlay that sits above the HUD during * recording pre-roll. diff --git a/src/App.tsx b/src/App.tsx index 843de8c54..56934fef1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ installBrowserShims(); import { AppErrorBoundary } from "./components/app/AppErrorBoundary"; import { GlobalErrorObserver } from "./components/app/GlobalErrorObserver"; +import { AreaSelector } from "./components/launch/AreaSelector"; import { CountdownOverlay } from "./components/launch/CountdownOverlay.tsx"; import { LaunchWindow } from "./components/launch/LaunchWindow"; import { NotesWindow } from "./components/launch/NotesWindow.tsx"; @@ -52,7 +53,12 @@ export default function App() { setWindowType(type); } - if (type === "hud-overlay" || type === "source-selector" || type === "countdown-overlay") { + if ( + type === "hud-overlay" || + type === "source-selector" || + type === "area-selector" || + type === "countdown-overlay" + ) { document.body.style.background = "transparent"; document.documentElement.style.background = "transparent"; document.getElementById("root")?.style.setProperty("background", "transparent"); @@ -79,6 +85,8 @@ export default function App() { return ; case "source-selector": return ; + case "area-selector": + return ; case "countdown-overlay": return ; case "cli-export": diff --git a/src/components/ai-edition/Modals.tsx b/src/components/ai-edition/Modals.tsx index 14cbc2d8f..0343e84ff 100644 --- a/src/components/ai-edition/Modals.tsx +++ b/src/components/ai-edition/Modals.tsx @@ -15,6 +15,7 @@ import { cropDraftFromRegion, cropDraftToPct, displayPct, + MIN_CROP_PCT as MIN_PCT, previewBoxStyle, stepPct, } from "./cropDraft"; @@ -609,7 +610,6 @@ function centeredFitPct(fr: number): { x: number; y: number; w: number; h: numbe return { x: (100 - w) / 2, y: 0, w, h: 100 }; } -const MIN_PCT = 4; const clampPct = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v)); type ResizeEdges = { left?: boolean; right?: boolean; top?: boolean; bottom?: boolean }; diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx index 67b72a17d..2081b1b78 100644 --- a/src/components/ai-edition/NewEditorShell.tsx +++ b/src/components/ai-edition/NewEditorShell.tsx @@ -262,6 +262,16 @@ export function NewEditorShell() { // "Edit clip" rail button — a single shell-level instance instead of one // mounted per trigger site. const [editClipTarget, setEditClipTarget] = useState(null); + // "Record an area" where the portal picked the monitor (see RecStage): the area is + // drawn on the recording, in the new clip's crop dialog, once that clip has the real + // duration the dialog's trim range needs. + const drawAreaOnImport = useRef(false); + useEffect(() => { + const clip = document?.timeline.clips[0]; + if (!drawAreaOnImport.current || !clip?.sourceEndSec) return; + drawAreaOnImport.current = false; + setEditClipTarget(clip); + }, [document]); const [exportOpen, setExportOpen] = useState(false); const [unsavedPrompt, setUnsavedPrompt] = useState<{ action: "close" | "new" | "open" | "record"; @@ -381,11 +391,17 @@ export function NewEditorShell() { void (async () => { if (!window.electronAPI) return; try { + // Up before the import resolves: it awaits the fresh-take auto-zoom pass, and the + // clip can get its duration in the meantime. + const prefs = await window.electronAPI.getRecordingPrefs?.().catch(() => null); + drawAreaOnImport.current = prefs?.drawAreaAfterRecording === true; if (await importPendingRecording()) { toast.success("Recording added to a new project"); return; } + drawAreaOnImport.current = false; } catch (err) { + drawAreaOnImport.current = false; toast.error("Could not auto-create project from recording", { description: err instanceof Error ? err.message : String(err), }); diff --git a/src/components/ai-edition/cropDraft.ts b/src/components/ai-edition/cropDraft.ts index ff09b314f..2087abd3f 100644 --- a/src/components/ai-edition/cropDraft.ts +++ b/src/components/ai-edition/cropDraft.ts @@ -9,6 +9,10 @@ export interface CropDraft { height: number; } +/** Smallest crop side, in percent of the frame. Shared with the record-area overlay + * (`src/lib/recordingArea.ts`) so a crop seeded from a recording is one this dialog accepts. */ +export const MIN_CROP_PCT = 4; + export function cropDraftFromRegion(region: CropDraft): CropDraft { return { x: region.x, y: region.y, width: region.width, height: region.height }; } diff --git a/src/components/ai-edition/recordingImport.test.ts b/src/components/ai-edition/recordingImport.test.ts index 32bf4899f..e86d3d8c5 100644 --- a/src/components/ai-edition/recordingImport.test.ts +++ b/src/components/ai-edition/recordingImport.test.ts @@ -1,6 +1,9 @@ // @vitest-environment jsdom import { beforeEach, describe, expect, it, vi } from "vitest"; -import { replaceTimeline as replaceTimelineOp } from "@/lib/ai-edition/document/timeline"; +import { + applyProbedDuration, + replaceTimeline as replaceTimelineOp, +} from "@/lib/ai-edition/document/timeline"; import { type AxcutDocument, createEmptyDocument } from "@/lib/ai-edition/schema"; import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; import { undo } from "@/lib/ai-edition/store/undo"; @@ -55,12 +58,16 @@ const realActions = { function stubElectronApi( screenVideoPath: string | null, cursorCaptureMode: "editable-overlay" | "system" = "editable-overlay", + cropRegion?: { x: number; y: number; width: number; height: number }, ) { let session: { screenVideoPath: string; createdAt: number; cursorCaptureMode: "editable-overlay" | "system"; - } | null = screenVideoPath ? { screenVideoPath, createdAt: 0, cursorCaptureMode } : null; + cropRegion?: { x: number; y: number; width: number; height: number }; + } | null = screenVideoPath + ? { screenVideoPath, createdAt: 0, cursorCaptureMode, cropRegion } + : null; const api = { getCurrentRecordingSession: vi.fn(async () => session ? { success: true, session } : { success: false }, @@ -192,6 +199,23 @@ describe("what the recording import leaves on the undo stack", () => { expect(undo()).toBe(false); }); + // A recorded area: the display was recorded whole and the main process handed over the + // rectangle as a crop. The clip has to carry it from the start, and still carry it once + // the
)} + {/* Where the portal picks the monitor, no overlay can be put on it before + recording (Wayland lets no client place a window on a given output). So + the area is drawn afterwards instead, on the recording itself: the + editor opens the new clip's crop dialog (NewEditorShell). */} + {portalOwnsSource ? ( +
+
+ + {t("rec.drawArea")} +
+ +
+ ) : null} +
{prefs.systemAudioEnabled ? : } @@ -423,6 +459,7 @@ export function RecStage({ onTabChange={setSourceTab} screenCount={screenSources.length} windowCount={windowSources.length} + showArea={offerArea && screenSources.length > 0} sources={visibleSources} selectedId={source?.id ?? null} onSelect={(s) => void chooseSource(s)} @@ -439,16 +476,18 @@ function SourceModal({ onTabChange, screenCount, windowCount, + showArea, sources, selectedId, onSelect, onClose, }: { loading: boolean; - tab: "screen" | "window"; - onTabChange: (tab: "screen" | "window") => void; + tab: "screen" | "window" | "area"; + onTabChange: (tab: "screen" | "window" | "area") => void; screenCount: number; windowCount: number; + showArea: boolean; sources: ProcessedDesktopSource[]; selectedId: string | null; onSelect: (source: ProcessedDesktopSource) => void; @@ -473,6 +512,15 @@ function SourceModal({ > {t("rec.sourceModal.windows", { count: windowCount })} + {showArea ? ( + + ) : null}
{loading ? ( @@ -482,9 +530,9 @@ function SourceModal({
) : sources.length === 0 ? (
- {tab === "screen" - ? t("rec.sourceModal.noScreensFound") - : t("rec.sourceModal.noWindowsFound")} + {tab === "window" + ? t("rec.sourceModal.noWindowsFound") + : t("rec.sourceModal.noScreensFound")}
) : ( sources.map((s) => ( diff --git a/src/components/launch/AreaSelector.tsx b/src/components/launch/AreaSelector.tsx new file mode 100644 index 000000000..9b8fafe5d --- /dev/null +++ b/src/components/launch/AreaSelector.tsx @@ -0,0 +1,169 @@ +import { + type CSSProperties, + type PointerEvent as ReactPointerEvent, + useEffect, + useState, +} from "react"; +import { useScopedT } from "@/contexts/I18nContext"; +import { type AreaRect, toPhysicalArea } from "@/lib/recordingArea"; + +// The last area, per display, in CSS pixels. The overlay's own storage rather than the +// main process: it is a preference of this screen, and it outlives a restart. +const STORAGE_KEY = `capturia.recordArea.${new URLSearchParams(window.location.search).get("displayId")}`; + +type Edges = { left?: boolean; top?: boolean; right?: boolean; bottom?: boolean }; + +const HANDLES: Array<[Edges, CSSProperties]> = [ + [ + { left: true, top: true }, + { left: -5, top: -5, cursor: "nwse-resize" }, + ], + [ + { right: true, top: true }, + { right: -5, top: -5, cursor: "nesw-resize" }, + ], + [ + { left: true, bottom: true }, + { left: -5, bottom: -5, cursor: "nesw-resize" }, + ], + [ + { right: true, bottom: true }, + { right: -5, bottom: -5, cursor: "nwse-resize" }, + ], + [{ top: true }, { left: "50%", top: -5, marginLeft: -5, cursor: "ns-resize" }], + [{ bottom: true }, { left: "50%", bottom: -5, marginLeft: -5, cursor: "ns-resize" }], + [{ left: true }, { top: "50%", left: -5, marginTop: -5, cursor: "ew-resize" }], + [{ right: true }, { top: "50%", right: -5, marginTop: -5, cursor: "ew-resize" }], +]; + +function rememberedArea(): AreaRect | null { + try { + return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "null"); + } catch { + return null; + } +} + +/** + * The "record an area" overlay: one display, dimmed, with a rectangle to drag out, move + * and resize. Enter confirms, Esc dismisses. The display is still recorded whole; the + * rectangle becomes the crop the clip opens with. + */ +export function AreaSelector() { + const t = useScopedT("launch"); + const [rect, setRect] = useState(rememberedArea); + + // Drawn through the very clamp the main process applies, so what is on screen and in + // the readout is exactly what will be cropped: whole physical pixels, inside the + // display, at least the crop minimum. + const scale = window.devicePixelRatio || 1; + const view = { width: window.innerWidth, height: window.innerHeight }; + const area = rect ? toPhysicalArea(rect, view, scale) : null; + const shown = area && { + x: area.x / scale, + y: area.y / scale, + width: area.width / scale, + height: area.height / scale, + }; + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") window.close(); + if (event.key === "Enter" && shown) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(shown)); + void window.electronAPI.finishAreaSelection(shown); + } + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [shown]); + + // Every gesture is edges following the pointer from where it went down: drawing is a + // zero-size rectangle growing its bottom-right corner, moving is all four edges. + const startGesture = (edges: Edges | "move" | "draw") => (event: ReactPointerEvent) => { + event.preventDefault(); + event.stopPropagation(); + const originX = event.clientX; + const originY = event.clientY; + const start = + edges === "draw" || !shown ? { x: originX, y: originY, width: 0, height: 0 } : shown; + const moving: Edges | "move" = edges === "draw" ? { right: true, bottom: true } : edges; + const onMove = (moveEvent: PointerEvent) => { + const dx = moveEvent.clientX - originX; + const dy = moveEvent.clientY - originY; + if (moving === "move") { + setRect({ + ...start, + x: Math.min(Math.max(0, start.x + dx), view.width - start.width), + y: Math.min(Math.max(0, start.y + dy), view.height - start.height), + }); + return; + } + const x0 = start.x + (moving.left ? dx : 0); + const x1 = start.x + start.width + (moving.right ? dx : 0); + const y0 = start.y + (moving.top ? dy : 0); + const y1 = start.y + start.height + (moving.bottom ? dy : 0); + setRect({ + x: Math.min(x0, x1), + y: Math.min(y0, y1), + width: Math.abs(x1 - x0), + height: Math.abs(y1 - y0), + }); + }; + const onUp = () => { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + }; + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + }; + + return ( +
+ {shown && area ? ( +
+ {HANDLES.map(([edges, position]) => ( +
+ ))} +
32 ? { top: -30 } : { bottom: -30 }} + > + {area.width} × {area.height} +
+
+ ) : null} +
+ {t("areaSelector.hint")} +
+
+ ); +} diff --git a/src/components/launch/SourceSelector.tsx b/src/components/launch/SourceSelector.tsx index caac8c1fa..cd0613f73 100644 --- a/src/components/launch/SourceSelector.tsx +++ b/src/components/launch/SourceSelector.tsx @@ -19,6 +19,10 @@ export function SourceSelector() { const [selectedSource, setSelectedSource] = useState(null); const [loading, setLoading] = useState(true); const [loadFailed, setLoadFailed] = useState(false); + const [areaTab, setAreaTab] = useState(false); + // No overlay on Linux: Wayland lets no client place a window on a chosen display, and + // with the capture helper present the portal, not this picker, chooses the screen. + const offerArea = window.electronAPI?.getPlatform?.() !== "linux"; const fetchSources = useCallback(async () => { setLoading(true); @@ -63,8 +67,13 @@ export function SourceSelector() { const hasNoSources = !loading && sources.length === 0; const handleSourceSelect = (source: DesktopSource) => setSelectedSource(source); + const areaPickable = !areaTab || selectedSource?.id.startsWith("screen:") === true; const handleShare = async () => { - if (selectedSource) await window.electronAPI.selectSource(selectedSource); + if (!selectedSource) return; + // An area pick closes this window only once the overlay confirms; Esc there + // leaves the picker open to try again. + if (areaTab) await window.electronAPI.selectArea(selectedSource); + else await window.electronAPI.selectSource(selectedSource); }; if (loading) { @@ -144,6 +153,7 @@ export function SourceSelector() {
setAreaTab(value === "area")} className="flex-1 flex flex-col min-h-0" > @@ -159,6 +169,14 @@ export function SourceSelector() { > {t("sourceSelector.windows", { count: String(windowSources.length) })} + {offerArea && screenSources.length > 0 ? ( + + {t("sourceSelector.area")} + + ) : null}
@@ -175,6 +193,13 @@ export function SourceSelector() { {windowSources.map(renderSourceCard)}
+ +
+ {screenSources.map(renderSourceCard)} +
+
@@ -189,7 +214,7 @@ export function SourceSelector() { diff --git a/src/lib/ai-edition/store/documentWriteAudit.test.ts b/src/lib/ai-edition/store/documentWriteAudit.test.ts index 3add7c1b1..7a6c3bf66 100644 --- a/src/lib/ai-edition/store/documentWriteAudit.test.ts +++ b/src/lib/ai-edition/store/documentWriteAudit.test.ts @@ -120,7 +120,9 @@ const DECLARED: WritePath[] = [ ), // Applying a saved look is a click in the "Saved looks" menu: one undo step. - w("src/components/ai-edition/LookPresetsMenu.tsx", "apply", "save", "gesture"), + w("src/components/ai-edition/LookPresetsMenu.tsx", "applyLookPreset", "save", "gesture"), + // ...and its optimistic half, so a pane edit during the save builds on the look. + w("src/components/ai-edition/LookPresetsMenu.tsx", "applyLookPreset", "set", "automatic"), // The persist that follows an undo. Recording it would undo the undo. w("src/components/ai-edition/NewEditorShell.tsx", "NewEditorShell", "save", "automatic"), From 666b5875d004aaff028b5254b605607cb2ee100b Mon Sep 17 00:00:00 2001 From: MinhOmega <49482201+MinhOmega@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:43:52 +0700 Subject: [PATCH 30/40] fix(media): keep one cached poster per source file The poster cache key carried the requested frame time in milliseconds, and a project poster asks for its first clip's start, so every trim of that clip left another JPEG under userData/posters for good; a renderer could mint entries at will through get-media-poster with any time it liked. Posters are now named -, the frame time only picks the frame on a miss (rounded to whole seconds), and writing a poster deletes that file's older entries. Entries are bounded by the number of distinct source files. --- electron/media/posterFrames.test.ts | 19 ++++++++++++++++++- electron/media/posterFrames.ts | 25 ++++++++++++++++++------- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/electron/media/posterFrames.test.ts b/electron/media/posterFrames.test.ts index 7505aaf79..7c605d0f2 100644 --- a/electron/media/posterFrames.test.ts +++ b/electron/media/posterFrames.test.ts @@ -1,6 +1,6 @@ // @vitest-environment node import { EventEmitter } from "node:events"; -import { mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readdirSync, rmSync, utimesSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; @@ -67,5 +67,22 @@ describe("getPosterFrame cache", () => { hoisted.spawn.mockImplementation(() => fakeFfmpeg("poster-3")); expect(await getPosterFrame(video, 1)).not.toBe(second); expect(hoisted.spawn).toHaveBeenCalledTimes(3); + + // Another frame time is the same poster (a trimmed first clip, or a renderer + // asking for any time it likes), and every older version is gone: one entry + // per source file, however it was asked for. + expect(await getPosterFrame(video, 12.345)).toBe( + `data:image/jpeg;base64,${Buffer.from("poster-3").toString("base64")}`, + ); + expect(hoisted.spawn).toHaveBeenCalledTimes(3); + expect(readdirSync(path.join(hoisted.userData, "posters"))).toHaveLength(1); + }); + + it("grabs the frame at whole seconds", async () => { + writeFileSync(path.join(root, "other.mp4"), "another file"); + hoisted.spawn.mockImplementation(() => fakeFfmpeg("poster")); + await getPosterFrame(path.join(root, "other.mp4"), 7.6); + const args = hoisted.spawn.mock.calls[0][1] as string[]; + expect(args[args.indexOf("-ss") + 1]).toBe("8"); }); }); diff --git a/electron/media/posterFrames.ts b/electron/media/posterFrames.ts index 6e70ff2cf..ac615e67e 100644 --- a/electron/media/posterFrames.ts +++ b/electron/media/posterFrames.ts @@ -1,5 +1,6 @@ import { spawn } from "node:child_process"; -import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { app } from "electron"; import { cacheKey, resolveFfmpeg } from "./audioPeaks"; @@ -101,20 +102,24 @@ function toDataUrl(jpeg: Buffer): string { let lane: Promise = Promise.resolve(); /** - * A `data:` URL of the frame at `atSec`, or null when there is no ffmpeg or the + * A `data:` URL of a frame near `atSec`, or null when there is no ffmpeg or the * file cannot be decoded (the caller keeps its placeholder). * - * Keyed on path + size + mtime (`cacheKey`), plus the requested time, so a - * re-encoded or replaced file gets a fresh poster rather than the old one. + * One poster per source file: named `-`, so + * a re-encoded or replaced file gets a fresh poster, and writing it deletes the + * file's older entries. `atSec` only picks the frame on a miss and is not part + * of the key — keyed on it, every trim of a project's first clip, or any time a + * renderer cared to ask for, left one more JPEG behind for good. */ export async function getPosterFrame(filePath: string, atSec = 0): Promise { const ffmpeg = resolveFfmpeg(); if (!ffmpeg) return null; + const fileId = createHash("sha1").update(filePath).digest("hex").slice(0, 16); // Throws for a missing file, which the IPC handler turns into a placeholder. - const key = `${await cacheKey(filePath)}-${Math.round(Math.max(0, atSec) * 1000)}`; + const name = `${fileId}-${await cacheKey(filePath)}.jpg`; const dir = posterCacheDir(); - const cachePath = dir ? path.join(dir, `${key}.jpg`) : null; + const cachePath = dir ? path.join(dir, name) : null; const readCached = async () => cachePath ? await readFile(cachePath).then(toDataUrl, () => null) : null; @@ -125,7 +130,7 @@ export async function getPosterFrame(filePath: string, atSec = 0): Promise Date: Fri, 11 Sep 2026 23:43:52 +0700 Subject: [PATCH 31/40] fix(stt): single-flight speech model switches Two switches to the same model (click Use, close and reopen AI settings, click again) ran two downloads into one `.partial`, and two pipelines writing one inode can rename a truncated file into place before the digest check of the other lands. A second request for a model now awaits the first one's promise. Deleting a model is refused while a switch to it is in flight; a switch from it was already covered, since a model stays active until the switch replacing it has landed. --- electron/stt/index.test.ts | 34 +++++++++++++++++++++++++ electron/stt/index.ts | 52 ++++++++++++++++++++++++++++---------- 2 files changed, 73 insertions(+), 13 deletions(-) diff --git a/electron/stt/index.test.ts b/electron/stt/index.test.ts index a14995699..70728aa4b 100644 --- a/electron/stt/index.test.ts +++ b/electron/stt/index.test.ts @@ -512,6 +512,40 @@ describe("SttManager", () => { } }); + it("runs one download for two switches to the same model, and refuses to delete it meanwhile", async () => { + const { ensureModels } = await import("./modelManager"); + const dir = mkdtempSync(path.join(tmpdir(), "capturia-stt-single-flight-")); + try { + const mgr = new SttManager(); + await mgr.init({ modelsBaseDir: dir }); + const mocked = vi.mocked(ensureModels); + mocked.mockClear(); + let finish: () => void = () => undefined; + mocked.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + + // "Use accurate", close and reopen AI settings, "Use accurate" again. + const first = mgr.setModel("accurate"); + const second = mgr.setModel("accurate"); + expect(second).toBe(first); + await expect(mgr.deleteModel("accurate")).rejects.toThrow(/being switched to/); + + finish(); + await Promise.all([first, second]); + expect(mocked).toHaveBeenCalledOnce(); + expect((await mgr.listModels()).active).toBe("accurate"); + // Settled, so the next switch is a real one again. + await mgr.setModel("fast"); + expect(mocked).toHaveBeenCalledTimes(2); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("fans status out to every sink, and detaching one leaves the others", async () => { const mgr = new SttManager(); const a = vi.fn<(e: SttStatusEvent) => void>(); diff --git a/electron/stt/index.ts b/electron/stt/index.ts index bb19cacea..5f2d5fba7 100644 --- a/electron/stt/index.ts +++ b/electron/stt/index.ts @@ -113,6 +113,12 @@ export class SttManager { private model: WhisperServerStartOptions | null = null; /** What the last run actually bound; the settings' CPU warning trusts it over a guess. */ private lastBackend: SttBackend | null = null; + /** + * Switches in flight, by the model they switch to. A second request for the + * same model awaits the first: two downloads of one model share its `.partial`, + * and two pipelines writing one inode can rename a truncated file into place. + */ + private readonly switching = new Map>(); /** * Bumped by `cancel()`. The chunk loop compares it against the value it * captured on entry, so a cancel that lands after a new run started cannot @@ -479,28 +485,48 @@ export class SttManager { * * A run already in flight finishes on the model it started with; clearing * `initPromise` makes the next one re-prepare, and `prepare()` swaps the helper. + * + * Single-flight per model (see `switching`); a joining caller's `onProgress` is + * not attached, but both requests come from the settings window, which listens + * on `stt:model-progress` rather than per request. */ - async setModel( - id: SttModelId, - onProgress?: (event: SttModelProgressEvent) => void, - ): Promise { + setModel(id: SttModelId, onProgress?: (event: SttModelProgressEvent) => void): Promise { + const inFlight = this.switching.get(id); + if (inFlight) return inFlight; const modelsDir = this.getModelsDir(); - await ensureModels({ - baseDir: modelsDir, - only: [id], - onProgress: (event) => - onProgress?.({ id, downloadedBytes: event.downloadedBytes, totalBytes: event.totalBytes }), - }); - await writeActiveModel(modelsDir, id); - this.initPromise = null; + const run = (async () => { + await ensureModels({ + baseDir: modelsDir, + only: [id], + onProgress: (event) => + onProgress?.({ + id, + downloadedBytes: event.downloadedBytes, + totalBytes: event.totalBytes, + }), + }); + await writeActiveModel(modelsDir, id); + this.initPromise = null; + })().finally(() => this.switching.delete(id)); + this.switching.set(id, run); + return run; } - /** Remove a downloaded model other than the active one. */ + /** + * Remove a downloaded model other than the active one. Refused while a switch + * TO it is in flight (it would delete the download from under it); a switch + * FROM it is covered by the active check, since a model stays active until the + * switch replacing it has landed. + */ async deleteModel(id: SttModelId): Promise { const modelsDir = this.getModelsDir(); if (id === (await readActiveModel(modelsDir))) { throw new Error("The active speech model cannot be deleted"); } + // After the await, so a switch that started while it ran is seen too. + if (this.switching.has(id)) { + throw new Error("This speech model is still being switched to"); + } const file = modelPath(modelsDir, id); await rm(file, { force: true }); await rm(`${file}.partial`, { force: true }); From c205fc076460b7a327b8037b72e4fafe8ae503f3 Mon Sep 17 00:00:00 2001 From: MinhOmega <49482201+MinhOmega@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:49:17 +0700 Subject: [PATCH 32/40] fix(stt): settle the speech model section when it remounts mid-download `pending` was cleared only by the `use()` call that started a download. The section unmounts with AI settings (and behind the provider form), so after Use Accurate -> close -> reopen the progress events set it again and nothing ever cleared it: the card sat at ~100%, every Use/Delete stayed disabled and Active still named the old model. The models snapshot now reports the switches in flight. A mount that finds one, or sees progress for a download it did not start, awaits setModel(id), which joins the in-flight switch in main, then clears pending and refreshes. Buttons are held from mount rather than from the next progress event, and only the mount that started a switch toasts its failure. --- electron/stt/index.test.ts | 2 + electron/stt/index.ts | 1 + electron/stt/transcriptionContract.ts | 2 + .../ai-edition/SpeechModelSettings.test.tsx | 65 +++++++++++++++++++ .../ai-edition/SpeechModelSettings.tsx | 62 +++++++++++++----- 5 files changed, 116 insertions(+), 16 deletions(-) create mode 100644 src/components/ai-edition/SpeechModelSettings.test.tsx diff --git a/electron/stt/index.test.ts b/electron/stt/index.test.ts index 70728aa4b..14d8a29f3 100644 --- a/electron/stt/index.test.ts +++ b/electron/stt/index.test.ts @@ -532,6 +532,8 @@ describe("SttManager", () => { const first = mgr.setModel("accurate"); const second = mgr.setModel("accurate"); expect(second).toBe(first); + // What a settings section mounted mid-download reads to find the switch to join. + expect((await mgr.listModels()).inFlight).toEqual(["accurate"]); await expect(mgr.deleteModel("accurate")).rejects.toThrow(/being switched to/); finish(); diff --git a/electron/stt/index.ts b/electron/stt/index.ts index 5f2d5fba7..726ed367c 100644 --- a/electron/stt/index.ts +++ b/electron/stt/index.ts @@ -472,6 +472,7 @@ export class SttManager { active: await readActiveModel(modelsDir), models, cpuOnly: backend === "whispercpp-cpu", + inFlight: [...this.switching.keys()], }; } diff --git a/electron/stt/transcriptionContract.ts b/electron/stt/transcriptionContract.ts index fcc4bcf7b..382308bc1 100644 --- a/electron/stt/transcriptionContract.ts +++ b/electron/stt/transcriptionContract.ts @@ -69,6 +69,8 @@ export interface SttModelsSnapshot { models: { id: SttModelId; bytes: number; downloaded: boolean }[]; /** No GPU backend is known to bind, so the bigger models run slowly. */ cpuOnly: boolean; + /** Models a switch is downloading right now, possibly started by an earlier mount. */ + inFlight: SttModelId[]; } /** Progress of a speech-model download started from the settings (`stt:set-model`). */ diff --git a/src/components/ai-edition/SpeechModelSettings.test.tsx b/src/components/ai-edition/SpeechModelSettings.test.tsx new file mode 100644 index 000000000..42aca3fc9 --- /dev/null +++ b/src/components/ai-edition/SpeechModelSettings.test.tsx @@ -0,0 +1,65 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom"; +import { cleanup, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import { I18nProvider } from "@/contexts/I18nContext"; +import { LOCALE_STORAGE_KEY } from "@/i18n/config"; +import type { SttModelsSnapshot } from "../../../electron/stt/transcriptionContract"; +import { SpeechModelSettings } from "./SpeechModelSettings"; + +afterEach(() => { + cleanup(); + localStorage.clear(); + (window as { electronAPI?: unknown }).electronAPI = undefined; +}); + +const snapshot = (over: Partial): SttModelsSnapshot => ({ + active: "balanced", + models: [ + { id: "fast", bytes: 81_768_585, downloaded: true }, + { id: "balanced", bytes: 264_464_607, downloaded: true }, + { id: "accurate", bytes: 574_041_195, downloaded: false }, + ], + cpuOnly: false, + inFlight: [], + ...over, +}); + +it("joins a switch an earlier mount started, and settles when it lands", async () => { + // "Use Accurate", close AI settings, reopen: this mount did not start the + // download, and main reports it in flight. + let land: () => void = () => undefined; + const setModel = vi.fn( + () => + new Promise((resolve) => { + land = resolve; + }), + ); + const listModels = vi + .fn() + .mockResolvedValueOnce(snapshot({ inFlight: ["accurate"] })) + .mockResolvedValue(snapshot({ active: "accurate" })); + (window as { electronAPI?: unknown }).electronAPI = { + stt: { listModels, setModel, deleteModel: vi.fn(), onModelProgress: () => () => undefined }, + }; + localStorage.setItem(LOCALE_STORAGE_KEY, "en"); + render( + + + , + ); + + // Held from the start, not from the next progress event, and waiting on the + // in-flight switch rather than starting one. + await waitFor(() => expect(setModel).toHaveBeenCalledWith("accurate")); + expect(setModel).toHaveBeenCalledOnce(); + for (const use of screen.getAllByRole("button", { name: "Use" })) expect(use).toBeDisabled(); + + land(); + + // The card it switched to reads Active, and nothing is left disabled. + const accurate = (await screen.findByText("Accurate")).closest("div") + ?.parentElement as HTMLElement; + await waitFor(() => expect(within(accurate).getByText("Active")).toBeInTheDocument()); + for (const use of screen.getAllByRole("button", { name: "Use" })) expect(use).toBeEnabled(); +}); diff --git a/src/components/ai-edition/SpeechModelSettings.tsx b/src/components/ai-edition/SpeechModelSettings.tsx index ab7ca951a..789a7d21a 100644 --- a/src/components/ai-edition/SpeechModelSettings.tsx +++ b/src/components/ai-edition/SpeechModelSettings.tsx @@ -6,7 +6,7 @@ // whichever model is active when it runs. import { AlertTriangle, Check, Loader2, Trash2 } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { useScopedT } from "@/contexts/I18nContext"; import { formatBytes } from "@/utils/formatBytes"; @@ -22,37 +22,67 @@ export function SpeechModelSettings() { /** The model being downloaded, and how far along it is (0-1). */ const [pending, setPending] = useState<{ id: SttModelId; fraction: number } | null>(null); + /** Switches this mount is awaiting, so a stream of progress events waits once. */ + const following = useRef(new Set()); + const refresh = useCallback(async () => { const stt = sttBridge(); if (stt?.listModels) setSnapshot(await stt.listModels().catch(() => null)); }, []); + + /** + * Await the switch to `id` and settle the section when it lands. It may be one + * an earlier mount started: the section unmounts with the dialog (and behind + * the provider form) while the download carries on in main, and `setModel` + * joins that in-flight switch rather than starting another. Only the mount that + * started a switch reports its failure, so it is not toasted twice. + */ + const follow = useCallback( + async (id: SttModelId, reportFailure: boolean) => { + if (following.current.has(id)) return; + following.current.add(id); + setPending((current) => current ?? { id, fraction: 0 }); + try { + await sttBridge()?.setModel(id); + } catch (err) { + if (reportFailure) { + toast.error(te("speechModel.switchFailed"), { + description: err instanceof Error ? err.message : String(err), + }); + } + } finally { + following.current.delete(id); + setPending(null); + await refresh(); + } + }, + [refresh, te], + ); + useEffect(() => { void refresh(); }, [refresh]); + // A switch already running when this mounted: hold every button from the start, + // not from whenever its next progress event happens to arrive. + useEffect(() => { + for (const id of snapshot?.inFlight ?? []) void follow(id, false); + }, [snapshot, follow]); useEffect( () => - sttBridge()?.onModelProgress?.(({ id, downloadedBytes, totalBytes }) => - setPending({ id, fraction: totalBytes > 0 ? downloadedBytes / totalBytes : 0 }), - ), - [], + sttBridge()?.onModelProgress?.(({ id, downloadedBytes, totalBytes }) => { + setPending({ id, fraction: totalBytes > 0 ? downloadedBytes / totalBytes : 0 }); + void follow(id, false); + }), + [follow], ); const stt = sttBridge(); // No STT bridge (browser preview, tests): nothing to choose between. if (!stt || !snapshot) return null; - const use = async (id: SttModelId) => { + const use = (id: SttModelId) => { setPending({ id, fraction: 0 }); - try { - await stt.setModel(id); - } catch (err) { - toast.error(te("speechModel.switchFailed"), { - description: err instanceof Error ? err.message : String(err), - }); - } finally { - setPending(null); - await refresh(); - } + return follow(id, true); }; const remove = async (id: SttModelId) => { From 165bd7455dc01831a6691217f72752e0d451de6a Mon Sep 17 00:00:00 2001 From: MinhOmega <49482201+MinhOmega@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:49:18 +0700 Subject: [PATCH 33/40] fix(media): stop the media card poster flickering when its duration lands The card asked for the frame at min(1, durationSec / 2), and durationSec is probed after mount, so the time moved from 0 to 1, the hook reset the poster to the placeholder and ffmpeg grabbed a second frame. The card now asks for a fixed second, main falls back to the first frame when the file is shorter than that, and the hook keeps the previous poster until a new one arrives. --- electron/media/posterFrames.ts | 7 ++++++- src/components/ai-edition/v4/MediaStage.tsx | 11 +++++------ src/hooks/usePosterFrame.ts | 10 +++++++--- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/electron/media/posterFrames.ts b/electron/media/posterFrames.ts index ac615e67e..f1fd20cc3 100644 --- a/electron/media/posterFrames.ts +++ b/electron/media/posterFrames.ts @@ -130,7 +130,12 @@ export async function getPosterFrame(filePath: string, atSec = 0): Promise + at > 0 ? grabFrame(ffmpeg, filePath, 0) : Promise.reject(error), + ); if (cachePath && dir) { try { await mkdir(dir, { recursive: true }); diff --git a/src/components/ai-edition/v4/MediaStage.tsx b/src/components/ai-edition/v4/MediaStage.tsx index 15d724b28..c78332014 100644 --- a/src/components/ai-edition/v4/MediaStage.tsx +++ b/src/components/ai-edition/v4/MediaStage.tsx @@ -40,13 +40,12 @@ function basename(path: string): string { } /** A real frame of the asset — a second in, past any fade from black — over the - * gradient placeholder, which stays for anything missing or undecodable. */ + * gradient placeholder, which stays for anything missing or undecodable. A fixed + * second rather than one derived from `durationSec`: that is probed after mount, + * and a time that moves with it re-requested (and re-grabbed) the poster. Main + * falls back to the first frame for a file shorter than that. */ function MediaThumb({ asset, index }: { asset: AxcutAsset; index: number }) { - const poster = usePosterFrame( - "media", - asset.originalPath, - Math.min(1, (asset.durationSec ?? 0) / 2), - ); + const poster = usePosterFrame("media", asset.originalPath, 1); return (
(null); useEffect(() => { - setPoster(null); const api = window.electronAPI; - if (!id || !api?.getProjectPoster || !api.getMediaPoster) return; + if (!id || !api?.getProjectPoster || !api.getMediaPoster) { + setPoster(null); + return; + } let live = true; const request = source === "project" ? api.getProjectPoster(id) : api.getMediaPoster(id, atSec); request.then( From bd2c1ef5143df11f38d6e30a838869c07c75e7ca Mon Sep 17 00:00:00 2001 From: MinhOmega <49482201+MinhOmega@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:45:30 +0700 Subject: [PATCH 34/40] fix(updates): self-update only the version the dialog names The release check picks the highest version on MinhOmega/Capturia, but electron-updater's GitHub feed does not: it keeps an RC install on RC entries and reads a stable install's feed head. An RC user could approve "2.1.0" and get 2.1.0-rc.3 installed, and a stable user offered an RC could be handed an older feed head that the updater then refused to download ("Please check update first"). checkForSelfUpdate now reports an update only when electron-updater says isUpdateAvailable, and the check-for-updates dialog offers Download only when the updater's version equals the one it names (leading v ignored). Otherwise it falls back to View Release. --- electron/auto-updater.test.ts | 39 +++++++++++++++++++++++++++++++++-- electron/auto-updater.ts | 22 ++++++++++++++------ electron/main.ts | 12 ++++++++++- 3 files changed, 64 insertions(+), 9 deletions(-) diff --git a/electron/auto-updater.test.ts b/electron/auto-updater.test.ts index 831e3ac3b..7a4c837cd 100644 --- a/electron/auto-updater.test.ts +++ b/electron/auto-updater.test.ts @@ -5,6 +5,7 @@ import { downloadSelfUpdate, type InstallReadiness, installSelfUpdate, + selfUpdateMatches, } from "./auto-updater"; const mocks = vi.hoisted(() => ({ @@ -131,7 +132,10 @@ describe("self-update flow", () => { }); it("reports current when the feed offers the running version", async () => { - mocks.autoUpdater.checkForUpdates.mockResolvedValue({ updateInfo: { version: "1.9.2" } }); + mocks.autoUpdater.checkForUpdates.mockResolvedValue({ + updateInfo: { version: "1.9.2" }, + isUpdateAvailable: false, + }); await expect(checkForSelfUpdate("appimage")).resolves.toEqual({ kind: "current" }); }); @@ -141,13 +145,27 @@ describe("self-update flow", () => { }); it("surfaces an available version", async () => { - mocks.autoUpdater.checkForUpdates.mockResolvedValue({ updateInfo: { version: "1.10.0" } }); + mocks.autoUpdater.checkForUpdates.mockResolvedValue({ + updateInfo: { version: "1.10.0" }, + isUpdateAvailable: true, + }); await expect(checkForSelfUpdate("dmg")).resolves.toEqual({ kind: "downloaded", version: "1.10.0", }); }); + // A stable 2.1.0 with pre-releases on: the release check offers 2.2.0-rc.1, but the feed + // head the updater reads is an older hotfix it will not install. Reporting that as an + // update made `downloadUpdate` reject with "Please check update first". + it("reports current when the feed's version is one the updater will not install", async () => { + mocks.autoUpdater.checkForUpdates.mockResolvedValue({ + updateInfo: { version: "2.0.2" }, + isUpdateAvailable: false, + }); + await expect(checkForSelfUpdate("nsis", true)).resolves.toEqual({ kind: "current" }); + }); + // A release published before the update feeds existed has no latest*.yml. That must degrade // to the release-page fallback, not throw into main-process-errors, which re-throws. it("reports a missing or broken feed as failed instead of throwing", async () => { @@ -172,3 +190,20 @@ describe("self-update flow", () => { expect(mocks.autoUpdater.quitAndInstall).toHaveBeenCalledWith(false, true); }); }); + +describe("selfUpdateMatches", () => { + // An RC install with pre-releases on, feed [2.1.0, 2.1.0-rc.3]: the release check names + // 2.1.0, the updater keeps the RC on its channel and would install 2.1.0-rc.3. + it("refuses a self-update of a version other than the one the dialog names", () => { + expect(selfUpdateMatches({ kind: "downloaded", version: "2.1.0-rc.3" }, "2.1.0")).toBe(false); + expect(selfUpdateMatches({ kind: "current" }, "2.1.0")).toBe(false); + expect(selfUpdateMatches({ kind: "failed", error: new Error("no feed") }, "2.1.0")).toBe(false); + }); + + it("allows it when both name the same version, with or without a leading v", () => { + expect(selfUpdateMatches({ kind: "downloaded", version: "2.1.0" }, "2.1.0")).toBe(true); + expect(selfUpdateMatches({ kind: "downloaded", version: "v2.2.0-rc.1" }, "2.2.0-rc.1")).toBe( + true, + ); + }); +}); diff --git a/electron/auto-updater.ts b/electron/auto-updater.ts index e7f9dfbfa..8e8739034 100644 --- a/electron/auto-updater.ts +++ b/electron/auto-updater.ts @@ -56,10 +56,11 @@ async function getUpdater() { /** Is an update available, and can this install apply it itself? * - * `allowPrerelease` is the "Get pre-release builds" setting, set on every check. Left to - * electron-updater it would follow the running version instead — an RC install would chase - * RCs the release check (update-checker.ts) never offered, and download one of those while - * the dialog named a stable. */ + * `allowPrerelease` is the "Get pre-release builds" setting, set on every check. That alone + * does not make the updater pick what the release check (update-checker.ts) picked: its + * GitHub feed keeps an RC install on RCs and reads a stable install's feed head, whatever + * version that is. So the version here is only ever the updater's own, and a caller naming + * a version to the user must hold it against `selfUpdateMatches`. */ export async function checkForSelfUpdate( channel: InstallChannel, allowPrerelease = false, @@ -69,9 +70,11 @@ export async function checkForSelfUpdate( const autoUpdater = await getUpdater(); autoUpdater.allowPrerelease = allowPrerelease; const result = await autoUpdater.checkForUpdates(); - // null when no feed resolved; equal versions come back with no downloadPromise. + // null when no feed resolved. A feed version the updater will not install (equal, or + // older than the running one) must not read as an update: `downloadUpdate` would then + // reject with "Please check update first". const version = result?.updateInfo?.version; - if (!version || version === app.getVersion()) return { kind: "current" }; + if (!version || !result?.isUpdateAvailable) return { kind: "current" }; return { kind: "downloaded", version }; } catch (error) { // A missing or malformed feed is the expected failure on any release published before @@ -80,6 +83,13 @@ export async function checkForSelfUpdate( } } +/** Whether the updater would install exactly the version the release check named — the one + * the dialog shows the user. Anything else is not theirs to approve with that button. */ +export function selfUpdateMatches(outcome: UpdateOutcome, latestVersion: string): boolean { + const bare = (version: string) => version.trim().replace(/^v/, ""); + return outcome.kind === "downloaded" && bare(outcome.version) === bare(latestVersion); +} + /** Download the pending update. Separate from the check so the user approves the transfer. */ export async function downloadSelfUpdate(): Promise { try { diff --git a/electron/main.ts b/electron/main.ts index aafe00c64..75e96dc65 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -33,6 +33,7 @@ import { checkForSelfUpdate, downloadSelfUpdate, installSelfUpdate, + selfUpdateMatches, type UpdateOutcome, } from "./auto-updater"; import { @@ -837,7 +838,16 @@ async function checkForUpdates(onVerdict?: () => void) { // never update — can only be pointed at the download page. Ask the updater first so the // buttons offered match what this install can actually do. const selfUpdate = await probeSelfUpdate(); - const canSelfUpdate = selfUpdate.kind === "downloaded"; + // Only when the updater would install the very version the dialog names. Its feed can + // disagree with the release check (an RC install is kept on RCs, a stable install reads + // the feed head), and "Download Update" must not install a version the user never saw. + const canSelfUpdate = selfUpdateMatches(selfUpdate, result.latestVersion); + if (selfUpdate.kind === "downloaded" && !canSelfUpdate) { + console.warn("[updates] the updater offers a different version, showing the release page", { + checker: result.latestVersion, + updater: selfUpdate.version, + }); + } if (selfUpdate.kind === "failed") { // A release published before the update feeds existed has no latest*.yml. Not worth a // dialog — the download page below still works — but it must not vanish silently. From e6f631cca5a3cec2409c46a007f9d6d294e41517 Mon Sep 17 00:00:00 2001 From: MinhOmega <49482201+MinhOmega@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:45:31 +0700 Subject: [PATCH 35/40] fix(recording): probe the chosen folder for real, contain renderer-named writes - Writability is decided by creating and deleting a probe file instead of access(W_OK). On Windows libuv answers W_OK for a directory without reading its ACL, so a read-only share or C:\Program Files was accepted and the take was lost at its first or final write. The probe is used both when the folder is chosen and right before a take. - A renderer-named take file must now resolve inside a recordings root after links are resolved. That uses the same realpath check reads use, so a recording-5.mp4 in the chosen folder that links out of it, dangling or not, can no longer be truncated or unlinked through the stream handlers. The name and containment rules move into recordingsFolder.ts as takeOutputPath, so they are tested directly. --- electron/ipc/handlers.ts | 31 +++-------- electron/recordingsFolder.test.ts | 85 +++++++++++++++++++++++++++++-- electron/recordingsFolder.ts | 64 +++++++++++++++++++++-- 3 files changed, 150 insertions(+), 30 deletions(-) diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index d7e3ce21d..d109259e9 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -42,7 +42,6 @@ import { type RecordingSession, type StoreRecordedSessionInput, } from "../../src/lib/recordingSession"; -import { recordingGroupKeyFromFileName } from "../../src/lib/recordingsCleanupPolicy"; import type { CursorRecordingData, CursorRecordingSample, @@ -115,6 +114,7 @@ import { loadRecordingsFolder, saveRecordingsFolder } from "../recording-setting import { isPathWithinDir, isPathWithinRecordingRoots, + takeOutputPath, validRecordingsFolder, } from "../recordingsFolder"; import { settingsPaneUrl } from "../windowPermissions"; @@ -445,29 +445,12 @@ function approveDocumentMedia(document: AxcutDocument): void { } function resolveRecordingOutputPath(fileName: string): string { - const trimmed = fileName.trim(); - if (!trimmed) { - throw new Error("Invalid recording file name"); - } - - const parsedPath = path.parse(trimmed); - const hasTraversalSegments = trimmed.split(/[\\/]+/).some((segment) => segment === ".."); - const isNestedPath = - parsedPath.dir !== "" || - path.isAbsolute(trimmed) || - trimmed.includes("/") || - trimmed.includes("\\"); - if (hasTraversalSegments || isNestedPath || parsedPath.base !== trimmed) { - throw new Error("Recording file name must not contain path segments"); - } - // The renderer names this file and main then creates, overwrites or deletes it (the stream - // handlers, the empty-take unlink). In a folder the user picked, that must never reach a - // file Capturia did not name itself. - if (recordingGroupKeyFromFileName(parsedPath.base) === null) { - throw new Error("Recording file name is not one Capturia writes"); - } - - return path.join(newTakeDir(), parsedPath.base); + return takeOutputPath( + fileName, + newTakeDir(), + RECORDINGS_DIR, + validRecordingsFolder(chosenRecordingsFolder), + ); } function isValidDurationMs(value: number | undefined): value is number { diff --git a/electron/recordingsFolder.test.ts b/electron/recordingsFolder.test.ts index 139ffa672..22d2d53b8 100644 --- a/electron/recordingsFolder.test.ts +++ b/electron/recordingsFolder.test.ts @@ -1,12 +1,42 @@ -import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { + chmodSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { isPathWithinRecordingRoots, validRecordingsFolder } from "./recordingsFolder"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + isPathWithinRecordingRoots, + takeOutputPath, + validRecordingsFolder, +} from "./recordingsFolder"; + +// Lets a test stand in for a folder whose permission bits say "writable" while creating a +// file there still fails — what a read-only share or `C:\Program Files` looks like on Windows. +const fsFaults = vi.hoisted(() => ({ refuseCreate: false })); +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + openSync: (...args: Parameters) => { + if (fsFaults.refuseCreate) { + throw Object.assign(new Error("EPERM: operation not permitted"), { code: "EPERM" }); + } + return actual.openSync(...args); + }, + }; +}); const temps: string[] = []; afterEach(() => { + fsFaults.refuseCreate = false; for (const dir of temps) rmSync(dir, { recursive: true, force: true }); temps.length = 0; }); @@ -180,4 +210,53 @@ describe("validRecordingsFolder", () => { } }, ); + + it("decides writability by creating a file, not by permission bits, and leaves nothing behind", () => { + const { chosen } = roots(); + writeFileSync(path.join(chosen, "holiday.mp4"), ""); + expect(validRecordingsFolder(chosen, { writable: true })).toBe(chosen); + expect(readdirSync(chosen)).toEqual(["holiday.mp4"]); + + fsFaults.refuseCreate = true; + expect(validRecordingsFolder(chosen)).toBe(chosen); + expect(validRecordingsFolder(chosen, { writable: true })).toBeNull(); + }); +}); + +describe("takeOutputPath", () => { + it("places a take-named file in the take folder", () => { + const { defaultDir, chosen } = roots(); + expect(takeOutputPath("recording-5.webm", chosen, defaultDir, chosen)).toBe( + path.join(chosen, "recording-5.webm"), + ); + expect(takeOutputPath("recording-5-webcam.webm", defaultDir, defaultDir, null)).toBe( + path.join(defaultDir, "recording-5-webcam.webm"), + ); + }); + + it("refuses paths, traversal and names Capturia does not write", () => { + const { defaultDir, chosen } = roots(); + for (const name of ["../recording-5.mp4", "sub/recording-5.mp4", "", "holiday.mp4"]) { + expect(() => takeOutputPath(name, chosen, defaultDir, chosen)).toThrow(); + } + expect(() => + takeOutputPath(path.join(chosen, "recording-5.mp4"), chosen, defaultDir, chosen), + ).toThrow(); + }); + + itWithSymlinks("refuses a take name in the chosen folder that links out of it", () => { + const { defaultDir, chosen, outside } = roots(); + const victim = path.join(outside, "other-tool.mp4"); + writeFileSync(victim, "keep me"); + symlinkSync(victim, path.join(chosen, "recording-5.mp4")); + symlinkSync(path.join(outside, "missing.mp4"), path.join(chosen, "recording-6.mp4")); + + expect(() => takeOutputPath("recording-5.mp4", chosen, defaultDir, chosen)).toThrow( + "outside the recordings folder", + ); + expect(() => takeOutputPath("recording-6.mp4", chosen, defaultDir, chosen)).toThrow( + "outside the recordings folder", + ); + expect(readFileSync(victim, "utf8")).toBe("keep me"); + }); }); diff --git a/electron/recordingsFolder.ts b/electron/recordingsFolder.ts index 924077109..7057e791d 100644 --- a/electron/recordingsFolder.ts +++ b/electron/recordingsFolder.ts @@ -7,13 +7,31 @@ // // Node-pure, like `recordingsCleanup.ts`: no `electron` import, every directory is injected. -import { accessSync, constants, lstatSync, realpathSync, statSync } from "node:fs"; +import { closeSync, lstatSync, openSync, realpathSync, statSync, unlinkSync } from "node:fs"; import path from "node:path"; import { recordingGroupKeyFromFileName } from "../src/lib/recordingsCleanupPolicy"; +/** + * Throws unless a file can really be created in `folder`. + * + * Not `access(W_OK)`: on Windows libuv answers W_OK for a directory without looking at its + * ACL, so a read-only share or `C:\Program Files` passes — and a take then streams nowhere, + * or buffers to the end and dies at the final write. Creating a file is the one answer that + * holds on every platform. + */ +function probeWritable(folder: string): void { + const probe = path.join(folder, `.capturia-probe-${process.pid}`); + const fd = openSync(probe, "wx"); + try { + closeSync(fd); + } finally { + unlinkSync(probe); + } +} + /** * `folder` when it is an absolute path to an existing directory — and, with `writable`, one this - * process may create files in — else null, which means "use the default folder". + * process can create files in — else null, which means "use the default folder". * * Asked on every use rather than once: a drive can be unplugged, or a permission revoked, * between two takes, and the saved file can say anything. @@ -25,7 +43,7 @@ export function validRecordingsFolder( if (!folder || !path.isAbsolute(folder)) return null; try { if (!statSync(folder).isDirectory()) return null; - if (options.writable) accessSync(folder, constants.W_OK); + if (options.writable) probeWritable(folder); return path.resolve(folder); } catch { return null; @@ -82,3 +100,43 @@ export function isPathWithinRecordingRoots( return false; } } + +/** + * Where a renderer-named take file goes: `fileName` inside `takeDir`, or a throw. + * + * The renderer names this file and main then creates, truncates or deletes it (the stream + * handlers, the empty-take unlink), so it must be a bare name, one Capturia writes, and still + * inside a recordings root once links are resolved — a `recording-5.mp4` in the chosen folder + * that links out of it is refused, dangling or not. + */ +export function takeOutputPath( + fileName: string, + takeDir: string, + defaultDir: string, + chosenDir: string | null, +): string { + const trimmed = fileName.trim(); + if (!trimmed) { + throw new Error("Invalid recording file name"); + } + + const parsedPath = path.parse(trimmed); + const hasTraversalSegments = trimmed.split(/[\\/]+/).some((segment) => segment === ".."); + const isNestedPath = + parsedPath.dir !== "" || + path.isAbsolute(trimmed) || + trimmed.includes("/") || + trimmed.includes("\\"); + if (hasTraversalSegments || isNestedPath || parsedPath.base !== trimmed) { + throw new Error("Recording file name must not contain path segments"); + } + if (recordingGroupKeyFromFileName(parsedPath.base) === null) { + throw new Error("Recording file name is not one Capturia writes"); + } + + const target = path.join(takeDir, parsedPath.base); + if (!isPathWithinRecordingRoots(target, defaultDir, chosenDir)) { + throw new Error("Recording file resolves outside the recordings folder"); + } + return target; +} From 164ab2b2c9f9392d99e0669cef48970f7dc873d5 Mon Sep 17 00:00:00 2001 From: MinhOmega <49482201+MinhOmega@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:53:26 +0700 Subject: [PATCH 36/40] fix(recording): keep a take's path from start to stop The output path was resolved again when a take stopped, so changing the recordings folder mid-take sent finalize, discard and store to the new folder while the bytes sat in the old one. - RecordingStreamRegistry records the absolute path at open(); finalize, discard and store use it, and only a take that streamed nothing is resolved fresh. - The Linux native session exposes its outputPath and discard deletes that. - Choosing or resetting the folder is refused while a take is active. --- electron/ipc/handlers.ts | 69 ++++++++++--------- electron/ipc/recordingStream.test.ts | 50 +++++++++++++- electron/ipc/recordingStream.ts | 28 ++++++-- .../capture/linuxNativeCaptureSession.ts | 6 ++ 4 files changed, 113 insertions(+), 40 deletions(-) diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index d109259e9..77be602af 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -444,10 +444,10 @@ function approveDocumentMedia(document: AxcutDocument): void { } } -function resolveRecordingOutputPath(fileName: string): string { +function resolveRecordingOutputPath(fileName: string, takeDir = newTakeDir()): string { return takeOutputPath( fileName, - newTakeDir(), + takeDir, RECORDINGS_DIR, validRecordingsFolder(chosenRecordingsFolder), ); @@ -1935,6 +1935,15 @@ export function registerIpcHandlers( onRecordingStateChange?: (recording: boolean, sourceName: string) => void, _switchToHud?: () => void, ) { + // Every start and stop path reports through here, so this is main's own answer to "is a + // take running" — the HUD's lock lives in a renderer, and a folder dialog opened before a + // tray or hotkey start would otherwise move the recordings folder under the take. + let takeInProgress = false; + const reportRecordingState = (recording: boolean, sourceName: string) => { + takeInProgress = recording; + onRecordingStateChange?.(recording, sourceName); + }; + async function requestScreenAccess() { if (process.platform !== "darwin") { return { success: true, granted: true, status: "granted" }; @@ -2596,9 +2605,7 @@ export function registerIpcHandlers( // "Screen" }`, so the tray confidently displayed the name of a // window the capture had never been told about. linuxNativeCaptureSourceLabel = linuxSourceLabel(session.grantedSourceKind); - if (onRecordingStateChange) { - onRecordingStateChange(true, linuxNativeCaptureSourceLabel); - } + reportRecordingState(true, linuxNativeCaptureSourceLabel); return { success: true, recordingId, path: outputPath }; } catch (error) { @@ -2639,9 +2646,7 @@ export function registerIpcHandlers( try { if (discard) { session.discard(); - // The folder the take started in: the HUD locks the setting while recording, and a - // folder that vanished mid-take took the take with it. - const discarded = path.join(newTakeDir(), `${RECORDING_FILE_PREFIX}${recordingId}.mp4`); + const discarded = session.outputPath; await Promise.all([ fs.rm(discarded, { force: true }), fs.rm(`${discarded}.cursor.json`, { force: true }), @@ -2701,9 +2706,7 @@ export function registerIpcHandlers( linuxNativeCaptureCursorMode = "editable-overlay"; const stoppedLabel = linuxNativeCaptureSourceLabel ?? linuxSourceLabel(); linuxNativeCaptureSourceLabel = null; - if (onRecordingStateChange) { - onRecordingStateChange(false, stoppedLabel); - } + reportRecordingState(false, stoppedLabel); } }); @@ -2888,9 +2891,7 @@ export function registerIpcHandlers( }); const source = selectedSource || { name: "Screen" }; - if (onRecordingStateChange) { - onRecordingStateChange(true, source.name); - } + reportRecordingState(true, source.name); // Reported at start, not at stop: the helper decides the camera is a // lost cause during its own init — before it announces "Recording @@ -3077,9 +3078,7 @@ export function registerIpcHandlers( : 0; const source = selectedSource || { name: "Screen" }; - if (onRecordingStateChange) { - onRecordingStateChange(true, source.name); - } + reportRecordingState(true, source.name); return { success: true, @@ -3234,9 +3233,7 @@ export function registerIpcHandlers( // leaving the handle set would make every later recording fail // with "already running" against a process nobody can stop. resetNativeWindowsCaptureState(); - if (onRecordingStateChange) { - onRecordingStateChange(false, (selectedSource || { name: "Screen" }).name); - } + reportRecordingState(false, (selectedSource || { name: "Screen" }).name); } } @@ -3396,9 +3393,7 @@ export function registerIpcHandlers( } finally { resetNativeWindowsCaptureState(); const source = selectedSource || { name: "Screen" }; - if (onRecordingStateChange) { - onRecordingStateChange(false, source.name); - } + reportRecordingState(false, source.name); } }); @@ -3483,9 +3478,7 @@ export function registerIpcHandlers( nativeMacIsPaused = false; activeMacCaptureBounds = null; const source = selectedSource || { name: "Screen" }; - if (onRecordingStateChange) { - onRecordingStateChange(false, source.name); - } + reportRecordingState(false, source.name); } }); @@ -3496,6 +3489,12 @@ export function registerIpcHandlers( const recordingStreams = new RecordingStreamRegistry(); registerRecordingStreamHandlers(ipcMain, recordingStreams, resolveRecordingOutputPath); + /** A take file's path at stop: where its stream was opened, if it streamed — never + * re-resolved, see `pathOf` — else a fresh resolve for the buffered bytes about to be + * written, beside `takeDir` when the take's own folder is already known. */ + const takeFilePath = (fileName: string, takeDir?: string) => + recordingStreams.pathOf(fileName) ?? resolveRecordingOutputPath(fileName, takeDir); + /** * Writes a browser-recorded webcam clip next to a natively-recorded screen * video and rewrites the session manifest to include both. @@ -3532,7 +3531,10 @@ export function registerIpcHandlers( }; } - const webcamVideoPath = resolveRecordingOutputPath(payload.webcam.fileName); + const webcamVideoPath = takeFilePath( + payload.webcam.fileName, + path.dirname(screenVideoPath), + ); // A streamed webcam arrives with an empty buffer: its bytes are already on // disk, so close the stream and keep the file rather than writing it here. // Nothing multi-gigabyte crosses IPC or gets flattened into one Buffer (#253). @@ -3643,7 +3645,7 @@ export function registerIpcHandlers( ? payload.createdAt : Date.now(); const cursorCaptureMode = normalizeCursorCaptureMode(payload.cursorCaptureMode); - const screenVideoPath = resolveRecordingOutputPath(payload.screen.fileName); + const screenVideoPath = takeFilePath(payload.screen.fileName); const screenStreamed = await finalizeRecordingFile( recordingStreams, payload.screen.fileName, @@ -3654,7 +3656,7 @@ export function registerIpcHandlers( let webcamVideoPath: string | undefined; let webcamStreamed = false; if (payload.webcam) { - webcamVideoPath = resolveRecordingOutputPath(payload.webcam.fileName); + webcamVideoPath = takeFilePath(payload.webcam.fileName, path.dirname(screenVideoPath)); webcamStreamed = await finalizeRecordingFile( recordingStreams, payload.webcam.fileName, @@ -3861,7 +3863,11 @@ export function registerIpcHandlers( validRecordingsFolder(chosenRecordingsFolder, { writable: true }) !== null, }); + // Refused mid-take, checked again once a picker returns: a dialog opened before a tray or + // hotkey start can be answered after it, and a take's later writes (markers, the webcam + // attach, the editor's reads) are confined to the folders that were roots when it began. const persistRecordingsFolder = (folder: string | null) => { + if (takeInProgress) return recordingsFolderState(); try { saveRecordingsFolder(app.getPath("userData"), folder); chosenRecordingsFolder = folder; @@ -3882,6 +3888,7 @@ export function registerIpcHandlers( // The path comes from the OS folder picker, never from the renderer. ipcMain.handle("choose-recordings-folder", async () => { + if (takeInProgress) return recordingsFolderState(); const result = await dialog.showOpenDialog( buildDialogOptions( { @@ -3937,9 +3944,7 @@ export function registerIpcHandlers( } const source = selectedSource || { name: "Screen" }; - if (onRecordingStateChange) { - onRecordingStateChange(recording, source.name); - } + reportRecordingState(recording, source.name); }, ); diff --git a/electron/ipc/recordingStream.test.ts b/electron/ipc/recordingStream.test.ts index 776fcf122..10c525289 100644 --- a/electron/ipc/recordingStream.test.ts +++ b/electron/ipc/recordingStream.test.ts @@ -1,8 +1,9 @@ -import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; +import type { IpcMain } from "electron"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { RecordingStreamRegistry } from "./recordingStream"; +import { RecordingStreamRegistry, registerRecordingStreamHandlers } from "./recordingStream"; describe("RecordingStreamRegistry", () => { let dir: string; @@ -81,4 +82,49 @@ describe("RecordingStreamRegistry", () => { expect(await readFile(pathFor("rec.webm"), "utf8")).toBe("second"); }); + + // A take that started while the chosen drive was unplugged streams into the default + // folder; the drive coming back mid-take moves where a fresh resolve points. Stop and + // discard must still find the file where it was opened. + describe("after the recordings folder changes mid-take", () => { + async function takeWithMovingFolder() { + await mkdir(pathFor("default")); + await mkdir(pathFor("chosen")); + let takeDir = pathFor("default"); + const resolve = (name: string) => path.join(takeDir, name); + const registry = new RecordingStreamRegistry(); + const handlers = new Map Promise>(); + const ipc = { + handle: (channel: string, fn: (...args: unknown[]) => Promise) => { + handlers.set(channel, fn); + }, + } as unknown as IpcMain; + registerRecordingStreamHandlers(ipc, registry, resolve); + const call = (channel: string, ...args: unknown[]) => + handlers.get(channel)?.({}, ...args) as Promise<{ success: boolean }>; + + expect(await call("open-recording-stream", "recording-1.webm")).toEqual({ success: true }); + await call("append-recording-chunk", "recording-1.webm", new TextEncoder().encode("take")); + takeDir = pathFor("chosen"); + return { registry, call }; + } + + it("finalizes at the path captured when the stream opened", async () => { + const { registry } = await takeWithMovingFolder(); + const opened = path.join(pathFor("default"), "recording-1.webm"); + + expect(registry.pathOf("recording-1.webm")).toBe(opened); + expect(await registry.finalize("recording-1.webm")).toBe(true); + expect(await readFile(opened, "utf8")).toBe("take"); + await expect(stat(path.join(pathFor("chosen"), "recording-1.webm"))).rejects.toThrow(); + }); + + it("discards the file it opened, not one at the new folder", async () => { + const { registry, call } = await takeWithMovingFolder(); + + expect(await call("close-recording-stream", "recording-1.webm")).toEqual({ success: true }); + expect(registry.has("recording-1.webm")).toBe(false); + await expect(stat(path.join(pathFor("default"), "recording-1.webm"))).rejects.toThrow(); + }); + }); }); diff --git a/electron/ipc/recordingStream.ts b/electron/ipc/recordingStream.ts index 665ea4194..a90db353c 100644 --- a/electron/ipc/recordingStream.ts +++ b/electron/ipc/recordingStream.ts @@ -9,7 +9,8 @@ import type { IpcMain } from "electron"; * because it's already exchanged across IPC and is unique per recording. */ export class RecordingStreamRegistry { - private readonly streams = new Map(); + /** The stream, and the path it was opened at — resolved once, when the take began. */ + private readonly streams = new Map(); /** * Open a write stream, resolving only on the `open` event so a bad path or @@ -34,16 +35,28 @@ export class RecordingStreamRegistry { console.error(`[recording-stream] ${fileName}:`, error); }); - this.streams.set(fileName, ws); + this.streams.set(fileName, { ws, filePath }); } has(fileName: string): boolean { return this.streams.has(fileName); } + /** + * Where the open stream for `fileName` writes, or undefined when none is open. + * + * Callers finishing or discarding a take ask this rather than resolving the name again: + * the recordings folder can change between the take's start and its stop (a chosen drive + * that comes back mid-take, a folder picked meanwhile), and a fresh resolve would then + * look for the file where it never was. + */ + pathOf(fileName: string): string | undefined { + return this.streams.get(fileName)?.filePath; + } + /** Append a chunk; rejects if no stream is open or the write fails. */ async append(fileName: string, chunk: Buffer): Promise { - const ws = this.streams.get(fileName); + const ws = this.streams.get(fileName)?.ws; if (!ws) { throw new Error(`No active recording stream for ${fileName}`); } @@ -57,7 +70,7 @@ export class RecordingStreamRegistry { * open (streamed to disk) or false if the caller still needs to write its buffer. */ async finalize(fileName: string): Promise { - const ws = this.streams.get(fileName); + const ws = this.streams.get(fileName)?.ws; if (!ws) { return false; } @@ -78,7 +91,7 @@ export class RecordingStreamRegistry { } private async endStream(fileName: string): Promise { - const ws = this.streams.get(fileName); + const ws = this.streams.get(fileName)?.ws; if (!ws) { return; } @@ -129,7 +142,10 @@ export function registerRecordingStreamHandlers( "close-recording-stream", async (_, fileName: string): Promise<{ success: boolean; error?: string }> => { try { - await registry.discard(fileName, resolveRecordingOutputPath(fileName)); + await registry.discard( + fileName, + registry.pathOf(fileName) ?? resolveRecordingOutputPath(fileName), + ); return { success: true }; } catch (error) { return { success: false, error: String(error) }; diff --git a/electron/native-bridge/capture/linuxNativeCaptureSession.ts b/electron/native-bridge/capture/linuxNativeCaptureSession.ts index 8afcae4a6..9695e30f4 100644 --- a/electron/native-bridge/capture/linuxNativeCaptureSession.ts +++ b/electron/native-bridge/capture/linuxNativeCaptureSession.ts @@ -294,6 +294,12 @@ export class LinuxNativeCaptureSession { return this.sourceKind; } + /** Where this take is written, fixed when the session was created. Asked at discard rather + * than re-resolving the recordings folder, which can have changed since. */ + get outputPath(): string { + return this.config.outputPath; + } + /** * Asks the helper to write the trailer and exit, then returns what it made. * From 2d3b9fee150fcb36708ae27e3877b504b1b3b5f1 Mon Sep 17 00:00:00 2001 From: MinhOmega <49482201+MinhOmega@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:49:34 +0700 Subject: [PATCH 37/40] fix(export): free the mapped VAAPI frame when send_frame fails (Linux) send_dmabuf returned r.map(|()| dst), so a failed avcodec_send_frame dropped the only pointer to the mapped frame. The frame was leaked, and with it the VA surface importing the staging dmabuf, both hw frames contexts, the VADisplay and the renderD128 fd, which stayed alive until the process exited. VaapiEncoder::probe reaches this path on every export, so a driver that rejects the frame at send time would leak all of that once per export attempt. On error the encoder holds no reference, so free dst before returning. --- crates/compositor/src/pipeline_linux.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/compositor/src/pipeline_linux.rs b/crates/compositor/src/pipeline_linux.rs index 7b1d834f7..057e4a2b8 100644 --- a/crates/compositor/src/pipeline_linux.rs +++ b/crates/compositor/src/pipeline_linux.rs @@ -1459,6 +1459,14 @@ impl VaapiEncoder { // cette frame mappe le dmabuf du slot : tant qu'elle vit, l'encodeur peut // encore lire cette memoire. L'appelant la garde et ne la relache — donc // ne recycle le slot — qu'apres avoir draine le paquet correspondant. + // + // Sauf si l'envoi a echoue : l'encodeur n'en a alors rien pris, et la + // frame perdue garderait en vie jusqu'a la fin du processus la surface + // VA qui importe le dmabuf, les deux contextes de frames et le device. + if r.is_err() { + let mut d = dst; + av_frame_free(&mut d); + } r.map(|()| dst) } From a243a35a8a60a5a7c0e45978eb956a22057692e2 Mon Sep 17 00:00:00 2001 From: MinhOmega <49482201+MinhOmega@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:54:48 +0700 Subject: [PATCH 38/40] fix(audio): carry the resampler position across packets so the mic stops drifting (Windows) The linear rate-conversion path sized each packet as llround(frames * ratio) on its own and never carried the remainder. WASAPI delivers 448-frame packets, and at 44.1 -> 48 kHz each one rounds 487.6 up to 488. That adds +0.38 frame per packet, or about 2.8 s of growing microphone lag per hour (3.75 s worst case). The mixer runs on steady_clock and never trims, so the lag only grows. AudioDecimatorState, the per-stream state that already rides across packets for the decimation path, now also keeps the linear path's running totals. Each packet owes round(framesIn * target / source) minus what earlier packets produced, computed in integers so the total is exact. Interpolation starts at the carried position. The carry is keyed on the rate pair and cleared by reset(), which the mixer already calls at start, beginTimeline and pause. The linear branch now calls resetDecimation(), so its per-packet reset no longer wipes its own carry. New case resample-44k-to-48k-packets-do-not-drift: 10000 packets of 448 frames. Before the fix it produced 4880000 frames against an exact 4876190.48. After: within 1 frame, and 488 again after reset(). --- .../wgc-capture/src/audio_sample_utils.cpp | 50 +++++++++++++++++-- .../wgc-capture/src/audio_sample_utils.h | 20 ++++++++ .../src/audio_sample_utils_test.cpp | 33 ++++++++++++ 3 files changed, 100 insertions(+), 3 deletions(-) diff --git a/electron/native/wgc-capture/src/audio_sample_utils.cpp b/electron/native/wgc-capture/src/audio_sample_utils.cpp index 4e7fb4ee9..87b1f6b40 100644 --- a/electron/native/wgc-capture/src/audio_sample_utils.cpp +++ b/electron/native/wgc-capture/src/audio_sample_utils.cpp @@ -180,11 +180,44 @@ constexpr double kKaiserBeta = 8.6; } // namespace void AudioDecimatorState::reset() { + resetDecimation(); + linearFramesIn_ = 0; + linearFramesOut_ = 0; +} + +void AudioDecimatorState::resetDecimation() { std::fill(history_.begin(), history_.end(), 0.0); position_ = 0; phase_ = 0; } +size_t AudioDecimatorState::advanceLinear( + size_t packetFrames, UINT32 sourceRate, UINT32 targetRate, double& firstSourcePosition) { + // A carry only means something against the ratio it was counted in. Keying + // it on the rates also keeps `framesOut - linearFramesOut_` from going + // negative when one state is reused across formats, as the suite does. + if (linearSourceRate_ != sourceRate || linearTargetRate_ != targetRate) { + linearSourceRate_ = sourceRate; + linearTargetRate_ = targetRate; + linearFramesIn_ = 0; + linearFramesOut_ = 0; + } + // All integer, so the running total is exact rather than a double that + // drifts: round-half-up of framesIn * target / source. + const uint64_t framesIn = linearFramesIn_ + packetFrames; + const uint64_t framesOut = (framesIn * targetRate + sourceRate / 2) / sourceRate; + // Output frame N sits at source frame N * source / target. Kept as an exact + // integer numerator over `targetRate` until the one division. + firstSourcePosition = + static_cast(static_cast(linearFramesOut_ * sourceRate) - + static_cast(linearFramesIn_ * targetRate)) / + static_cast(targetRate); + const size_t owed = static_cast(framesOut - linearFramesOut_); + linearFramesIn_ = framesIn; + linearFramesOut_ = framesOut; + return owed; +} + void AudioDecimatorState::prepare(UINT32 factor, UINT32 channels) { if (factor_ == factor && channels_ == channels && !taps_.empty()) { return; @@ -406,15 +439,26 @@ void convertAudioWithGain( // 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(); + decimator.resetDecimation(); const size_t sourceFrames = packetFrames; const double rateRatio = static_cast(targetFormat.sampleRate) / static_cast(sourceFormat.sampleRate); - const size_t targetFrames = std::max(1, static_cast(std::llround(sourceFrames * rateRatio))); + // The count comes from the running total, not from this packet alone (see + // AudioDecimatorState). A packet may therefore owe zero frames, or one more + // than its own length would round to. + double firstSourcePosition = 0.0; + const size_t targetFrames = decimator.advanceLinear( + sourceFrames, sourceFormat.sampleRate, targetFormat.sampleRate, firstSourcePosition); destination.assign(targetFrames * targetFormat.blockAlign, 0); for (size_t targetFrame = 0; targetFrame < targetFrames; ++targetFrame) { - const double sourcePosition = static_cast(targetFrame) / rateRatio; + // Clamped at both ends: the carried position can sit up to half an + // output frame before this packet's first frame or past its last, and + // the neighbour packet's samples are not available here. + const double sourcePosition = std::clamp( + firstSourcePosition + static_cast(targetFrame) / rateRatio, + 0.0, + static_cast(sourceFrames - 1)); const size_t sourceFrame = std::min(sourceFrames - 1, static_cast(sourcePosition)); const size_t nextFrame = std::min(sourceFrames - 1, sourceFrame + 1); const double frac = sourcePosition - static_cast(sourceFrame); diff --git a/electron/native/wgc-capture/src/audio_sample_utils.h b/electron/native/wgc-capture/src/audio_sample_utils.h index d1eaa49a8..b4b39138d 100644 --- a/electron/native/wgc-capture/src/audio_sample_utils.h +++ b/electron/native/wgc-capture/src/audio_sample_utils.h @@ -34,6 +34,12 @@ void convertAudioWithGain( // 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. +// +// It also carries the linear (non-integer ratio) path's position, for the same +// reason: rounding each packet's output length on its own drifts. 448-frame +// packets at 44.1 -> 48 kHz round 487.6 up to 488 every time, +0.38 frame a +// packet, which is ~2.8 s of microphone lag per hour against a mixer that runs +// on the wall clock and never trims. class AudioDecimatorState { public: void reset(); @@ -45,6 +51,16 @@ class AudioDecimatorState { // survives the decimation. void prepare(UINT32 factor, UINT32 channels); bool consume(const double* frame, double* out); + // Clears the decimation half only. The linear path calls it on every packet + // and must not lose its own carry doing so. + void resetDecimation(); + + // Used by the linear path. Returns how many output frames this packet owes + // -- the exact running total round(framesIn * target / source) minus what + // earlier packets already produced -- and writes where the first of them + // falls, in this packet's source frames (may be slightly negative). + size_t advanceLinear( + size_t packetFrames, UINT32 sourceRate, UINT32 targetRate, double& firstSourcePosition); private: std::vector taps_; @@ -53,6 +69,10 @@ class AudioDecimatorState { size_t phase_ = 0; UINT32 factor_ = 0; UINT32 channels_ = 0; + uint64_t linearFramesIn_ = 0; + uint64_t linearFramesOut_ = 0; + UINT32 linearSourceRate_ = 0; + UINT32 linearTargetRate_ = 0; }; void convertAudioWithGain( 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 6297cbe0b..b6482a308 100644 --- a/electron/native/wgc-capture/src/audio_sample_utils_test.cpp +++ b/electron/native/wgc-capture/src/audio_sample_utils_test.cpp @@ -714,6 +714,39 @@ int main() { std::to_string(afterMix == fromCold)); } + // The interpolation path must not drift with the packet size. A 44.1 kHz + // microphone against a 48 kHz target, in the 448-frame packets WASAPI hands + // out: rounding each packet on its own gives 488 frames for 487.6, and + // +0.38 frame a packet is 3810 frames (~79 ms) over these 10000 packets, + // ~2.8 s an hour. The carried position has to land within one frame of the + // exact total over the whole run, and start over on reset(). + { + const AudioInputFormat mic44k = makeFormat(MFAudioFormat_Float, 44100, 2, 32); + const AudioInputFormat target = makeFormat(MFAudioFormat_PCM, 48000, 2, 16); + constexpr size_t kPacketFrames = 448; + constexpr size_t kPackets = 10000; + std::vector packet(kPacketFrames * mic44k.blockAlign, 0); + AudioDecimatorState carry; + std::vector out; + uint64_t produced = 0; + for (size_t p = 0; p < kPackets; p += 1) { + convertAudioWithGain( + packet.data(), static_cast(packet.size()), mic44k, target, 1.0, out, carry); + produced += out.size() / target.blockAlign; + } + const double exact = static_cast(kPackets * kPacketFrames) * 48000.0 / 44100.0; + const double drift = static_cast(produced) - exact; + carry.reset(); + convertAudioWithGain( + packet.data(), static_cast(packet.size()), mic44k, target, 1.0, out, carry); + const size_t afterReset = out.size() / target.blockAlign; + expect( + "resample-44k-to-48k-packets-do-not-drift", + std::abs(drift) <= 1.0 && afterReset == 488, + "produced=" + std::to_string(produced) + " exact=" + std::to_string(exact) + + " afterReset=" + std::to_string(afterReset)); + } + auto fillStereoFrame = [](std::vector& packet, int16_t left, int16_t right) { auto* samples = reinterpret_cast(packet.data()); samples[0] = left; From 687a5c605332754fda850d43bdf02c5a5d2408d9 Mon Sep 17 00:00:00 2001 From: MinhOmega <49482201+MinhOmega@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:57:59 +0700 Subject: [PATCH 39/40] fix(audio): snap 88.2/176.4/352.8 kHz to 44.1 kHz so they are decimated, not aliased (Windows) aacCompatibleSampleRate sent every rate outside the AAC list to 48 kHz. For 88.2, 176.4 and 352.8 kHz that is a 1.84, 3.68 or 7.35 ratio, so they bypassed the anti-alias decimator and took the unfiltered linear path. Measured with the old snap, 30 kHz at 88.2 kHz folds to 18 kHz at -3.44 dB. 60 kHz at 176.4 kHz lands at 12 kHz at -3.45 dB, and 100 kHz at 352.8 kHz at 4 kHz at -2.35 dB. Multiples of 44100 now snap to 44100, which makes them integer factors 2, 4 and 8. The decimator's filter is relative to the factor: the cutoff is 11/24 of the source rate divided by the factor, with 80 * factor + 1 taps. Nothing in it is tied to 48 kHz. The same tones now read below one PCM16 LSB (-300 dB), and 1 kHz passes at -0.00 dB. measureTone now targets makeAacCompatibleAudioFormat(source), which leaves every 48 kHz-family case on exactly the target it had before. New cases: snap-{88200,176400,352800}-to-44100, alias rejection at factors 2, 4 and 8, and passband at factors 2 and 8. --- .../wgc-capture/src/audio_sample_utils.cpp | 12 +++++-- .../src/audio_sample_utils_test.cpp | 31 +++++++++++++++++-- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/electron/native/wgc-capture/src/audio_sample_utils.cpp b/electron/native/wgc-capture/src/audio_sample_utils.cpp index 87b1f6b40..ee3f04d0f 100644 --- a/electron/native/wgc-capture/src/audio_sample_utils.cpp +++ b/electron/native/wgc-capture/src/audio_sample_utils.cpp @@ -101,6 +101,13 @@ UINT32 aacCompatibleSampleRate(UINT32 sampleRate) { return rate; } } + // 88.2 / 176.4 / 352.8 kHz go to 44.1, not 48: only an integer factor + // reaches the anti-alias decimator, whose filter is designed relative to + // the factor. 48 kHz from these is a 1.84 / 3.68 / 7.35 ratio, which lands + // on the unfiltered linear path and aliases. + if (sampleRate > 44100 && sampleRate % 44100 == 0) { + return 44100; + } return 48000; } @@ -121,8 +128,9 @@ bool sameAudioFormatForMixing(const AudioInputFormat& left, const AudioInputForm // often reports 96000 or 192000; those are legal PCM mix rates but not AAC // input rates, and SetInputMediaType then fails with MF_E_INVALIDMEDIATYPE // (0xc00d36b4). Keep legal rates as-is so a working 44100/48000 path is -// unchanged; snap everything else (including 0) to 48000. The mixer already -// resamples through convertAudioWithGain when the source rate differs. +// unchanged; snap multiples of 44100 to 44100 and everything else (including +// 0) to 48000. The mixer already resamples through convertAudioWithGain when +// the source rate differs. AudioInputFormat makeAacCompatibleAudioFormat(const AudioInputFormat& source) { AudioInputFormat format{}; format.subtype = MFAudioFormat_PCM; 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 b6482a308..f221a8e73 100644 --- a/electron/native/wgc-capture/src/audio_sample_utils_test.cpp +++ b/electron/native/wgc-capture/src/audio_sample_utils_test.cpp @@ -395,9 +395,14 @@ int main() { // 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. + // + // The target is whatever the encoder would get for this source, so the + // 44.1 kHz family below is measured at 44.1 kHz; every 48 kHz-family source + // here snaps to exactly target48k. const auto measureTone = [&](const AudioInputFormat& sourceFormat, unsigned toneHz, unsigned readHz) -> double { + const AudioInputFormat target = makeAacCompatibleAudioFormat(sourceFormat); const double amplitude = 16384.0; const size_t toneFrames = static_cast(sourceFormat.sampleRate) / 4; std::vector toneBytes(toneFrames * sourceFormat.blockAlign, 0); @@ -411,14 +416,14 @@ int main() { } std::vector toneOut; convertAudioWithGain( - toneBytes.data(), static_cast(toneBytes.size()), sourceFormat, target48k, 1.0, toneOut); - const size_t outFrames = toneOut.size() / target48k.blockAlign; + toneBytes.data(), static_cast(toneBytes.size()), sourceFormat, target, 1.0, toneOut); + const size_t outFrames = toneOut.size() / target.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); + static_cast(target.sampleRate); const double coeff = 2.0 * std::cos(omega); size_t counted = 0; for (size_t frame = skip; frame < outFrames; frame += 1) { @@ -468,6 +473,26 @@ int main() { 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 44.1 kHz family has to reach the decimator too. Snapped to 48 kHz, + // 88.2 / 176.4 / 352.8 kHz are non-integer ratios and take the unfiltered + // linear path; snapped to 44.1 kHz they are factors 2 / 4 / 8. Folds are + // f mod 44100, reflected about 22050. + for (UINT32 rate : {88200u, 176400u, 352800u}) { + const AudioInputFormat snappedRate = + makeAacCompatibleAudioFormat(makeFormat(MFAudioFormat_PCM, rate, 2, 16)); + expect( + ("snap-" + std::to_string(rate) + "-to-44100").c_str(), + snappedRate.sampleRate == 44100, describe(snappedRate)); + } + const AudioInputFormat source88k = makeFormat(MFAudioFormat_PCM, 88200, 2, 16); + const AudioInputFormat source176k = makeFormat(MFAudioFormat_PCM, 176400, 2, 16); + const AudioInputFormat source352k = makeFormat(MFAudioFormat_PCM, 352800, 2, 16); + expectTone(source88k, 30000, 14100, true, -60.0, "resample-88k-f2-30k-alias"); + expectTone(source176k, 60000, 15900, true, -60.0, "resample-176k-f4-60k-alias"); + expectTone(source352k, 100000, 11800, true, -60.0, "resample-352k-f8-100k-alias"); + expectTone(source88k, 1000, 1000, false, -0.5, "resample-88k-1k-passband-intact"); + expectTone(source352k, 1000, 1000, false, -0.5, "resample-352k-1k-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 From da2fc985969d2b7df74eb5fbed01ab3131eb63aa Mon Sep 17 00:00:00 2001 From: MinhOmega <49482201+MinhOmega@users.noreply.github.com> Date: Sat, 12 Sep 2026 00:01:59 +0700 Subject: [PATCH 40/40] docs(roadmap): record the 2.1 additions and the Arc export fallback --- ROADMAP.md | 1 + 1 file changed, 1 insertion(+) diff --git a/ROADMAP.md b/ROADMAP.md index 05fe5ac11..55b306342 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -87,3 +87,4 @@ Entries dated before 2026-09-10 are inherited from upstream's roadmap document a - **2026-08-01** — the platform-parity tier was the stalest thing on this page: it still described the compositor as Direct3D 11 and listed MP4 export on macOS and Linux as unstarted, while v1.8.0-rc.5 was already publishing DMGs and Linux packages built on the Metal and WGSL backends. #18 (software encoder fallback) shipped with them, as an automatically-selected CPU backend rather than an encoder flag. Two real gaps replace them: Linux export is software-encoded, and every performance number on record still comes from one passive-iGPU laptop. Also corrected the framing that produced this drift — the tier was written as "porting it off Windows is the biggest open item", which stayed true in the text long after it stopped being true in the tree. - **2026-09-10** — rebranded this document to Capturia and published it on the site at `/roadmap`. Upstream's issue numbers are now linked as `upstream #N` against getopenscreen/openscreen, where they actually live: relative `../../issues/N` links resolved to *this* repo, where those numbers are unrelated PRs. Corrected the project file extension (`.capturia`; `.openscreen` and `.axcut` still open as legacy) and the site URL. Dropped the Discord section — that invite pointed at upstream's server, and Capturia does not run one; GitHub issues are the channel. - **2026-09-11** — shipped the right-click menu for upstream #24, and corrected the copy/paste line, which had been ticked for applying attributes onto another region. Paste has only ever created a new region at the playhead, so applying attributes onto an existing region is now listed separately, unticked. Split one-click cleanup: the silence and filler-word pass has shipped as *Remove dead air*, and only voice enhancement is still open. Retired upstream #19, because nothing in the preview uses WebGL any more. Marked upstream #22 as fixed in code but unverified, because no one has confirmed it on a Mac. +- **2026-09-12** — 2.1 additions, none of which had a line here: recording an area of the screen, saved looks with a default for new projects, zooms at the moments flagged while recording, real poster frames in the project list, a choice of speech model, a recordings folder of your choosing, and an opt-in pre-release update channel. Hardware encode on Linux stays open, although export no longer dies on Intel Arc: when the VAAPI driver refuses an imported frame, which iHD 24.1 does, the export now falls back to software instead of failing at the first frame.