add native export ipc handlers

This commit is contained in:
webadderall
2026-04-04 13:07:49 +11:00
parent ae2aefe4e2
commit acf359d3cc
+725 -2
View File
@@ -1,16 +1,27 @@
import type { ChildProcessWithoutNullStreams } from 'node:child_process'
import type { ChildProcessByStdio, ChildProcessWithoutNullStreams } from 'node:child_process'
import { execFile, spawn, spawnSync } from 'node:child_process'
import { createWriteStream, constants as fsConstants, existsSync } from 'node:fs'
import fs from 'node:fs/promises'
import { get as httpsGet } from 'node:https'
import { createRequire } from 'node:module'
import path from 'node:path'
import type { Readable, Writable } from 'node:stream'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { promisify } from 'node:util'
import type { SaveDialogOptions } from 'electron'
import type { SaveDialogOptions, WebContents } from 'electron'
import { app, BrowserWindow, desktopCapturer, dialog, ipcMain, shell, systemPreferences } from 'electron'
import { RECORDINGS_DIR, USER_DATA_PATH } from '../appPaths'
import { hideCursor, showCursor } from '../cursorHider'
import {
buildNativeVideoExportArgs,
buildTrimmedSourceAudioFilter,
getEditedAudioExtension,
getNativeVideoInputByteSize,
getPreferredNativeVideoEncoders,
parseAvailableFfmpegEncoders,
type NativeExportEncodingMode,
type NativeVideoExportFinishOptions,
} from './nativeVideoExport'
import { closeCountdownWindow, createCountdownWindow, getCountdownWindow } from '../windows'
import { resolveWindowsCaptureDisplay } from './windowsCaptureSelection'
@@ -1166,6 +1177,414 @@ function getFfmpegBinaryPath() {
return ffmpegStatic
}
type NativeVideoExportSession = {
ffmpegProcess: ChildProcessByStdio<Writable, null, Readable>
outputPath: string
inputByteSize: number
maxQueuedWriteBytes: number
stderrOutput: string
encoderName: string
processError: Error | null
stdinError: Error | null
terminating: boolean
writeSequence: Promise<void>
completionPromise: Promise<void>
sender: WebContents | null
pendingWriteRequestIds: Set<number>
}
const nativeVideoExportSessions = new Map<string, NativeVideoExportSession>()
let cachedNativeVideoEncoder: { ffmpegPath: string; encoderName: string } | null = null
export function cleanupNativeVideoExportSessions() {
for (const [sessionId, session] of nativeVideoExportSessions) {
session.terminating = true
try {
if (!session.ffmpegProcess.stdin.destroyed) {
session.ffmpegProcess.stdin.destroy()
}
} catch { /* stream may already be closed */ }
try {
session.ffmpegProcess.kill('SIGKILL')
} catch { /* process may already be exited */ }
nativeVideoExportSessions.delete(sessionId)
}
}
function getNativeVideoExportMaxQueuedWriteBytes(inputByteSize: number) {
return Math.min(
64 * 1024 * 1024,
Math.max(16 * 1024 * 1024, inputByteSize * 4),
)
}
function isHardwareAcceleratedVideoEncoder(encoderName: string) {
return /(videotoolbox|nvenc|qsv|amf|mf)/i.test(encoderName)
}
async function removeTemporaryExportFile(filePath: string | null | undefined) {
if (!filePath) {
return
}
try {
await fs.rm(filePath, { force: true })
} catch {
// Ignore cleanup failures for temp export artifacts.
}
}
function getNativeVideoExportSessionError(
session: NativeVideoExportSession,
fallback: string,
) {
return (
session.stdinError?.message ||
session.processError?.message ||
session.stderrOutput.trim() ||
fallback
)
}
function sendNativeVideoExportWriteFrameResult(
sender: WebContents | null | undefined,
sessionId: string,
requestId: number,
result: { success: boolean; error?: string },
) {
if (!sender || sender.isDestroyed()) {
return
}
sender.send('native-video-export-write-frame-result', {
sessionId,
requestId,
...result,
})
}
function settleNativeVideoExportWriteFrameRequest(
sessionId: string,
session: NativeVideoExportSession,
requestId: number,
result: { success: boolean; error?: string },
) {
session.pendingWriteRequestIds.delete(requestId)
sendNativeVideoExportWriteFrameResult(session.sender, sessionId, requestId, result)
}
function flushNativeVideoExportPendingWriteRequests(
sessionId: string,
session: NativeVideoExportSession,
error: string,
) {
for (const requestId of session.pendingWriteRequestIds) {
sendNativeVideoExportWriteFrameResult(session.sender, sessionId, requestId, {
success: false,
error,
})
}
session.pendingWriteRequestIds.clear()
}
function isIgnorableNativeVideoExportStreamError(error: Error | null | undefined): boolean {
if (!error) {
return false
}
const errno = error as NodeJS.ErrnoException
return (
errno.code === 'EPIPE' ||
errno.code === 'ERR_STREAM_DESTROYED' ||
/broken pipe|stream destroyed|eof/i.test(error.message)
)
}
async function waitForNativeVideoExportDrain(session: NativeVideoExportSession) {
if (
session.stdinError ||
session.processError ||
session.ffmpegProcess.stdin.destroyed ||
session.ffmpegProcess.stdin.writableEnded ||
!session.ffmpegProcess.stdin.writable ||
session.ffmpegProcess.stdin.writableLength <= 0
) {
return
}
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
cleanup()
reject(new Error('Timed out while waiting for native export writer backpressure to clear'))
}, 15000)
const cleanup = () => {
clearTimeout(timeout)
session.ffmpegProcess.stdin.off('drain', handleDrain)
session.ffmpegProcess.stdin.off('error', handleError)
session.ffmpegProcess.off('close', handleClose)
}
const handleDrain = () => {
cleanup()
resolve()
}
const handleError = (error: Error) => {
cleanup()
reject(error)
}
const handleClose = () => {
cleanup()
reject(
new Error(
getNativeVideoExportSessionError(
session,
'Native video export writer closed before draining',
),
),
)
}
session.ffmpegProcess.stdin.once('drain', handleDrain)
session.ffmpegProcess.stdin.once('error', handleError)
session.ffmpegProcess.once('close', handleClose)
})
}
function getNativeVideoExportFrameLength(frameData: Uint8Array | ArrayBuffer) {
return frameData.byteLength
}
async function writeNativeVideoExportFrame(
session: NativeVideoExportSession,
frameData: Uint8Array | ArrayBuffer,
) {
if (getNativeVideoExportFrameLength(frameData) !== session.inputByteSize) {
throw new Error(
`Native video export expected ${session.inputByteSize} bytes per frame but received ${getNativeVideoExportFrameLength(frameData)}`,
)
}
if (
session.stdinError ||
session.processError ||
session.ffmpegProcess.stdin.destroyed ||
session.ffmpegProcess.stdin.writableEnded ||
!session.ffmpegProcess.stdin.writable
) {
throw new Error(
getNativeVideoExportSessionError(
session,
'Native video export encoder is not accepting frames',
),
)
}
const frameBuffer = frameData instanceof ArrayBuffer
? Buffer.from(frameData)
: Buffer.from(frameData.buffer, frameData.byteOffset, frameData.byteLength)
try {
session.ffmpegProcess.stdin.write(frameBuffer)
} catch (error) {
session.stdinError = error instanceof Error ? error : new Error(String(error))
throw session.stdinError
}
if (session.ffmpegProcess.stdin.writableLength >= session.maxQueuedWriteBytes) {
try {
await waitForNativeVideoExportDrain(session)
} catch (error) {
session.stdinError = error instanceof Error ? error : new Error(String(error))
throw session.stdinError
}
}
}
async function enqueueNativeVideoExportFrameWrite(
session: NativeVideoExportSession,
frameData: Uint8Array | ArrayBuffer,
) {
const writePromise = session.writeSequence.then(async () => {
if (session.terminating) {
throw new Error('Native video export session was cancelled')
}
await writeNativeVideoExportFrame(session, frameData)
})
session.writeSequence = writePromise.catch(() => undefined)
await writePromise
}
async function getAvailableNativeVideoEncoders(ffmpegPath: string) {
const { stdout } = await execFileAsync(ffmpegPath, ['-hide_banner', '-encoders'], {
timeout: 15000,
maxBuffer: 20 * 1024 * 1024,
})
return parseAvailableFfmpegEncoders(stdout)
}
async function probeNativeVideoEncoder(
ffmpegPath: string,
encoderName: string,
encodingMode: NativeExportEncodingMode,
) {
const outputPath = path.join(
app.getPath('temp'),
`recordly-export-probe-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.mp4`,
)
const args = buildNativeVideoExportArgs(
encoderName,
{
width: 64,
height: 64,
frameRate: 1,
bitrate: 1_500_000,
encodingMode,
},
outputPath,
)
return new Promise<boolean>((resolve) => {
const process = spawn(ffmpegPath, args, {
stdio: ['pipe', 'ignore', 'pipe'],
})
let stderrOutput = ''
const timeout = setTimeout(() => {
try {
process.kill('SIGKILL')
} catch {
// ignore
}
resolve(false)
}, 15000)
process.stderr.on('data', (chunk: Buffer) => {
stderrOutput += chunk.toString()
})
process.on('close', (code) => {
clearTimeout(timeout)
void removeTemporaryExportFile(outputPath)
if (code !== 0 && stderrOutput.trim().length > 0) {
console.warn(
`[native-export] Encoder probe failed for ${encoderName}:`,
stderrOutput.trim(),
)
}
resolve(code === 0)
})
process.stdin.end(Buffer.alloc(getNativeVideoInputByteSize(64, 64), 0))
})
}
async function resolveNativeVideoEncoder(
ffmpegPath: string,
encodingMode: NativeExportEncodingMode,
) {
if (cachedNativeVideoEncoder?.ffmpegPath === ffmpegPath) {
return cachedNativeVideoEncoder.encoderName
}
const availableEncoders = await getAvailableNativeVideoEncoders(ffmpegPath)
const candidates = [...new Set([...getPreferredNativeVideoEncoders(process.platform), 'libx264'])]
for (const encoderName of candidates) {
if (!availableEncoders.has(encoderName)) {
continue
}
if (await probeNativeVideoEncoder(ffmpegPath, encoderName, encodingMode)) {
cachedNativeVideoEncoder = { ffmpegPath, encoderName }
return encoderName
}
}
throw new Error('No usable FFmpeg encoder was available for native export')
}
async function muxNativeVideoExportAudio(
videoPath: string,
options: NativeVideoExportFinishOptions,
) {
const audioMode = options.audioMode ?? 'none'
if (audioMode === 'none') {
return videoPath
}
const ffmpegPath = getFfmpegBinaryPath()
const tempArtifacts: string[] = []
let audioInputPath = options.audioSourcePath ?? null
if (audioMode === 'edited-track') {
if (!options.editedAudioData) {
throw new Error('Edited audio data is missing for native export')
}
const extension = getEditedAudioExtension(options.editedAudioMimeType)
audioInputPath = path.join(
app.getPath('temp'),
`recordly-export-audio-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${extension}`,
)
await fs.writeFile(audioInputPath, Buffer.from(options.editedAudioData))
tempArtifacts.push(audioInputPath)
}
if (!audioInputPath) {
return videoPath
}
const outputPath = path.join(
path.dirname(videoPath),
`${path.basename(videoPath, path.extname(videoPath))}-final.mp4`,
)
const args = ['-y', '-hide_banner', '-loglevel', 'error', '-i', videoPath, '-i', audioInputPath]
if (audioMode === 'trim-source') {
const filter = buildTrimmedSourceAudioFilter(options.trimSegments ?? [])
if (filter) {
args.push('-filter_complex', filter, '-map', '0:v:0', '-map', '[aout]')
} else {
args.push('-map', '0:v:0', '-map', '1:a:0')
}
} else {
args.push('-map', '0:v:0', '-map', '1:a:0')
}
args.push(
'-c:v',
'copy',
'-c:a',
'aac',
'-b:a',
'192k',
'-shortest',
'-movflags',
'+faststart',
outputPath,
)
try {
await execFileAsync(ffmpegPath, args, {
timeout: 15 * 60 * 1000,
maxBuffer: 20 * 1024 * 1024,
})
await removeTemporaryExportFile(videoPath)
return outputPath
} finally {
await Promise.allSettled(
tempArtifacts.map((artifactPath) => removeTemporaryExportFile(artifactPath)),
)
}
}
/** Probe the duration of a media file (in seconds) using ffmpeg. */
async function probeMediaDurationSeconds(filePath: string): Promise<number> {
const ffmpegPath = getFfmpegBinaryPath()
@@ -3606,11 +4025,35 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh}
createSourceSelectorWindow()
})
let recordingFinalizing = false
ipcMain.handle('switch-to-editor', () => {
console.log('[switch-to-editor] Opening editor window')
recordingFinalizing = false
createEditorWindow()
})
ipcMain.handle('open-editor-early', () => {
console.log('[open-editor-early] Opening editor window while recording finalizes')
recordingFinalizing = true
createEditorWindow()
})
ipcMain.handle('is-recording-finalizing', () => {
return recordingFinalizing
})
ipcMain.handle('notify-recording-finalized', () => {
console.log('[notify-recording-finalized] Recording finalization complete')
recordingFinalizing = false
const allWindows = BrowserWindow.getAllWindows()
for (const win of allWindows) {
if (!win.isDestroyed()) {
win.webContents.send('recording-finalized')
}
}
})
ipcMain.handle('start-native-screen-recording', async (_, source: SelectedSource, options?: NativeMacRecordingOptions) => {
// Windows native capture path
if (process.platform === 'win32') {
@@ -4696,6 +5139,255 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh}
}
})
ipcMain.handle(
'native-video-export-start',
async (
event,
options: {
width: number
height: number
frameRate: number
bitrate: number
encodingMode: NativeExportEncodingMode
},
) => {
try {
if (options.width % 2 !== 0 || options.height % 2 !== 0) {
throw new Error('Native export requires even output dimensions')
}
const ffmpegPath = getFfmpegBinaryPath()
const encoderName = await resolveNativeVideoEncoder(ffmpegPath, options.encodingMode)
const sessionId = `recordly-export-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const outputPath = path.join(app.getPath('temp'), `${sessionId}.mp4`)
const ffmpegArgs = buildNativeVideoExportArgs(encoderName, options, outputPath)
const ffmpegProcess = spawn(ffmpegPath, ffmpegArgs, {
stdio: ['pipe', 'ignore', 'pipe'],
}) as ChildProcessByStdio<Writable, null, Readable>
const inputByteSize = getNativeVideoInputByteSize(options.width, options.height)
const session: NativeVideoExportSession = {
ffmpegProcess,
outputPath,
inputByteSize,
maxQueuedWriteBytes: getNativeVideoExportMaxQueuedWriteBytes(inputByteSize),
stderrOutput: '',
encoderName,
processError: null,
stdinError: null,
terminating: false,
writeSequence: Promise.resolve(),
sender: event.sender,
pendingWriteRequestIds: new Set<number>(),
completionPromise: new Promise<void>((resolve, reject) => {
ffmpegProcess.once('error', (error) => {
const processError = error instanceof Error ? error : new Error(String(error))
if (session.terminating) {
resolve()
return
}
session.processError = processError
reject(processError)
})
ffmpegProcess.stdin.once('error', (error) => {
const stdinError = error instanceof Error ? error : new Error(String(error))
if (session.terminating && isIgnorableNativeVideoExportStreamError(stdinError)) {
return
}
session.stdinError = stdinError
})
ffmpegProcess.once('close', (code, signal) => {
if (session.terminating) {
resolve()
return
}
if (code === 0) {
resolve()
return
}
reject(
new Error(
getNativeVideoExportSessionError(
session,
`FFmpeg exited with code ${code ?? 'unknown'}${signal ? ` (signal ${signal})` : ''}`,
),
),
)
})
}),
}
void session.completionPromise.catch(() => undefined)
ffmpegProcess.stderr.on('data', (chunk: Buffer) => {
session.stderrOutput += chunk.toString()
})
nativeVideoExportSessions.set(sessionId, session)
console.log(
`[native-export] Started ${isHardwareAcceleratedVideoEncoder(encoderName) ? 'hardware' : 'software'} session ${sessionId} with ${encoderName}`,
)
return {
success: true,
sessionId,
encoderName,
}
} catch (error) {
console.error('[native-export] Failed to start native video export session:', error)
return {
success: false,
error: String(error),
}
}
},
)
ipcMain.on(
'native-video-export-write-frame-async',
(
event,
payload: {
sessionId: string
requestId: number
frameData: Uint8Array
},
) => {
const sessionId = payload?.sessionId
const requestId = payload?.requestId
const frameData = payload?.frameData
if (typeof sessionId !== 'string' || typeof requestId !== 'number' || !frameData) {
return
}
const session = nativeVideoExportSessions.get(sessionId)
if (!session) {
sendNativeVideoExportWriteFrameResult(event.sender, sessionId, requestId, {
success: false,
error: 'Invalid native export session',
})
return
}
session.sender = event.sender
session.pendingWriteRequestIds.add(requestId)
if (session.terminating) {
settleNativeVideoExportWriteFrameRequest(sessionId, session, requestId, {
success: false,
error: 'Native video export session was cancelled',
})
return
}
if (frameData.byteLength !== session.inputByteSize) {
settleNativeVideoExportWriteFrameRequest(sessionId, session, requestId, {
success: false,
error: `Native video export expected ${session.inputByteSize} bytes per frame but received ${frameData.byteLength}`,
})
return
}
void enqueueNativeVideoExportFrameWrite(session, frameData)
.then(() => {
settleNativeVideoExportWriteFrameRequest(sessionId, session, requestId, {
success: true,
})
})
.catch((error) => {
session.stdinError = error instanceof Error ? error : new Error(String(error))
settleNativeVideoExportWriteFrameRequest(sessionId, session, requestId, {
success: false,
error: getNativeVideoExportSessionError(
session,
session.stdinError.message,
),
})
})
},
)
ipcMain.handle(
'native-video-export-finish',
async (_, sessionId: string, options?: NativeVideoExportFinishOptions) => {
const session = nativeVideoExportSessions.get(sessionId)
if (!session) {
return { success: false, error: 'Invalid native export session' }
}
try {
await session.writeSequence
if (!session.ffmpegProcess.stdin.destroyed && !session.ffmpegProcess.stdin.writableEnded) {
session.ffmpegProcess.stdin.end()
}
await session.completionPromise
const finalizedPath = await muxNativeVideoExportAudio(session.outputPath, options ?? {})
const data = await fs.readFile(finalizedPath)
nativeVideoExportSessions.delete(sessionId)
await removeTemporaryExportFile(finalizedPath)
return {
success: true,
data: new Uint8Array(data),
encoderName: session.encoderName,
}
} catch (error) {
flushNativeVideoExportPendingWriteRequests(
sessionId,
session,
String(error),
)
nativeVideoExportSessions.delete(sessionId)
await removeTemporaryExportFile(session.outputPath)
const finalizedSuffix = session.outputPath.replace(/\.mp4$/, '-final.mp4')
await removeTemporaryExportFile(finalizedSuffix)
return {
success: false,
error: String(error),
}
}
},
)
ipcMain.handle('native-video-export-cancel', async (_, sessionId: string) => {
const session = nativeVideoExportSessions.get(sessionId)
if (!session) {
return { success: true }
}
session.terminating = true
nativeVideoExportSessions.delete(sessionId)
flushNativeVideoExportPendingWriteRequests(
sessionId,
session,
'Native video export session was cancelled',
)
try {
if (!session.ffmpegProcess.stdin.destroyed && !session.ffmpegProcess.stdin.writableEnded) {
session.ffmpegProcess.stdin.destroy()
}
} catch {
// Stream may already be closed.
}
try {
session.ffmpegProcess.kill('SIGKILL')
} catch {
// Process may already be closed.
}
await session.completionPromise.catch(() => undefined)
await removeTemporaryExportFile(session.outputPath)
return { success: true }
})
ipcMain.handle('save-exported-video', async (event, videoData: ArrayBuffer, fileName: string) => {
try {
// Determine file type from extension
@@ -4740,6 +5432,37 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh}
}
})
ipcMain.handle('write-exported-video-to-path', async (_event, videoData: ArrayBuffer, outputPath: string) => {
try {
const resolvedPath = path.resolve(outputPath)
const allowedPrefixes = [app.getPath('temp'), app.getPath('downloads'), RECORDINGS_DIR]
if (!allowedPrefixes.some((prefix) => resolvedPath.startsWith(prefix))) {
return {
success: false,
message: 'Output path is not in an allowed directory',
canceled: false,
}
}
await fs.mkdir(path.dirname(resolvedPath), { recursive: true });
await fs.writeFile(resolvedPath, Buffer.from(videoData));
return {
success: true,
path: outputPath,
message: 'Video exported successfully',
canceled: false,
};
} catch (error) {
console.error('Failed to write exported video to path:', error)
return {
success: false,
message: 'Failed to write exported video',
canceled: false,
error: String(error)
}
}
})
ipcMain.handle('open-video-file-picker', async () => {
try {
const recordingsDir = await getRecordingsDir()