From 59fc8f1e9851c619f9e82ff0ec5830d48aafd954 Mon Sep 17 00:00:00 2001 From: Jeremy Andre Date: Thu, 21 May 2026 02:50:51 +0200 Subject: [PATCH] feat(electron): add --export CLI flag for headless project rendering Opens a hidden editor window, applies a .openscreen project, calls handleExport() directly, and writes the rendered MP4/GIF to the path passed via --output. No UI is shown, no save dialog opens. Usage: Openscreen --export --output [--format mp4|gif] [--quality good|medium|source] Implementation: - main.ts parses CLI args, forces HEADLESS=true, overrides the pick-export-save-path + write-export-to-path IPCs to route the blob to --output, then signals the renderer via "trigger-headless-export". - preload.ts exposes onHeadlessExportTrigger() with a typed payload. - VideoEditor.tsx listens for the trigger, scans annotations for fontFamily references and preloads them via addCustomFont() so ctx.font doesn't silently fall back to sans-serif, applies the project state, waits for video.readyState>=2, then calls handleExport(settings) directly. Enables batch rendering of OpenScreen projects from CI / shell scripts. --- electron/electron-env.d.ts | 9 ++ electron/main.ts | 131 +++++++++++++++++++- electron/preload.ts | 13 ++ src/components/video-editor/VideoEditor.tsx | 110 ++++++++++++++++ 4 files changed, 262 insertions(+), 1 deletion(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index abb688d1..e080b491 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -234,6 +234,15 @@ interface Window { onMenuLoadProject: (callback: () => void) => () => void; onMenuSaveProject: (callback: () => void) => () => void; onMenuSaveProjectAs: (callback: () => void) => () => void; + onHeadlessExportTrigger: ( + callback: (payload: { + projectPath: string; + project: unknown; + format: "mp4" | "gif"; + quality: "good" | "medium" | "source"; + outputPath: string; + }) => void, + ) => () => void; getPlatform: () => Promise; revealInFolder: ( filePath: string, diff --git a/electron/main.ts b/electron/main.ts index 3e2258f8..44a44e11 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -22,6 +22,42 @@ import { const __dirname = path.dirname(fileURLToPath(import.meta.url)); +// ────────────────────────────────────────────────────────────────────────── +// Headless export CLI mode. +// +// Usage: +// Openscreen --export --output +// [--format mp4|gif] [--quality good|medium|source] +// +// When --export is set, the app boots straight into a hidden editor window, +// sends a "trigger-headless-export" IPC to the renderer, intercepts the +// pick-export-save-path + write-export-to-path IPCs to route the resulting +// blob to --output, then quits. +// ────────────────────────────────────────────────────────────────────────── +function getCliArg(name: string): string | undefined { + const idx = process.argv.indexOf(`--${name}`); + if (idx < 0) return undefined; + const next = process.argv[idx + 1]; + if (!next || next.startsWith("--")) return undefined; + return next; +} + +const HEADLESS_EXPORT_PROJECT = getCliArg("export"); +const HEADLESS_EXPORT_OUTPUT = getCliArg("output"); +const HEADLESS_EXPORT_FORMAT = (getCliArg("format") ?? "mp4") as "mp4" | "gif"; +const HEADLESS_EXPORT_QUALITY = (getCliArg("quality") ?? "good") as "good" | "medium" | "source"; +const IS_HEADLESS_EXPORT = Boolean(HEADLESS_EXPORT_PROJECT && HEADLESS_EXPORT_OUTPUT); + +if (IS_HEADLESS_EXPORT) { + // Force HEADLESS=true so createEditorWindow uses `show: false`. + process.env.HEADLESS = "true"; + console.log(`[headless-export] project=${HEADLESS_EXPORT_PROJECT}`); + console.log(`[headless-export] output=${HEADLESS_EXPORT_OUTPUT}`); + console.log( + `[headless-export] format=${HEADLESS_EXPORT_FORMAT}, quality=${HEADLESS_EXPORT_QUALITY}`, + ); +} + // Use Screen & System Audio Recording permissions instead of CoreAudio Tap API on macOS. // CoreAudio Tap requires NSAudioCaptureUsageDescription in the parent app's Info.plist, // which doesn't work when running from a terminal/IDE during development, makes my life easier @@ -545,5 +581,98 @@ app.whenReady().then(async () => { }, switchToHudWrapper, ); - createWindow(); + + if (IS_HEADLESS_EXPORT) { + await runHeadlessExport(); + } else { + createWindow(); + } }); + +// ────────────────────────────────────────────────────────────────────────── +// Headless export driver: boots the editor window invisibly, signals the +// renderer to apply a project and trigger handleExport, then routes the +// resulting blob to --output and quits. +// ────────────────────────────────────────────────────────────────────────── +async function runHeadlessExport() { + if (!HEADLESS_EXPORT_PROJECT || !HEADLESS_EXPORT_OUTPUT) return; + + if (process.platform === "darwin") { + // No Dock icon, no menu-bar tray entry of our own. + app.dock?.hide(); + } + + // Read the project file. The editor needs its `screenVideoPath` to load + // the underlying recording. + let project: unknown; + try { + const content = await fs.readFile(HEADLESS_EXPORT_PROJECT, "utf-8"); + project = JSON.parse(content); + } catch (err) { + console.error(`[headless-export] failed to read project file:`, err); + app.exit(1); + return; + } + + // Intercept the renderer's "where do I save?" + "write the blob" IPCs so + // the export blob lands at --output without ever opening a save dialog. + ipcMain.removeHandler("pick-export-save-path"); + ipcMain.handle("pick-export-save-path", () => ({ + success: true, + canceled: false, + path: HEADLESS_EXPORT_OUTPUT, + })); + + ipcMain.removeHandler("write-export-to-path"); + ipcMain.handle("write-export-to-path", async (_e, buffer: ArrayBuffer, targetPath: string) => { + const writePath = targetPath || HEADLESS_EXPORT_OUTPUT!; + try { + await fs.writeFile(writePath, Buffer.from(buffer)); + console.log(`[headless-export] ✓ ${writePath}`); + // Defer quit so the renderer's "success" path can run. + setTimeout(() => app.quit(), 150); + return { success: true, path: writePath }; + } catch (err) { + console.error(`[headless-export] write failed:`, err); + setTimeout(() => app.exit(1), 150); + return { success: false, message: String(err) }; + } + }); + + // Boot the editor window. HEADLESS=true is set at module top so the + // window is created with `show: false`. + createEditorWindowWrapper(); + + if (!mainWindow) { + console.error("[headless-export] editor window failed to open"); + app.exit(1); + return; + } + + // Resize the offscreen window so the React layout has room to render the + // settings rail (default 1200×800 clips it). + mainWindow.setBounds({ x: 0, y: 0, width: 2560, height: 1440 }); + + mainWindow.webContents.once("did-finish-load", () => { + // Give React + nativeBridge a moment to wire up listeners before we + // fire the trigger. 1500ms is empirical; tune lower once stable. + setTimeout(() => { + mainWindow?.webContents.send("trigger-headless-export", { + projectPath: HEADLESS_EXPORT_PROJECT, + project, + format: HEADLESS_EXPORT_FORMAT, + quality: HEADLESS_EXPORT_QUALITY, + outputPath: HEADLESS_EXPORT_OUTPUT, + }); + }, 1500); + }); + + // Failsafe: if the export never completes within 10 min, bail. + setTimeout( + () => { + console.error("[headless-export] timed out after 10 minutes"); + app.exit(1); + }, + 10 * 60 * 1000, + ); +} diff --git a/electron/preload.ts b/electron/preload.ts index 361eb18d..07c0d78b 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -11,6 +11,14 @@ 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) : ""; +export interface HeadlessExportPayload { + projectPath: string; + project: unknown; + format: "mp4" | "gif"; + quality: "good" | "medium" | "source"; + outputPath: string; +} + contextBridge.exposeInMainWorld("electronAPI", { assetBaseUrl, invokeNativeBridge: (request: NativeBridgeRequest) => { @@ -175,6 +183,11 @@ contextBridge.exposeInMainWorld("electronAPI", { ipcRenderer.on("menu-save-project-as", listener); return () => ipcRenderer.removeListener("menu-save-project-as", listener); }, + onHeadlessExportTrigger: (callback: (payload: HeadlessExportPayload) => void) => { + const listener = (_event: unknown, payload: HeadlessExportPayload) => callback(payload); + ipcRenderer.on("trigger-headless-export", listener); + return () => ipcRenderer.removeListener("trigger-headless-export", listener); + }, getPlatform: () => { return ipcRenderer.invoke("get-platform"); }, diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index ce6314f1..ce9c4c59 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -1909,6 +1909,116 @@ export default function VideoEditor() { } }, []); + // ──────────────────────────────────────────────────────────────────────── + // Headless export hook (CLI mode). + // Main process sets `--export --output ` and fires the + // "trigger-headless-export" IPC after the editor finishes loading. We + // apply the project state, wait for the