Desktop MP4 export renders frames from a hidden BrowserWindow via capturePage, pipes PNGs to a bundled ffmpeg (libx264/yuv420p/+faststart), and writes the file through an atomic temp-file swap. It shares scene validation (shared/renderValidation.cjs) and the render.html renderer with the standalone Puppeteer server, which is hardened in parallel. Render worker & main (electron/): - abortable render loop with an inactivity watchdog and cancel plumbing - atomic temp-file + rename so a cancel/failure never truncates a pre-existing target (#7); stdin EPIPE / early-exit / backpressure-hang handled (#8) - HiDPI frames normalized to canvas size (#10); odd dimensions padded to even (#13, also server); backgroundThrottling disabled (#12) - reject canvases larger than the display (#11); single-render guard; isolated render session that blocks network requests (#5) Tests: - Electron harnesses (scripts/verify-*.cjs) for capture, export, cancel, timeout, assets, background, capture-fidelity, resolution, odd-dims, aggregated by verify-desktop-render.cjs - server smoke + e2e and shared validation unit tests - headless hook (src/devtools/headless.ts) to run the in-app suite under browser/electron hosts; pixel-diff util + capture fixtures (test/) - dev_docs plans: integration, fixes (#7-#13), and the e2e/capture strategy Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
393 lines
12 KiB
TypeScript
393 lines
12 KiB
TypeScript
import { app, BrowserWindow, dialog, ipcMain, Menu, screen, shell } from 'electron'
|
||
import type { BrowserWindow as BrowserWindowInstance, Event, IpcMainInvokeEvent, MenuItemConstructorOptions } from 'electron'
|
||
import { randomUUID } from 'node:crypto'
|
||
import path from 'node:path'
|
||
import { fileURLToPath } from 'node:url'
|
||
import { renderMp4Job } from './renderWorker.cjs'
|
||
|
||
const { validateRenderRequest } = require('../shared/renderValidation.cjs') as {
|
||
validateRenderRequest(scene: unknown, options: unknown): RenderValidation
|
||
}
|
||
|
||
const isDev = !app.isPackaged
|
||
const devServerUrl = process.env.VITE_DEV_SERVER_URL ?? 'http://localhost:5200'
|
||
const devServerOrigin = new URL(devServerUrl).origin
|
||
const appIconPath = isDev
|
||
? path.join(process.cwd(), 'build', 'icon.png')
|
||
: path.join(process.resourcesPath, 'build', 'icon.png')
|
||
|
||
let mainWindow: BrowserWindowInstance | null = null
|
||
const renderJobs = new Map<string, AbortController>()
|
||
// Serialize desktop renders: one active export at a time, mirroring the
|
||
// standalone server's MAX_ACTIVE_JOBS=1 (finding #6). The UI also guards this,
|
||
// but IPC is directly invokable, so enforce it in main too.
|
||
let renderActive = false
|
||
|
||
interface RenderValidation {
|
||
ok: boolean
|
||
error?: string
|
||
scene?: Parameters<typeof renderMp4Job>[0]['scene']
|
||
options?: Parameters<typeof renderMp4Job>[0]['options']
|
||
}
|
||
|
||
interface RenderMp4Payload {
|
||
scene?: unknown
|
||
fps?: unknown
|
||
crf?: unknown
|
||
preset?: unknown
|
||
suggestedName?: unknown
|
||
}
|
||
|
||
function sendAppCommand(command: string): void {
|
||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||
mainWindow.webContents.send('ruwio:menu-command', command)
|
||
}
|
||
|
||
function sendRenderProgress(
|
||
target: Electron.WebContents,
|
||
event: { jobId: string; progress: number; done: boolean; error?: string },
|
||
): void {
|
||
if (!target.isDestroyed()) target.send('ruwio:render-progress', event)
|
||
}
|
||
|
||
function sanitizeMp4Name(value: unknown): string {
|
||
const raw = typeof value === 'string' && value.trim() ? value.trim() : 'animation.mp4'
|
||
const noPath = raw.replace(/[\\/]+/g, '-')
|
||
const safe = noPath.replace(/[^a-z0-9äöüß._-]+/gi, '-').replace(/^-+|-+$/g, '')
|
||
const named = safe || 'animation.mp4'
|
||
return named.toLowerCase().endsWith('.mp4') ? named : `${named}.mp4`
|
||
}
|
||
|
||
async function handleRenderMp4(event: IpcMainInvokeEvent, payload: RenderMp4Payload): Promise<{ path: string } | null> {
|
||
if (renderActive) {
|
||
throw new Error('A render is already in progress')
|
||
}
|
||
renderActive = true
|
||
try {
|
||
return await runRenderMp4(event, payload)
|
||
} finally {
|
||
renderActive = false
|
||
}
|
||
}
|
||
|
||
async function runRenderMp4(event: IpcMainInvokeEvent, payload: RenderMp4Payload): Promise<{ path: string } | null> {
|
||
const validation = validateRenderRequest(payload?.scene, {
|
||
fps: payload?.fps,
|
||
crf: payload?.crf,
|
||
preset: payload?.preset,
|
||
})
|
||
if (!validation.ok || !validation.scene || !validation.options) {
|
||
throw new Error(validation.error || 'invalid render request')
|
||
}
|
||
|
||
// The desktop path captures a hidden, on-screen BrowserWindow, which Chromium
|
||
// can't composite/capture beyond the physical display bounds — a canvas larger
|
||
// than the screen would silently yield truncated frames. Fail fast with a clear
|
||
// message instead (finding #11). The web/server path uses a headless viewport
|
||
// and has no such limit, so this is desktop-only.
|
||
const { width: canvasWidth, height: canvasHeight } = validation.scene.canvas
|
||
const { width: displayWidth, height: displayHeight } = screen.getPrimaryDisplay().size
|
||
if (canvasWidth > displayWidth || canvasHeight > displayHeight) {
|
||
throw new Error(
|
||
`Canvas ${canvasWidth}×${canvasHeight} is larger than this display ` +
|
||
`(${displayWidth}×${displayHeight}). Desktop export can't capture a canvas ` +
|
||
`bigger than the screen — reduce the canvas size and try again.`,
|
||
)
|
||
}
|
||
|
||
const window = BrowserWindow.fromWebContents(event.sender)
|
||
const saveOptions = {
|
||
title: 'Export MP4',
|
||
defaultPath: sanitizeMp4Name(payload?.suggestedName),
|
||
filters: [{ name: 'MP4 Video', extensions: ['mp4'] }],
|
||
}
|
||
const saveResult = window
|
||
? await dialog.showSaveDialog(window, saveOptions)
|
||
: await dialog.showSaveDialog(saveOptions)
|
||
if (saveResult.canceled || !saveResult.filePath) return null
|
||
|
||
const jobId = randomUUID()
|
||
const controller = new AbortController()
|
||
renderJobs.set(jobId, controller)
|
||
sendRenderProgress(event.sender, { jobId, progress: 0, done: false })
|
||
|
||
try {
|
||
await renderMp4Job({
|
||
jobId,
|
||
scene: validation.scene,
|
||
options: validation.options,
|
||
outputPath: saveResult.filePath,
|
||
signal: controller.signal,
|
||
onProgress: (progress) => {
|
||
sendRenderProgress(event.sender, { jobId, progress, done: false })
|
||
},
|
||
})
|
||
sendRenderProgress(event.sender, { jobId, progress: 1, done: true })
|
||
return { path: saveResult.filePath }
|
||
} catch (err) {
|
||
const message = (err as Error).message || String(err)
|
||
sendRenderProgress(event.sender, { jobId, progress: 0, done: true, error: message })
|
||
throw err
|
||
} finally {
|
||
renderJobs.delete(jobId)
|
||
}
|
||
}
|
||
|
||
async function handleRenderCancel(_event: IpcMainInvokeEvent, jobId: unknown): Promise<void> {
|
||
if (typeof jobId !== 'string' || !jobId) return
|
||
renderJobs.get(jobId)?.abort()
|
||
}
|
||
|
||
function parseUrl(raw: string): URL | null {
|
||
try {
|
||
return new URL(raw)
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
function isAllowedAppNavigation(raw: string): boolean {
|
||
const url = parseUrl(raw)
|
||
if (!url) return false
|
||
if (isDev) return url.origin === devServerOrigin
|
||
if (url.protocol !== 'file:') return false
|
||
const appEntry = path.join(app.getAppPath(), 'dist', 'index.html')
|
||
return path.normalize(fileURLToPath(url)) === path.normalize(appEntry)
|
||
}
|
||
|
||
function isAllowedExternalUrl(raw: string): boolean {
|
||
const url = parseUrl(raw)
|
||
return url?.protocol === 'https:'
|
||
}
|
||
|
||
function createMainWindow(): void {
|
||
mainWindow = new BrowserWindow({
|
||
width: 1280,
|
||
height: 860,
|
||
minWidth: 1024,
|
||
minHeight: 700,
|
||
title: 'RUWIO',
|
||
icon: appIconPath,
|
||
show: false,
|
||
backgroundColor: '#14171f',
|
||
webPreferences: {
|
||
preload: path.join(__dirname, 'preload.cjs'),
|
||
contextIsolation: true,
|
||
nodeIntegration: false,
|
||
sandbox: true,
|
||
},
|
||
})
|
||
|
||
mainWindow.once('ready-to-show', () => {
|
||
mainWindow?.show()
|
||
})
|
||
|
||
mainWindow.webContents.setWindowOpenHandler(({ url }: { url: string }) => {
|
||
if (isAllowedExternalUrl(url)) {
|
||
void shell.openExternal(url)
|
||
}
|
||
return { action: 'deny' }
|
||
})
|
||
|
||
mainWindow.webContents.on('will-navigate', (event: Event, url: string) => {
|
||
if (!isAllowedAppNavigation(url)) {
|
||
event.preventDefault()
|
||
}
|
||
})
|
||
|
||
if (isDev) {
|
||
void mainWindow.loadURL(devServerUrl)
|
||
mainWindow.webContents.openDevTools({ mode: 'detach' })
|
||
} else {
|
||
void mainWindow.loadFile(path.join(app.getAppPath(), 'dist', 'index.html'))
|
||
}
|
||
}
|
||
|
||
function createMenu(): void {
|
||
const template: MenuItemConstructorOptions[] = [
|
||
{
|
||
label: 'File',
|
||
submenu: [
|
||
{
|
||
label: 'New Project',
|
||
accelerator: 'CmdOrCtrl+N',
|
||
click: () => sendAppCommand('new-project'),
|
||
},
|
||
{
|
||
label: 'Open...',
|
||
accelerator: 'CmdOrCtrl+O',
|
||
click: () => sendAppCommand('open-project'),
|
||
},
|
||
{
|
||
label: 'Recent Projects',
|
||
accelerator: 'CmdOrCtrl+Shift+O',
|
||
click: () => sendAppCommand('recent-projects'),
|
||
},
|
||
{ type: 'separator' },
|
||
{
|
||
label: 'Save',
|
||
accelerator: 'CmdOrCtrl+S',
|
||
click: () => sendAppCommand('save-project'),
|
||
},
|
||
{
|
||
label: 'Save As...',
|
||
accelerator: 'CmdOrCtrl+Shift+S',
|
||
click: () => sendAppCommand('save-project-as'),
|
||
},
|
||
{ type: 'separator' },
|
||
{
|
||
label: 'Export...',
|
||
accelerator: 'CmdOrCtrl+E',
|
||
click: () => sendAppCommand('export-project'),
|
||
},
|
||
{ type: 'separator' },
|
||
process.platform === 'darwin' ? { role: 'close' } : { role: 'quit' },
|
||
],
|
||
},
|
||
{
|
||
label: 'Edit',
|
||
submenu: [
|
||
{
|
||
label: 'Undo',
|
||
accelerator: 'CmdOrCtrl+Z',
|
||
click: () => sendAppCommand('undo'),
|
||
},
|
||
{
|
||
label: 'Redo',
|
||
accelerator: 'CmdOrCtrl+Shift+Z',
|
||
click: () => sendAppCommand('redo'),
|
||
},
|
||
{ type: 'separator' },
|
||
{ role: 'cut' },
|
||
{ role: 'copy' },
|
||
{ role: 'paste' },
|
||
{ role: 'selectAll' },
|
||
{ type: 'separator' },
|
||
{
|
||
label: 'Copy Current Frame',
|
||
accelerator: 'CmdOrCtrl+Alt+C',
|
||
click: () => sendAppCommand('copy-current-frame'),
|
||
},
|
||
],
|
||
},
|
||
{
|
||
label: 'View',
|
||
submenu: [
|
||
{ role: 'reload' },
|
||
{ role: 'forceReload' },
|
||
{ role: 'toggleDevTools' },
|
||
{ type: 'separator' },
|
||
{
|
||
label: 'Play / Pause',
|
||
accelerator: 'Space',
|
||
click: () => sendAppCommand('toggle-playback'),
|
||
},
|
||
{
|
||
label: 'Stop',
|
||
accelerator: 'Escape',
|
||
click: () => sendAppCommand('stop-playback'),
|
||
},
|
||
{
|
||
label: 'Jump to Start',
|
||
accelerator: 'R',
|
||
click: () => sendAppCommand('rewind'),
|
||
},
|
||
{
|
||
label: 'Fullscreen Preview',
|
||
accelerator: 'Shift+Space',
|
||
click: () => sendAppCommand('fullscreen-preview'),
|
||
},
|
||
{ type: 'separator' },
|
||
{
|
||
label: 'Toggle Side Panels',
|
||
accelerator: 'Tab',
|
||
click: () => sendAppCommand('toggle-panels'),
|
||
},
|
||
{
|
||
label: 'Media Library',
|
||
accelerator: 'CmdOrCtrl+M',
|
||
click: () => sendAppCommand('media-library'),
|
||
},
|
||
{
|
||
label: 'Document Settings',
|
||
accelerator: 'CmdOrCtrl+,',
|
||
click: () => sendAppCommand('document-settings'),
|
||
},
|
||
{ type: 'separator' },
|
||
{ role: 'resetZoom' },
|
||
{ role: 'zoomIn' },
|
||
{ role: 'zoomOut' },
|
||
{ type: 'separator' },
|
||
{ role: 'togglefullscreen' },
|
||
],
|
||
},
|
||
{
|
||
label: 'Window',
|
||
submenu: [
|
||
{ role: 'minimize' },
|
||
{ role: 'zoom' },
|
||
],
|
||
},
|
||
{
|
||
label: 'Help',
|
||
submenu: [
|
||
{
|
||
label: 'RUWIO Help',
|
||
accelerator: 'F1',
|
||
click: () => sendAppCommand('help'),
|
||
},
|
||
{
|
||
label: 'About RUWIO',
|
||
click: () => sendAppCommand('about'),
|
||
},
|
||
...(isDev
|
||
? [
|
||
{ type: 'separator' as const },
|
||
{
|
||
label: 'Open Test Runner',
|
||
accelerator: 'CmdOrCtrl+Shift+Alt+T',
|
||
click: () => sendAppCommand('test-runner'),
|
||
},
|
||
]
|
||
: []),
|
||
],
|
||
},
|
||
]
|
||
|
||
if (process.platform === 'darwin') {
|
||
template.unshift({
|
||
label: app.name,
|
||
submenu: [
|
||
{ role: 'about' },
|
||
{ type: 'separator' },
|
||
{ role: 'services' },
|
||
{ type: 'separator' },
|
||
{ role: 'hide' },
|
||
{ role: 'hideOthers' },
|
||
{ role: 'unhide' },
|
||
{ type: 'separator' },
|
||
{ role: 'quit' },
|
||
],
|
||
})
|
||
}
|
||
|
||
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
|
||
}
|
||
|
||
app.setName('RUWIO')
|
||
|
||
app.whenReady().then(() => {
|
||
ipcMain.handle('ruwio:render-mp4', handleRenderMp4)
|
||
ipcMain.handle('ruwio:render-cancel', handleRenderCancel)
|
||
createMenu()
|
||
createMainWindow()
|
||
|
||
app.on('activate', () => {
|
||
if (BrowserWindow.getAllWindows().length === 0) createMainWindow()
|
||
})
|
||
})
|
||
|
||
app.on('window-all-closed', () => {
|
||
if (process.platform !== 'darwin') app.quit()
|
||
})
|