From 1155a5d6e655797323ed49badb4a65db0d5ce1fd Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Mon, 6 Jul 2026 19:14:07 +0200 Subject: [PATCH 1/2] feat: implement optimized webm patching and saving state --- electron/recording/webm-duration.test.ts | 131 ++++++++++++++++ electron/recording/webm-duration.ts | 163 ++++++++++++++++++-- package-lock.json | 4 +- src/components/launch/LaunchWindow.test.tsx | 1 + src/components/launch/LaunchWindow.tsx | 109 +++++++++---- src/hooks/useScreenRecorder.ts | 13 ++ 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 +- 19 files changed, 395 insertions(+), 65 deletions(-) create mode 100644 electron/recording/webm-duration.test.ts diff --git a/electron/recording/webm-duration.test.ts b/electron/recording/webm-duration.test.ts new file mode 100644 index 000000000..e5719c450 --- /dev/null +++ b/electron/recording/webm-duration.test.ts @@ -0,0 +1,131 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { WebmBase, WebmContainer, WebmFile, WebmString, WebmUint } from "@fix-webm-duration/parser"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { patchWebmDurationOnDisk } from "./webm-duration"; + +describe("webm-duration patching", () => { + let dir: string; + const pathFor = (name: string) => path.join(dir, name); + + beforeEach(async () => { + dir = await mkdtemp(path.join(tmpdir(), "openscreen-duration-")); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + function createDummyWebm(includeCluster = true, clusterSize = 100): Uint8Array { + const ebml = new WebmContainer("EBML"); + ebml.data = []; + const docType = new WebmString("DocType"); + docType.setValue("webm"); + ebml.data.push({ id: 0x282, idHex: "282", data: docType }); + ebml.updateByData(); + + const segment = new WebmContainer("Segment"); + segment.data = []; + segment.isInfinite = true; + + const info = new WebmContainer("Info"); + info.data = []; + const timecodeScale = new WebmUint("TimecodeScale"); + timecodeScale.setValue(1000000); + info.data.push({ id: 0xad7b1, idHex: "ad7b1", data: timecodeScale }); + info.updateByData(); + segment.data.push({ id: 0x549a966, idHex: "549a966", data: info }); + + if (includeCluster) { + const cluster = new WebmBase("Cluster"); + // A valid EBML element for Cluster: ID 0x1f43b675 (stripped as 0xf43b675) + // Followed by a VINT for length (e.g. clusterSize bytes) + const header = Buffer.from([0x1f, 0x43, 0xb6, 0x75, 0x01, 0x00]); // 6 bytes header (id + length) + const body = Buffer.alloc(clusterSize, 0x42); // cluster data filled with 0x42 + const clusterBytes = Buffer.concat([header, body]); + + cluster.setSource(new Uint8Array(clusterBytes)); + segment.data.push({ id: 0xf43b675, idHex: "f43b675", data: cluster }); + } + + segment.updateByData(); + + const file = new WebmContainer("File"); + file.data = []; + file.data.push({ id: 0xa45dfa3, idHex: "a45dfa3", data: ebml }); + file.data.push({ id: 0x8538067, idHex: "8538067", data: segment }); + + file.updateByData(); + return file.source; + } + + it("patches small WebM files under 2MB in memory successfully", async () => { + const webmBytes = createDummyWebm(true, 100); + const filePath = pathFor("small.webm"); + await writeFile(filePath, webmBytes); + + const result = await patchWebmDurationOnDisk(filePath, 5000); + expect(result.patched).toBe(true); + + const patchedBytes = await readFile(filePath); + const webm = new WebmFile(new Uint8Array(patchedBytes)); + interface WebmElementMock { + getSectionById: (id: number) => WebmElementMock; + getValue: () => number; + } + + const segment = webm.getSectionById(0x8538067) as unknown as WebmElementMock; + const info = segment.getSectionById(0x549a966); + const duration = info.getSectionById(0x489); + + expect(duration).toBeDefined(); + expect(duration.getValue()).toBe(5000); + }); + + it("patches large WebM files over 2MB using the optimized streaming method successfully", async () => { + // Create a file larger than 2MB (e.g. 2.5MB cluster data) to trigger the optimized method + const webmBytes = createDummyWebm(true, 2.5 * 1024 * 1024); + const filePath = pathFor("large.webm"); + await writeFile(filePath, webmBytes); + + const result = await patchWebmDurationOnDisk(filePath, 12000); + expect(result.patched).toBe(true); + + const patchedBytes = await readFile(filePath); + const webm = new WebmFile(new Uint8Array(patchedBytes)); + const segment = webm.getSectionById(0x8538067) as unknown as WebmElementMock; + const info = segment.getSectionById(0x549a966); + const duration = info.getSectionById(0x489); + + expect(duration).toBeDefined(); + expect(duration.getValue()).toBe(12000); + + // Verify the Cluster data (filled with 0x42) is intact at the end + const lastBytes = patchedBytes.subarray(patchedBytes.length - 100); + expect(lastBytes.every((b) => b === 0x42)).toBe(true); + }); + + it("falls back to in-memory patching if no Cluster section is found in the large file header chunk", async () => { + // File is over 2MB, but has no Cluster (very large header or weird file) + const webmBytes = createDummyWebm(false); + // Pad to 2.5MB without Cluster + const padding = Buffer.alloc(2.5 * 1024 * 1024, 0); + const largeNoClusterBytes = Buffer.concat([Buffer.from(webmBytes), padding]); + + const filePath = pathFor("large_no_cluster.webm"); + await writeFile(filePath, largeNoClusterBytes); + + const result = await patchWebmDurationOnDisk(filePath, 8000); + expect(result.patched).toBe(true); + + const patchedBytes = await readFile(filePath); + const webm = new WebmFile(new Uint8Array(patchedBytes)); + const segment = webm.getSectionById(0x8538067) as unknown as WebmElementMock; + const info = segment.getSectionById(0x549a966); + const duration = info.getSectionById(0x489); + + expect(duration).toBeDefined(); + expect(duration.getValue()).toBe(8000); + }); +}); diff --git a/electron/recording/webm-duration.ts b/electron/recording/webm-duration.ts index a93451f0a..4e5a847cb 100644 --- a/electron/recording/webm-duration.ts +++ b/electron/recording/webm-duration.ts @@ -1,4 +1,6 @@ +import { createReadStream, createWriteStream } from "node:fs"; import fs from "node:fs/promises"; +import { pipeline } from "node:stream/promises"; import { fixParsedWebmDuration } from "@fix-webm-duration/fix"; import { WebmFile } from "@fix-webm-duration/parser"; @@ -6,32 +8,165 @@ export type DurationPatchResult = | { patched: true } | { patched: false; reason: "no-section" | "already-valid" | "io-error" | "internal" }; +// Read 2MB from the start of the file. Headers for WebM recordings are typically well under 100KB. +const HEADER_CHUNK_SIZE = 2 * 1024 * 1024; + /** * Patch the WebM Duration header on a finalized recording file. * - * MediaRecorder writes WebM with no Duration EBML element, and the streaming-to-disk - * path never holds the blob so the old `fixWebmDuration(blob, durationMs)` can't run. - * Patching on disk after `WriteStream.end()` gives the editor a real duration instead of `N/A`. + * MediaRecorder writes WebM with no Duration EBML element. * - * Atomic: writes to `.duration-patch.tmp` and renames in place, so a mid-rewrite - * crash leaves the original intact. Best-effort: any read/parse/write failure logs and returns - * a non-`patched` result rather than throwing; the file still plays without the patch (decoders - * walk frames sequentially), only the seek bar and timeline break. + * Rather than reading the entire multi-gigabyte file into memory (which crashes + * the main process on long recordings), we read only the first 2MB chunk containing + * the metadata headers, patch the Info section, and stream the remaining clusters + * from the original file using Node streams. * - * Reads the whole file into a main-process Buffer, off the renderer so it dodges V8's heap cap. + * NOTE: This optimization handles the saving/finalization phase. For issues related + * to streaming large files during the editor/export phase, see also PR #74. */ export async function patchWebmDurationOnDisk( filePath: string, durationMs: number, ): Promise { + let fileHandle: fs.FileHandle | null = null; + const tmpPath = `${filePath}.duration-patch.tmp`; + try { + const stat = await fs.stat(filePath); + if (stat.size < HEADER_CHUNK_SIZE) { + // Fallback: If file is smaller than 2MB, just use the in-memory method. + return await patchWebmDurationInMemory(filePath, durationMs); + } + + fileHandle = await fs.open(filePath, "r"); + const buffer = Buffer.alloc(HEADER_CHUNK_SIZE); + const { bytesRead } = await fileHandle.read(buffer, 0, HEADER_CHUNK_SIZE, 0); + await fileHandle.close(); + fileHandle = null; + + const chunk = buffer.subarray(0, bytesRead); + const webm = new WebmFile(new Uint8Array(chunk)); + + if (!webm.data || !webm.source) { + console.warn( + `[webm-duration] Segment data or source is missing in chunk for ${filePath}; falling back to whole-file`, + ); + return await patchWebmDurationInMemory(filePath, durationMs); + } + + // Find Segment in webm.data + const segmentSec = webm.data.find((sec) => sec.id === 0x8538067); + if (!segmentSec || !segmentSec.data) { + console.warn( + `[webm-duration] Segment section is missing in chunk for ${filePath}; falling back to whole-file`, + ); + return await patchWebmDurationInMemory(filePath, durationMs); + } + + interface WebmContainerShape { + start: number; + isInfinite?: boolean; + data: { id: number; data: unknown }[]; + } + const segment = segmentSec.data as WebmContainerShape; + + const info = ( + segment as unknown as { getSectionById?: (id: number) => unknown } + ).getSectionById?.(0x549a966); + if (!info) { + console.warn( + `[webm-duration] Info section is missing in chunk for ${filePath}; falling back to whole-file`, + ); + return await patchWebmDurationInMemory(filePath, durationMs); + } + + // Calculate the start of the Segment's payload (content) using EBML VINT length rules + const segmentStart = segment.start; + const idByte = webm.source[segmentStart]; + const idLen = 9 - idByte.toString(2).length; + const lenByte = webm.source[segmentStart + idLen]; + const lenLen = 9 - lenByte.toString(2).length; + const segmentPayloadStart = segmentStart + idLen + lenLen; + + // Find the first Cluster section. ID is 0xf43b675. + const segmentData = segment.data; + const clusterIdx = segmentData.findIndex((sec) => sec.id === 0xf43b675); + if (clusterIdx === -1) { + console.warn( + `[webm-duration] No Cluster section found in header chunk for ${filePath}; falling back to whole-file`, + ); + return await patchWebmDurationInMemory(filePath, durationMs); + } + + const clusterSec = segmentData[clusterIdx]; + const clusterData = clusterSec.data as { start: number }; + const clusterOffset = segmentPayloadStart + clusterData.start; + + // Truncate the segment children to remove the Cluster and everything after it. + // This forces updateByData() to only regenerate/write the metadata headers. + segment.data = segmentData.slice(0, clusterIdx); + + // Segment length was likely infinite (-1) or matching the original file. Since we are patching + // headers and appending the original stream, setting Segment length to infinite (-1) is safe and standard. + segment.isInfinite = true; + + const patched = fixParsedWebmDuration(webm, durationMs, { logger: false }); + if (!patched) { + const reason = inferUnpatchedReason(webm); + return { patched: false, reason }; + } + + if (!webm.source) { + console.error(`[webm-duration] patched but source missing for ${filePath}`); + return { patched: false, reason: "internal" }; + } + + const patchedBytes = Buffer.from( + webm.source.buffer, + webm.source.byteOffset, + webm.source.byteLength, + ); + + // Now write the patched headers and stream append the rest of the original file + const ws = createWriteStream(tmpPath); + const rs = createReadStream(filePath, { start: clusterOffset }); + + await new Promise((resolve, reject) => { + ws.write(patchedBytes, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + + try { + await pipeline(rs, ws); + } finally { + rs.destroy(); + ws.destroy(); + } + + await fs.rename(tmpPath, filePath); + return { patched: true }; + } catch (error) { + console.error(`[webm-duration] failed to patch ${filePath} using optimized method:`, error); + if (fileHandle) { + await fileHandle.close().catch(() => undefined); + } + await fs.unlink(tmpPath).catch(() => undefined); + return { patched: false, reason: "io-error" }; + } +} + +async function patchWebmDurationInMemory( + filePath: string, + durationMs: number, +): Promise { + const tmpPath = `${filePath}.duration-patch.tmp`; try { const fileBytes = await fs.readFile(filePath); const webm = new WebmFile(new Uint8Array(fileBytes)); const patched = fixParsedWebmDuration(webm, durationMs, { logger: false }); if (!patched) { - // false means missing Segment, missing Info, or an already-valid Duration. - // The first two mean a malformed (likely truncated) file; the third is a no-op. const reason = inferUnpatchedReason(webm); if (reason === "no-section") { console.warn( @@ -46,7 +181,6 @@ export async function patchWebmDurationOnDisk( return { patched: false, reason: "internal" }; } - const tmpPath = `${filePath}.duration-patch.tmp`; const patchedBytes = Buffer.from( webm.source.buffer, webm.source.byteOffset, @@ -58,12 +192,11 @@ export async function patchWebmDurationOnDisk( return { patched: true }; } catch (writeError) { console.error(`[webm-duration] failed to write patched ${filePath}:`, writeError); - // Clean up the temp file; the original is untouched since the rename never ran. await fs.unlink(tmpPath).catch(() => undefined); return { patched: false, reason: "io-error" }; } } catch (error) { - console.error(`[webm-duration] failed to patch ${filePath}:`, error); + console.error(`[webm-duration] failed to patch ${filePath} in memory:`, error); return { patched: false, reason: "io-error" }; } } @@ -71,10 +204,6 @@ export async function patchWebmDurationOnDisk( /** * Distinguish "no Segment/Info section" (malformed/truncated file) from "Info present * but Duration already valid" (patch unnecessary). - * - * The IDs are the length-descriptor-stripped form @fix-webm-duration/parser uses as lookup - * keys (Segment `0x8538067`, Info `0x549a966`), per the parser's `src/lib/sections.js`, not - * the canonical 4-byte EBML IDs (`0x18538067` / `0x1549A966`) that `getSectionById` never matches. */ function inferUnpatchedReason(webm: WebmFile): "no-section" | "already-valid" { const segment = webm.getSectionById?.(0x8538067); diff --git a/package-lock.json b/package-lock.json index e189674bd..02f9cdb1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openscreen", - "version": "1.6.0-rc.1", + "version": "1.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openscreen", - "version": "1.6.0-rc.1", + "version": "1.6.0", "dependencies": { "@fix-webm-duration/fix": "^1.0.1", "@pixi/filter-drop-shadow": "^5.2.0", diff --git a/src/components/launch/LaunchWindow.test.tsx b/src/components/launch/LaunchWindow.test.tsx index bfdddad26..8f9a508c4 100644 --- a/src/components/launch/LaunchWindow.test.tsx +++ b/src/components/launch/LaunchWindow.test.tsx @@ -34,6 +34,7 @@ const recorderState = vi.hoisted(() => ({ value: { recording: false, paused: false, + saving: false, elapsedSeconds: 0, toggleRecording: vi.fn(), togglePaused: vi.fn(), diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index bf7d7298e..db789cce2 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -4,6 +4,7 @@ import { Clapperboard, Columns3, Languages, + Loader2, NotepadText, Rows3, } from "lucide-react"; @@ -70,6 +71,7 @@ const ICON_CONFIG = { folder: { icon: FaFolderOpen, size: ICON_SIZE }, minimize: { icon: FiMinus, size: ICON_SIZE }, close: { icon: FiX, size: ICON_SIZE }, + spinner: { icon: Loader2, size: ICON_SIZE }, } as const; type IconName = keyof typeof ICON_CONFIG; @@ -81,16 +83,16 @@ function getIcon(name: IconName, className?: string) { } const hudGroupClasses = - "flex items-center gap-0.5 rounded-xl border border-white/[0.07] bg-white/[0.045] transition-colors duration-150 hover:bg-white/[0.075]"; + "flex items-center gap-0.5 rounded-xl border border-white/[0.07] bg-white/[0.045] transition-colors duration-150 hover:bg-white/[0.075] disabled:opacity-30 disabled:cursor-not-allowed disabled:pointer-events-none"; const hudIconBtnClasses = - "flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150 cursor-pointer text-white hover:bg-white/10 active:scale-95"; + "flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150 cursor-pointer text-white hover:bg-white/10 active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed disabled:pointer-events-none"; const hudAuxIconBtnClasses = - "flex h-7 w-7 items-center justify-center rounded-lg transition-colors duration-150 text-white/55 hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed"; + "flex h-7 w-7 items-center justify-center rounded-lg transition-colors duration-150 text-white/55 hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed disabled:pointer-events-none"; const windowBtnClasses = - "flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150 cursor-pointer opacity-50 hover:opacity-90 hover:bg-white/[0.08]"; + "flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150 cursor-pointer opacity-50 hover:opacity-90 hover:bg-white/[0.08] disabled:opacity-30 disabled:cursor-not-allowed disabled:pointer-events-none"; const hudSidebarClasses = "ml-0.5 pl-1.5 border-l border-white/10 flex items-center gap-0.5"; const hudSidebarVerticalClasses = @@ -114,6 +116,7 @@ export function LaunchWindow() { const { recording, paused, + saving, elapsedSeconds, toggleRecording, togglePaused, @@ -525,6 +528,9 @@ export function LaunchWindow() { }; const handleRecordButtonClick = () => { + if (saving) { + return; + } if (!hasSelectedSource && !recording) { recordAfterSourceSelectionRef.current = true; void openSourceSelector() @@ -560,7 +566,7 @@ export function LaunchWindow() { }; const toggleMicrophone = () => { - if (!recording) { + if (!recording && !saving) { setMicrophoneEnabled(!microphoneEnabled); } }; @@ -894,7 +900,7 @@ export function LaunchWindow() { data-testid="launch-source-selector-button" className={`${hudGroupClasses} h-8 ${trayLayout === "vertical" ? "w-8 justify-center px-0" : "px-2.5"} ${styles.electronNoDrag}`} onClick={openSourceSelector} - disabled={recording} + disabled={recording || saving} title={selectedSource} aria-label={selectedSource} > @@ -913,8 +919,8 @@ export function LaunchWindow() { @@ -1062,8 +1101,9 @@ export function LaunchWindow() { @@ -1081,11 +1121,12 @@ export function LaunchWindow() { aria-label={t("language")} aria-expanded={isLanguageMenuOpen} aria-haspopup="menu" - onClick={() => setIsLanguageMenuOpen((open) => !open)} + disabled={saving} + onClick={() => !saving && setIsLanguageMenuOpen((open) => !open)} title={activeLanguageLabel} className={`flex h-8 items-center rounded-lg border border-white/10 bg-white/[0.045] text-white/85 shadow-none transition-colors hover:bg-white/10 ${ trayLayout === "vertical" ? "w-8 justify-center px-0" : "gap-1.5 px-2" - } ${styles.electronNoDrag}`} + } ${styles.electronNoDrag} ${saving ? "opacity-30 cursor-not-allowed pointer-events-none" : ""}`} > {getIcon("minimize", "text-white")} @@ -1157,6 +1199,7 @@ export function LaunchWindow() { className={windowBtnClasses} title={t("tooltips.closeApp")} onClick={sendHudOverlayClose} + disabled={saving} > {getIcon("close", "text-white")} diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index ce0e0b77f..640ce34b1 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -50,6 +50,7 @@ const WEBCAM_TARGET_FRAME_RATE = 30; type UseScreenRecorderReturn = { recording: boolean; paused: boolean; + saving: boolean; elapsedSeconds: number; toggleRecording: () => void; togglePaused: () => void; @@ -91,6 +92,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const t = useScopedT("editor"); const [recording, setRecording] = useState(false); const [paused, setPaused] = useState(false); + const [saving, setSaving] = useState(false); const [elapsedSeconds, setElapsedSeconds] = useState(0); const [microphoneEnabled, setMicrophoneEnabled] = useState(false); const [microphoneDeviceId, setMicrophoneDeviceId] = useState(undefined); @@ -310,6 +312,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return; } finalizingRecordingId.current = activeRecordingId; + setSaving(true); if (screenRecorder.current === activeScreenRecorder) { screenRecorder.current = null; @@ -412,6 +415,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (discardRecordingId.current === activeRecordingId) { discardRecordingId.current = null; } + setSaving(false); } })(); }, @@ -426,6 +430,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } activeNativeRecording.finalizing = true; + if (!discard) { + setSaving(true); + } const activeWebcamRecorder = activeNativeRecording.webcamRecorder; const duration = Math.max(0, getRecordingDurationMs()); if ( @@ -513,6 +520,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (discardRecordingId.current === activeNativeRecording.recordingId) { discardRecordingId.current = null; } + setSaving(false); } }, [cursorCaptureMode, getRecordingDurationMs], @@ -526,6 +534,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } activeNativeRecording.finalizing = true; + if (!discard) { + setSaving(true); + } const duration = Math.max(0, getRecordingDurationMs()); const activeWebcamRecorder = webcamRecorder.current; if (activeWebcamRecorder && webcamRecorder.current === activeWebcamRecorder) { @@ -613,6 +624,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (discardRecordingId.current === activeNativeRecording.recordingId) { discardRecordingId.current = null; } + setSaving(false); } }, [cursorCaptureMode, getRecordingDurationMs], @@ -1660,6 +1672,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return { recording, paused, + saving, elapsedSeconds, toggleRecording, togglePaused, diff --git a/src/i18n/locales/ar/launch.json b/src/i18n/locales/ar/launch.json index f902e8127..e3a855e65 100644 --- a/src/i18n/locales/ar/launch.json +++ b/src/i18n/locales/ar/launch.json @@ -44,7 +44,8 @@ "defaultSourceName": "الشاشة" }, "recording": { - "selectSource": "يرجى تحديد مصدر للتسجيل" + "selectSource": "يرجى تحديد مصدر للتسجيل", + "saving": "جاري الحفظ..." }, "language": "اللغة", "systemLanguagePrompt": { diff --git a/src/i18n/locales/en/launch.json b/src/i18n/locales/en/launch.json index 0bdb21d64..0066a3fc9 100644 --- a/src/i18n/locales/en/launch.json +++ b/src/i18n/locales/en/launch.json @@ -52,7 +52,8 @@ "defaultSourceName": "Screen" }, "recording": { - "selectSource": "Please select a source to record" + "selectSource": "Please select a source to record", + "saving": "Saving..." }, "language": "Language", "systemLanguagePrompt": { diff --git a/src/i18n/locales/es/launch.json b/src/i18n/locales/es/launch.json index 1d78246f6..90ef56865 100644 --- a/src/i18n/locales/es/launch.json +++ b/src/i18n/locales/es/launch.json @@ -48,7 +48,8 @@ "defaultSourceName": "Pantalla" }, "recording": { - "selectSource": "Por favor selecciona una fuente para grabar" + "selectSource": "Por favor selecciona una fuente para grabar", + "saving": "Guardando..." }, "language": "Idioma", "systemLanguagePrompt": { diff --git a/src/i18n/locales/fr/launch.json b/src/i18n/locales/fr/launch.json index 36b4fdc65..8c0b59e59 100644 --- a/src/i18n/locales/fr/launch.json +++ b/src/i18n/locales/fr/launch.json @@ -48,7 +48,8 @@ "defaultSourceName": "Écran" }, "recording": { - "selectSource": "Veuillez sélectionner une source à enregistrer" + "selectSource": "Veuillez sélectionner une source à enregistrer", + "saving": "Sauvegarde..." }, "language": "Langue", "systemLanguagePrompt": { diff --git a/src/i18n/locales/it/launch.json b/src/i18n/locales/it/launch.json index 4d89c40dd..b46adbd86 100644 --- a/src/i18n/locales/it/launch.json +++ b/src/i18n/locales/it/launch.json @@ -48,7 +48,8 @@ "defaultSourceName": "Schermo" }, "recording": { - "selectSource": "Seleziona una sorgente da registrare" + "selectSource": "Seleziona una sorgente da registrare", + "saving": "Salvataggio..." }, "language": "Lingua", "systemLanguagePrompt": { diff --git a/src/i18n/locales/ja-JP/launch.json b/src/i18n/locales/ja-JP/launch.json index 1010f1676..a9291b02b 100644 --- a/src/i18n/locales/ja-JP/launch.json +++ b/src/i18n/locales/ja-JP/launch.json @@ -48,7 +48,8 @@ "defaultSourceName": "画面" }, "recording": { - "selectSource": "録画するソースを選択してください" + "selectSource": "録画するソースを選択してください", + "saving": "保存中..." }, "language": "言語", "systemLanguagePrompt": { diff --git a/src/i18n/locales/ko-KR/launch.json b/src/i18n/locales/ko-KR/launch.json index 28a27a7cc..3c20f2236 100644 --- a/src/i18n/locales/ko-KR/launch.json +++ b/src/i18n/locales/ko-KR/launch.json @@ -48,7 +48,8 @@ "defaultSourceName": "화면" }, "recording": { - "selectSource": "녹화할 소스를 선택해 주세요" + "selectSource": "녹화할 소스를 선택해 주세요", + "saving": "저장 중..." }, "language": "언어", "systemLanguagePrompt": { diff --git a/src/i18n/locales/pt-BR/launch.json b/src/i18n/locales/pt-BR/launch.json index 4f865d452..79d44fc61 100644 --- a/src/i18n/locales/pt-BR/launch.json +++ b/src/i18n/locales/pt-BR/launch.json @@ -46,7 +46,8 @@ "defaultSourceName": "Tela" }, "recording": { - "selectSource": "Por favor, selecione uma fonte para gravar" + "selectSource": "Por favor, selecione uma fonte para gravar", + "saving": "Salvando..." }, "language": "Idioma", "systemLanguagePrompt": { diff --git a/src/i18n/locales/ru/launch.json b/src/i18n/locales/ru/launch.json index 5f9b68d68..00a227ca4 100644 --- a/src/i18n/locales/ru/launch.json +++ b/src/i18n/locales/ru/launch.json @@ -44,7 +44,8 @@ "defaultSourceName": "Экран" }, "recording": { - "selectSource": "Пожалуйста, выберите источник для записи" + "selectSource": "Пожалуйста, выберите источник для записи", + "saving": "Сохранение..." }, "language": "Язык", "systemLanguagePrompt": { diff --git a/src/i18n/locales/tr/launch.json b/src/i18n/locales/tr/launch.json index 4fb7e98f3..b670178a7 100644 --- a/src/i18n/locales/tr/launch.json +++ b/src/i18n/locales/tr/launch.json @@ -48,7 +48,8 @@ "defaultSourceName": "Ekran" }, "recording": { - "selectSource": "Lütfen kayıt için bir kaynak seçin" + "selectSource": "Lütfen kayıt için bir kaynak seçin", + "saving": "Kaydediliyor..." }, "language": "Dil", "systemLanguagePrompt": { diff --git a/src/i18n/locales/vi/launch.json b/src/i18n/locales/vi/launch.json index 0a237bb6a..203f2b414 100644 --- a/src/i18n/locales/vi/launch.json +++ b/src/i18n/locales/vi/launch.json @@ -44,7 +44,8 @@ "defaultSourceName": "Màn hình" }, "recording": { - "selectSource": "Vui lòng chọn một nguồn để ghi" + "selectSource": "Vui lòng chọn một nguồn để ghi", + "saving": "Đang lưu..." }, "language": "Ngôn ngữ", "systemLanguagePrompt": { diff --git a/src/i18n/locales/zh-CN/launch.json b/src/i18n/locales/zh-CN/launch.json index 0e7d66a8a..f78a611c0 100644 --- a/src/i18n/locales/zh-CN/launch.json +++ b/src/i18n/locales/zh-CN/launch.json @@ -48,7 +48,8 @@ "defaultSourceName": "屏幕" }, "recording": { - "selectSource": "请选择要录制的源" + "selectSource": "请选择要录制的源", + "saving": "正在保存..." }, "language": "语言", "systemLanguagePrompt": { diff --git a/src/i18n/locales/zh-TW/launch.json b/src/i18n/locales/zh-TW/launch.json index ca64e3299..6887546d9 100644 --- a/src/i18n/locales/zh-TW/launch.json +++ b/src/i18n/locales/zh-TW/launch.json @@ -48,7 +48,8 @@ "defaultSourceName": "螢幕" }, "recording": { - "selectSource": "請選擇要錄製的來源" + "selectSource": "請選擇要錄製的來源", + "saving": "正在儲存..." }, "language": "語言", "systemLanguagePrompt": { From f394035b457d623632cd01c541472332404e1e50 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Mon, 6 Jul 2026 20:52:11 +0200 Subject: [PATCH 2/2] fix: address CodeRabbit review feedback - Hoist WebmElementMock interface to describe scope in webm-duration.test.ts - Wrap ws.write inside try/finally to ensure streams are always destroyed on failure - Disable pause/restart/cancel HUD buttons while saving to prevent race conditions - Skip setSaving(true) for cancelled/restarted recordings (discardRecordingId guard) - Extract hudDisabledClasses constant to reduce repetition in LaunchWindow.tsx --- electron/recording/webm-duration.test.ts | 9 ++++--- electron/recording/webm-duration.ts | 13 +++++----- src/components/launch/LaunchWindow.tsx | 33 ++++++++++++++++-------- src/hooks/useScreenRecorder.ts | 7 ++++- 4 files changed, 39 insertions(+), 23 deletions(-) diff --git a/electron/recording/webm-duration.test.ts b/electron/recording/webm-duration.test.ts index e5719c450..d796bb36b 100644 --- a/electron/recording/webm-duration.test.ts +++ b/electron/recording/webm-duration.test.ts @@ -5,6 +5,11 @@ import { WebmBase, WebmContainer, WebmFile, WebmString, WebmUint } from "@fix-we import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { patchWebmDurationOnDisk } from "./webm-duration"; +interface WebmElementMock { + getSectionById: (id: number) => WebmElementMock; + getValue: () => number; +} + describe("webm-duration patching", () => { let dir: string; const pathFor = (name: string) => path.join(dir, name); @@ -70,10 +75,6 @@ describe("webm-duration patching", () => { const patchedBytes = await readFile(filePath); const webm = new WebmFile(new Uint8Array(patchedBytes)); - interface WebmElementMock { - getSectionById: (id: number) => WebmElementMock; - getValue: () => number; - } const segment = webm.getSectionById(0x8538067) as unknown as WebmElementMock; const info = segment.getSectionById(0x549a966); diff --git a/electron/recording/webm-duration.ts b/electron/recording/webm-duration.ts index 4e5a847cb..d9b4b6197 100644 --- a/electron/recording/webm-duration.ts +++ b/electron/recording/webm-duration.ts @@ -130,14 +130,13 @@ export async function patchWebmDurationOnDisk( const ws = createWriteStream(tmpPath); const rs = createReadStream(filePath, { start: clusterOffset }); - await new Promise((resolve, reject) => { - ws.write(patchedBytes, (err) => { - if (err) reject(err); - else resolve(); - }); - }); - try { + await new Promise((resolve, reject) => { + ws.write(patchedBytes, (err) => { + if (err) reject(err); + else resolve(); + }); + }); await pipeline(rs, ws); } finally { rs.destroy(); diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index db789cce2..b8cf24f22 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -82,17 +82,16 @@ function getIcon(name: IconName, className?: string) { return ; } -const hudGroupClasses = - "flex items-center gap-0.5 rounded-xl border border-white/[0.07] bg-white/[0.045] transition-colors duration-150 hover:bg-white/[0.075] disabled:opacity-30 disabled:cursor-not-allowed disabled:pointer-events-none"; +const hudDisabledClasses = + "disabled:opacity-30 disabled:cursor-not-allowed disabled:pointer-events-none"; -const hudIconBtnClasses = - "flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150 cursor-pointer text-white hover:bg-white/10 active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed disabled:pointer-events-none"; +const hudGroupClasses = `flex items-center gap-0.5 rounded-xl border border-white/[0.07] bg-white/[0.045] transition-colors duration-150 hover:bg-white/[0.075] ${hudDisabledClasses}`; -const hudAuxIconBtnClasses = - "flex h-7 w-7 items-center justify-center rounded-lg transition-colors duration-150 text-white/55 hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed disabled:pointer-events-none"; +const hudIconBtnClasses = `flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150 cursor-pointer text-white hover:bg-white/10 active:scale-95 ${hudDisabledClasses}`; -const windowBtnClasses = - "flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150 cursor-pointer opacity-50 hover:opacity-90 hover:bg-white/[0.08] disabled:opacity-30 disabled:cursor-not-allowed disabled:pointer-events-none"; +const hudAuxIconBtnClasses = `flex h-7 w-7 items-center justify-center rounded-lg transition-colors duration-150 text-white/55 hover:bg-white/10 ${hudDisabledClasses}`; + +const windowBtnClasses = `flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150 cursor-pointer opacity-50 hover:opacity-90 hover:bg-white/[0.08] ${hudDisabledClasses}`; const hudSidebarClasses = "ml-0.5 pl-1.5 border-l border-white/10 flex items-center gap-0.5"; const hudSidebarVerticalClasses = @@ -1062,7 +1061,11 @@ export function LaunchWindow() { - - diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 640ce34b1..f39b03dd2 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -312,7 +312,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return; } finalizingRecordingId.current = activeRecordingId; - setSaving(true); + // Only show the "Saving…" spinner for genuine saves — not for cancel/restart + // flows where discardRecordingId has already been set. + const isDiscarded = discardRecordingId.current === activeRecordingId; + if (!isDiscarded) { + setSaving(true); + } if (screenRecorder.current === activeScreenRecorder) { screenRecorder.current = null;