727e395fcf
Recordings longer than ~10 minutes silently fail to save (#616). The renderer buffers the whole WebM as a Blob[], then on stop makes several in-memory copies (fixWebmDuration -> arrayBuffer -> Buffer.from) before writing. A long 1080p recording duplicates hundreds of MB several times in the renderer, exceeds Electron's memory limit, and the renderer crashes silently with no file saved. Two changes: 1. Stream chunks to disk (originally @Amanuel2x's contribution in #617). Open an fs.WriteStream in the main process at recording start and send each ~1s ondataavailable chunk straight to disk over two new IPC calls (open-recording-stream, append-recording-chunk), so the renderer never holds more than a single chunk. A full in-memory fallback is preserved for environments where the IPC stream cannot open. 2. Patch the WebM Duration header on disk after the stream closes. Browser MediaRecorder writes WebM with no Duration element, so streamed files save with duration=N/A and the editor's seek bar, timeline, and any scrub/trim break. A new electron/recording/webm-duration.ts module rewrites the Duration element, writing to a temp file and renaming in place so a crash mid-write cannot corrupt the recording. Streaming is opt-in: the screen recorder and the browser-only webcam recorder stream to disk; native-capture webcam sidecars (Windows, macOS) keep buffering in-memory, since their finalize path reads the recorder blob directly to attach the webcam track. Verified: tsc --noEmit clean; biome clean; vitest 166/166. Closes #616 Supersedes #617 Co-Authored-By: Amanuel <amanuel@localboostnetworking.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
254 lines
9.6 KiB
TypeScript
254 lines
9.6 KiB
TypeScript
import { contextBridge, ipcRenderer } from "electron";
|
|
import type { NativeMacRecordingRequest } from "../src/lib/nativeMacRecording";
|
|
import type { NativeWindowsRecordingRequest } from "../src/lib/nativeWindowsRecording";
|
|
import type { RecordingSession, StoreRecordedSessionInput } from "../src/lib/recordingSession";
|
|
import { NATIVE_BRIDGE_CHANNEL, type NativeBridgeRequest } from "../src/native/contracts";
|
|
|
|
// Asset base URL is passed from the main process via webPreferences.additionalArguments
|
|
// (see windows.ts). Sandboxed preloads cannot import node:path / node:url, so we
|
|
// can't compute it here.
|
|
const ASSET_BASE_URL_ARG_PREFIX = "--asset-base-url=";
|
|
const assetBaseUrlArg = process.argv.find((arg) => arg.startsWith(ASSET_BASE_URL_ARG_PREFIX));
|
|
const assetBaseUrl = assetBaseUrlArg ? assetBaseUrlArg.slice(ASSET_BASE_URL_ARG_PREFIX.length) : "";
|
|
|
|
contextBridge.exposeInMainWorld("electronAPI", {
|
|
assetBaseUrl,
|
|
invokeNativeBridge: <TData>(request: NativeBridgeRequest) => {
|
|
return ipcRenderer.invoke(NATIVE_BRIDGE_CHANNEL, request) as Promise<TData>;
|
|
},
|
|
hudOverlayHide: () => {
|
|
ipcRenderer.send("hud-overlay-hide");
|
|
},
|
|
hudOverlayClose: () => {
|
|
ipcRenderer.send("hud-overlay-close");
|
|
},
|
|
setHudOverlayIgnoreMouseEvents: (ignore: boolean) => {
|
|
ipcRenderer.send("hud-overlay-ignore-mouse-events", ignore);
|
|
},
|
|
moveHudOverlayBy: (deltaX: number, deltaY: number) => {
|
|
ipcRenderer.send("hud-overlay-move-by", deltaX, deltaY);
|
|
},
|
|
getSources: async (opts: Electron.SourcesOptions) => {
|
|
return await ipcRenderer.invoke("get-sources", opts);
|
|
},
|
|
switchToEditor: () => {
|
|
return ipcRenderer.invoke("switch-to-editor");
|
|
},
|
|
switchToHud: () => {
|
|
return ipcRenderer.invoke("switch-to-hud");
|
|
},
|
|
startNewRecording: () => {
|
|
return ipcRenderer.invoke("start-new-recording");
|
|
},
|
|
openSourceSelector: () => {
|
|
return ipcRenderer.invoke("open-source-selector");
|
|
},
|
|
selectSource: (source: ProcessedDesktopSource) => {
|
|
return ipcRenderer.invoke("select-source", source);
|
|
},
|
|
getSelectedSource: () => {
|
|
return ipcRenderer.invoke("get-selected-source");
|
|
},
|
|
requestCameraAccess: () => {
|
|
return ipcRenderer.invoke("request-camera-access");
|
|
},
|
|
requestScreenAccess: () => {
|
|
return ipcRenderer.invoke("request-screen-access");
|
|
},
|
|
requestNativeMacCursorAccess: () => {
|
|
return ipcRenderer.invoke("request-native-mac-cursor-access");
|
|
},
|
|
storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => {
|
|
return ipcRenderer.invoke("store-recorded-video", videoData, fileName);
|
|
},
|
|
storeRecordedSession: (payload: StoreRecordedSessionInput) => {
|
|
return ipcRenderer.invoke("store-recorded-session", payload);
|
|
},
|
|
openRecordingStream: (recordingId: number, fileName: string) => {
|
|
return ipcRenderer.invoke("open-recording-stream", recordingId, fileName);
|
|
},
|
|
appendRecordingChunk: (recordingId: number, chunk: ArrayBuffer) => {
|
|
return ipcRenderer.invoke("append-recording-chunk", recordingId, chunk);
|
|
},
|
|
|
|
getRecordedVideoPath: () => {
|
|
return ipcRenderer.invoke("get-recorded-video-path");
|
|
},
|
|
setRecordingState: (
|
|
recording: boolean,
|
|
recordingId?: number,
|
|
cursorCaptureMode?: import("../src/lib/recordingSession").CursorCaptureMode,
|
|
) => {
|
|
return ipcRenderer.invoke("set-recording-state", recording, recordingId, cursorCaptureMode);
|
|
},
|
|
isNativeWindowsCaptureAvailable: () => {
|
|
return ipcRenderer.invoke("is-native-windows-capture-available");
|
|
},
|
|
isNativeMacCaptureAvailable: () => {
|
|
return ipcRenderer.invoke("is-native-mac-capture-available");
|
|
},
|
|
startNativeWindowsRecording: (request: NativeWindowsRecordingRequest) => {
|
|
return ipcRenderer.invoke("start-native-windows-recording", request);
|
|
},
|
|
stopNativeWindowsRecording: (discard?: boolean) => {
|
|
return ipcRenderer.invoke("stop-native-windows-recording", discard);
|
|
},
|
|
pauseNativeWindowsRecording: () => {
|
|
return ipcRenderer.invoke("pause-native-windows-recording");
|
|
},
|
|
resumeNativeWindowsRecording: () => {
|
|
return ipcRenderer.invoke("resume-native-windows-recording");
|
|
},
|
|
startNativeMacRecording: (request: NativeMacRecordingRequest) => {
|
|
return ipcRenderer.invoke("start-native-mac-recording", request);
|
|
},
|
|
pauseNativeMacRecording: () => {
|
|
return ipcRenderer.invoke("pause-native-mac-recording");
|
|
},
|
|
resumeNativeMacRecording: () => {
|
|
return ipcRenderer.invoke("resume-native-mac-recording");
|
|
},
|
|
stopNativeMacRecording: (discard?: boolean) => {
|
|
return ipcRenderer.invoke("stop-native-mac-recording", discard);
|
|
},
|
|
attachNativeMacWebcamRecording: (payload: {
|
|
screenVideoPath: string;
|
|
recordingId: number;
|
|
webcam: { fileName: string; videoData: ArrayBuffer };
|
|
cursorCaptureMode?: import("../src/lib/recordingSession").CursorCaptureMode;
|
|
}) => {
|
|
return ipcRenderer.invoke("attach-native-mac-webcam-recording", payload);
|
|
},
|
|
getCursorTelemetry: (videoPath?: string) => {
|
|
return ipcRenderer.invoke("get-cursor-telemetry", videoPath);
|
|
},
|
|
discardCursorTelemetry: (recordingId: number) => {
|
|
return ipcRenderer.invoke("discard-cursor-telemetry", recordingId);
|
|
},
|
|
onStopRecordingFromTray: (callback: () => void) => {
|
|
const listener = () => callback();
|
|
ipcRenderer.on("stop-recording-from-tray", listener);
|
|
return () => ipcRenderer.removeListener("stop-recording-from-tray", listener);
|
|
},
|
|
openExternalUrl: (url: string) => {
|
|
return ipcRenderer.invoke("open-external-url", url);
|
|
},
|
|
pickExportSavePath: (fileName: string, exportFolder?: string) => {
|
|
return ipcRenderer.invoke("pick-export-save-path", fileName, exportFolder);
|
|
},
|
|
writeExportToPath: (videoData: ArrayBuffer, filePath: string) => {
|
|
return ipcRenderer.invoke("write-export-to-path", videoData, filePath);
|
|
},
|
|
openVideoFilePicker: () => {
|
|
return ipcRenderer.invoke("open-video-file-picker");
|
|
},
|
|
setCurrentVideoPath: (path: string) => {
|
|
return ipcRenderer.invoke("set-current-video-path", path);
|
|
},
|
|
setCurrentRecordingSession: (session: RecordingSession | null) => {
|
|
return ipcRenderer.invoke("set-current-recording-session", session);
|
|
},
|
|
getCurrentVideoPath: () => {
|
|
return ipcRenderer.invoke("get-current-video-path");
|
|
},
|
|
getCurrentRecordingSession: () => {
|
|
return ipcRenderer.invoke("get-current-recording-session");
|
|
},
|
|
readBinaryFile: (filePath: string) => {
|
|
return ipcRenderer.invoke("read-binary-file", filePath);
|
|
},
|
|
preparePreviewAudioTrack: (filePath: string) => {
|
|
return ipcRenderer.invoke("prepare-preview-audio-track", filePath);
|
|
},
|
|
clearCurrentVideoPath: () => {
|
|
return ipcRenderer.invoke("clear-current-video-path");
|
|
},
|
|
saveProjectFile: (projectData: unknown, suggestedName?: string, existingProjectPath?: string) => {
|
|
return ipcRenderer.invoke("save-project-file", projectData, suggestedName, existingProjectPath);
|
|
},
|
|
loadProjectFile: () => {
|
|
return ipcRenderer.invoke("load-project-file");
|
|
},
|
|
loadCurrentProjectFile: () => {
|
|
return ipcRenderer.invoke("load-current-project-file");
|
|
},
|
|
onMenuLoadProject: (callback: () => void) => {
|
|
const listener = () => callback();
|
|
ipcRenderer.on("menu-load-project", listener);
|
|
return () => ipcRenderer.removeListener("menu-load-project", listener);
|
|
},
|
|
onMenuSaveProject: (callback: () => void) => {
|
|
const listener = () => callback();
|
|
ipcRenderer.on("menu-save-project", listener);
|
|
return () => ipcRenderer.removeListener("menu-save-project", listener);
|
|
},
|
|
onMenuSaveProjectAs: (callback: () => void) => {
|
|
const listener = () => callback();
|
|
ipcRenderer.on("menu-save-project-as", listener);
|
|
return () => ipcRenderer.removeListener("menu-save-project-as", listener);
|
|
},
|
|
getPlatform: () => {
|
|
return ipcRenderer.invoke("get-platform");
|
|
},
|
|
revealInFolder: (filePath: string) => {
|
|
return ipcRenderer.invoke("reveal-in-folder", filePath);
|
|
},
|
|
getShortcuts: () => {
|
|
return ipcRenderer.invoke("get-shortcuts");
|
|
},
|
|
saveShortcuts: (shortcuts: unknown) => {
|
|
return ipcRenderer.invoke("save-shortcuts", shortcuts);
|
|
},
|
|
setLocale: (locale: string) => {
|
|
return ipcRenderer.invoke("set-locale", locale);
|
|
},
|
|
saveDiagnostic: (payload: {
|
|
error: string;
|
|
stack?: string;
|
|
projectState: unknown;
|
|
logs: string[];
|
|
}) => {
|
|
return ipcRenderer.invoke("save-diagnostic", payload);
|
|
},
|
|
setMicrophoneExpanded: (expanded: boolean) => {
|
|
ipcRenderer.send("hud:setMicrophoneExpanded", expanded);
|
|
},
|
|
setHasUnsavedChanges: (hasChanges: boolean) => {
|
|
ipcRenderer.send("set-has-unsaved-changes", hasChanges);
|
|
},
|
|
showCountdownOverlay: (value: number, runId: number) => {
|
|
return ipcRenderer.invoke("countdown-overlay-show", value, runId);
|
|
},
|
|
setCountdownOverlayValue: (value: number, runId: number) => {
|
|
return ipcRenderer.invoke("countdown-overlay-set-value", value, runId);
|
|
},
|
|
hideCountdownOverlay: (runId: number) => {
|
|
return ipcRenderer.invoke("countdown-overlay-hide", runId);
|
|
},
|
|
onCountdownOverlayValue: (callback: (value: number | null) => void) => {
|
|
const listener = (_event: unknown, value: number | null) => callback(value);
|
|
ipcRenderer.on("countdown-overlay-value", listener);
|
|
return () => ipcRenderer.removeListener("countdown-overlay-value", listener);
|
|
},
|
|
onRequestSaveBeforeClose: (callback: () => Promise<boolean> | boolean) => {
|
|
const listener = async () => {
|
|
try {
|
|
const shouldClose = await callback();
|
|
ipcRenderer.send("save-before-close-done", shouldClose);
|
|
} catch {
|
|
ipcRenderer.send("save-before-close-done", false);
|
|
}
|
|
};
|
|
ipcRenderer.on("request-save-before-close", listener);
|
|
return () => ipcRenderer.removeListener("request-save-before-close", listener);
|
|
},
|
|
onRequestCloseConfirm: (callback: () => void) => {
|
|
const listener = () => callback();
|
|
ipcRenderer.on("request-close-confirm", listener);
|
|
return () => ipcRenderer.removeListener("request-close-confirm", listener);
|
|
},
|
|
sendCloseConfirmResponse: (choice: "save" | "discard" | "cancel") => {
|
|
ipcRenderer.send("close-confirm-response", choice);
|
|
},
|
|
});
|