Merge pull request #658 from neurot1cal/fix/streaming-recording-duration
Fix: stream long recordings to disk and patch WebM duration on save (#616)
This commit is contained in:
Vendored
+6
@@ -81,6 +81,12 @@ interface Window {
|
|||||||
message?: string;
|
message?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
}>;
|
}>;
|
||||||
|
openRecordingStream: (fileName: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
|
appendRecordingChunk: (
|
||||||
|
fileName: string,
|
||||||
|
chunk: ArrayBuffer,
|
||||||
|
) => Promise<{ success: boolean; error?: string }>;
|
||||||
|
closeRecordingStream: (fileName: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
getRecordedVideoPath: () => Promise<{
|
getRecordedVideoPath: () => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
path?: string;
|
path?: string;
|
||||||
|
|||||||
@@ -40,7 +40,9 @@ import { RECORDINGS_DIR } from "../main";
|
|||||||
import { createCursorRecordingSession } from "../native-bridge/cursor/recording/factory";
|
import { createCursorRecordingSession } from "../native-bridge/cursor/recording/factory";
|
||||||
import { requestMacCursorAccessibilityAccess } from "../native-bridge/cursor/recording/macNativeCursorRecordingSession";
|
import { requestMacCursorAccessibilityAccess } from "../native-bridge/cursor/recording/macNativeCursorRecordingSession";
|
||||||
import type { CursorRecordingSession } from "../native-bridge/cursor/recording/session";
|
import type { CursorRecordingSession } from "../native-bridge/cursor/recording/session";
|
||||||
|
import { patchWebmDurationOnDisk } from "../recording/webm-duration";
|
||||||
import { registerNativeBridgeHandlers } from "./nativeBridge";
|
import { registerNativeBridgeHandlers } from "./nativeBridge";
|
||||||
|
import { RecordingStreamRegistry, registerRecordingStreamHandlers } from "./recordingStream";
|
||||||
|
|
||||||
const PROJECT_FILE_EXTENSION = "openscreen";
|
const PROJECT_FILE_EXTENSION = "openscreen";
|
||||||
const SHORTCUTS_FILE = path.join(app.getPath("userData"), "shortcuts.json");
|
const SHORTCUTS_FILE = path.join(app.getPath("userData"), "shortcuts.json");
|
||||||
@@ -265,6 +267,30 @@ function resolveRecordingOutputPath(fileName: string): string {
|
|||||||
return path.join(RECORDINGS_DIR, parsedPath.base);
|
return path.join(RECORDINGS_DIR, parsedPath.base);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isValidDurationMs(value: number | undefined): value is number {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) && value > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finalize a single recording file: if it was streamed to disk, flush and close
|
||||||
|
* the stream; otherwise (a short recording, or the stream failed to open and the
|
||||||
|
* renderer fell back to in-memory buffering) write the buffered bytes. Returns
|
||||||
|
* whether the file was streamed, which the caller uses to decide whether the
|
||||||
|
* WebM duration needs patching on disk.
|
||||||
|
*/
|
||||||
|
async function finalizeRecordingFile(
|
||||||
|
registry: RecordingStreamRegistry,
|
||||||
|
fileName: string,
|
||||||
|
filePath: string,
|
||||||
|
videoData?: ArrayBuffer,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const streamed = await registry.finalize(fileName);
|
||||||
|
if (!streamed && videoData && videoData.byteLength > 0) {
|
||||||
|
await fs.writeFile(filePath, Buffer.from(videoData));
|
||||||
|
}
|
||||||
|
return streamed;
|
||||||
|
}
|
||||||
|
|
||||||
async function getApprovedProjectSession(
|
async function getApprovedProjectSession(
|
||||||
project: unknown,
|
project: unknown,
|
||||||
projectFilePath?: string,
|
projectFilePath?: string,
|
||||||
@@ -2141,6 +2167,12 @@ export function registerIpcHandlers(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// On-disk write streams for in-progress recordings, keyed by output file name.
|
||||||
|
// Chunks are appended as they arrive from ondataavailable so the renderer
|
||||||
|
// never buffers the full video in memory (the #616 fix).
|
||||||
|
const recordingStreams = new RecordingStreamRegistry();
|
||||||
|
registerRecordingStreamHandlers(ipcMain, recordingStreams, resolveRecordingOutputPath);
|
||||||
|
|
||||||
ipcMain.handle("store-recorded-session", async (_, payload: StoreRecordedSessionInput) => {
|
ipcMain.handle("store-recorded-session", async (_, payload: StoreRecordedSessionInput) => {
|
||||||
try {
|
try {
|
||||||
return await storeRecordedSessionFiles(payload);
|
return await storeRecordedSessionFiles(payload);
|
||||||
@@ -2161,12 +2193,37 @@ export function registerIpcHandlers(
|
|||||||
: Date.now();
|
: Date.now();
|
||||||
const cursorCaptureMode = normalizeCursorCaptureMode(payload.cursorCaptureMode);
|
const cursorCaptureMode = normalizeCursorCaptureMode(payload.cursorCaptureMode);
|
||||||
const screenVideoPath = resolveRecordingOutputPath(payload.screen.fileName);
|
const screenVideoPath = resolveRecordingOutputPath(payload.screen.fileName);
|
||||||
await fs.writeFile(screenVideoPath, Buffer.from(payload.screen.videoData));
|
const screenStreamed = await finalizeRecordingFile(
|
||||||
|
recordingStreams,
|
||||||
|
payload.screen.fileName,
|
||||||
|
screenVideoPath,
|
||||||
|
payload.screen.videoData,
|
||||||
|
);
|
||||||
|
|
||||||
let webcamVideoPath: string | undefined;
|
let webcamVideoPath: string | undefined;
|
||||||
|
let webcamStreamed = false;
|
||||||
if (payload.webcam) {
|
if (payload.webcam) {
|
||||||
webcamVideoPath = resolveRecordingOutputPath(payload.webcam.fileName);
|
webcamVideoPath = resolveRecordingOutputPath(payload.webcam.fileName);
|
||||||
await fs.writeFile(webcamVideoPath, Buffer.from(payload.webcam.videoData));
|
webcamStreamed = await finalizeRecordingFile(
|
||||||
|
recordingStreams,
|
||||||
|
payload.webcam.fileName,
|
||||||
|
webcamVideoPath,
|
||||||
|
payload.webcam.videoData,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Streamed files lack the WebM Duration header (the renderer no longer holds
|
||||||
|
// the blob to patch). Patch on disk so the editor's seek bar and timeline
|
||||||
|
// work. Best-effort and independent per file, so the patches run together.
|
||||||
|
if (isValidDurationMs(payload.durationMs)) {
|
||||||
|
const patches: Promise<unknown>[] = [];
|
||||||
|
if (screenStreamed) {
|
||||||
|
patches.push(patchWebmDurationOnDisk(screenVideoPath, payload.durationMs));
|
||||||
|
}
|
||||||
|
if (webcamStreamed && webcamVideoPath) {
|
||||||
|
patches.push(patchWebmDurationOnDisk(webcamVideoPath, payload.durationMs));
|
||||||
|
}
|
||||||
|
await Promise.all(patches);
|
||||||
}
|
}
|
||||||
|
|
||||||
const session: RecordingSession = webcamVideoPath
|
const session: RecordingSession = webcamVideoPath
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { RecordingStreamRegistry } from "./recordingStream";
|
||||||
|
|
||||||
|
describe("RecordingStreamRegistry", () => {
|
||||||
|
let dir: string;
|
||||||
|
const pathFor = (name: string) => path.join(dir, name);
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
dir = await mkdtemp(path.join(tmpdir(), "openscreen-stream-"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("streams chunks to disk in order and reports streamed on finalize", async () => {
|
||||||
|
const registry = new RecordingStreamRegistry();
|
||||||
|
await registry.open("rec.webm", pathFor("rec.webm"));
|
||||||
|
await registry.append("rec.webm", Buffer.from("hello "));
|
||||||
|
await registry.append("rec.webm", Buffer.from("world"));
|
||||||
|
|
||||||
|
const streamed = await registry.finalize("rec.webm");
|
||||||
|
|
||||||
|
expect(streamed).toBe(true);
|
||||||
|
expect(await readFile(pathFor("rec.webm"), "utf8")).toBe("hello world");
|
||||||
|
// A second finalize has nothing to close.
|
||||||
|
expect(await registry.finalize("rec.webm")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports not-streamed when no stream was opened", async () => {
|
||||||
|
const registry = new RecordingStreamRegistry();
|
||||||
|
expect(await registry.finalize("missing.webm")).toBe(false);
|
||||||
|
expect(registry.has("missing.webm")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects open when the target path is not writable (open is awaited, not assumed)", async () => {
|
||||||
|
const registry = new RecordingStreamRegistry();
|
||||||
|
// Parent directory does not exist, so createWriteStream emits 'error' on open.
|
||||||
|
await expect(
|
||||||
|
registry.open("rec.webm", path.join(dir, "does-not-exist", "rec.webm")),
|
||||||
|
).rejects.toThrow();
|
||||||
|
// A failed open must not register a stream the renderer would treat as live.
|
||||||
|
expect(registry.has("rec.webm")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects append when no stream is open", async () => {
|
||||||
|
const registry = new RecordingStreamRegistry();
|
||||||
|
await expect(registry.append("rec.webm", Buffer.from("x"))).rejects.toThrow(
|
||||||
|
/No active recording stream/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("discard closes the stream and removes the partial file", async () => {
|
||||||
|
const registry = new RecordingStreamRegistry();
|
||||||
|
await registry.open("rec.webm", pathFor("rec.webm"));
|
||||||
|
await registry.append("rec.webm", Buffer.from("partial"));
|
||||||
|
|
||||||
|
await registry.discard("rec.webm", pathFor("rec.webm"));
|
||||||
|
|
||||||
|
expect(registry.has("rec.webm")).toBe(false);
|
||||||
|
await expect(stat(pathFor("rec.webm"))).rejects.toThrow();
|
||||||
|
// Nothing left to finalize after a discard.
|
||||||
|
expect(await registry.finalize("rec.webm")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("discard tolerates a missing file", async () => {
|
||||||
|
const registry = new RecordingStreamRegistry();
|
||||||
|
await expect(registry.discard("never.webm", pathFor("never.webm"))).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opening the same file twice replaces the prior stream", async () => {
|
||||||
|
const registry = new RecordingStreamRegistry();
|
||||||
|
await registry.open("rec.webm", pathFor("rec.webm"));
|
||||||
|
await registry.append("rec.webm", Buffer.from("first"));
|
||||||
|
await registry.open("rec.webm", pathFor("rec.webm"));
|
||||||
|
await registry.append("rec.webm", Buffer.from("second"));
|
||||||
|
await registry.finalize("rec.webm");
|
||||||
|
|
||||||
|
expect(await readFile(pathFor("rec.webm"), "utf8")).toBe("second");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { createWriteStream, type WriteStream } from "node:fs";
|
||||||
|
import { unlink } from "node:fs/promises";
|
||||||
|
import type { IpcMain } from "electron";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the lifecycle of on-disk write streams for in-progress recordings, keyed
|
||||||
|
* by the recording's output file name. Browser MediaRecorder chunks are appended
|
||||||
|
* here as they arrive so a long recording never buffers the whole video in the
|
||||||
|
* renderer (the #616 fix).
|
||||||
|
*
|
||||||
|
* The file name is the key because it is the one value the renderer and main
|
||||||
|
* process already exchange and it is globally unique per recording, so there is
|
||||||
|
* no derived/offset key to keep in sync across the IPC boundary.
|
||||||
|
*/
|
||||||
|
export class RecordingStreamRegistry {
|
||||||
|
private readonly streams = new Map<string, WriteStream>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a write stream and resolve only once the OS confirms it is writable.
|
||||||
|
* Resolving on the `open` event (rather than on `createWriteStream` returning)
|
||||||
|
* means a bad path or permission error rejects here instead of surfacing as a
|
||||||
|
* silent chunk drop later, so the renderer's fallback can take over.
|
||||||
|
*/
|
||||||
|
async open(fileName: string, filePath: string): Promise<void> {
|
||||||
|
await this.endStream(fileName);
|
||||||
|
|
||||||
|
const ws = createWriteStream(filePath, { flags: "w" });
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const onError = (error: Error) => reject(error);
|
||||||
|
ws.once("error", onError);
|
||||||
|
ws.once("open", () => {
|
||||||
|
ws.removeListener("error", onError);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// Keep a listener for the stream's lifetime so a late error logs rather
|
||||||
|
// than crashing the main process with an unhandled 'error' event. Per-write
|
||||||
|
// failures still surface through the `append` callback below.
|
||||||
|
ws.on("error", (error) => {
|
||||||
|
console.error(`[recording-stream] ${fileName}:`, error);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.streams.set(fileName, ws);
|
||||||
|
}
|
||||||
|
|
||||||
|
has(fileName: string): boolean {
|
||||||
|
return this.streams.has(fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Append a chunk; rejects if no stream is open or the write fails. */
|
||||||
|
async append(fileName: string, chunk: Buffer): Promise<void> {
|
||||||
|
const ws = this.streams.get(fileName);
|
||||||
|
if (!ws) {
|
||||||
|
throw new Error(`No active recording stream for ${fileName}`);
|
||||||
|
}
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
ws.write(chunk, (error) => (error ? reject(error) : resolve()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flush and close the stream, keeping the file. Returns whether a stream was
|
||||||
|
* open — i.e. whether the recording was streamed to disk (true) or needs its
|
||||||
|
* in-memory buffer written by the caller (false).
|
||||||
|
*/
|
||||||
|
async finalize(fileName: string): Promise<boolean> {
|
||||||
|
const ws = this.streams.get(fileName);
|
||||||
|
if (!ws) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.streams.delete(fileName);
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
ws.end((error?: Error | null) => (error ? reject(error) : resolve()));
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the stream (if any) and delete the partial file. Used when a streamed
|
||||||
|
* recording is discarded or fails before a successful save, so cancelled runs
|
||||||
|
* don't leak file descriptors or orphan partial recordings on disk.
|
||||||
|
*/
|
||||||
|
async discard(fileName: string, filePath: string): Promise<void> {
|
||||||
|
await this.endStream(fileName);
|
||||||
|
await unlink(filePath).catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async endStream(fileName: string): Promise<void> {
|
||||||
|
const ws = this.streams.get(fileName);
|
||||||
|
if (!ws) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.streams.delete(fileName);
|
||||||
|
await new Promise<void>((resolve) => ws.end(() => resolve()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register the streaming IPC handlers. Thin wrappers that translate the
|
||||||
|
* registry's throw-on-failure contract into the `{ success, error }` shape the
|
||||||
|
* renderer expects.
|
||||||
|
*/
|
||||||
|
export function registerRecordingStreamHandlers(
|
||||||
|
ipcMain: IpcMain,
|
||||||
|
registry: RecordingStreamRegistry,
|
||||||
|
resolveRecordingOutputPath: (fileName: string) => string,
|
||||||
|
): void {
|
||||||
|
ipcMain.handle(
|
||||||
|
"open-recording-stream",
|
||||||
|
async (_, fileName: string): Promise<{ success: boolean; error?: string }> => {
|
||||||
|
try {
|
||||||
|
await registry.open(fileName, resolveRecordingOutputPath(fileName));
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: String(error) };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
"append-recording-chunk",
|
||||||
|
async (
|
||||||
|
_,
|
||||||
|
fileName: string,
|
||||||
|
chunk: ArrayBuffer,
|
||||||
|
): Promise<{ success: boolean; error?: string }> => {
|
||||||
|
try {
|
||||||
|
await registry.append(fileName, Buffer.from(chunk));
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: String(error) };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
"close-recording-stream",
|
||||||
|
async (_, fileName: string): Promise<{ success: boolean; error?: string }> => {
|
||||||
|
try {
|
||||||
|
await registry.discard(fileName, resolveRecordingOutputPath(fileName));
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: String(error) };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -64,6 +64,15 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
|||||||
storeRecordedSession: (payload: StoreRecordedSessionInput) => {
|
storeRecordedSession: (payload: StoreRecordedSessionInput) => {
|
||||||
return ipcRenderer.invoke("store-recorded-session", payload);
|
return ipcRenderer.invoke("store-recorded-session", payload);
|
||||||
},
|
},
|
||||||
|
openRecordingStream: (fileName: string) => {
|
||||||
|
return ipcRenderer.invoke("open-recording-stream", fileName);
|
||||||
|
},
|
||||||
|
appendRecordingChunk: (fileName: string, chunk: ArrayBuffer) => {
|
||||||
|
return ipcRenderer.invoke("append-recording-chunk", fileName, chunk);
|
||||||
|
},
|
||||||
|
closeRecordingStream: (fileName: string) => {
|
||||||
|
return ipcRenderer.invoke("close-recording-stream", fileName);
|
||||||
|
},
|
||||||
|
|
||||||
getRecordedVideoPath: () => {
|
getRecordedVideoPath: () => {
|
||||||
return ipcRenderer.invoke("get-recorded-video-path");
|
return ipcRenderer.invoke("get-recorded-video-path");
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import { fixParsedWebmDuration } from "@fix-webm-duration/fix";
|
||||||
|
import { WebmFile } from "@fix-webm-duration/parser";
|
||||||
|
|
||||||
|
export type DurationPatchResult =
|
||||||
|
| { patched: true }
|
||||||
|
| { patched: false; reason: "no-section" | "already-valid" | "io-error" | "internal" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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`.
|
||||||
|
*
|
||||||
|
* Atomic by design: writes the patched bytes to `<filePath>.duration-patch.tmp`
|
||||||
|
* and renames in place. If the process crashes mid-rewrite, the original file
|
||||||
|
* survives intact, so the user never loses their recording to a partial write.
|
||||||
|
*
|
||||||
|
* Best-effort by intent: any failure (read, parse, write) logs and returns a
|
||||||
|
* non-`patched` result rather than throwing. The file is still playable without
|
||||||
|
* the patch (decoders walk frames sequentially); the only cost is that the
|
||||||
|
* editor's seek bar and timeline break until it is patched.
|
||||||
|
*
|
||||||
|
* Memory: reads the whole file into a main-process Buffer, the same footprint
|
||||||
|
* as the pre-streaming renderer path, just on the side without V8's heap cap.
|
||||||
|
*/
|
||||||
|
export async function patchWebmDurationOnDisk(
|
||||||
|
filePath: string,
|
||||||
|
durationMs: number,
|
||||||
|
): Promise<DurationPatchResult> {
|
||||||
|
try {
|
||||||
|
const fileBytes = await fs.readFile(filePath);
|
||||||
|
const webm = new WebmFile(new Uint8Array(fileBytes));
|
||||||
|
|
||||||
|
const patched = fixParsedWebmDuration(webm, durationMs, { logger: false });
|
||||||
|
if (!patched) {
|
||||||
|
// fixParsedWebmDuration returns false for: missing Segment, missing
|
||||||
|
// Info, or a Duration that is already valid. The first two mean a
|
||||||
|
// malformed (most likely truncated) file; the third is a no-op.
|
||||||
|
const reason = inferUnpatchedReason(webm);
|
||||||
|
if (reason === "no-section") {
|
||||||
|
console.warn(
|
||||||
|
`[webm-duration] no Segment/Info section in ${filePath}; file may be truncated`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { patched: false, reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!webm.source) {
|
||||||
|
console.error(`[webm-duration] patched but source missing for ${filePath}`);
|
||||||
|
return { patched: false, reason: "internal" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmpPath = `${filePath}.duration-patch.tmp`;
|
||||||
|
const patchedBytes = Buffer.from(
|
||||||
|
webm.source.buffer,
|
||||||
|
webm.source.byteOffset,
|
||||||
|
webm.source.byteLength,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await fs.writeFile(tmpPath, patchedBytes);
|
||||||
|
await fs.rename(tmpPath, filePath);
|
||||||
|
return { patched: true };
|
||||||
|
} catch (writeError) {
|
||||||
|
console.error(`[webm-duration] failed to write patched ${filePath}:`, writeError);
|
||||||
|
// Best-effort cleanup of the temp file; if unlink also fails, leave it.
|
||||||
|
// The original recording is untouched because 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);
|
||||||
|
return { patched: false, reason: "io-error" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 that @fix-webm-duration/parser
|
||||||
|
* uses as its lookup keys (Segment `0x8538067`, Info `0x549a966`), verified
|
||||||
|
* against the parser's `src/lib/sections.js` — not the canonical 4-byte EBML
|
||||||
|
* IDs (`0x18538067` / `0x1549A966`), which this parser's `getSectionById` would
|
||||||
|
* never match.
|
||||||
|
*/
|
||||||
|
function inferUnpatchedReason(webm: WebmFile): "no-section" | "already-valid" {
|
||||||
|
const segment = webm.getSectionById?.(0x8538067);
|
||||||
|
if (!segment) return "no-section";
|
||||||
|
const info = (
|
||||||
|
segment as unknown as { getSectionById?: (id: number) => unknown }
|
||||||
|
).getSectionById?.(0x549a966);
|
||||||
|
return info ? "already-valid" : "no-section";
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createRecorderHandle } from "./recorderHandle";
|
||||||
|
|
||||||
|
type ElectronAPI = Window["electronAPI"];
|
||||||
|
|
||||||
|
const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||||
|
const decode = (buffer: ArrayBuffer) => new TextDecoder().decode(new Uint8Array(buffer));
|
||||||
|
|
||||||
|
/** Minimal MediaRecorder stand-in the tests can drive directly. */
|
||||||
|
class FakeMediaRecorder {
|
||||||
|
ondataavailable: ((event: BlobEvent) => void) | null = null;
|
||||||
|
onstop: (() => void) | null = null;
|
||||||
|
onerror: (() => void) | null = null;
|
||||||
|
state: "inactive" | "recording" = "inactive";
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
this.state = "recording";
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.state = "inactive";
|
||||||
|
this.onstop?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(data: Blob): void {
|
||||||
|
this.ondataavailable?.({ data } as BlobEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stubElectronAPI(api: Partial<ElectronAPI>): void {
|
||||||
|
window.electronAPI = api as unknown as ElectronAPI;
|
||||||
|
}
|
||||||
|
|
||||||
|
function driver(handle: { recorder: MediaRecorder }): FakeMediaRecorder {
|
||||||
|
return handle.recorder as unknown as FakeMediaRecorder;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("createRecorderHandle", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal("MediaRecorder", FakeMediaRecorder);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
window.electronAPI = undefined as unknown as ElectronAPI;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("streams chunks to disk in arrival order and resolves an empty blob", async () => {
|
||||||
|
const appended: string[] = [];
|
||||||
|
const openRecordingStream = vi.fn(async () => ({ success: true }));
|
||||||
|
const appendRecordingChunk = vi.fn(async (_fileName: string, buffer: ArrayBuffer) => {
|
||||||
|
appended.push(decode(buffer));
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
stubElectronAPI({ openRecordingStream, appendRecordingChunk });
|
||||||
|
|
||||||
|
const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" }, "rec.webm");
|
||||||
|
const fake = driver(handle);
|
||||||
|
|
||||||
|
fake.emit(new Blob(["a"])); // arrives before open resolves -> buffered
|
||||||
|
await tick(); // open resolves -> buffered chunk flushes, mode becomes streaming
|
||||||
|
fake.emit(new Blob(["b"]));
|
||||||
|
fake.emit(new Blob(["c"]));
|
||||||
|
fake.stop();
|
||||||
|
|
||||||
|
const blob = await handle.recordedBlobPromise;
|
||||||
|
expect(openRecordingStream).toHaveBeenCalledWith("rec.webm");
|
||||||
|
expect(appended).toEqual(["a", "b", "c"]);
|
||||||
|
expect(blob.size).toBe(0);
|
||||||
|
expect(handle.isStreaming()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to a complete in-memory blob when the stream fails to open", async () => {
|
||||||
|
const openRecordingStream = vi.fn(async () => ({ success: false, error: "nope" }));
|
||||||
|
const appendRecordingChunk = vi.fn(async () => ({ success: true }));
|
||||||
|
stubElectronAPI({ openRecordingStream, appendRecordingChunk });
|
||||||
|
|
||||||
|
const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" }, "rec.webm");
|
||||||
|
const fake = driver(handle);
|
||||||
|
|
||||||
|
fake.emit(new Blob(["a"]));
|
||||||
|
await tick(); // open resolves false -> buffering, keep everything in memory
|
||||||
|
fake.emit(new Blob(["bc"]));
|
||||||
|
fake.stop();
|
||||||
|
|
||||||
|
const blob = await handle.recordedBlobPromise;
|
||||||
|
expect(appendRecordingChunk).not.toHaveBeenCalled();
|
||||||
|
expect(handle.isStreaming()).toBe(false);
|
||||||
|
expect(blob.size).toBe(3);
|
||||||
|
expect(decode(await blob.arrayBuffer())).toBe("abc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to in-memory buffering when the open IPC call rejects", async () => {
|
||||||
|
const openRecordingStream = vi.fn(async () => {
|
||||||
|
throw new Error("ipc channel closed");
|
||||||
|
});
|
||||||
|
stubElectronAPI({
|
||||||
|
openRecordingStream,
|
||||||
|
appendRecordingChunk: vi.fn(async () => ({ success: true })),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" }, "rec.webm");
|
||||||
|
const fake = driver(handle);
|
||||||
|
|
||||||
|
fake.emit(new Blob(["a"]));
|
||||||
|
await tick(); // open rejects -> treated as a failed open, keep buffering
|
||||||
|
fake.emit(new Blob(["b"]));
|
||||||
|
fake.stop();
|
||||||
|
|
||||||
|
const blob = await handle.recordedBlobPromise;
|
||||||
|
expect(handle.isStreaming()).toBe(false);
|
||||||
|
expect(blob.size).toBe(2);
|
||||||
|
expect(decode(await blob.arrayBuffer())).toBe("ab");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waits for in-flight chunk writes before stop resolves (no truncation)", async () => {
|
||||||
|
let releaseAppend: () => void = () => undefined;
|
||||||
|
const appendGate = new Promise<void>((resolve) => {
|
||||||
|
releaseAppend = resolve;
|
||||||
|
});
|
||||||
|
const appendRecordingChunk = vi.fn(async () => {
|
||||||
|
await appendGate;
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
stubElectronAPI({
|
||||||
|
openRecordingStream: vi.fn(async () => ({ success: true })),
|
||||||
|
appendRecordingChunk,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" }, "rec.webm");
|
||||||
|
const fake = driver(handle);
|
||||||
|
|
||||||
|
await tick(); // open resolves
|
||||||
|
fake.emit(new Blob(["a"])); // write blocks on the gate
|
||||||
|
fake.stop();
|
||||||
|
|
||||||
|
let resolved = false;
|
||||||
|
void handle.recordedBlobPromise.then(() => {
|
||||||
|
resolved = true;
|
||||||
|
});
|
||||||
|
await tick();
|
||||||
|
expect(resolved).toBe(false); // must not resolve while the write is in flight
|
||||||
|
|
||||||
|
releaseAppend();
|
||||||
|
await handle.recordedBlobPromise;
|
||||||
|
expect(resolved).toBe(true);
|
||||||
|
expect(appendRecordingChunk).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when a chunk fails to write mid-stream", async () => {
|
||||||
|
stubElectronAPI({
|
||||||
|
openRecordingStream: vi.fn(async () => ({ success: true })),
|
||||||
|
appendRecordingChunk: vi.fn(async () => ({ success: false, error: "disk full" })),
|
||||||
|
closeRecordingStream: vi.fn(async () => ({ success: true })),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" }, "rec.webm");
|
||||||
|
const fake = driver(handle);
|
||||||
|
|
||||||
|
await tick();
|
||||||
|
fake.emit(new Blob(["a"]));
|
||||||
|
fake.stop();
|
||||||
|
|
||||||
|
await expect(handle.recordedBlobPromise).rejects.toThrow(/disk full/);
|
||||||
|
expect(handle.isStreaming()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a rejected append the same as a failed write", async () => {
|
||||||
|
stubElectronAPI({
|
||||||
|
openRecordingStream: vi.fn(async () => ({ success: true })),
|
||||||
|
appendRecordingChunk: vi.fn(async () => {
|
||||||
|
throw new Error("kernel said no");
|
||||||
|
}),
|
||||||
|
closeRecordingStream: vi.fn(async () => ({ success: true })),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" }, "rec.webm");
|
||||||
|
const fake = driver(handle);
|
||||||
|
|
||||||
|
await tick();
|
||||||
|
fake.emit(new Blob(["a"]));
|
||||||
|
fake.stop();
|
||||||
|
|
||||||
|
await expect(handle.recordedBlobPromise).rejects.toThrow(/kernel said no/);
|
||||||
|
expect(handle.isStreaming()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("buffers in memory and never opens a stream when no file name is given", async () => {
|
||||||
|
const openRecordingStream = vi.fn(async () => ({ success: true }));
|
||||||
|
stubElectronAPI({
|
||||||
|
openRecordingStream,
|
||||||
|
appendRecordingChunk: vi.fn(async () => ({ success: true })),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" });
|
||||||
|
const fake = driver(handle);
|
||||||
|
|
||||||
|
fake.emit(new Blob(["xy"]));
|
||||||
|
await tick();
|
||||||
|
fake.stop();
|
||||||
|
|
||||||
|
const blob = await handle.recordedBlobPromise;
|
||||||
|
expect(openRecordingStream).not.toHaveBeenCalled();
|
||||||
|
expect(handle.isStreaming()).toBe(false);
|
||||||
|
expect(blob.size).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("buffers in memory when appendRecordingChunk is unavailable (version skew)", async () => {
|
||||||
|
const openRecordingStream = vi.fn(async () => ({ success: true }));
|
||||||
|
// appendRecordingChunk intentionally omitted to simulate renderer/main skew.
|
||||||
|
stubElectronAPI({ openRecordingStream });
|
||||||
|
|
||||||
|
const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" }, "rec.webm");
|
||||||
|
const fake = driver(handle);
|
||||||
|
|
||||||
|
fake.emit(new Blob(["a"]));
|
||||||
|
await tick();
|
||||||
|
fake.emit(new Blob(["b"]));
|
||||||
|
fake.stop();
|
||||||
|
|
||||||
|
const blob = await handle.recordedBlobPromise;
|
||||||
|
// Never even attempts to open the stream when it can't append to it.
|
||||||
|
expect(openRecordingStream).not.toHaveBeenCalled();
|
||||||
|
expect(handle.isStreaming()).toBe(false);
|
||||||
|
expect(blob.size).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("discard closes the disk stream for a streamed recording", async () => {
|
||||||
|
const closeRecordingStream = vi.fn(async () => ({ success: true }));
|
||||||
|
stubElectronAPI({
|
||||||
|
openRecordingStream: vi.fn(async () => ({ success: true })),
|
||||||
|
appendRecordingChunk: vi.fn(async () => ({ success: true })),
|
||||||
|
closeRecordingStream,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" }, "rec.webm");
|
||||||
|
const fake = driver(handle);
|
||||||
|
await tick();
|
||||||
|
fake.emit(new Blob(["a"]));
|
||||||
|
fake.stop();
|
||||||
|
await handle.recordedBlobPromise;
|
||||||
|
|
||||||
|
await handle.discard();
|
||||||
|
expect(closeRecordingStream).toHaveBeenCalledWith("rec.webm");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("discard is a no-op when the stream never opened", async () => {
|
||||||
|
const closeRecordingStream = vi.fn(async () => ({ success: true }));
|
||||||
|
stubElectronAPI({
|
||||||
|
openRecordingStream: vi.fn(async () => ({ success: false })),
|
||||||
|
appendRecordingChunk: vi.fn(async () => ({ success: true })),
|
||||||
|
closeRecordingStream,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" }, "rec.webm");
|
||||||
|
const fake = driver(handle);
|
||||||
|
await tick();
|
||||||
|
fake.stop();
|
||||||
|
await handle.recordedBlobPromise;
|
||||||
|
|
||||||
|
await handle.discard();
|
||||||
|
expect(closeRecordingStream).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
const RECORDER_TIMESLICE_MS = 1000;
|
||||||
|
|
||||||
|
export type RecorderHandle = {
|
||||||
|
recorder: MediaRecorder;
|
||||||
|
/**
|
||||||
|
* Resolves once the recording has fully drained. For a streamed recording the
|
||||||
|
* blob is empty (the bytes are already on disk); for an in-memory recording it
|
||||||
|
* holds the full WebM. Rejects if a chunk failed to write to disk mid-stream,
|
||||||
|
* so a truncated recording surfaces as an error instead of a silent partial save.
|
||||||
|
*/
|
||||||
|
recordedBlobPromise: Promise<Blob>;
|
||||||
|
/**
|
||||||
|
* Whether the recording's bytes went to disk via the streaming path. Computed
|
||||||
|
* at finalize time rather than construction, so a stream that fails to open is
|
||||||
|
* correctly reported as not-streamed and its in-memory fallback is used.
|
||||||
|
*/
|
||||||
|
isStreaming: () => boolean;
|
||||||
|
/**
|
||||||
|
* Close the disk stream (if one opened) and delete its partial file. Called
|
||||||
|
* when a recording is discarded or fails before a successful save, so cancelled
|
||||||
|
* runs don't leak the stream or orphan a partial file. No-op for in-memory
|
||||||
|
* recorders.
|
||||||
|
*/
|
||||||
|
discard: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap a MediaRecorder, optionally streaming its chunks to disk.
|
||||||
|
*
|
||||||
|
* When `fileName` is given, chunks are written to disk in arrival order through
|
||||||
|
* the main process as they arrive, so a long recording never buffers the whole
|
||||||
|
* video in the renderer (the #616 fix). Until the disk stream confirms it is
|
||||||
|
* open, chunks are held in memory; if the open fails, that buffer becomes a
|
||||||
|
* complete in-memory fallback so nothing is lost. Native-capture webcam sidecars
|
||||||
|
* omit `fileName` and always buffer in memory, since their finalize path reads
|
||||||
|
* the blob directly to attach the webcam track.
|
||||||
|
*/
|
||||||
|
export function createRecorderHandle(
|
||||||
|
stream: MediaStream,
|
||||||
|
options: MediaRecorderOptions,
|
||||||
|
fileName?: string,
|
||||||
|
): RecorderHandle {
|
||||||
|
const recorder = new MediaRecorder(stream, options);
|
||||||
|
const mimeType = options.mimeType || "video/webm";
|
||||||
|
const api = window.electronAPI;
|
||||||
|
|
||||||
|
// Chunks held in memory: everything before the stream opens, plus everything
|
||||||
|
// when not streaming at all. On a successful open these flush to disk and are
|
||||||
|
// dropped; on open failure they remain as the complete fallback recording.
|
||||||
|
const memoryChunks: Blob[] = [];
|
||||||
|
let mode: "pending" | "streaming" | "buffering" = fileName ? "pending" : "buffering";
|
||||||
|
let streamOpened = false;
|
||||||
|
let appendError: Error | null = null;
|
||||||
|
|
||||||
|
// Serialize chunk writes so they land on disk in arrival order, and so stop
|
||||||
|
// can await every in-flight write before the main process closes the stream
|
||||||
|
// (otherwise a late chunk arrives after close and truncates the recording).
|
||||||
|
let writeChain: Promise<void> = Promise.resolve();
|
||||||
|
const enqueueWrite = (chunk: Blob) => {
|
||||||
|
writeChain = writeChain.then(async () => {
|
||||||
|
if (appendError || !fileName || !api?.appendRecordingChunk) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Capture both outcomes — a `{ success: false }` result and an outright
|
||||||
|
// rejection (channel/handler error) — into appendError, so writeChain
|
||||||
|
// never rejects and isStreaming() stays consistent after a failure.
|
||||||
|
try {
|
||||||
|
const buffer = await chunk.arrayBuffer();
|
||||||
|
const result = await api.appendRecordingChunk(fileName, buffer);
|
||||||
|
if (!result.success) {
|
||||||
|
appendError = new Error(result.error ?? "Failed to write recording chunk to disk");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
appendError = error instanceof Error ? error : new Error(String(error));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Require BOTH stream IPC methods before attempting to stream. If only
|
||||||
|
// openRecordingStream exists (renderer/main version skew), streaming would
|
||||||
|
// open but every append would silently no-op, saving an empty file — so in
|
||||||
|
// that case fall through to in-memory buffering instead.
|
||||||
|
const openPromise: Promise<{ success: boolean; error?: string }> =
|
||||||
|
fileName !== undefined &&
|
||||||
|
typeof api?.openRecordingStream === "function" &&
|
||||||
|
typeof api?.appendRecordingChunk === "function"
|
||||||
|
? api.openRecordingStream(fileName)
|
||||||
|
: Promise.resolve({ success: false });
|
||||||
|
|
||||||
|
void openPromise.then(
|
||||||
|
(result) => {
|
||||||
|
if (result.success) {
|
||||||
|
streamOpened = true;
|
||||||
|
mode = "streaming";
|
||||||
|
for (const chunk of memoryChunks) {
|
||||||
|
enqueueWrite(chunk);
|
||||||
|
}
|
||||||
|
memoryChunks.length = 0;
|
||||||
|
} else {
|
||||||
|
mode = "buffering";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
// The IPC call itself rejected (channel or handler error). Treat it the
|
||||||
|
// same as a failed open: keep buffering in memory so nothing is lost.
|
||||||
|
mode = "buffering";
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const recordedBlobPromise = new Promise<Blob>((resolve, reject) => {
|
||||||
|
recorder.ondataavailable = (event: BlobEvent) => {
|
||||||
|
if (!event.data || event.data.size === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mode === "streaming") {
|
||||||
|
enqueueWrite(event.data);
|
||||||
|
} else {
|
||||||
|
// "pending" (stream not open yet) or "buffering" (not streaming).
|
||||||
|
memoryChunks.push(event.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
recorder.onerror = () => {
|
||||||
|
reject(new Error("Recording failed"));
|
||||||
|
};
|
||||||
|
|
||||||
|
recorder.onstop = () => {
|
||||||
|
resolve(finalizeBlob());
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
async function finalizeBlob(): Promise<Blob> {
|
||||||
|
// Wait for the open attempt to settle so its flush (or fallback switch) has
|
||||||
|
// been applied, then for every queued write to land, so we never resolve
|
||||||
|
// while chunks are still in flight to the about-to-close disk stream.
|
||||||
|
await openPromise.catch(() => undefined);
|
||||||
|
await writeChain;
|
||||||
|
if (appendError) {
|
||||||
|
throw appendError;
|
||||||
|
}
|
||||||
|
if (mode === "streaming") {
|
||||||
|
return new Blob([], { type: mimeType });
|
||||||
|
}
|
||||||
|
return new Blob(memoryChunks, { type: mimeType });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function discard(): Promise<void> {
|
||||||
|
if (streamOpened && fileName && api?.closeRecordingStream) {
|
||||||
|
await api.closeRecordingStream(fileName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recorder.start(RECORDER_TIMESLICE_MS);
|
||||||
|
return {
|
||||||
|
recorder,
|
||||||
|
recordedBlobPromise,
|
||||||
|
isStreaming: () => mode === "streaming" && !appendError,
|
||||||
|
discard,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from "@/lib/nativeWindowsRecording";
|
} from "@/lib/nativeWindowsRecording";
|
||||||
import type { CursorCaptureMode, RecordedVideoAssetInput } from "@/lib/recordingSession";
|
import type { CursorCaptureMode, RecordedVideoAssetInput } from "@/lib/recordingSession";
|
||||||
import { requestCameraAccess } from "@/lib/requestCameraAccess";
|
import { requestCameraAccess } from "@/lib/requestCameraAccess";
|
||||||
|
import { createRecorderHandle, type RecorderHandle } from "./recorderHandle";
|
||||||
|
|
||||||
const TARGET_FRAME_RATE = 60;
|
const TARGET_FRAME_RATE = 60;
|
||||||
const MIN_FRAME_RATE = 30;
|
const MIN_FRAME_RATE = 30;
|
||||||
@@ -34,7 +35,6 @@ const DEFAULT_HEIGHT = 1080;
|
|||||||
|
|
||||||
const CODEC_ALIGNMENT = 2;
|
const CODEC_ALIGNMENT = 2;
|
||||||
|
|
||||||
const RECORDER_TIMESLICE_MS = 1000;
|
|
||||||
const BITS_PER_MEGABIT = 1_000_000;
|
const BITS_PER_MEGABIT = 1_000_000;
|
||||||
const CHROME_MEDIA_SOURCE = "desktop";
|
const CHROME_MEDIA_SOURCE = "desktop";
|
||||||
const RECORDING_FILE_PREFIX = "recording-";
|
const RECORDING_FILE_PREFIX = "recording-";
|
||||||
@@ -74,11 +74,6 @@ type UseScreenRecorderReturn = {
|
|||||||
setCursorCaptureMode: (mode: CursorCaptureMode) => void;
|
setCursorCaptureMode: (mode: CursorCaptureMode) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type RecorderHandle = {
|
|
||||||
recorder: MediaRecorder;
|
|
||||||
recordedBlobPromise: Promise<Blob>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type NativeWindowsRecordingHandle = {
|
type NativeWindowsRecordingHandle = {
|
||||||
recordingId: number;
|
recordingId: number;
|
||||||
finalizing: boolean;
|
finalizing: boolean;
|
||||||
@@ -92,28 +87,6 @@ type NativeMacRecordingHandle = {
|
|||||||
paused: boolean;
|
paused: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function createRecorderHandle(stream: MediaStream, options: MediaRecorderOptions): RecorderHandle {
|
|
||||||
const recorder = new MediaRecorder(stream, options);
|
|
||||||
const chunks: Blob[] = [];
|
|
||||||
const mimeType = options.mimeType || "video/webm";
|
|
||||||
const recordedBlobPromise = new Promise<Blob>((resolve, reject) => {
|
|
||||||
recorder.ondataavailable = (event: BlobEvent) => {
|
|
||||||
if (event.data && event.data.size > 0) {
|
|
||||||
chunks.push(event.data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
recorder.onerror = () => {
|
|
||||||
reject(new Error("Recording failed"));
|
|
||||||
};
|
|
||||||
recorder.onstop = () => {
|
|
||||||
resolve(new Blob(chunks, { type: mimeType }));
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
recorder.start(RECORDER_TIMESLICE_MS);
|
|
||||||
return { recorder, recordedBlobPromise };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useScreenRecorder(): UseScreenRecorderReturn {
|
export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||||
const t = useScopedT("editor");
|
const t = useScopedT("editor");
|
||||||
const [recording, setRecording] = useState(false);
|
const [recording, setRecording] = useState(false);
|
||||||
@@ -355,46 +328,65 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
window.electronAPI?.setRecordingState(false);
|
window.electronAPI?.setRecordingState(false);
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
// Each disk stream must end up either saved or explicitly discarded.
|
||||||
|
// store-recorded-session finalizes the streams included in a successful
|
||||||
|
// save; the finally block discards everything else.
|
||||||
|
let storeSucceeded = false;
|
||||||
|
let webcamIncludedInSave = false;
|
||||||
try {
|
try {
|
||||||
const screenBlob = await activeScreenRecorder.recordedBlobPromise;
|
const screenBlob = await activeScreenRecorder.recordedBlobPromise;
|
||||||
if (discardRecordingId.current === activeRecordingId) {
|
if (discardRecordingId.current === activeRecordingId) {
|
||||||
window.electronAPI?.discardCursorTelemetry(activeRecordingId);
|
window.electronAPI?.discardCursorTelemetry(activeRecordingId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (screenBlob.size === 0) {
|
// When streaming succeeded the blob is empty — the data is already on disk.
|
||||||
|
if (!activeScreenRecorder.isStreaming() && screenBlob.size === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fixedScreenBlob = await fixWebmDuration(screenBlob, duration);
|
|
||||||
let fixedWebcamBlob: Blob | null = null;
|
|
||||||
if (activeWebcamRecorder) {
|
|
||||||
const webcamBlob = await activeWebcamRecorder.recordedBlobPromise.catch(() => null);
|
|
||||||
if (webcamBlob && webcamBlob.size > 0) {
|
|
||||||
fixedWebcamBlob = await fixWebmDuration(webcamBlob, duration);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const screenFileName = `${RECORDING_FILE_PREFIX}${activeRecordingId}${VIDEO_FILE_EXTENSION}`;
|
const screenFileName = `${RECORDING_FILE_PREFIX}${activeRecordingId}${VIDEO_FILE_EXTENSION}`;
|
||||||
const webcamFileName = `${RECORDING_FILE_PREFIX}${activeRecordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`;
|
const webcamFileName = `${RECORDING_FILE_PREFIX}${activeRecordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`;
|
||||||
|
|
||||||
|
// Only fix duration / convert to ArrayBuffer for in-memory data;
|
||||||
|
// streamed recordings are patched on disk by the main process.
|
||||||
|
let screenVideoData: ArrayBuffer = new ArrayBuffer(0);
|
||||||
|
if (!activeScreenRecorder.isStreaming() && screenBlob.size > 0) {
|
||||||
|
const fixedScreenBlob = await fixWebmDuration(screenBlob, duration);
|
||||||
|
screenVideoData = await fixedScreenBlob.arrayBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
let webcamVideoData: ArrayBuffer | undefined;
|
||||||
|
if (activeWebcamRecorder) {
|
||||||
|
const webcamBlob = await activeWebcamRecorder.recordedBlobPromise.catch(() => null);
|
||||||
|
if (!activeWebcamRecorder.isStreaming() && webcamBlob && webcamBlob.size > 0) {
|
||||||
|
const fixedWebcamBlob = await fixWebmDuration(webcamBlob, duration);
|
||||||
|
webcamVideoData = await fixedWebcamBlob.arrayBuffer();
|
||||||
|
} else if (activeWebcamRecorder.isStreaming()) {
|
||||||
|
webcamVideoData = new ArrayBuffer(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
webcamIncludedInSave = webcamVideoData !== undefined;
|
||||||
|
|
||||||
const result = await window.electronAPI.storeRecordedSession({
|
const result = await window.electronAPI.storeRecordedSession({
|
||||||
screen: {
|
screen: {
|
||||||
videoData: await fixedScreenBlob.arrayBuffer(),
|
videoData: screenVideoData,
|
||||||
fileName: screenFileName,
|
fileName: screenFileName,
|
||||||
},
|
},
|
||||||
webcam: fixedWebcamBlob
|
webcam:
|
||||||
? {
|
webcamVideoData !== undefined
|
||||||
videoData: await fixedWebcamBlob.arrayBuffer(),
|
? { videoData: webcamVideoData, fileName: webcamFileName }
|
||||||
fileName: webcamFileName,
|
: undefined,
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
createdAt: activeRecordingId,
|
createdAt: activeRecordingId,
|
||||||
cursorCaptureMode,
|
cursorCaptureMode,
|
||||||
|
durationMs: duration,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
console.error("Failed to store recording session:", result.message);
|
console.error("Failed to store recording session:", result.message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// store-recorded-session has flushed and closed the saved streams.
|
||||||
|
storeSucceeded = true;
|
||||||
|
|
||||||
if (result.session) {
|
if (result.session) {
|
||||||
await window.electronAPI.setCurrentRecordingSession(result.session);
|
await window.electronAPI.setCurrentRecordingSession(result.session);
|
||||||
@@ -406,6 +398,16 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error saving recording:", error);
|
console.error("Error saving recording:", error);
|
||||||
} finally {
|
} finally {
|
||||||
|
// Discard any recorder whose data was not part of a successful save
|
||||||
|
// — a discarded run, a failed save, or a webcam whose disk write
|
||||||
|
// failed (so it was omitted while the screen still saved) — so no
|
||||||
|
// stream or partial file is left open or orphaned.
|
||||||
|
if (!storeSucceeded) {
|
||||||
|
await activeScreenRecorder.discard().catch(() => undefined);
|
||||||
|
}
|
||||||
|
if (activeWebcamRecorder && !(storeSucceeded && webcamIncludedInSave)) {
|
||||||
|
await activeWebcamRecorder.discard().catch(() => undefined);
|
||||||
|
}
|
||||||
if (finalizingRecordingId.current === activeRecordingId) {
|
if (finalizingRecordingId.current === activeRecordingId) {
|
||||||
finalizingRecordingId.current = null;
|
finalizingRecordingId.current = null;
|
||||||
}
|
}
|
||||||
@@ -1336,13 +1338,17 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
|
|
||||||
recordingId.current = Date.now();
|
recordingId.current = Date.now();
|
||||||
const activeRecordingId = recordingId.current;
|
const activeRecordingId = recordingId.current;
|
||||||
screenRecorder.current = createRecorderHandle(stream.current, {
|
screenRecorder.current = createRecorderHandle(
|
||||||
mimeType,
|
stream.current,
|
||||||
videoBitsPerSecond,
|
{
|
||||||
...(hasAudio
|
mimeType,
|
||||||
? { audioBitsPerSecond: systemAudioTrack ? AUDIO_BITRATE_SYSTEM : AUDIO_BITRATE_VOICE }
|
videoBitsPerSecond,
|
||||||
: {}),
|
...(hasAudio
|
||||||
});
|
? { audioBitsPerSecond: systemAudioTrack ? AUDIO_BITRATE_SYSTEM : AUDIO_BITRATE_VOICE }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
`${RECORDING_FILE_PREFIX}${activeRecordingId}${VIDEO_FILE_EXTENSION}`,
|
||||||
|
);
|
||||||
screenRecorder.current.recorder.addEventListener(
|
screenRecorder.current.recorder.addEventListener(
|
||||||
"error",
|
"error",
|
||||||
() => {
|
() => {
|
||||||
@@ -1352,10 +1358,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (webcamStream.current) {
|
if (webcamStream.current) {
|
||||||
webcamRecorder.current = createRecorderHandle(webcamStream.current, {
|
webcamRecorder.current = createRecorderHandle(
|
||||||
mimeType,
|
webcamStream.current,
|
||||||
videoBitsPerSecond: Math.min(videoBitsPerSecond, BITRATE_BASE),
|
{ mimeType, videoBitsPerSecond: Math.min(videoBitsPerSecond, BITRATE_BASE) },
|
||||||
});
|
`${RECORDING_FILE_PREFIX}${activeRecordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
accumulatedDurationMs.current = 0;
|
accumulatedDurationMs.current = 0;
|
||||||
|
|||||||
@@ -20,6 +20,14 @@ export interface StoreRecordedSessionInput {
|
|||||||
webcam?: RecordedVideoAssetInput;
|
webcam?: RecordedVideoAssetInput;
|
||||||
createdAt?: number;
|
createdAt?: number;
|
||||||
cursorCaptureMode?: CursorCaptureMode;
|
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 {
|
export function normalizeCursorCaptureMode(value: unknown): CursorCaptureMode | undefined {
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: "jsdom",
|
environment: "jsdom",
|
||||||
include: ["src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
|
include: ["{src,electron}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
|
||||||
exclude: ["src/**/*.browser.test.{ts,tsx}"],
|
exclude: ["src/**/*.browser.test.{ts,tsx}"],
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
|
|||||||
Reference in New Issue
Block a user