-
Notifications
You must be signed in to change notification settings - Fork 2.7k
feat: Implement zero-copy hardware-accelerated hybrid FFmpeg export pipeline #443
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
askadityapandey
wants to merge
4
commits into
siddharthvaddem:main
Choose a base branch
from
askadityapandey:main
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 1 commit
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
a897240
feat: Implement zero-copy hardware-accelerated hybrid FFmpeg export p…
askadityapandey d7d9613
fix: Resolve PR review findings on webcam pipeline, packaging filters…
askadityapandey c591804
fix(export): Harden FFmpeg export IPC boundaries, fix webcam sync reg…
askadityapandey 43efd52
fix(export): Detect VideoEncoder buffer failures and trace IPC resets…
askadityapandey File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,253 @@ | ||
| import { execFile } from "node:child_process"; | ||
| import fs from "node:fs"; | ||
| import path from "node:path"; | ||
| import { app } from "electron"; | ||
|
|
||
| let cachedFFmpegPath: string | null = null; | ||
| let cachedEncoders: string[] | null = null; | ||
|
|
||
| /** | ||
| * Resolves the FFmpeg binary path. | ||
| * - In packaged builds: looks in extraResources/ffmpeg/ | ||
| * - In development: looks for system FFmpeg on PATH, or a local vendor copy | ||
| */ | ||
| export function getFFmpegPath(): string | null { | ||
| if (cachedFFmpegPath !== null) { | ||
| return cachedFFmpegPath; | ||
| } | ||
|
|
||
| const isWin = process.platform === "win32"; | ||
| const binaryName = isWin ? "ffmpeg.exe" : "ffmpeg"; | ||
|
|
||
| // 1. Packaged build — extraResources | ||
| if (app.isPackaged) { | ||
| const resourcePath = path.join(process.resourcesPath, "ffmpeg", binaryName); | ||
| if (fs.existsSync(resourcePath)) { | ||
| cachedFFmpegPath = resourcePath; | ||
| return cachedFFmpegPath; | ||
| } | ||
| } | ||
|
|
||
| // 2. Development — local vendor directory | ||
| const vendorPath = path.join(app.getAppPath(), "vendor", "ffmpeg", binaryName); | ||
| if (fs.existsSync(vendorPath)) { | ||
| cachedFFmpegPath = vendorPath; | ||
| return cachedFFmpegPath; | ||
| } | ||
|
|
||
| // 3. System PATH fallback | ||
| const systemPath = findOnPath(binaryName); | ||
| if (systemPath) { | ||
| cachedFFmpegPath = systemPath; | ||
| return cachedFFmpegPath; | ||
| } | ||
|
|
||
| cachedFFmpegPath = null; | ||
| return null; | ||
| } | ||
|
|
||
| /** | ||
| * Checks if FFmpeg is available. | ||
| */ | ||
| export function isFFmpegAvailable(): boolean { | ||
| return getFFmpegPath() !== null; | ||
| } | ||
|
|
||
| /** | ||
| * Probes available hardware encoders by running `ffmpeg -encoders`. | ||
| * Caches the result after the first call. | ||
| */ | ||
| export async function probeHardwareEncoders(): Promise<string[]> { | ||
| if (cachedEncoders !== null) { | ||
| return cachedEncoders; | ||
| } | ||
|
|
||
| const ffmpegPath = getFFmpegPath(); | ||
| if (!ffmpegPath) { | ||
| cachedEncoders = []; | ||
| return cachedEncoders; | ||
| } | ||
|
|
||
| try { | ||
| const output = await execFileAsync(ffmpegPath, ["-hide_banner", "-encoders"]); | ||
| const encoders: string[] = []; | ||
|
|
||
| // Check for hardware H.264 encoders | ||
| const hwEncoders = [ | ||
| "h264_nvenc", // NVIDIA | ||
| "h264_qsv", // Intel Quick Sync | ||
| "h264_amf", // AMD | ||
| ]; | ||
|
|
||
| for (const encoder of hwEncoders) { | ||
| if (output.includes(encoder)) { | ||
| // Verify the encoder actually works by trying to initialize it | ||
| const works = await testEncoder(ffmpegPath, encoder); | ||
| if (works) { | ||
| encoders.push(encoder); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Software fallback is always available if FFmpeg exists | ||
| if (output.includes("libx264")) { | ||
| encoders.push("libx264"); | ||
| } | ||
|
|
||
| cachedEncoders = encoders; | ||
| console.log("[FFmpegManager] Available encoders:", encoders); | ||
| return cachedEncoders; | ||
| } catch (error) { | ||
| console.warn("[FFmpegManager] Failed to probe encoders:", error); | ||
| cachedEncoders = []; | ||
| return cachedEncoders; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Selects the best available encoder. | ||
| * Priority: NVENC > QSV > AMF > libx264 | ||
| */ | ||
| export async function selectBestEncoder(): Promise<string | null> { | ||
| const encoders = await probeHardwareEncoders(); | ||
| const priority = ["h264_nvenc", "h264_qsv", "h264_amf", "libx264"]; | ||
| for (const encoder of priority) { | ||
| if (encoders.includes(encoder)) { | ||
| return encoder; | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| /** | ||
| * Gets the full FFmpeg capabilities object for the renderer. | ||
| */ | ||
| export async function getFFmpegCapabilities(): Promise<{ | ||
| available: boolean; | ||
| encoders: string[]; | ||
| bestEncoder: string | null; | ||
| path: string | null; | ||
| }> { | ||
| const ffmpegPath = getFFmpegPath(); | ||
| if (!ffmpegPath) { | ||
| return { available: false, encoders: [], bestEncoder: null, path: null }; | ||
| } | ||
|
|
||
| const encoders = await probeHardwareEncoders(); | ||
| const bestEncoder = await selectBestEncoder(); | ||
|
|
||
| return { | ||
| available: true, | ||
| encoders, | ||
| bestEncoder, | ||
| path: ffmpegPath, | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Builds FFmpeg arguments for encoding raw RGBA frames piped to stdin. | ||
| */ | ||
| export function buildFFmpegArgs(config: { | ||
| width: number; | ||
| height: number; | ||
| frameRate: number; | ||
| encoder: string; | ||
| bitrate: number; | ||
| outputPath: string; | ||
| audioSourcePath?: string; | ||
| hasAudio?: boolean; | ||
| }): string[] { | ||
| const args: string[] = [ | ||
| "-hide_banner", | ||
| "-loglevel", | ||
| "warning", | ||
| "-y", // overwrite output | ||
|
|
||
| // Input 0: Raw H.264 video stream from stdin (encoded by Chrome's hardware encoder) | ||
| "-f", | ||
| "h264", | ||
| "-r", | ||
| String(config.frameRate), | ||
| "-i", | ||
| "pipe:0", | ||
| ]; | ||
|
|
||
| // Input 1: audio from source file (if available) | ||
| if (config.audioSourcePath && config.hasAudio) { | ||
| args.push("-i", config.audioSourcePath); | ||
| } | ||
|
|
||
| // Video encoding settings - we just copy the stream since it's already hardware-encoded H.264! | ||
| args.push("-map", "0:v", "-c:v", "copy"); | ||
|
|
||
| // Audio settings | ||
| if (config.audioSourcePath && config.hasAudio) { | ||
| args.push("-map", "1:a", "-c:a", "aac", "-b:a", "192k", "-ac", "2"); | ||
| } | ||
|
|
||
| // MP4 settings | ||
| args.push( | ||
| "-movflags", | ||
| "+faststart", | ||
| "-shortest", // end when shortest stream ends | ||
| config.outputPath, | ||
| ); | ||
|
|
||
| return args; | ||
| } | ||
|
|
||
| // ---- Helpers ---- | ||
|
|
||
| function findOnPath(binaryName: string): string | null { | ||
| const pathEnv = process.env.PATH || ""; | ||
| const separator = process.platform === "win32" ? ";" : ":"; | ||
| const dirs = pathEnv.split(separator); | ||
|
|
||
| for (const dir of dirs) { | ||
| const fullPath = path.join(dir, binaryName); | ||
| if (fs.existsSync(fullPath)) { | ||
| return fullPath; | ||
| } | ||
| } | ||
|
|
||
| return null; | ||
| } | ||
|
|
||
| function execFileAsync(cmd: string, args: string[]): Promise<string> { | ||
| return new Promise((resolve, reject) => { | ||
| execFile(cmd, args, { maxBuffer: 1024 * 1024 }, (error, stdout, stderr) => { | ||
| if (error) { | ||
| reject(error); | ||
| return; | ||
| } | ||
| resolve(stdout + stderr); | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| async function testEncoder(ffmpegPath: string, encoder: string): Promise<boolean> { | ||
| try { | ||
| // Try encoding 1 black frame with the encoder to see if it actually initializes | ||
| // Using 256x256 because some hardware encoders (NVENC/QSV) fail on very small dimensions like 64x64 | ||
| await execFileAsync(ffmpegPath, [ | ||
| "-hide_banner", | ||
| "-loglevel", | ||
| "error", | ||
| "-f", | ||
| "lavfi", | ||
| "-i", | ||
| "color=c=black:s=256x256:d=0.1", | ||
| "-c:v", | ||
| encoder, | ||
| "-frames:v", | ||
| "1", | ||
| "-f", | ||
| "null", | ||
| "-", | ||
| ]); | ||
| return true; | ||
| } catch { | ||
| console.warn(`[FFmpegManager] Encoder ${encoder} failed validation test`); | ||
| return false; | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.