diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 15f61382a..0937ffd55 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -5,6 +5,8 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; +import { fixParsedWebmDuration } from "@fix-webm-duration/fix"; +import { WebmFile } from "@fix-webm-duration/parser"; import type { DesktopCapturerSource } from "electron"; import { app, @@ -1217,6 +1219,43 @@ async function loadRecordedSessionForVideoPath( } } +/** + * Patch the WebM Duration header on a finalized recording file. + * + * Browser MediaRecorder writes WebM with no Duration EBML element. With the + * streaming-to-disk path, the renderer never holds the blob, so the historical + * `fixWebmDuration(blob, durationMs)` call can't run. Patching on disk after + * `WriteStream.end()` produces an equivalent result: the editor's seek bar and + * timeline read a real duration instead of `N/A`. + * + * Best-effort by design — the file is still playable without the patch (decoders + * walk frames sequentially), so a failure here logs and returns rather than + * surfacing as an error to the user. Reads the whole file into a Buffer in the + * main process; that's the same memory profile as the pre-PR renderer path, + * just on the side that doesn't have V8's heap cap. + */ +async function patchWebmDurationOnDisk(filePath: string, durationMs: number): Promise { + try { + const fileBytes = await fs.readFile(filePath); + const webm = new WebmFile(new Uint8Array(fileBytes)); + const patched = fixParsedWebmDuration(webm, durationMs, { logger: false }); + if (!patched) { + // Either no Segment/Info section, or the Duration field was already valid. + // Both cases are fine — log at debug level and move on. + console.debug(`[patchWebmDurationOnDisk] no patch applied to ${filePath}`); + return; + } + if (!webm.source) { + console.error(`[patchWebmDurationOnDisk] patched but source missing for ${filePath}`); + return; + } + const patchedBytes = Buffer.from(webm.source.buffer, webm.source.byteOffset, webm.source.byteLength); + await fs.writeFile(filePath, patchedBytes); + } catch (error) { + console.error(`[patchWebmDurationOnDisk] failed to patch ${filePath}:`, error); + } +} + export function registerIpcHandlers( createEditorWindow: () => void, createSourceSelectorWindow: () => BrowserWindow, @@ -2131,16 +2170,19 @@ export function registerIpcHandlers( // Close the streaming write stream if one was used; otherwise fall back to // writing the full buffer (short recordings that never opened a stream). const screenWs = activeWriteStreams.get(createdAt); + let screenStreamed = false; if (screenWs) { await new Promise((resolve, reject) => screenWs.end((err?: Error | null) => (err ? reject(err) : resolve())), ); activeWriteStreams.delete(createdAt); + screenStreamed = true; } else if (payload.screen.videoData && payload.screen.videoData.byteLength > 0) { await fs.writeFile(screenVideoPath, Buffer.from(payload.screen.videoData)); } let webcamVideoPath: string | undefined; + let webcamStreamed = false; if (payload.webcam) { webcamVideoPath = resolveRecordingOutputPath(payload.webcam.fileName); const webcamWs = activeWriteStreams.get(createdAt + 1); // webcam stream keyed as recordingId+1 @@ -2149,11 +2191,33 @@ export function registerIpcHandlers( webcamWs.end((err?: Error | null) => (err ? reject(err) : resolve())), ); activeWriteStreams.delete(createdAt + 1); + webcamStreamed = true; } else if (payload.webcam.videoData && payload.webcam.videoData.byteLength > 0) { await fs.writeFile(webcamVideoPath, Buffer.from(payload.webcam.videoData)); } } + // Streamed files lack the WebM Duration header (renderer no longer holds the + // blob to patch). Patch on disk so the editor's seek bar and timeline work. + // Best-effort: log on failure but don't block, since the file is still playable. + if ( + screenStreamed && + typeof payload.durationMs === "number" && + Number.isFinite(payload.durationMs) && + payload.durationMs > 0 + ) { + await patchWebmDurationOnDisk(screenVideoPath, payload.durationMs); + } + if ( + webcamStreamed && + webcamVideoPath && + typeof payload.durationMs === "number" && + Number.isFinite(payload.durationMs) && + payload.durationMs > 0 + ) { + await patchWebmDurationOnDisk(webcamVideoPath, payload.durationMs); + } + const session: RecordingSession = webcamVideoPath ? { screenVideoPath, diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 2d2147bc5..711b28b2f 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -454,6 +454,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { : undefined, createdAt: activeRecordingId, cursorCaptureMode, + durationMs: duration, }); if (!result.success) { diff --git a/src/lib/recordingSession.ts b/src/lib/recordingSession.ts index f5ebf9ca2..12a6afd22 100644 --- a/src/lib/recordingSession.ts +++ b/src/lib/recordingSession.ts @@ -20,6 +20,14 @@ export interface StoreRecordedSessionInput { webcam?: RecordedVideoAssetInput; createdAt?: number; cursorCaptureMode?: CursorCaptureMode; + /** + * Recording wall-clock duration in milliseconds. Used by the main process + * to patch the WebM Duration header on streamed recordings, since the + * renderer no longer holds the bytes. Browser MediaRecorder writes WebM + * with no/zero duration; without this patch, the editor's seek bar and + * timeline break for any recording that took the streaming path. + */ + durationMs?: number; } export function normalizeCursorCaptureMode(value: unknown): CursorCaptureMode | undefined {