diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 4469e6e7..1ad80ad3 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -367,6 +367,30 @@ interface Window { cancelCountdown: () => Promise<{ success: boolean }>; getActiveCountdown: () => Promise<{ success: boolean; seconds: number | null }>; onCountdownTick: (callback: (seconds: number) => void) => () => void; + // FFmpeg hardware-accelerated encoding (streaming) + ffmpegStartEncode: (options: { + width: number; + height: number; + frameRate: number; + bitrate: number; + useNVENC: boolean; + useAMF: boolean; + useQuickSync: boolean; + }) => Promise<{ success: boolean; sessionId?: string; error?: string }>; + ffmpegWriteFrame: (sessionId: string, frameData: Uint8Array) => Promise<{ success: boolean; error?: string }>; + ffmpegFinishEncode: (sessionId: string) => Promise<{ + success: boolean; + outputPath?: string; + error?: string; + encoding?: { + encoder: string; + codecFamily: 'hevc' | 'h264' | 'unknown'; + acceleration: 'nvenc' | 'amf' | 'qsv' | 'cpu' | 'unknown'; + hardwareAccelerated: boolean; + }; + }>; + ffmpegCancelEncode: (sessionId: string) => Promise<{ success: boolean; error?: string }>; + readEncodedFile: (outputPath: string) => Promise; }; } diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 0b030be7..573047ff 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -4898,5 +4898,390 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh} seconds: countdownInProgress ? countdownRemaining : null, } }) + + // --- FFmpeg hardware-accelerated encoding (NVENC/AMF/QuickSync) --- + // Streams RGBA frames via stdin so we avoid multi-GB raw temp files. + + type FfmpegEncodeOptions = { + width: number; + height: number; + frameRate: number; + bitrate: number; + useNVENC: boolean; + useAMF: boolean; + useQuickSync: boolean; + }; + + type FfmpegEncodingInfo = { + encoder: string; + codecFamily: 'hevc' | 'h264' | 'unknown'; + acceleration: 'nvenc' | 'amf' | 'qsv' | 'cpu' | 'unknown'; + hardwareAccelerated: boolean; + }; + + type EncoderConfig = { + hevc: string; + h264: string; + preset: { hevc: string; h264: string }; + additionalArgs: string[]; + hevcExtraArgs: string[]; + preferH264?: boolean; + }; + + type FfmpegSession = { + ffmpegProcess: ChildProcessWithoutNullStreams; + outputPath: string; + stderrOutput: string; + encoding?: FfmpegEncodingInfo; + completionPromise: Promise; + }; + + const ffmpegSessions = new Map(); + + const buildEncoderConfigs = (options: FfmpegEncodeOptions): EncoderConfig[] => { + const configs: EncoderConfig[] = []; + + if (options.useNVENC) { + configs.push({ + hevc: 'hevc_nvenc', + h264: 'h264_nvenc', + preset: { hevc: 'p2', h264: 'p2' }, + additionalArgs: ['-rc', 'vbr', '-cq', '23'], + hevcExtraArgs: ['-tag:v', 'hvc1'], + preferH264: true, + }); + } + if (options.useAMF) { + configs.push({ + hevc: 'hevc_amf', + h264: 'h264_amf', + preset: { hevc: 'balanced', h264: 'balanced' }, + additionalArgs: ['-rc', 'vbr_latency'], + hevcExtraArgs: ['-tag:v', 'hvc1'], + }); + } + if (options.useQuickSync) { + configs.push({ + hevc: 'hevc_qsv', + h264: 'h264_qsv', + preset: { hevc: 'medium', h264: 'medium' }, + additionalArgs: ['-global_quality', '23'], + hevcExtraArgs: ['-tag:v', 'hvc1'], + }); + } + + // CPU fallback — always present + configs.push({ + hevc: 'libx265', + h264: 'libx264', + preset: { hevc: 'medium', h264: 'medium' }, + additionalArgs: [], + hevcExtraArgs: ['-tag:v', 'hvc1'], + }); + + return configs; + }; + + const describeEncoding = (encoder: string): FfmpegEncodingInfo => { + const codecFamily = encoder.startsWith('hevc') + ? 'hevc' + : encoder.startsWith('h264') + ? 'h264' + : 'unknown'; + + const acceleration = encoder.includes('nvenc') + ? 'nvenc' + : encoder.includes('amf') + ? 'amf' + : encoder.includes('qsv') + ? 'qsv' + : encoder.includes('libx265') || encoder.includes('libx264') + ? 'cpu' + : 'unknown'; + + return { + encoder, + codecFamily, + acceleration, + hardwareAccelerated: acceleration === 'nvenc' || acceleration === 'amf' || acceleration === 'qsv', + }; + }; + + const checkEncoderAvailable = async (ffmpegPath: string, encoder: string): Promise => { + return new Promise((resolve) => { + const proc = spawn(ffmpegPath, ['-hide_banner', '-encoders']); + let output = ''; + proc.stdout.on('data', (data: Buffer) => { output += data.toString(); }); + proc.stderr.on('data', (data: Buffer) => { output += data.toString(); }); + proc.on('close', () => { + resolve(output.includes(encoder)); + }); + }); + }; + + const probeEncoderUsability = async ( + ffmpegPath: string, + encoder: string, + preset: string, + additionalArgs: string[], + extraVideoArgs: string[], + ): Promise<{ success: boolean; error?: string }> => { + return new Promise((resolve) => { + const tmpOut = path.join(app.getPath('temp'), `recordly-probe-${Date.now()}.mp4`); + const args = [ + '-hide_banner', + '-f', 'rawvideo', '-pix_fmt', 'rgba', + '-s', '64x64', '-r', '1', + '-i', 'pipe:0', + '-c:v', encoder, + '-preset', preset, + ...additionalArgs, + ...extraVideoArgs, + '-frames:v', '1', + '-y', tmpOut, + ]; + + const proc = spawn(ffmpegPath, args); + let stderr = ''; + proc.stderr.on('data', (data: Buffer) => { stderr += data.toString(); }); + + const timeout = setTimeout(() => { + proc.kill(); + resolve({ success: false, error: 'Encoder probe timed out' }); + }, 15000); + + proc.on('close', (code) => { + clearTimeout(timeout); + fs.unlink(tmpOut).catch(() => {}); + if (code === 0) { + resolve({ success: true }); + } else { + resolve({ success: false, error: stderr.trim() || `Exit code ${code}` }); + } + }); + + // Write a single dummy frame (64x64 RGBA = 16384 bytes) + proc.stdin.write(Buffer.alloc(64 * 64 * 4, 0)); + proc.stdin.end(); + }); + }; + + const removeTempOutput = async (outputPath: string) => { + try { + await fs.access(outputPath); + await fs.unlink(outputPath); + } catch { + // Ignore cleanup errors. + } + }; + + ipcMain.handle('ffmpeg-start-encode', async (_, options: FfmpegEncodeOptions) => { + try { + const ffmpegPath = getFfmpegBinaryPath(); + const tempDir = app.getPath('temp'); + const sessionId = `recordly-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const outputPath = path.join(tempDir, `${sessionId}.mp4`); + + let lastError: string | undefined; + + for (const config of buildEncoderConfigs(options)) { + const candidates = config.preferH264 + ? [ + { + encoder: config.h264, + preset: config.preset.h264, + extraVideoArgs: [] as string[], + }, + { + encoder: config.hevc, + preset: config.preset.hevc, + extraVideoArgs: config.hevcExtraArgs, + }, + ] + : [ + { + encoder: config.hevc, + preset: config.preset.hevc, + extraVideoArgs: config.hevcExtraArgs, + }, + { + encoder: config.h264, + preset: config.preset.h264, + extraVideoArgs: [] as string[], + }, + ]; + + for (const candidate of candidates) { + const available = await checkEncoderAvailable(ffmpegPath, candidate.encoder); + if (!available) { + continue; + } + + console.log(`[FFmpegExporter] Probing encoder: ${candidate.encoder}`); + const probe = await probeEncoderUsability( + ffmpegPath, + candidate.encoder, + candidate.preset, + config.additionalArgs, + candidate.extraVideoArgs, + ); + + if (!probe.success) { + lastError = probe.error; + console.warn(`[FFmpegExporter] Encoder probe failed for ${candidate.encoder}:`, probe.error); + continue; + } + + const ffmpegArgs = [ + '-hide_banner', + '-f', 'rawvideo', + '-pix_fmt', 'rgba', + '-s', `${options.width}x${options.height}`, + '-r', String(options.frameRate), + '-i', 'pipe:0', + '-c:v', candidate.encoder, + '-preset', candidate.preset, + ...config.additionalArgs, + ...candidate.extraVideoArgs, + '-b:v', String(options.bitrate), + '-movflags', + '+faststart', + '-y', + outputPath, + ]; + + console.log('[FFmpegExporter] Starting stream encode with encoder:', candidate.encoder); + const ffmpegProcess = spawn(ffmpegPath, ffmpegArgs); + + const session: FfmpegSession = { + ffmpegProcess, + outputPath, + stderrOutput: '', + encoding: describeEncoding(candidate.encoder), + completionPromise: new Promise((resolve, reject) => { + ffmpegProcess.stderr.on('data', (data: Buffer) => { + session.stderrOutput += data.toString(); + }); + ffmpegProcess.on('close', (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(session.stderrOutput.trim() || `FFmpeg exited with code ${code}`)); + } + }); + }), + }; + + ffmpegSessions.set(sessionId, session); + console.log('[FFmpegExporter] Started encode session:', sessionId); + return { success: true, sessionId }; + } + } + + console.error('[FFmpegExporter] No encoder passed the startup probe'); + return { + success: false, + error: lastError || 'No usable FFmpeg encoder was available for streaming export', + }; + } catch (error) { + console.error('[FFmpegExporter] Failed to start encode session:', error); + return { success: false, error: String(error) }; + } + }); + + ipcMain.handle('ffmpeg-write-frame', async (_, sessionId: string, frameData: Uint8Array) => { + const session = ffmpegSessions.get(sessionId); + if (!session) { + return { success: false, error: 'Invalid session ID' }; + } + if ( + session.ffmpegProcess.stdin.destroyed + || !session.ffmpegProcess.stdin.writable + ) { + return { + success: false, + error: session.stderrOutput.trim() || 'FFmpeg encoder is no longer accepting frames', + }; + } + + return new Promise<{ success: boolean; error?: string }>((resolve) => { + session.ffmpegProcess.stdin.write(Buffer.from(frameData), (err) => { + if (err) { + resolve({ success: false, error: err.message }); + } else { + resolve({ success: true }); + } + }); + }); + }); + + ipcMain.handle('ffmpeg-finish-encode', async (_, sessionId: string) => { + const session = ffmpegSessions.get(sessionId); + if (!session) { + return { success: false, error: 'Invalid session ID' }; + } + + try { + session.ffmpegProcess.stdin.end(); + + try { + await session.completionPromise; + } catch (error) { + await removeTempOutput(session.outputPath); + return { success: false, error: String(error) }; + } + + try { + await fs.access(session.outputPath); + } catch { + return { success: false, error: 'Output file was not created' }; + } + + console.log('[FFmpegExporter] Successfully encoded to:', session.outputPath); + const result: { success: boolean; outputPath?: string; error?: string; encoding?: FfmpegEncodingInfo } = { + success: true, + outputPath: session.outputPath, + encoding: session.encoding, + }; + + // Clean up session after successful finish + setTimeout(() => ffmpegSessions.delete(sessionId), 5000); + return result; + } catch (error) { + await removeTempOutput(session.outputPath); + return { success: false, error: String(error) }; + } + }); + + ipcMain.handle('ffmpeg-cancel-encode', async (_, sessionId: string) => { + const session = ffmpegSessions.get(sessionId); + if (!session) { + return { success: true }; + } + + try { + session.ffmpegProcess.kill('SIGKILL'); + } catch { + // Process may already be dead + } + + await removeTempOutput(session.outputPath); + ffmpegSessions.delete(sessionId); + return { success: true }; + }); + + ipcMain.handle('read-encoded-file', async (_, outputPath: string) => { + try { + const data = await fs.readFile(outputPath); + // Clean up temp file after reading + fs.unlink(outputPath).catch(() => {}); + + // Return ArrayBuffer + return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength); + } catch (error) { + console.error('[FFmpegExporter] Failed to read encoded file:', error); + throw error; + } + }); } diff --git a/electron/native/wgc-capture/src/main.cpp b/electron/native/wgc-capture/src/main.cpp index c949e8a2..c53cddb9 100644 --- a/electron/native/wgc-capture/src/main.cpp +++ b/electron/native/wgc-capture/src/main.cpp @@ -281,7 +281,6 @@ int main(int argc, char* argv[]) { // Start stdin listener std::thread stdinThread(stdinListenerThread); - stdinThread.detach(); // Initialize WASAPI captures (but don't start yet) WasapiCapture loopback; @@ -348,6 +347,10 @@ int main(int argc, char* argv[]) { return 1; } + if (stdinThread.joinable()) { + stdinThread.join(); + } + std::cout << "Recording stopped. Output path: " << config.outputPath << std::endl; if (audioActive) { std::cout << "Audio path: " << config.audioOutputPath << std::endl; @@ -357,9 +360,5 @@ int main(int argc, char* argv[]) { } std::cout.flush(); - // Allow pipe buffers to drain before forceful exit - Sleep(100); - - // Fast exit to avoid WinRT/COM teardown crashes during apartment cleanup - ExitProcess(0); + return 0; } diff --git a/electron/preload.ts b/electron/preload.ts index 94ea61ce..e6b73296 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -389,4 +389,26 @@ contextBridge.exposeInMainWorld("electronAPI", { ipcRenderer.on("countdown-tick", listener); return () => ipcRenderer.removeListener("countdown-tick", listener); }, + + // FFmpeg hardware-accelerated encoding (streaming — one frame at a time) + ffmpegStartEncode: (options: { + width: number; + height: number; + frameRate: number; + bitrate: number; + useNVENC: boolean; + useAMF: boolean; + useQuickSync: boolean; + }) => ipcRenderer.invoke("ffmpeg-start-encode", options), + + ffmpegWriteFrame: (sessionId: string, frameData: Uint8Array) => + ipcRenderer.invoke("ffmpeg-write-frame", sessionId, frameData), + + ffmpegFinishEncode: (sessionId: string) => + ipcRenderer.invoke("ffmpeg-finish-encode", sessionId), + + ffmpegCancelEncode: (sessionId: string) => + ipcRenderer.invoke("ffmpeg-cancel-encode", sessionId), + + readEncodedFile: (outputPath: string) => ipcRenderer.invoke("read-encoded-file", outputPath), }); diff --git a/package-lock.json b/package-lock.json index 8d224f16..85facd72 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "recordly", - "version": "1.1.8", + "version": "1.1.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "recordly", - "version": "1.1.8", + "version": "1.1.9", "hasInstallScript": true, "dependencies": { "capturekit": "^1.0.13", @@ -121,6 +121,7 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -350,6 +351,7 @@ "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -1357,7 +1359,6 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, - "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -1379,7 +1380,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -1396,7 +1396,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -1411,7 +1410,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">= 10.0.0" } @@ -2108,6 +2106,7 @@ "integrity": "sha512-LTATglVUPGkPf15zX1wTMlZ0+AU7cGEGF6ekVF1crA8eHUWsGjrYTB+Ht4E3HTrCok8weQG+K01rJndCp/l4XA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.7.2", "@jimp/core": "^0.16.13" @@ -2150,6 +2149,7 @@ "integrity": "sha512-8Z1k96ZFxlhK2bgrY1JNWNwvaBeI/bciLM0yDOni2+aZwfIIiC7Y6PeWHTAvjHNjphz+XCt01WQmOYWCn0ML6g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.7.2", "@jimp/utils": "^0.16.13" @@ -2164,6 +2164,7 @@ "integrity": "sha512-PvLrfa8vkej3qinlebyhLpksJgCF5aiysDMSVhOZqwH5nQLLtDE9WYbnsofGw4r0VVpyw3H/ANCIzYTyCtP9Cg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.7.2", "@jimp/utils": "^0.16.13" @@ -2192,6 +2193,7 @@ "integrity": "sha512-xW+9BtEvoIkkH/Wde9ql4nAFbYLkVINhpgAE7VcBUsuuB34WUbcBl/taOuUYQrPEFQJ4jfXiAJZ2H/rvKjCVnQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.7.2", "@jimp/utils": "^0.16.13", @@ -2241,6 +2243,7 @@ "integrity": "sha512-WEl2tPVYwzYL8OKme6Go2xqiWgKsgxlMwyHabdAU4tXaRwOCnOI7v4021gCcBb9zn/oWwguHuKHmK30Fw2Z/PA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.7.2", "@jimp/utils": "^0.16.13" @@ -2384,6 +2387,7 @@ "integrity": "sha512-qoqtN8LDknm3fJm9nuPygJv30O3vGhSBD2TxrsCnhtOsxKAqVPJtFVdGd/qVuZ8nqQANQmTlfqTiK9mVWQ7MiQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.7.2", "@jimp/utils": "^0.16.13" @@ -2398,6 +2402,7 @@ "integrity": "sha512-Ev+Jjmj1nHYw897z9C3R9dYsPv7S2/nxdgfFb/h8hOwK0Ovd1k/+yYS46A0uj/JCKK0pQk8wOslYBkPwdnLorw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.7.2", "@jimp/utils": "^0.16.13" @@ -2415,6 +2420,7 @@ "integrity": "sha512-05POQaEJVucjTiSGMoH68ZiELc7QqpIpuQlZ2JBbhCV+WCbPFUBcGSmE7w4Jd0E2GvCho/NoMODLwgcVGQA97A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.7.2", "@jimp/utils": "^0.16.13" @@ -2849,7 +2855,6 @@ "integrity": "sha512-a6R+bXKeXMDcRmjYQoBIK+v2EYqxSX49wcjAY579EYM/WrFKS98nSees6lqVUcLKrcQh2DT9srJHX7XMny3voQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@pixi/colord": "^2.9.6" } @@ -2866,8 +2871,7 @@ "resolved": "https://registry.npmjs.org/@pixi/constants/-/constants-7.4.3.tgz", "integrity": "sha512-QGmwJUNQy/vVEHzL6VGQvnwawLZ1wceZMI8HwJAT4/I2uAzbBeFDdmCS8WsTpSWLZjF/DszDc1D8BFp4pVJ5UQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@pixi/core": { "version": "7.4.3", @@ -2896,8 +2900,7 @@ "resolved": "https://registry.npmjs.org/@pixi/extensions/-/extensions-7.4.3.tgz", "integrity": "sha512-FhoiYkHQEDYHUE7wXhqfsTRz6KxLXjuMbSiAwnLb9uG1vAgp6q6qd6HEsf4X30YaZbLFY8a4KY6hFZWjF+4Fdw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@pixi/filter-drop-shadow": { "version": "5.2.0", @@ -2937,16 +2940,14 @@ "resolved": "https://registry.npmjs.org/@pixi/math/-/math-7.4.3.tgz", "integrity": "sha512-/uJOVhR2DOZ+zgdI6Bs/CwcXT4bNRKsS+TqX3ekRIxPCwaLra+Qdm7aDxT5cTToDzdxbKL5+rwiLu3Y1egILDw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@pixi/runner": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@pixi/runner/-/runner-7.4.3.tgz", "integrity": "sha512-TJyfp7y23u5vvRAyYhVSa7ytq0PdKSvPLXu4G3meoFh1oxTLHH6g/RIzLuxUAThPG2z7ftthuW3qWq6dRV+dhw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@pixi/settings": { "version": "7.4.3", @@ -2954,7 +2955,6 @@ "integrity": "sha512-SmGK8smc0PxRB9nr0UJioEtE9hl4gvj9OedCvZx3bxBwA3omA5BmP3CyhQfN8XJ29+o2OUL01r3zAPVol4l4lA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@pixi/constants": "7.4.3", "@types/css-font-loading-module": "^0.0.12", @@ -2967,7 +2967,6 @@ "integrity": "sha512-tHsAD0iOUb6QSGGw+c8cyRBvxsq/NlfzIFBZLEHhWZ+Bx4a0MmXup6I/yJDGmyPCYE+ctCcAfY13wKAzdiVFgQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@pixi/extensions": "7.4.3", "@pixi/settings": "7.4.3", @@ -2980,7 +2979,6 @@ "integrity": "sha512-NO3Y9HAn2UKS1YdxffqsPp+kDpVm8XWvkZcS/E+rBzY9VTLnNOI7cawSRm+dacdET3a8Jad3aDKEDZ0HmAqAFA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@pixi/color": "7.4.3", "@pixi/constants": "7.4.3", @@ -2996,24 +2994,21 @@ "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-2.1.4.tgz", "integrity": "sha512-qp3m9PPz4gULB9MhjGID7wpo3gJ4bTGXm7ltNDsmOvsPduTeHp8wSW9YckBj3mljeOh4F0m2z/0JKAALRKbmLQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@pixi/utils/node_modules/earcut": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/@pixi/utils/node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", @@ -4547,6 +4542,7 @@ "integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -4558,6 +4554,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -4871,6 +4868,7 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5684,6 +5682,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -5996,7 +5995,6 @@ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" @@ -6457,8 +6455,7 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -6757,6 +6754,7 @@ "integrity": "sha512-uOOBA3f+kW3o4KpSoMQ6SNpdXU7WtxlJRb9vCZgOvqhTz4b3GjcoWKstdisizNZLsylhTMv8TLHFPFW0Uxsj/g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "26.7.0", "builder-util": "26.4.1", @@ -7238,7 +7236,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -7259,7 +7256,6 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -9024,6 +9020,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -10231,7 +10228,6 @@ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4" }, @@ -10779,6 +10775,7 @@ "integrity": "sha512-ituDiEBb1Oqx56RYwTtC6MjPUhPfF/i15fpUv5oEqmzC/ce3SaSumulJcOjKG7+y0J0Ekl9Rl4XTxaUw+MVFZw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@pixi/colord": "^2.9.6", "@types/css-font-loading-module": "^0.0.12", @@ -10849,6 +10846,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -10999,7 +10997,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -11017,7 +11014,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -11167,7 +11163,6 @@ "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "side-channel": "^1.1.0" }, @@ -11229,6 +11224,7 @@ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -11242,6 +11238,7 @@ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -12039,7 +12036,6 @@ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", @@ -12060,7 +12056,6 @@ "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" @@ -12078,7 +12073,6 @@ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -12098,7 +12092,6 @@ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -12758,6 +12751,7 @@ "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -12831,7 +12825,6 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -12895,7 +12888,6 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -12910,7 +12902,6 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -12924,6 +12915,7 @@ "integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -13085,6 +13077,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -13334,7 +13327,6 @@ "integrity": "sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "punycode": "^1.4.1", "qs": "^6.12.3" @@ -13348,8 +13340,7 @@ "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/use-callback-ref": { "version": "1.3.3", @@ -13466,6 +13457,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -13540,7 +13532,8 @@ "resolved": "https://registry.npmjs.org/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz", "integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/vitest": { "version": "4.0.16", @@ -14104,6 +14097,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -14117,6 +14111,7 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/src/lib/exporter/ffmpegExporter.ts b/src/lib/exporter/ffmpegExporter.ts new file mode 100644 index 00000000..9b57f0f2 --- /dev/null +++ b/src/lib/exporter/ffmpegExporter.ts @@ -0,0 +1,295 @@ +import type { + AnnotationRegion, + AudioRegion, + AutoCaptionSettings, + CaptionCue, + CropRegion, + CursorStyle, + CursorTelemetryPoint, + SpeedRegion, + TrimRegion, + WebcamOverlaySettings, + ZoomRegion, + ZoomTransitionEasing, +} from "@/components/video-editor/types"; +import { AudioProcessor } from "./audioEncoder"; +import { FrameRenderer } from "./frameRenderer"; +import { StreamingVideoDecoder } from "./streamingDecoder"; +import type { ExportConfig, ExportProgress, ExportResult } from "./types"; + +interface FFmpegExporterConfig extends ExportConfig { + videoUrl: string; + wallpaper: string; + zoomRegions: ZoomRegion[]; + trimRegions?: TrimRegion[]; + speedRegions?: SpeedRegion[]; + showShadow: boolean; + shadowIntensity: number; + backgroundBlur: number; + zoomMotionBlur?: number; + connectZooms?: boolean; + zoomInDurationMs?: number; + zoomInOverlapMs?: number; + zoomOutDurationMs?: number; + connectedZoomGapMs?: number; + connectedZoomDurationMs?: number; + zoomInEasing?: ZoomTransitionEasing; + zoomOutEasing?: ZoomTransitionEasing; + connectedZoomEasing?: ZoomTransitionEasing; + borderRadius?: number; + padding?: number; + videoPadding?: number; + cropRegion: CropRegion; + webcam?: WebcamOverlaySettings; + webcamUrl?: string | null; + annotationRegions?: AnnotationRegion[]; + autoCaptions?: CaptionCue[]; + autoCaptionSettings?: AutoCaptionSettings; + cursorTelemetry?: CursorTelemetryPoint[]; + showCursor?: boolean; + cursorStyle?: CursorStyle; + cursorSize?: number; + cursorSmoothing?: number; + cursorMotionBlur?: number; + cursorClickBounce?: number; + cursorClickBounceDuration?: number; + cursorSway?: number; + audioRegions?: AudioRegion[]; + previewWidth?: number; + previewHeight?: number; + onProgress?: (progress: ExportProgress) => void; + useNVENC?: boolean; + useAMF?: boolean; + useQuickSync?: boolean; +} + +/** + * FFmpeg-based exporter with hardware acceleration support. + * Uses NVENC (NVIDIA), AMF (AMD), or QuickSync (Intel) for GPU encoding. + * Streams frames to main process incrementally to avoid OOM. + */ +export class FFmpegExporter { + private config: FFmpegExporterConfig; + private streamingDecoder: StreamingVideoDecoder | null = null; + private renderer: FrameRenderer | null = null; + private audioProcessor: AudioProcessor | null = null; + private cancelled = false; + private sessionId: string | null = null; + + constructor(config: FFmpegExporterConfig) { + this.config = config; + } + + private getEffectiveFrameRate(sourceFrameRate?: number): number { + if (!Number.isFinite(sourceFrameRate) || !sourceFrameRate || sourceFrameRate <= 0) { + return this.config.frameRate; + } + + const roundedSourceFrameRate = Math.max(1, Math.round(sourceFrameRate)); + return Math.min(this.config.frameRate, roundedSourceFrameRate); + } + + async export(): Promise { + try { + this.cancelled = false; + + // Initialize streaming decoder + this.streamingDecoder = new StreamingVideoDecoder(); + const videoInfo = await this.streamingDecoder.loadMetadata(this.config.videoUrl); + const effectiveFrameRate = this.getEffectiveFrameRate(videoInfo.frameRate); + + // Initialize frame renderer + this.renderer = new FrameRenderer({ + width: this.config.width, + height: this.config.height, + wallpaper: this.config.wallpaper, + zoomRegions: this.config.zoomRegions, + showShadow: this.config.showShadow, + shadowIntensity: this.config.shadowIntensity, + backgroundBlur: this.config.backgroundBlur, + zoomMotionBlur: this.config.zoomMotionBlur, + connectZooms: this.config.connectZooms, + zoomInDurationMs: this.config.zoomInDurationMs, + zoomInOverlapMs: this.config.zoomInOverlapMs, + zoomOutDurationMs: this.config.zoomOutDurationMs, + connectedZoomGapMs: this.config.connectedZoomGapMs, + connectedZoomDurationMs: this.config.connectedZoomDurationMs, + zoomInEasing: this.config.zoomInEasing, + zoomOutEasing: this.config.zoomOutEasing, + connectedZoomEasing: this.config.connectedZoomEasing, + borderRadius: this.config.borderRadius, + padding: this.config.padding, + cropRegion: this.config.cropRegion, + webcam: this.config.webcam, + webcamUrl: this.config.webcamUrl, + videoWidth: videoInfo.width, + videoHeight: videoInfo.height, + annotationRegions: this.config.annotationRegions, + autoCaptions: this.config.autoCaptions, + autoCaptionSettings: this.config.autoCaptionSettings, + speedRegions: this.config.speedRegions, + previewWidth: this.config.previewWidth, + previewHeight: this.config.previewHeight, + cursorTelemetry: this.config.cursorTelemetry, + showCursor: this.config.showCursor, + cursorStyle: this.config.cursorStyle, + cursorSize: this.config.cursorSize, + cursorSmoothing: this.config.cursorSmoothing, + cursorMotionBlur: this.config.cursorMotionBlur, + cursorClickBounce: this.config.cursorClickBounce, + cursorClickBounceDuration: this.config.cursorClickBounceDuration, + cursorSway: this.config.cursorSway, + }); + await this.renderer.initialize(); + + // Calculate frame count + const effectiveDuration = this.streamingDecoder.getEffectiveDuration( + this.config.trimRegions, + this.config.speedRegions, + ); + const totalFrames = Math.ceil(effectiveDuration * effectiveFrameRate); + + console.log("[FFmpegExporter] Total frames:", totalFrames); + console.log("[FFmpegExporter] Source frame rate:", videoInfo.frameRate); + console.log("[FFmpegExporter] Effective export frame rate:", effectiveFrameRate); + console.log("[FFmpegExporter] Using FFmpeg hardware acceleration"); + + // Step 1: Start encoding session + const session = await window.electronAPI.ffmpegStartEncode({ + width: this.config.width, + height: this.config.height, + frameRate: effectiveFrameRate, + bitrate: this.config.bitrate, + useNVENC: this.config.useNVENC ?? true, + useAMF: this.config.useAMF ?? false, + useQuickSync: this.config.useQuickSync ?? false, + }); + + if (!session.success) { + return { success: false, error: session.error || "Failed to start FFmpeg encode session" }; + } + + const sessionId = session.sessionId!; + this.sessionId = sessionId; + + // Step 2: Stream frames + let frameIndex = 0; + + await this.streamingDecoder.decodeAll( + effectiveFrameRate, + this.config.trimRegions, + this.config.speedRegions, + async (videoFrame, _exportTimestampUs, sourceTimestampMs) => { + if (this.cancelled) { + videoFrame.close(); + return; + } + + const sourceTimestampUs = sourceTimestampMs * 1000; + await this.renderer!.renderFrame(videoFrame, sourceTimestampUs); + videoFrame.close(); + + // Extract raw RGBA data from the composite canvas + const writeResult = await window.electronAPI.ffmpegWriteFrame( + sessionId, + this.renderer!.readCompositeRgbaFrame(), + ); + if (!writeResult.success) { + throw new Error(writeResult.error || "Failed to stream frame to FFmpeg"); + } + + frameIndex++; + this.reportProgress(frameIndex, totalFrames, "rendering"); + }, + ); + + if (this.cancelled) { + return { success: false, error: "Export cancelled" }; + } + + // Step 3: Finish encoding + this.reportProgress(totalFrames, totalFrames, "encoding"); + + this.sessionId = null; + const encodeResult = await window.electronAPI.ffmpegFinishEncode(sessionId); + + if (!encodeResult.success) { + return { success: false, error: encodeResult.error || "FFmpeg encoding failed" }; + } + + // Step 4: Read the encoded file and construct a Blob + const arrayBuffer = await window.electronAPI.readEncodedFile(encodeResult.outputPath!); + const blob = new Blob([arrayBuffer], { type: "video/mp4" }); + + return { success: true, blob, encoding: encodeResult.encoding }; + } catch (error) { + console.error("FFmpeg export error:", error); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } finally { + this.cleanup(); + } + } + + private reportProgress( + currentFrame: number, + totalFrames: number, + phase: "rendering" | "encoding", + ) { + if (this.config.onProgress) { + this.config.onProgress({ + currentFrame, + totalFrames, + percentage: + phase === "rendering" + ? (currentFrame / totalFrames) * 90 + : 90 + (currentFrame / totalFrames) * 10, + estimatedTimeRemaining: 0, + phase, + }); + } + } + + cancel(): void { + this.cancelled = true; + if (this.streamingDecoder) { + this.streamingDecoder.cancel(); + } + if (this.audioProcessor) { + this.audioProcessor.cancel(); + } + this.cleanup(); + } + + private cleanup(): void { + const sessionId = this.sessionId; + this.sessionId = null; + if (sessionId) { + void window.electronAPI.ffmpegCancelEncode(sessionId).catch((error) => { + console.warn("Error cancelling FFmpeg encode session:", error); + }); + } + + if (this.streamingDecoder) { + try { + this.streamingDecoder.destroy(); + } catch (e) { + console.warn("Error destroying streaming decoder:", e); + } + this.streamingDecoder = null; + } + + if (this.renderer) { + try { + this.renderer.destroy(); + } catch (e) { + console.warn("Error destroying renderer:", e); + } + this.renderer = null; + } + + this.audioProcessor = null; + } +} diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index 716fd711..9466e483 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -1276,6 +1276,19 @@ export class FrameRenderer { return this.compositeCanvas; } + /** + * Read the raw RGBA pixel data from the composite canvas. + * Used by FFmpegExporter to stream frames to FFmpeg via IPC. + */ + readCompositeRgbaFrame(): Uint8Array { + if (!this.compositeCanvas || !this.compositeCtx) { + throw new Error("Renderer not initialized"); + } + const { width, height } = this.compositeCanvas; + const imageData = this.compositeCtx.getImageData(0, 0, width, height); + return new Uint8Array(imageData.data.buffer, imageData.data.byteOffset, imageData.data.byteLength); + } + destroy(): void { if (this.videoSprite) { const videoTexture = this.videoSprite.texture; diff --git a/src/lib/exporter/index.ts b/src/lib/exporter/index.ts index 9c10b3b8..c431adf7 100644 --- a/src/lib/exporter/index.ts +++ b/src/lib/exporter/index.ts @@ -1,4 +1,5 @@ export { VideoExporter } from './videoExporter'; +export { FFmpegExporter } from './ffmpegExporter'; export { VideoFileDecoder } from './videoDecoder'; export { StreamingVideoDecoder } from './streamingDecoder'; export { FrameRenderer } from './frameRenderer'; @@ -12,7 +13,8 @@ export { export type { ExportConfig, ExportProgress, - ExportResult, + ExportResult, + ExportEncodingInfo, VideoFrameData, ExportQuality, ExportFormat, diff --git a/src/lib/exporter/types.ts b/src/lib/exporter/types.ts index a92d28db..c42f530b 100644 --- a/src/lib/exporter/types.ts +++ b/src/lib/exporter/types.ts @@ -11,14 +11,22 @@ export interface ExportProgress { totalFrames: number; percentage: number; estimatedTimeRemaining: number; // in seconds - phase?: 'extracting' | 'finalizing' | 'saving'; // Phase of export + phase?: 'extracting' | 'rendering' | 'encoding' | 'finalizing' | 'saving'; // Phase of export renderProgress?: number; // 0-100, progress of GIF rendering phase } +export interface ExportEncodingInfo { + encoder: string; + codecFamily: 'hevc' | 'h264' | 'unknown'; + acceleration: 'nvenc' | 'amf' | 'qsv' | 'cpu' | 'webcodecs' | 'unknown'; + hardwareAccelerated: boolean; +} + export interface ExportResult { success: boolean; blob?: Blob; error?: string; + encoding?: ExportEncodingInfo; } export interface VideoFrameData {