Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,11 @@ test-results
playwright-report/

# Vitest browser mode screenshots
__screenshots__/
__screenshots__/

# Native downloads
vendor/

# AI workspaces
.qwen
.gemini
5 changes: 5 additions & 0 deletions electron-builder.json5
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@
{
"from": "public/wallpapers",
"to": "assets/wallpapers"
},
{
"from": "vendor/ffmpeg",
"to": "ffmpeg",
"filter": ["ffmpeg.exe", "ffprobe.exe", "ffmpeg", "ffprobe"]
}
],

Expand Down
44 changes: 44 additions & 0 deletions electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,50 @@ interface Window {
setHasUnsavedChanges: (hasChanges: boolean) => void;
onRequestSaveBeforeClose: (callback: () => Promise<boolean> | boolean) => () => void;
setLocale: (locale: string) => Promise<void>;

// ---- FFmpeg Native Export ----
ffmpegGetCapabilities: () => Promise<{
available: boolean;
encoders: string[];
bestEncoder: string | null;
path: string | null;
}>;
ffmpegExportStart: (config: {
width: number;
height: number;
frameRate: number;
encoder: string;
bitrate: number;
audioSourcePath?: string;
hasAudio?: boolean;
}) => Promise<{
success: boolean;
sessionId?: string;
error?: string;
}>;
ffmpegExportFrame: (
sessionId: string,
frameData: ArrayBuffer,
) => Promise<{
success: boolean;
backpressure?: boolean;
frameCount?: number;
error?: string;
}>;
ffmpegExportFinish: (
sessionId: string,
fileName: string,
) => Promise<{
success: boolean;
path?: string;
message?: string;
canceled?: boolean;
error?: string;
}>;
ffmpegExportCancel: (sessionId: string) => Promise<{
success: boolean;
error?: string;
}>;
};
}

Expand Down
253 changes: 253 additions & 0 deletions electron/ffmpeg/ffmpegManager.ts
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;
}
}
Loading