feat: integrate Electron for desktop app
- Add electron-vite + Electron Forge tooling - Create Electron main process with embedded Express server - Create preload script with native dialog IPC bridge - Refactor server.ts to export createExpressApp() (dual web/electron) - Adapt renderQueue.ts for packaged binaries + pre-built bundle - Add ensureBrowser() for Chrome Headless Shell pre-download - Add scripts/bundle-remotion.js for packaging - Data persists in ~/Library/Application Support/Bradly/ - Web mode preserved via npm run dev:web
This commit is contained in:
@@ -4,6 +4,7 @@ node_modules/
|
|||||||
# Build output
|
# Build output
|
||||||
build/
|
build/
|
||||||
dist/
|
dist/
|
||||||
|
out/
|
||||||
*.cjs
|
*.cjs
|
||||||
*.cjs.map
|
*.cjs.map
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* electron-vite Configuration — Bradly Desktop App
|
||||||
|
*
|
||||||
|
* Configures three build targets:
|
||||||
|
* - main: Electron main process (Node.js)
|
||||||
|
* - preload: Sandboxed bridge scripts
|
||||||
|
* - renderer: React app (browser)
|
||||||
|
*/
|
||||||
|
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
// ═══ Main Process (Node.js environment) ═══
|
||||||
|
main: {
|
||||||
|
plugins: [externalizeDepsPlugin()],
|
||||||
|
build: {
|
||||||
|
outDir: 'out/main',
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
index: path.resolve(__dirname, 'src/electron/main.ts'),
|
||||||
|
},
|
||||||
|
// Keep Remotion + Express packages external (they have native deps)
|
||||||
|
external: [
|
||||||
|
'@remotion/bundler',
|
||||||
|
'@remotion/renderer',
|
||||||
|
/^@remotion\/compositor/,
|
||||||
|
'express',
|
||||||
|
'multer',
|
||||||
|
'vite',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ═══ Preload Scripts (sandboxed bridge) ═══
|
||||||
|
preload: {
|
||||||
|
plugins: [externalizeDepsPlugin()],
|
||||||
|
build: {
|
||||||
|
outDir: 'out/preload',
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
index: path.resolve(__dirname, 'src/electron/preload.ts'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ═══ Renderer (React app — browser environment) ═══
|
||||||
|
renderer: {
|
||||||
|
root: '.',
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
build: {
|
||||||
|
outDir: 'out/renderer',
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
index: path.resolve(__dirname, 'index.html'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, '.'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
hmr: process.env.DISABLE_HMR !== 'true',
|
||||||
|
watch: process.env.DISABLE_HMR === 'true' ? null : {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Electron Forge Configuration — Bradly Desktop App
|
||||||
|
*
|
||||||
|
* Handles packaging and creating distributable installers.
|
||||||
|
* Currently targets macOS (.dmg) only.
|
||||||
|
*/
|
||||||
|
import type { ForgeConfig } from '@electron-forge/shared-types';
|
||||||
|
import { MakerDMG } from '@electron-forge/maker-dmg';
|
||||||
|
|
||||||
|
const config: ForgeConfig = {
|
||||||
|
packagerConfig: {
|
||||||
|
name: 'Bradly',
|
||||||
|
executableName: 'bradly',
|
||||||
|
asar: {
|
||||||
|
// Remotion compositor binaries MUST be outside app.asar
|
||||||
|
// because they are native executables that need direct filesystem access
|
||||||
|
unpack: '{**/node_modules/@remotion/compositor-*/**,**/node_modules/@remotion/renderer/**}',
|
||||||
|
},
|
||||||
|
// Ignore development files during packaging
|
||||||
|
ignore: [
|
||||||
|
/^\/src$/,
|
||||||
|
/^\/scripts$/,
|
||||||
|
/^\/uploads$/,
|
||||||
|
/^\/renders$/,
|
||||||
|
/^\/\.vscode$/,
|
||||||
|
/^\/\.git$/,
|
||||||
|
/^\/\.env/,
|
||||||
|
/^\/\.antigravity$/,
|
||||||
|
/^\/vite\.config\.ts$/,
|
||||||
|
/^\/electron\.vite\.config\.ts$/,
|
||||||
|
/^\/tsconfig\.json$/,
|
||||||
|
/^\/ABOUT\.md$/,
|
||||||
|
/^\/AGENTS\.md$/,
|
||||||
|
/^\/README\.md$/,
|
||||||
|
/^\/schema\.sql$/,
|
||||||
|
/^\/backend_endpoint\.py$/,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
makers: [
|
||||||
|
new MakerDMG({
|
||||||
|
format: 'ULFO',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
Generated
+5664
File diff suppressed because it is too large
Load Diff
+19
-7
@@ -1,15 +1,23 @@
|
|||||||
{
|
{
|
||||||
"name": "react-example",
|
"name": "bradly",
|
||||||
|
"productName": "Bradly",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"main": "out/main/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx server.ts",
|
"dev": "electron-vite dev",
|
||||||
"build": "vite build && esbuild server.ts --bundle --platform=node --format=cjs --packages=external --sourcemap --outfile=dist/server.cjs",
|
"dev:web": "tsx server.ts",
|
||||||
"start": "node dist/server.cjs",
|
"build": "electron-vite build",
|
||||||
|
"build:web": "vite build && esbuild server.ts --bundle --platform=node --format=cjs --packages=external --sourcemap --outfile=dist/server.cjs",
|
||||||
|
"start": "electron-vite preview",
|
||||||
|
"start:web": "node dist/server.cjs",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"clean": "rm -rf dist server.js",
|
"clean": "rm -rf dist out server.js",
|
||||||
"lint": "tsc --noEmit"
|
"lint": "tsc --noEmit",
|
||||||
|
"remotion:bundle": "node scripts/bundle-remotion.js",
|
||||||
|
"package": "npm run remotion:bundle && npm run build && electron-forge package",
|
||||||
|
"make": "npm run remotion:bundle && npm run build && electron-forge make"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
@@ -35,12 +43,16 @@
|
|||||||
"vite": "^6.2.3"
|
"vite": "^6.2.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@electron-forge/cli": "^7.11.2",
|
||||||
|
"@electron-forge/maker-dmg": "^7.11.2",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/file-saver": "^2.0.7",
|
"@types/file-saver": "^2.0.7",
|
||||||
"@types/multer": "^2.1.0",
|
"@types/multer": "^2.1.0",
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
"@types/papaparse": "^5.5.2",
|
"@types/papaparse": "^5.5.2",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
|
"electron": "^41.7.1",
|
||||||
|
"electron-vite": "^5.0.0",
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"tailwindcss": "^4.1.14",
|
"tailwindcss": "^4.1.14",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* Pre-build Remotion Bundle for Packaging
|
||||||
|
*
|
||||||
|
* This script runs BEFORE electron-forge packages the app.
|
||||||
|
* It pre-bundles the Remotion project so the packaged app
|
||||||
|
* doesn't need to call bundle() at runtime (which requires
|
||||||
|
* webpack and other heavy deps).
|
||||||
|
*
|
||||||
|
* Usage: node scripts/bundle-remotion.js
|
||||||
|
* Output: out/remotion-bundle/
|
||||||
|
*/
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const outDir = path.join(__dirname, '..', 'out', 'remotion-bundle');
|
||||||
|
|
||||||
|
// Clean previous bundle
|
||||||
|
if (fs.existsSync(outDir)) {
|
||||||
|
fs.rmSync(outDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('📦 Building Remotion bundle for packaging...');
|
||||||
|
console.log(' Entry: src/Root.tsx');
|
||||||
|
console.log(' Output:', outDir);
|
||||||
|
|
||||||
|
const { bundle } = require('@remotion/bundler');
|
||||||
|
const entryPoint = path.join(__dirname, '..', 'src', 'Root.tsx');
|
||||||
|
|
||||||
|
const bundlePath = await bundle({
|
||||||
|
entryPoint,
|
||||||
|
outDir,
|
||||||
|
webpackOverride: (config) => config,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Remotion bundle ready:', bundlePath);
|
||||||
|
|
||||||
|
// Verify the bundle exists
|
||||||
|
const indexPath = path.join(bundlePath, 'index.html');
|
||||||
|
if (!fs.existsSync(indexPath)) {
|
||||||
|
throw new Error(`Bundle verification failed: ${indexPath} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Bundle verified (index.html exists)');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('❌ Remotion bundle failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -6,7 +6,7 @@ import { createServer as createViteServer } from "vite";
|
|||||||
import multer from "multer";
|
import multer from "multer";
|
||||||
|
|
||||||
// ═══ Uploads directory ═══
|
// ═══ Uploads directory ═══
|
||||||
const UPLOADS_DIR = path.join(process.cwd(), "uploads");
|
const UPLOADS_DIR = process.env.BRADLY_UPLOADS_DIR || path.join(process.cwd(), "uploads");
|
||||||
if (!fs.existsSync(UPLOADS_DIR)) {
|
if (!fs.existsSync(UPLOADS_DIR)) {
|
||||||
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
|
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
|
||||||
}
|
}
|
||||||
@@ -40,6 +40,14 @@ const mediaUpload = multer({
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function startServer() {
|
async function startServer() {
|
||||||
|
const app = await createExpressApp();
|
||||||
|
const PORT = 3000;
|
||||||
|
app.listen(PORT, "0.0.0.0", () => {
|
||||||
|
console.log(`Server running on http://localhost:${PORT}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createExpressApp() {
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = 3000;
|
const PORT = 3000;
|
||||||
|
|
||||||
@@ -299,23 +307,26 @@ async function startServer() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== "production") {
|
if (!process.env.BRADLY_ELECTRON) {
|
||||||
const vite = await createViteServer({
|
if (process.env.NODE_ENV !== "production") {
|
||||||
server: { middlewareMode: true },
|
const vite = await createViteServer({
|
||||||
appType: "spa",
|
server: { middlewareMode: true },
|
||||||
});
|
appType: "spa",
|
||||||
app.use(vite.middlewares);
|
});
|
||||||
} else {
|
app.use(vite.middlewares);
|
||||||
const distPath = path.join(process.cwd(), "dist");
|
} else {
|
||||||
app.use(express.static(distPath));
|
const distPath = path.join(process.cwd(), "dist");
|
||||||
app.get("*", (req, res) => {
|
app.use(express.static(distPath));
|
||||||
res.sendFile(path.join(distPath, "index.html"));
|
app.get("*", (req, res) => {
|
||||||
});
|
res.sendFile(path.join(distPath, "index.html"));
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
app.listen(PORT, "0.0.0.0", () => {
|
return app;
|
||||||
console.log(`Server running on http://localhost:${PORT}`);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
startServer();
|
// Direct execution (web mode only)
|
||||||
|
if (!process.env.BRADLY_ELECTRON) {
|
||||||
|
startServer();
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
/**
|
||||||
|
* Electron Main Process — Bradly Desktop App
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* - Creates the BrowserWindow
|
||||||
|
* - Starts the embedded Express server
|
||||||
|
* - Pre-downloads Chrome Headless Shell for Remotion
|
||||||
|
* - Manages IPC handlers for native features
|
||||||
|
*/
|
||||||
|
import { app, BrowserWindow, ipcMain, dialog } from 'electron';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
// Signal to server.ts / renderQueue.ts that we're in Electron
|
||||||
|
process.env.BRADLY_ELECTRON = 'true';
|
||||||
|
|
||||||
|
const EXPRESS_PORT = 3000;
|
||||||
|
let mainWindow: BrowserWindow | null = null;
|
||||||
|
|
||||||
|
// ═══ Path Setup for Electron ═══
|
||||||
|
|
||||||
|
function setupPaths() {
|
||||||
|
const userDataPath = app.getPath('userData');
|
||||||
|
|
||||||
|
// Uploads and renders persist in the user's app data directory
|
||||||
|
const uploadsDir = path.join(userDataPath, 'uploads');
|
||||||
|
const rendersDir = path.join(userDataPath, 'renders');
|
||||||
|
|
||||||
|
// Create directories if they don't exist
|
||||||
|
for (const dir of [uploadsDir, rendersDir]) {
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.env.BRADLY_UPLOADS_DIR = uploadsDir;
|
||||||
|
process.env.BRADLY_RENDERS_DIR = rendersDir;
|
||||||
|
|
||||||
|
// In packaged app, configure Remotion paths
|
||||||
|
if (app.isPackaged) {
|
||||||
|
const resourcesPath = process.resourcesPath;
|
||||||
|
|
||||||
|
// Pre-built Remotion bundle (created during packaging)
|
||||||
|
const remotionBundle = path.join(resourcesPath, 'remotion-bundle');
|
||||||
|
if (fs.existsSync(remotionBundle)) {
|
||||||
|
process.env.BRADLY_REMOTION_BUNDLE = remotionBundle;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compositor binaries (unpacked from asar)
|
||||||
|
const platform = process.platform === 'darwin' ? 'darwin' : 'linux';
|
||||||
|
const arch = process.arch;
|
||||||
|
const compositorPath = path.join(
|
||||||
|
resourcesPath,
|
||||||
|
'app.asar.unpacked',
|
||||||
|
'node_modules',
|
||||||
|
`@remotion/compositor-${platform}-${arch}`,
|
||||||
|
);
|
||||||
|
if (fs.existsSync(compositorPath)) {
|
||||||
|
process.env.BRADLY_BINARIES_DIR = compositorPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('📂 User data:', userDataPath);
|
||||||
|
console.log('📂 Uploads:', uploadsDir);
|
||||||
|
console.log('📂 Renders:', rendersDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ Express Server ═══
|
||||||
|
|
||||||
|
async function startExpressServer(): Promise<void> {
|
||||||
|
const { createExpressApp } = await import('../../server');
|
||||||
|
const expressApp = await createExpressApp();
|
||||||
|
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
const server = expressApp.listen(EXPRESS_PORT, '127.0.0.1', () => {
|
||||||
|
console.log(`🚀 Express server on http://127.0.0.1:${EXPRESS_PORT}`);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
server.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ Window Management ═══
|
||||||
|
|
||||||
|
function createWindow() {
|
||||||
|
mainWindow = new BrowserWindow({
|
||||||
|
width: 1440,
|
||||||
|
height: 900,
|
||||||
|
minWidth: 1024,
|
||||||
|
minHeight: 600,
|
||||||
|
titleBarStyle: 'hiddenInset',
|
||||||
|
trafficLightPosition: { x: 15, y: 15 },
|
||||||
|
backgroundColor: '#0a0a0a',
|
||||||
|
show: false, // Show when ready to prevent visual flash
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, '../preload/index.js'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
sandbox: false, // Required for preload to access Node APIs
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show window when content is ready
|
||||||
|
mainWindow.once('ready-to-show', () => {
|
||||||
|
mainWindow?.show();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load the app
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
// In dev, electron-vite serves the renderer on a dev server URL
|
||||||
|
const rendererUrl = process.env.ELECTRON_RENDERER_URL || 'http://localhost:5173';
|
||||||
|
mainWindow.loadURL(rendererUrl);
|
||||||
|
// Open DevTools in dev mode
|
||||||
|
mainWindow.webContents.openDevTools({ mode: 'detach' });
|
||||||
|
} else {
|
||||||
|
// In production, load from the embedded Express server
|
||||||
|
// which serves the built renderer assets
|
||||||
|
mainWindow.loadURL(`http://127.0.0.1:${EXPRESS_PORT}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
mainWindow.on('closed', () => {
|
||||||
|
mainWindow = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ IPC Handlers ═══
|
||||||
|
|
||||||
|
function setupIPC() {
|
||||||
|
// Native save dialog
|
||||||
|
ipcMain.handle('dialog:save', async (_event, options) => {
|
||||||
|
if (!mainWindow) return null;
|
||||||
|
const result = await dialog.showSaveDialog(mainWindow, options);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Native open dialog
|
||||||
|
ipcMain.handle('dialog:open', async (_event, options) => {
|
||||||
|
if (!mainWindow) return null;
|
||||||
|
const result = await dialog.showOpenDialog(mainWindow, options);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
// App info
|
||||||
|
ipcMain.handle('app:info', () => ({
|
||||||
|
version: app.getVersion(),
|
||||||
|
name: app.getName(),
|
||||||
|
userData: app.getPath('userData'),
|
||||||
|
isPackaged: app.isPackaged,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ Remotion Browser Pre-download ═══
|
||||||
|
|
||||||
|
async function predownloadBrowser() {
|
||||||
|
try {
|
||||||
|
const { ensureBrowser } = await import('@remotion/renderer');
|
||||||
|
console.log('🌐 Ensuring Chrome Headless Shell is available...');
|
||||||
|
await ensureBrowser();
|
||||||
|
console.log('✅ Chrome Headless Shell ready');
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('⚠️ Failed to pre-download browser (renders may download on first use):', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ App Lifecycle ═══
|
||||||
|
|
||||||
|
app.whenReady().then(async () => {
|
||||||
|
// 1. Configure paths
|
||||||
|
setupPaths();
|
||||||
|
|
||||||
|
// 2. Setup IPC handlers
|
||||||
|
setupIPC();
|
||||||
|
|
||||||
|
// 3. Start Express server
|
||||||
|
try {
|
||||||
|
await startExpressServer();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ Failed to start Express server:', err);
|
||||||
|
app.quit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Create the main window
|
||||||
|
createWindow();
|
||||||
|
|
||||||
|
// 5. Pre-download Chrome Headless Shell in background
|
||||||
|
predownloadBrowser();
|
||||||
|
|
||||||
|
// macOS: re-create window when dock icon is clicked
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
|
createWindow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Quit when all windows are closed
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
app.quit();
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Electron Preload Script — Bradly Desktop App
|
||||||
|
*
|
||||||
|
* Exposes a safe, typed API to the renderer process via contextBridge.
|
||||||
|
* The renderer (React app) can access these via `window.electronAPI`.
|
||||||
|
*
|
||||||
|
* Security: contextIsolation is enabled, nodeIntegration is disabled.
|
||||||
|
* Only explicitly exposed functions are available to the renderer.
|
||||||
|
*/
|
||||||
|
import { contextBridge, ipcRenderer } from 'electron';
|
||||||
|
|
||||||
|
const electronAPI = {
|
||||||
|
// ─── Platform Detection ───
|
||||||
|
isElectron: true as const,
|
||||||
|
platform: process.platform,
|
||||||
|
|
||||||
|
// ─── Native Dialogs ───
|
||||||
|
|
||||||
|
/** Show a native save dialog and return the chosen path */
|
||||||
|
showSaveDialog: (options: Electron.SaveDialogOptions) =>
|
||||||
|
ipcRenderer.invoke('dialog:save', options) as Promise<Electron.SaveDialogReturnValue>,
|
||||||
|
|
||||||
|
/** Show a native open dialog and return the chosen paths */
|
||||||
|
showOpenDialog: (options: Electron.OpenDialogOptions) =>
|
||||||
|
ipcRenderer.invoke('dialog:open', options) as Promise<Electron.OpenDialogReturnValue>,
|
||||||
|
|
||||||
|
// ─── App Info ───
|
||||||
|
getAppInfo: () =>
|
||||||
|
ipcRenderer.invoke('app:info') as Promise<{
|
||||||
|
version: string;
|
||||||
|
name: string;
|
||||||
|
userData: string;
|
||||||
|
isPackaged: boolean;
|
||||||
|
}>,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Expose to the renderer process
|
||||||
|
contextBridge.exposeInMainWorld('electronAPI', electronAPI);
|
||||||
|
|
||||||
|
// TypeScript: Augment Window interface for consumers
|
||||||
|
export type ElectronAPI = typeof electronAPI;
|
||||||
@@ -53,7 +53,7 @@ export interface RenderJobCreateParams {
|
|||||||
// ═══ Constants ═══
|
// ═══ Constants ═══
|
||||||
|
|
||||||
const MAX_CONCURRENT = 1; // Remotion renders are CPU-intensive
|
const MAX_CONCURRENT = 1; // Remotion renders are CPU-intensive
|
||||||
const RENDERS_DIR = path.join(process.cwd(), 'renders');
|
const RENDERS_DIR = process.env.BRADLY_RENDERS_DIR || path.join(process.cwd(), 'renders');
|
||||||
|
|
||||||
// Ensure renders directory exists
|
// Ensure renders directory exists
|
||||||
if (!fs.existsSync(RENDERS_DIR)) {
|
if (!fs.existsSync(RENDERS_DIR)) {
|
||||||
@@ -109,6 +109,14 @@ export function addSSEClient(clientId: string, send: (data: string) => void): ()
|
|||||||
// ═══ Bundle Management ═══
|
// ═══ Bundle Management ═══
|
||||||
|
|
||||||
async function ensureBundle(): Promise<string> {
|
async function ensureBundle(): Promise<string> {
|
||||||
|
// In packaged Electron, use pre-built bundle from app resources
|
||||||
|
if (process.env.BRADLY_REMOTION_BUNDLE) {
|
||||||
|
const prebuilt = process.env.BRADLY_REMOTION_BUNDLE;
|
||||||
|
if (fs.existsSync(prebuilt)) return prebuilt;
|
||||||
|
throw new Error(`Pre-built Remotion bundle not found at: ${prebuilt}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Development: bundle on demand
|
||||||
if (bundlePath) return bundlePath;
|
if (bundlePath) return bundlePath;
|
||||||
if (isBundling) {
|
if (isBundling) {
|
||||||
// Wait for existing bundle
|
// Wait for existing bundle
|
||||||
@@ -127,7 +135,8 @@ async function ensureBundle(): Promise<string> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { bundle } = await import('@remotion/bundler');
|
const { bundle } = await import('@remotion/bundler');
|
||||||
const entryPoint = path.join(process.cwd(), 'src', 'Root.tsx');
|
const entryPoint = process.env.BRADLY_REMOTION_ENTRY
|
||||||
|
|| path.join(process.cwd(), 'src', 'Root.tsx');
|
||||||
|
|
||||||
bundlePath = await bundle({
|
bundlePath = await bundle({
|
||||||
entryPoint,
|
entryPoint,
|
||||||
@@ -225,6 +234,9 @@ async function renderJob(job: RenderJob): Promise<void> {
|
|||||||
const ext = job.format;
|
const ext = job.format;
|
||||||
const outputPath = path.join(RENDERS_DIR, `${job.id}.${ext}`);
|
const outputPath = path.join(RENDERS_DIR, `${job.id}.${ext}`);
|
||||||
|
|
||||||
|
// In packaged Electron, point to unpacked compositor binaries
|
||||||
|
const binariesDirectory = process.env.BRADLY_BINARIES_DIR || undefined;
|
||||||
|
|
||||||
console.log(`🎬 Rendering [${job.id}] → ${job.format} (${job.width}×${job.height})`);
|
console.log(`🎬 Rendering [${job.id}] → ${job.format} (${job.width}×${job.height})`);
|
||||||
|
|
||||||
// Resolve the full composition config from the bundle
|
// Resolve the full composition config from the bundle
|
||||||
@@ -253,6 +265,7 @@ async function renderJob(job: RenderJob): Promise<void> {
|
|||||||
output: outputPath,
|
output: outputPath,
|
||||||
imageFormat: job.format as 'png' | 'jpeg',
|
imageFormat: job.format as 'png' | 'jpeg',
|
||||||
inputProps: job.inputProps,
|
inputProps: job.inputProps,
|
||||||
|
binariesDirectory,
|
||||||
});
|
});
|
||||||
|
|
||||||
job.progress = 100;
|
job.progress = 100;
|
||||||
@@ -267,6 +280,7 @@ async function renderJob(job: RenderJob): Promise<void> {
|
|||||||
codec: job.format === 'webm' ? 'vp8' : 'h264',
|
codec: job.format === 'webm' ? 'vp8' : 'h264',
|
||||||
outputLocation: outputPath,
|
outputLocation: outputPath,
|
||||||
inputProps: job.inputProps,
|
inputProps: job.inputProps,
|
||||||
|
binariesDirectory,
|
||||||
onProgress: ({ renderedFrames, encodedFrames }) => {
|
onProgress: ({ renderedFrames, encodedFrames }) => {
|
||||||
const progress = Math.round(
|
const progress = Math.round(
|
||||||
((renderedFrames ?? encodedFrames ?? 0) / job.durationInFrames) * 100
|
((renderedFrames ?? encodedFrames ?? 0) / job.durationInFrames) * 100
|
||||||
|
|||||||
Reference in New Issue
Block a user