213 lines
4.9 KiB
TypeScript
213 lines
4.9 KiB
TypeScript
|
|
import type { FFmpeg } from '@ffmpeg/ffmpeg';
|
||
|
|
import { fetchFile } from '@ffmpeg/util';
|
||
|
|
import { loadFFmpeg } from '@/lib/wasm/wasmLoader';
|
||
|
|
import type { ConversionOptions, ProgressCallback, ConversionResult } from '@/types/conversion';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Convert video/audio using FFmpeg
|
||
|
|
*/
|
||
|
|
export async function convertWithFFmpeg(
|
||
|
|
file: File,
|
||
|
|
outputFormat: string,
|
||
|
|
options: ConversionOptions = {},
|
||
|
|
onProgress?: ProgressCallback
|
||
|
|
): Promise<ConversionResult> {
|
||
|
|
const startTime = Date.now();
|
||
|
|
|
||
|
|
try {
|
||
|
|
// Load FFmpeg instance
|
||
|
|
const ffmpeg: FFmpeg = await loadFFmpeg();
|
||
|
|
|
||
|
|
// Set up progress tracking
|
||
|
|
if (onProgress) {
|
||
|
|
ffmpeg.on('progress', ({ progress }) => {
|
||
|
|
onProgress(Math.round(progress * 100));
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// Input filename
|
||
|
|
const inputName = file.name;
|
||
|
|
const outputName = `output.${outputFormat}`;
|
||
|
|
|
||
|
|
// Write input file to FFmpeg virtual file system
|
||
|
|
await ffmpeg.writeFile(inputName, await fetchFile(file));
|
||
|
|
|
||
|
|
// Build FFmpeg command based on format and options
|
||
|
|
const args = buildFFmpegArgs(inputName, outputName, outputFormat, options);
|
||
|
|
|
||
|
|
console.log('[FFmpeg] Running command:', args.join(' '));
|
||
|
|
|
||
|
|
// Execute FFmpeg command
|
||
|
|
await ffmpeg.exec(args);
|
||
|
|
|
||
|
|
// Read output file
|
||
|
|
const data = await ffmpeg.readFile(outputName);
|
||
|
|
const blob = new Blob([data as BlobPart], { type: getMimeType(outputFormat) });
|
||
|
|
|
||
|
|
// Clean up virtual file system
|
||
|
|
await ffmpeg.deleteFile(inputName);
|
||
|
|
await ffmpeg.deleteFile(outputName);
|
||
|
|
|
||
|
|
const duration = Date.now() - startTime;
|
||
|
|
|
||
|
|
return {
|
||
|
|
success: true,
|
||
|
|
blob,
|
||
|
|
duration,
|
||
|
|
};
|
||
|
|
} catch (error) {
|
||
|
|
console.error('[FFmpeg] Conversion error:', error);
|
||
|
|
|
||
|
|
return {
|
||
|
|
success: false,
|
||
|
|
error: error instanceof Error ? error.message : 'Unknown conversion error',
|
||
|
|
duration: Date.now() - startTime,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Build FFmpeg command arguments
|
||
|
|
*/
|
||
|
|
function buildFFmpegArgs(
|
||
|
|
inputName: string,
|
||
|
|
outputName: string,
|
||
|
|
outputFormat: string,
|
||
|
|
options: ConversionOptions
|
||
|
|
): string[] {
|
||
|
|
const args = ['-i', inputName];
|
||
|
|
|
||
|
|
// Video codec
|
||
|
|
if (options.videoCodec) {
|
||
|
|
args.push('-c:v', options.videoCodec);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Video bitrate
|
||
|
|
if (options.videoBitrate) {
|
||
|
|
args.push('-b:v', options.videoBitrate);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Video resolution
|
||
|
|
if (options.videoResolution) {
|
||
|
|
args.push('-s', options.videoResolution);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Video FPS
|
||
|
|
if (options.videoFps) {
|
||
|
|
args.push('-r', options.videoFps.toString());
|
||
|
|
}
|
||
|
|
|
||
|
|
// Audio codec
|
||
|
|
if (options.audioCodec) {
|
||
|
|
args.push('-c:a', options.audioCodec);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Audio bitrate
|
||
|
|
if (options.audioBitrate) {
|
||
|
|
args.push('-b:a', options.audioBitrate);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Audio sample rate
|
||
|
|
if (options.audioSampleRate) {
|
||
|
|
args.push('-ar', options.audioSampleRate.toString());
|
||
|
|
}
|
||
|
|
|
||
|
|
// Audio channels
|
||
|
|
if (options.audioChannels) {
|
||
|
|
args.push('-ac', options.audioChannels.toString());
|
||
|
|
}
|
||
|
|
|
||
|
|
// Format-specific settings
|
||
|
|
switch (outputFormat) {
|
||
|
|
case 'webm':
|
||
|
|
if (!options.videoCodec) args.push('-c:v', 'libvpx-vp9');
|
||
|
|
if (!options.audioCodec) args.push('-c:a', 'libopus');
|
||
|
|
break;
|
||
|
|
case 'mp4':
|
||
|
|
if (!options.videoCodec) args.push('-c:v', 'libx264');
|
||
|
|
if (!options.audioCodec) args.push('-c:a', 'aac');
|
||
|
|
break;
|
||
|
|
case 'mp3':
|
||
|
|
if (!options.audioCodec) args.push('-c:a', 'libmp3lame');
|
||
|
|
args.push('-vn'); // No video
|
||
|
|
break;
|
||
|
|
case 'wav':
|
||
|
|
if (!options.audioCodec) args.push('-c:a', 'pcm_s16le');
|
||
|
|
args.push('-vn'); // No video
|
||
|
|
break;
|
||
|
|
case 'ogg':
|
||
|
|
if (!options.audioCodec) args.push('-c:a', 'libvorbis');
|
||
|
|
args.push('-vn'); // No video
|
||
|
|
break;
|
||
|
|
case 'gif':
|
||
|
|
// For GIF, use filter to optimize
|
||
|
|
args.push('-vf', 'fps=15,scale=480:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse');
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Output file
|
||
|
|
args.push(outputName);
|
||
|
|
|
||
|
|
return args;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get MIME type for output format
|
||
|
|
*/
|
||
|
|
function getMimeType(format: string): string {
|
||
|
|
const mimeTypes: Record<string, string> = {
|
||
|
|
mp4: 'video/mp4',
|
||
|
|
webm: 'video/webm',
|
||
|
|
avi: 'video/x-msvideo',
|
||
|
|
mov: 'video/quicktime',
|
||
|
|
mkv: 'video/x-matroska',
|
||
|
|
mp3: 'audio/mpeg',
|
||
|
|
wav: 'audio/wav',
|
||
|
|
ogg: 'audio/ogg',
|
||
|
|
aac: 'audio/aac',
|
||
|
|
flac: 'audio/flac',
|
||
|
|
gif: 'image/gif',
|
||
|
|
};
|
||
|
|
|
||
|
|
return mimeTypes[format] || 'application/octet-stream';
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Extract audio from video
|
||
|
|
*/
|
||
|
|
export async function extractAudio(
|
||
|
|
file: File,
|
||
|
|
outputFormat: string = 'mp3',
|
||
|
|
onProgress?: ProgressCallback
|
||
|
|
): Promise<ConversionResult> {
|
||
|
|
return convertWithFFmpeg(
|
||
|
|
file,
|
||
|
|
outputFormat,
|
||
|
|
{
|
||
|
|
audioCodec: outputFormat === 'mp3' ? 'libmp3lame' : undefined,
|
||
|
|
audioBitrate: '192k',
|
||
|
|
},
|
||
|
|
onProgress
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Convert video to GIF
|
||
|
|
*/
|
||
|
|
export async function videoToGif(
|
||
|
|
file: File,
|
||
|
|
fps: number = 15,
|
||
|
|
width: number = 480,
|
||
|
|
onProgress?: ProgressCallback
|
||
|
|
): Promise<ConversionResult> {
|
||
|
|
return convertWithFFmpeg(
|
||
|
|
file,
|
||
|
|
'gif',
|
||
|
|
{
|
||
|
|
videoFps: fps,
|
||
|
|
videoResolution: `${width}:-1`,
|
||
|
|
},
|
||
|
|
onProgress
|
||
|
|
);
|
||
|
|
}
|