-
Notifications
You must be signed in to change notification settings - Fork 2.5k
feat(electron): add --export CLI flag for headless project rendering #628
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,6 +22,42 @@ import { | |
|
|
||
| const __dirname = path.dirname(fileURLToPath(import.meta.url)); | ||
|
|
||
| // ────────────────────────────────────────────────────────────────────────── | ||
| // Headless export CLI mode. | ||
| // | ||
| // Usage: | ||
| // Openscreen --export <project.openscreen> --output <out.mp4|gif> | ||
| // [--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"; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Setting Useful? React with 👍 / 👎. |
||
| 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, | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1909,6 +1909,116 @@ export default function VideoEditor() { | |
| } | ||
| }, []); | ||
|
|
||
| // ──────────────────────────────────────────────────────────────────────── | ||
| // Headless export hook (CLI mode). | ||
| // Main process sets `--export <project> --output <out>` and fires the | ||
| // "trigger-headless-export" IPC after the editor finishes loading. We | ||
| // apply the project state, wait for the <video> to be decode-ready, | ||
| // then call handleExport directly. The blob lands at --output via | ||
| // the pick-export-save-path + write-export-to-path IPC overrides | ||
| // installed in main.ts (the editor doesn't need to know this). | ||
| // ──────────────────────────────────────────────────────────────────────── | ||
| const applyLoadedProjectRef = useRef(applyLoadedProject); | ||
| useEffect(() => { | ||
| applyLoadedProjectRef.current = applyLoadedProject; | ||
| }, [applyLoadedProject]); | ||
| const handleExportRef = useRef(handleExport); | ||
| useEffect(() => { | ||
| handleExportRef.current = handleExport; | ||
| }, [handleExport]); | ||
| const videoPlaybackRefRef = videoPlaybackRef; | ||
|
|
||
| useEffect(() => { | ||
| if (!window.electronAPI.onHeadlessExportTrigger) return; | ||
| const remove = window.electronAPI.onHeadlessExportTrigger(async (payload) => { | ||
| try { | ||
| console.log("[headless-export] trigger received", payload.outputPath); | ||
|
|
||
| // 1. Load any custom fonts referenced by annotations BEFORE applying | ||
| // the project. Without this, ctx.font silently falls back to a | ||
| // generic sans-serif when the canvas renderer draws annotations | ||
| // in a fresh Electron session (no custom-font localStorage entry). | ||
| try { | ||
| const proj = payload.project as { | ||
| editor?: { annotationRegions?: Array<{ style?: { fontFamily?: string } }> }; | ||
| } | null; | ||
| const families = new Set<string>(); | ||
| for (const a of proj?.editor?.annotationRegions ?? []) { | ||
| const f = a?.style?.fontFamily; | ||
| if (f && f !== "Inter") families.add(f); | ||
| } | ||
| const { addCustomFont, generateFontId } = await import("@/lib/customFonts"); | ||
| for (const family of families) { | ||
| const importUrl = `https://fonts.googleapis.com/css2?family=${encodeURIComponent( | ||
| family, | ||
| )}:wght@400;700&display=swap`; | ||
| try { | ||
| await addCustomFont({ | ||
| id: generateFontId(family), | ||
| name: family, | ||
| fontFamily: family, | ||
| importUrl, | ||
| }); | ||
| console.log(`[headless-export] loaded font: ${family}`); | ||
| } catch (e) { | ||
| console.warn(`[headless-export] font load failed: ${family}`, e); | ||
| } | ||
| } | ||
| } catch (e) { | ||
| console.warn("[headless-export] font preload step failed:", e); | ||
| } | ||
|
|
||
| // 2. Apply the project state (trim/zoom/annotations/etc). | ||
| if (payload.project) { | ||
| await applyLoadedProjectRef.current( | ||
| payload.project as Parameters<typeof applyLoadedProject>[0], | ||
| payload.projectPath, | ||
| ); | ||
|
Comment on lines
+1973
to
+1976
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
| } | ||
|
Comment on lines
+1972
to
+1977
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don't swallow headless setup failures here.
🚨 Fail fast instead of timing out- if (payload.project) {
- await applyLoadedProjectRef.current(
+ if (payload.project) {
+ const applied = await applyLoadedProjectRef.current(
payload.project as Parameters<typeof applyLoadedProject>[0],
payload.projectPath,
);
+ if (!applied) {
+ throw new Error("Project could not be loaded for headless export");
+ }
}
- const deadline = Date.now() + 60_000;
+ const deadline = Date.now() + 60_000;
+ let videoReady = false;
while (Date.now() < deadline) {
const v = videoPlaybackRefRef.current?.video;
- if (v && v.readyState >= 2 && v.duration > 0) break;
+ if (v && v.readyState >= 2 && v.duration > 0) {
+ videoReady = true;
+ break;
+ }
await new Promise((r) => setTimeout(r, 200));
}
+ if (!videoReady) {
+ throw new Error("Video never became ready for headless export");
+ }
...
} catch (err) {
console.error("[headless-export] failed:", err);
+ window.electronAPI.reportHeadlessExportFailure?.(
+ err instanceof Error ? err.message : String(err),
+ );
}You'll need a tiny preload/main IPC for Also applies to: 1981-2012 🤖 Prompt for AI Agents |
||
|
|
||
| // 2. Wait for <video> to be ready to decode (readyState >= 2) | ||
| // and for React to flush the project state into closures. | ||
| const deadline = Date.now() + 60_000; | ||
| while (Date.now() < deadline) { | ||
| const v = videoPlaybackRefRef.current?.video; | ||
| if (v && v.readyState >= 2 && v.duration > 0) break; | ||
| await new Promise((r) => setTimeout(r, 200)); | ||
| } | ||
| // One extra tick so the just-updated React state has been | ||
| // captured by the latest handleExport closure. | ||
| await new Promise((r) => setTimeout(r, 300)); | ||
|
|
||
| // 3. Construct settings & trigger the export directly. | ||
| const settings: ExportSettings = { | ||
| format: payload.format, | ||
| quality: payload.format === "mp4" ? payload.quality : undefined, | ||
| gifConfig: | ||
| payload.format === "gif" | ||
| ? { | ||
| frameRate: 30 as GifFrameRate, | ||
| loop: true, | ||
| sizePreset: "medium" as GifSizePreset, | ||
| width: 1280, | ||
| height: 720, | ||
| } | ||
|
Comment on lines
+1992
to
+2003
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Headless GIF export ignores the project's aspect ratio. This always forces 🎞️ Match the normal GIF sizing path- const settings: ExportSettings = {
+ const video = videoPlaybackRefRef.current?.video;
+ const sourceWidth = video?.videoWidth || 1920;
+ const sourceHeight = video?.videoHeight || 1080;
+ const effectiveSourceDimensions = calculateEffectiveSourceDimensions(
+ sourceWidth,
+ sourceHeight,
+ cropRegion,
+ );
+ const aspectRatioValue =
+ aspectRatio === "native"
+ ? getNativeAspectRatioValue(sourceWidth, sourceHeight, cropRegion)
+ : getAspectRatioValue(aspectRatio);
+ const gifDimensions = calculateOutputDimensions(
+ effectiveSourceDimensions.width,
+ effectiveSourceDimensions.height,
+ gifSizePreset,
+ GIF_SIZE_PRESETS,
+ aspectRatioValue,
+ );
+
+ const settings: ExportSettings = {
format: payload.format,
quality: payload.format === "mp4" ? payload.quality : undefined,
gifConfig:
payload.format === "gif"
? {
frameRate: 30 as GifFrameRate,
loop: true,
- sizePreset: "medium" as GifSizePreset,
- width: 1280,
- height: 720,
+ sizePreset: gifSizePreset,
+ width: gifDimensions.width,
+ height: gifDimensions.height,
}
: undefined,
};That probably wants refs for 🤖 Prompt for AI Agents |
||
| : undefined, | ||
| }; | ||
|
|
||
| console.log("[headless-export] calling handleExport", settings); | ||
| await handleExportRef.current(settings); | ||
| console.log("[headless-export] handleExport returned"); | ||
| } catch (err) { | ||
| console.error("[headless-export] failed:", err); | ||
| } | ||
| }); | ||
| return () => { | ||
| remove?.(); | ||
| }; | ||
| // We intentionally use refs for applyLoadedProject + handleExport so the | ||
| // listener stays stable; only the API binding triggers re-subscription. | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, []); | ||
|
|
||
| const handleSaveDiagnostic = useCallback(async () => { | ||
| const result = await window.electronAPI.saveDiagnostic({ | ||
| error: exportError ?? "Manual diagnostic export", | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Validate the headless CLI args before enabling this path.
Right now
--exportwithout--outputquietly falls back to the normal app, and unknown--format/--qualityvalues are just cast through. In automation that's kinda cursed: a typo can open the UI or send MP4 bytes to a.gifpath. Fail fast here instead of treating bad args as valid.🛠️ Possible guardrail
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); +const rawHeadlessExportFormat = getCliArg("format") ?? "mp4"; +const rawHeadlessExportQuality = getCliArg("quality") ?? "good"; +const IS_HEADLESS_EXPORT = + HEADLESS_EXPORT_PROJECT !== undefined || HEADLESS_EXPORT_OUTPUT !== undefined; + +if (IS_HEADLESS_EXPORT && (!HEADLESS_EXPORT_PROJECT || !HEADLESS_EXPORT_OUTPUT)) { + throw new Error("`--export` and `--output` must be provided together"); +} + +if (rawHeadlessExportFormat !== "mp4" && rawHeadlessExportFormat !== "gif") { + throw new Error(`Unsupported export format: ${rawHeadlessExportFormat}`); +} + +if ( + rawHeadlessExportQuality !== "good" && + rawHeadlessExportQuality !== "medium" && + rawHeadlessExportQuality !== "source" +) { + throw new Error(`Unsupported export quality: ${rawHeadlessExportQuality}`); +} + +const HEADLESS_EXPORT_FORMAT = rawHeadlessExportFormat; +const HEADLESS_EXPORT_QUALITY = rawHeadlessExportQuality;📝 Committable suggestion
🤖 Prompt for AI Agents