Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<void> {
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,
Expand Down Expand Up @@ -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<void>((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
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/hooks/useScreenRecorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
: undefined,
createdAt: activeRecordingId,
cursorCaptureMode,
durationMs: duration,
});

if (!result.success) {
Expand Down
8 changes: 8 additions & 0 deletions src/lib/recordingSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down