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
9 changes: 9 additions & 0 deletions electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
revealInFolder: (
filePath: string,
Expand Down
131 changes: 130 additions & 1 deletion electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment on lines +45 to +49
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate the headless CLI args before enabling this path.

Right now --export without --output quietly falls back to the normal app, and unknown --format / --quality values are just cast through. In automation that's kinda cursed: a typo can open the UI or send MP4 bytes to a .gif path. 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 HEADLESS_EXPORT_PROJECT = getCliArg("export");
const HEADLESS_EXPORT_OUTPUT = getCliArg("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;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/main.ts` around lines 45 - 49, Ensure headless export args are
validated and fail fast: when HEADLESS_EXPORT_PROJECT (from getCliArg("export"))
is present require HEADLESS_EXPORT_OUTPUT (from getCliArg("output")) and
validate HEADLESS_EXPORT_FORMAT and HEADLESS_EXPORT_QUALITY values before
setting IS_HEADLESS_EXPORT; if format is not exactly "mp4" or "gif" or quality
is not "good" | "medium" | "source", log a clear error (including the bad value
and accepted values) and exit process with non‑zero status so the headless path
is not enabled accidentally.


if (IS_HEADLESS_EXPORT) {
// Force HEADLESS=true so createEditorWindow uses `show: false`.
process.env.HEADLESS = "true";
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Initialize headless mode before window module reads env

Setting process.env.HEADLESS here is too late for the --export path because main.ts has already imported createEditorWindow from electron/windows.ts, and that module snapshots const HEADLESS = process.env["HEADLESS"] === "true" at import time. In runs where the env var was not pre-set, the editor window is still constructed with show: true, so the supposedly headless CLI export can still open/focus a UI window.

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
Expand Down Expand Up @@ -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,
);
}
13 changes: 13 additions & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: <TData>(request: NativeBridgeRequest) => {
Expand Down Expand Up @@ -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");
},
Expand Down
110 changes: 110 additions & 0 deletions src/components/video-editor/VideoEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Fail headless export when project restore is invalid

applyLoadedProject returns false for invalid project payloads, but this headless path ignores that result and proceeds to export anyway. That means a malformed .openscreen file can lead to exporting stale/default loaded media (or waiting until timeout) instead of failing fast for bad input, which is risky for batch/CI automation correctness.

Useful? React with 👍 / 👎.

}
Comment on lines +1972 to +1977
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Don't swallow headless setup failures here.

applyLoadedProject() can return false, the 60s readiness loop can expire, and handleExport() can still no-op on “video not ready”. This callback just logs and returns, so the main process only learns about the failure via its 10-minute watchdog. For CI, that's lowkey brutal — this needs an explicit failure path back to main.

🚨 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 reportHeadlessExportFailure so the app can exit immediately with a non-zero code.

Also applies to: 1981-2012

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/video-editor/VideoEditor.tsx` around lines 1972 - 1977, The
callback that handles payload.project must not swallow headless setup failures:
check the boolean result of applyLoadedProject (used in
VideoEditor.applyLoadedProject via applyLoadedProjectRef.current) and if it
returns false or throws, immediately report the failure to the main process via
a small IPC (e.g., reportHeadlessExportFailure) instead of merely logging and
returning; update the same pattern in the other callback range (the block
spanning the nearby lines 1981–2012) to call the preload IPC on failure and
rethrow or abort the export flow so the app exits with a non-zero code, and add
corresponding IPC handlers in preload/main to receive
reportHeadlessExportFailure and exit/return failure to CI.


// 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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Headless GIF export ignores the project's aspect ratio.

This always forces 1280x720 + "medium", so portrait and square projects will export as 16:9 in CLI mode even after you just applied the saved editor state. Reuse the same GIF dimension calculation as the interactive path once the video is ready.

🎞️ 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 cropRegion, aspectRatio, and gifSizePreset the same way you're already stabilizing applyLoadedProject and handleExport.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/video-editor/VideoEditor.tsx` around lines 1992 - 2003, The
headless GIF export hardcodes gifConfig width/height and sizePreset (1280x720,
"medium") in ExportSettings, causing incorrect aspect ratios for portrait/square
projects; update the headless branch that sets payload.format === "gif" to
compute gifConfig using the same sizing logic used by the interactive path
(reuse the dimension calculation used in applyLoadedProject / handleExport),
pulling the current cropRegion, aspectRatio, and gifSizePreset values instead of
hardcoded values, and populate gifConfig.width, gifConfig.height and sizePreset
accordingly so CLI exports match editor state.

: 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",
Expand Down
Loading