feat: add media converter app and fix compilation errors

This commit is contained in:
2026-02-25 10:06:50 +01:00
parent 1da6168f37
commit fbc8cdeebe
53 changed files with 3925 additions and 490 deletions

View File

@@ -0,0 +1,224 @@
import type { FFmpeg } from '@ffmpeg/ffmpeg';
import { fetchFile } from '@ffmpeg/util';
import { loadFFmpeg } from '@/lib/media/wasm/wasmLoader';
import type { ConversionOptions, ProgressCallback, ConversionResult } from '@/types/media';
/**
* 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':
// Use VP8 by default (less memory-intensive than VP9)
if (!options.videoCodec) args.push('-c:v', 'libvpx');
if (!options.audioCodec) args.push('-c:a', 'libvorbis');
// Optimize for faster encoding and lower memory usage
args.push('-deadline', 'realtime');
args.push('-cpu-used', '8');
// Set quality/bitrate if not specified
if (!options.videoBitrate) args.push('-b:v', '1M');
if (!options.audioBitrate) args.push('-b:a', '128k');
break;
case 'mp4':
if (!options.videoCodec) args.push('-c:v', 'libx264');
if (!options.audioCodec) args.push('-c:a', 'aac');
// Use faster preset for browser encoding
args.push('-preset', 'ultrafast');
args.push('-tune', 'zerolatency');
if (!options.videoBitrate) args.push('-b:v', '1M');
if (!options.audioBitrate) args.push('-b:a', '128k');
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
);
}

View File

@@ -0,0 +1,248 @@
import { loadImageMagick } from '@/lib/media/wasm/wasmLoader';
import type { ConversionOptions, ProgressCallback, ConversionResult } from '@/types/media';
/**
* Convert image using ImageMagick
*/
export async function convertWithImageMagick(
file: File,
outputFormat: string,
options: ConversionOptions = {},
onProgress?: ProgressCallback
): Promise<ConversionResult> {
const startTime = Date.now();
try {
// Load ImageMagick instance
await loadImageMagick();
// Report initial progress
if (onProgress) onProgress(10);
// Read input file as ArrayBuffer
const arrayBuffer = await file.arrayBuffer();
const inputData = new Uint8Array(arrayBuffer);
if (onProgress) onProgress(30);
// Import ImageMagick functions (already initialized by loadImageMagick)
const { ImageMagick } = await import('@imagemagick/magick-wasm');
if (onProgress) onProgress(40);
// Get output format enum
const outputFormatEnum = await getMagickFormatEnum(outputFormat);
if (onProgress) onProgress(50);
// Convert image using ImageMagick
let result: Uint8Array | undefined;
await ImageMagick.read(inputData, (image) => {
// Apply quality setting if specified
if (options.imageQuality !== undefined) {
image.quality = options.imageQuality;
}
// Apply resize if specified
if (options.imageWidth || options.imageHeight) {
const width = options.imageWidth || 0;
const height = options.imageHeight || 0;
if (width > 0 && height > 0) {
// Both dimensions specified
image.resize(width, height);
} else if (width > 0) {
// Only width specified, maintain aspect ratio
const aspectRatio = image.height / image.width;
image.resize(width, Math.round(width * aspectRatio));
} else if (height > 0) {
// Only height specified, maintain aspect ratio
const aspectRatio = image.width / image.height;
image.resize(Math.round(height * aspectRatio), height);
}
}
if (onProgress) onProgress(70);
// Write the image data with format
image.write(outputFormatEnum, (data) => {
result = data;
});
if (onProgress) onProgress(90);
});
// Verify we have a result
if (!result || result.length === 0) {
throw new Error('ImageMagick conversion produced empty result');
}
console.log('[ImageMagick] Conversion complete:', {
inputSize: inputData.length,
outputSize: result.length,
format: outputFormat,
quality: options.imageQuality,
});
// Verify the data looks like valid image data by checking magic bytes
const first4Bytes = Array.from(result.slice(0, 4)).map(b => b.toString(16).padStart(2, '0')).join(' ');
console.log('[ImageMagick] First 4 bytes:', first4Bytes);
// Create blob from result
const mimeType = getMimeType(outputFormat);
const blob = new Blob([result as BlobPart], { type: mimeType });
console.log('[ImageMagick] Created blob:', {
size: blob.size,
type: blob.type,
});
// Verify blob can be read
try {
const testReader = new FileReader();
const testPromise = new Promise((resolve) => {
testReader.onloadend = () => {
if (testReader.result instanceof ArrayBuffer) {
const testArr = new Uint8Array(testReader.result);
console.log('[ImageMagick] Blob verification - first 4 bytes:',
Array.from(testArr.slice(0, 4)).map(b => b.toString(16).padStart(2, '0')).join(' '));
}
resolve(true);
};
});
testReader.readAsArrayBuffer(blob.slice(0, 4));
await testPromise;
} catch (err) {
console.error('[ImageMagick] Blob verification failed:', err);
}
if (onProgress) onProgress(100);
const duration = Date.now() - startTime;
return {
success: true,
blob,
duration,
};
} catch (error) {
console.error('[ImageMagick] Conversion error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown conversion error',
duration: Date.now() - startTime,
};
}
}
/**
* Get ImageMagick format enum
*/
async function getMagickFormatEnum(format: string): Promise<any> {
const { MagickFormat } = await import('@imagemagick/magick-wasm');
const formatMap: Record<string, any> = {
png: MagickFormat.Png,
jpg: MagickFormat.Jpg,
jpeg: MagickFormat.Jpg,
webp: MagickFormat.WebP,
gif: MagickFormat.Gif,
bmp: MagickFormat.Bmp,
tiff: MagickFormat.Tiff,
svg: MagickFormat.Svg,
};
return formatMap[format.toLowerCase()] || MagickFormat.Png;
}
/**
* Get MIME type for output format
*/
function getMimeType(format: string): string {
const mimeTypes: Record<string, string> = {
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
webp: 'image/webp',
gif: 'image/gif',
bmp: 'image/bmp',
tiff: 'image/tiff',
svg: 'image/svg+xml',
};
return mimeTypes[format.toLowerCase()] || 'application/octet-stream';
}
/**
* Resize image
*/
export async function resizeImage(
file: File,
width: number,
height: number,
outputFormat?: string,
onProgress?: ProgressCallback
): Promise<ConversionResult> {
const format = outputFormat || file.name.split('.').pop() || 'png';
return convertWithImageMagick(
file,
format,
{
imageWidth: width,
imageHeight: height,
},
onProgress
);
}
/**
* Convert image to WebP
*/
export async function convertToWebP(
file: File,
quality: number = 85,
onProgress?: ProgressCallback
): Promise<ConversionResult> {
return convertWithImageMagick(
file,
'webp',
{
imageQuality: quality,
},
onProgress
);
}
/**
* Batch convert images
*/
export async function batchConvertImages(
files: File[],
outputFormat: string,
options: ConversionOptions = {},
onProgress?: (fileIndex: number, progress: number) => void
): Promise<ConversionResult[]> {
const results: ConversionResult[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
const result = await convertWithImageMagick(
file,
outputFormat,
options,
(progress) => {
if (onProgress) {
onProgress(i, progress);
}
}
);
results.push(result);
}
return results;
}