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:
2026-06-02 03:57:17 -05:00
parent b135a70cc7
commit 92a8cf78a9
10 changed files with 6137 additions and 26 deletions
+16 -2
View File
@@ -53,7 +53,7 @@ export interface RenderJobCreateParams {
// ═══ Constants ═══
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
if (!fs.existsSync(RENDERS_DIR)) {
@@ -109,6 +109,14 @@ export function addSSEClient(clientId: string, send: (data: string) => void): ()
// ═══ Bundle Management ═══
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 (isBundling) {
// Wait for existing bundle
@@ -127,7 +135,8 @@ async function ensureBundle(): Promise<string> {
try {
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({
entryPoint,
@@ -225,6 +234,9 @@ async function renderJob(job: RenderJob): Promise<void> {
const ext = job.format;
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})`);
// Resolve the full composition config from the bundle
@@ -253,6 +265,7 @@ async function renderJob(job: RenderJob): Promise<void> {
output: outputPath,
imageFormat: job.format as 'png' | 'jpeg',
inputProps: job.inputProps,
binariesDirectory,
});
job.progress = 100;
@@ -267,6 +280,7 @@ async function renderJob(job: RenderJob): Promise<void> {
codec: job.format === 'webm' ? 'vp8' : 'h264',
outputLocation: outputPath,
inputProps: job.inputProps,
binariesDirectory,
onProgress: ({ renderedFrames, encodedFrames }) => {
const progress = Math.round(
((renderedFrames ?? encodedFrames ?? 0) / job.durationInFrames) * 100