diff --git a/electron/ipc/nativeVideoExport.ts b/electron/ipc/nativeVideoExport.ts new file mode 100644 index 00000000..abbdabbb --- /dev/null +++ b/electron/ipc/nativeVideoExport.ts @@ -0,0 +1,172 @@ +const NATIVE_EXPORT_INPUT_BYTES_PER_PIXEL = 4 + +export type NativeExportEncodingMode = 'fast' | 'balanced' | 'quality' + +export type NativeVideoExportAudioMode = 'none' | 'copy-source' | 'trim-source' | 'edited-track' + +export interface NativeVideoExportStartOptions { + width: number + height: number + frameRate: number + bitrate: number + encodingMode: NativeExportEncodingMode +} + +export interface NativeVideoExportAudioSegment { + startMs: number + endMs: number +} + +export interface NativeVideoExportFinishOptions { + audioMode?: NativeVideoExportAudioMode + audioSourcePath?: string | null + trimSegments?: NativeVideoExportAudioSegment[] + editedAudioData?: ArrayBuffer + editedAudioMimeType?: string | null +} + +export function getNativeVideoInputByteSize(width: number, height: number): number { + return width * height * NATIVE_EXPORT_INPUT_BYTES_PER_PIXEL +} + +export function parseAvailableFfmpegEncoders(stdout: string): Set { + const encoders = new Set() + + for (const line of stdout.split(/\r?\n/)) { + const match = line.match(/^\s*[A-Z.]{6}\s+([a-z0-9_]+)/i) + if (match?.[1]) { + encoders.add(match[1]) + } + } + + return encoders +} + +export function getPreferredNativeVideoEncoders(platform: NodeJS.Platform): string[] { + switch (platform) { + case 'darwin': + return ['h264_videotoolbox', 'libx264'] + case 'win32': + return ['h264_nvenc', 'h264_qsv', 'h264_amf', 'h264_mf', 'libx264'] + case 'linux': + return ['h264_nvenc', 'h264_qsv', 'libx264'] + default: + return ['libx264'] + } +} + +function getLibx264ModeArgs(encodingMode: NativeExportEncodingMode): string[] { + switch (encodingMode) { + case 'fast': + return ['-preset', 'ultrafast', '-tune', 'zerolatency'] + case 'quality': + return ['-preset', 'slow'] + case 'balanced': + default: + return ['-preset', 'medium'] + } +} + +function getBitrateArgs(bitrate: number): string[] { + const effectiveBitrate = Math.max(1_500_000, Math.round(bitrate)) + const maxRate = Math.max(effectiveBitrate, Math.round(effectiveBitrate * 1.2)) + const bufferSize = Math.max(maxRate * 2, effectiveBitrate * 2) + + return [ + '-b:v', + String(effectiveBitrate), + '-maxrate', + String(maxRate), + '-bufsize', + String(bufferSize), + ] +} + +export function buildNativeVideoExportArgs( + encoder: string, + options: NativeVideoExportStartOptions, + outputPath: string, +): string[] { + const args = [ + '-y', + '-hide_banner', + '-loglevel', + 'error', + '-f', + 'rawvideo', + '-pix_fmt', + 'rgba', + '-s:v', + `${options.width}x${options.height}`, + '-framerate', + String(options.frameRate), + '-i', + 'pipe:0', + '-vf', + 'vflip', + '-an', + '-c:v', + encoder, + '-g', + String(Math.max(1, Math.round(options.frameRate * 5))), + ...getBitrateArgs(options.bitrate), + ] + + if (encoder === 'libx264') { + args.push(...getLibx264ModeArgs(options.encodingMode)) + } + + args.push('-pix_fmt', 'yuv420p', '-movflags', '+faststart', outputPath) + return args +} + +function formatFfmpegSeconds(milliseconds: number): string { + return (milliseconds / 1000).toFixed(3) +} + +export function buildTrimmedSourceAudioFilter( + segments: NativeVideoExportAudioSegment[], +): string | null { + if (segments.length === 0) { + return null + } + + const filterParts: string[] = [] + const segmentLabels: string[] = [] + + segments.forEach((segment, index) => { + const label = `trimmed_audio_${index}` + filterParts.push( + `[1:a]atrim=start=${formatFfmpegSeconds(segment.startMs)}:end=${formatFfmpegSeconds(segment.endMs)},asetpts=PTS-STARTPTS[${label}]`, + ) + segmentLabels.push(`[${label}]`) + }) + + if (segmentLabels.length === 1) { + filterParts.push(`${segmentLabels[0]}anull[aout]`) + } else { + filterParts.push(`${segmentLabels.join('')}concat=n=${segmentLabels.length}:v=0:a=1[aout]`) + } + + return filterParts.join(';') +} + +export function getEditedAudioExtension(mimeType?: string | null): string { + if (!mimeType) { + return '.webm' + } + + if (mimeType.includes('wav')) { + return '.wav' + } + + if (mimeType.includes('mp4') || mimeType.includes('m4a')) { + return '.m4a' + } + + if (mimeType.includes('ogg')) { + return '.ogg' + } + + return '.webm' +} \ No newline at end of file