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;
}

View File

@@ -0,0 +1,65 @@
import { useEffect } from 'react';
export interface KeyboardShortcut {
key: string;
ctrl?: boolean;
alt?: boolean;
shift?: boolean;
description: string;
action: () => void;
}
/**
* Hook for managing keyboard shortcuts
*/
export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[], enabled: boolean = true) {
useEffect(() => {
if (!enabled) return;
const handleKeyDown = (event: KeyboardEvent) => {
// Find matching shortcut
const shortcut = shortcuts.find((s) => {
const keyMatch = s.key.toLowerCase() === event.key.toLowerCase();
const ctrlMatch = s.ctrl ? (event.ctrlKey || event.metaKey) : !event.ctrlKey && !event.metaKey;
const altMatch = s.alt ? event.altKey : !event.altKey;
const shiftMatch = s.shift ? event.shiftKey : !event.shiftKey;
return keyMatch && ctrlMatch && altMatch && shiftMatch;
});
if (shortcut) {
event.preventDefault();
shortcut.action();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [shortcuts, enabled]);
}
/**
* Format shortcut key combination for display
*/
export function formatShortcut(shortcut: KeyboardShortcut): string {
const parts: string[] = [];
// Use Cmd on Mac, Ctrl on others
const isMac = typeof window !== 'undefined' && /Mac|iPhone|iPod|iPad/.test(navigator.platform);
if (shortcut.ctrl) {
parts.push(isMac ? '⌘' : 'Ctrl');
}
if (shortcut.alt) {
parts.push(isMac ? '⌥' : 'Alt');
}
if (shortcut.shift) {
parts.push(isMac ? '⇧' : 'Shift');
}
parts.push(shortcut.key.toUpperCase());
return parts.join(' + ');
}

View File

@@ -0,0 +1,88 @@
import type { ConversionHistoryItem } from '@/types/media';
const HISTORY_KEY = 'convert-ui-history';
const MAX_HISTORY_ITEMS = 10;
/**
* Get conversion history from localStorage
*/
export function getHistory(): ConversionHistoryItem[] {
if (typeof window === 'undefined') return [];
try {
const stored = localStorage.getItem(HISTORY_KEY);
if (!stored) return [];
const history = JSON.parse(stored);
return Array.isArray(history) ? history : [];
} catch (error) {
console.error('Failed to load history:', error);
return [];
}
}
/**
* Add item to conversion history
*/
export function addToHistory(item: Omit<ConversionHistoryItem, 'id' | 'timestamp'>): void {
if (typeof window === 'undefined') return;
try {
const history = getHistory();
const newItem: ConversionHistoryItem = {
...item,
id: Math.random().toString(36).substring(7),
timestamp: Date.now(),
};
// Add to beginning of array
history.unshift(newItem);
// Keep only the latest MAX_HISTORY_ITEMS
const trimmed = history.slice(0, MAX_HISTORY_ITEMS);
localStorage.setItem(HISTORY_KEY, JSON.stringify(trimmed));
// Dispatch custom event for same-page updates
window.dispatchEvent(new CustomEvent('conversionHistoryUpdated'));
} catch (error) {
console.error('Failed to save history:', error);
}
}
/**
* Clear all conversion history
*/
export function clearHistory(): void {
if (typeof window === 'undefined') return;
try {
localStorage.removeItem(HISTORY_KEY);
} catch (error) {
console.error('Failed to clear history:', error);
}
}
/**
* Remove single item from history
*/
export function removeHistoryItem(id: string): void {
if (typeof window === 'undefined') return;
try {
const history = getHistory();
const filtered = history.filter((item) => item.id !== id);
localStorage.setItem(HISTORY_KEY, JSON.stringify(filtered));
} catch (error) {
console.error('Failed to remove history item:', error);
}
}
/**
* Get history item by ID
*/
export function getHistoryItem(id: string): ConversionHistoryItem | undefined {
const history = getHistory();
return history.find((item) => item.id === id);
}

View File

@@ -0,0 +1,73 @@
export interface UserSettings {
// Quality preferences
defaultQualityPreset: 'high-quality' | 'balanced' | 'small-file' | 'web-optimized';
// Behavior preferences
autoStartConversion: boolean;
showConversionHistory: boolean;
clearHistoryOnReset: boolean;
// Default formats (optional)
defaultVideoFormat?: string;
defaultAudioFormat?: string;
defaultImageFormat?: string;
}
const SETTINGS_KEY = 'convert-ui-settings';
const DEFAULT_SETTINGS: UserSettings = {
defaultQualityPreset: 'balanced',
autoStartConversion: false,
showConversionHistory: true,
clearHistoryOnReset: false,
};
/**
* Get user settings from localStorage
*/
export function getSettings(): UserSettings {
if (typeof window === 'undefined') return DEFAULT_SETTINGS;
try {
const stored = localStorage.getItem(SETTINGS_KEY);
if (!stored) return DEFAULT_SETTINGS;
const settings = JSON.parse(stored);
return { ...DEFAULT_SETTINGS, ...settings };
} catch (error) {
console.error('Failed to load settings:', error);
return DEFAULT_SETTINGS;
}
}
/**
* Save user settings to localStorage
*/
export function saveSettings(settings: Partial<UserSettings>): void {
if (typeof window === 'undefined') return;
try {
const current = getSettings();
const updated = { ...current, ...settings };
localStorage.setItem(SETTINGS_KEY, JSON.stringify(updated));
// Dispatch custom event for settings updates
window.dispatchEvent(new CustomEvent('settingsUpdated', { detail: updated }));
} catch (error) {
console.error('Failed to save settings:', error);
}
}
/**
* Reset settings to defaults
*/
export function resetSettings(): void {
if (typeof window === 'undefined') return;
try {
localStorage.setItem(SETTINGS_KEY, JSON.stringify(DEFAULT_SETTINGS));
window.dispatchEvent(new CustomEvent('settingsUpdated', { detail: DEFAULT_SETTINGS }));
} catch (error) {
console.error('Failed to reset settings:', error);
}
}

View File

@@ -0,0 +1,114 @@
/**
* Format file size in human-readable format
*/
export function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${Math.round(bytes / Math.pow(k, i) * 100) / 100} ${sizes[i]}`;
}
/**
* Validate file size (max 500MB for browser processing)
*/
export function validateFileSize(file: File, maxSizeMB: number = 500): boolean {
const maxBytes = maxSizeMB * 1024 * 1024;
return file.size <= maxBytes;
}
/**
* Get file extension from filename
*/
export function getFileExtension(filename: string): string {
const lastDot = filename.lastIndexOf('.');
return lastDot === -1 ? '' : filename.substring(lastDot + 1).toLowerCase();
}
/**
* Get filename without extension
*/
export function getFilenameWithoutExtension(filename: string): string {
const lastDot = filename.lastIndexOf('.');
return lastDot === -1 ? filename : filename.substring(0, lastDot);
}
/**
* Generate output filename
*/
export function generateOutputFilename(inputFilename: string, outputExtension: string): string {
const basename = getFilenameWithoutExtension(inputFilename);
return `${basename}.${outputExtension}`;
}
/**
* Download blob as file
*/
export function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/**
* Read file as ArrayBuffer
*/
export async function readFileAsArrayBuffer(file: File): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as ArrayBuffer);
reader.onerror = () => reject(reader.error);
reader.readAsArrayBuffer(file);
});
}
/**
* Read file as Data URL
*/
export async function readFileAsDataURL(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
}
/**
* Validate file type against allowed MIME types
*/
export function validateFileType(file: File, allowedTypes: string[]): boolean {
return allowedTypes.some((type) => {
if (type.endsWith('/*')) {
const category = type.split('/')[0];
return file.type.startsWith(`${category}/`);
}
return file.type === type;
});
}
/**
* Download multiple blobs as a ZIP file
*/
export async function downloadBlobsAsZip(files: Array<{ blob: Blob; filename: string }>, zipFilename: string): Promise<void> {
const JSZip = (await import('jszip')).default;
const zip = new JSZip();
// Add all files to ZIP
files.forEach(({ blob, filename }) => {
zip.file(filename, blob);
});
// Generate ZIP blob
const zipBlob = await zip.generateAsync({ type: 'blob' });
// Download ZIP
downloadBlob(zipBlob, zipFilename);
}

View File

@@ -0,0 +1,272 @@
import type { ConversionFormat, FormatPreset } from '@/types/media';
/**
* All supported conversion formats
*/
export const SUPPORTED_FORMATS: ConversionFormat[] = [
// Video formats (FFmpeg)
{
id: 'mp4',
name: 'MP4',
extension: 'mp4',
mimeType: 'video/mp4',
category: 'video',
converter: 'ffmpeg',
description: 'MPEG-4 video format',
},
{
id: 'webm',
name: 'WebM',
extension: 'webm',
mimeType: 'video/webm',
category: 'video',
converter: 'ffmpeg',
description: 'WebM video format',
},
{
id: 'avi',
name: 'AVI',
extension: 'avi',
mimeType: 'video/x-msvideo',
category: 'video',
converter: 'ffmpeg',
description: 'Audio Video Interleave',
},
{
id: 'mov',
name: 'MOV',
extension: 'mov',
mimeType: 'video/quicktime',
category: 'video',
converter: 'ffmpeg',
description: 'QuickTime movie',
},
{
id: 'mkv',
name: 'MKV',
extension: 'mkv',
mimeType: 'video/x-matroska',
category: 'video',
converter: 'ffmpeg',
description: 'Matroska video',
},
// Audio formats (FFmpeg)
{
id: 'mp3',
name: 'MP3',
extension: 'mp3',
mimeType: 'audio/mpeg',
category: 'audio',
converter: 'ffmpeg',
description: 'MPEG audio layer 3',
},
{
id: 'wav',
name: 'WAV',
extension: 'wav',
mimeType: 'audio/wav',
category: 'audio',
converter: 'ffmpeg',
description: 'Waveform audio',
},
{
id: 'ogg',
name: 'OGG',
extension: 'ogg',
mimeType: 'audio/ogg',
category: 'audio',
converter: 'ffmpeg',
description: 'Ogg Vorbis audio',
},
{
id: 'aac',
name: 'AAC',
extension: 'aac',
mimeType: 'audio/aac',
category: 'audio',
converter: 'ffmpeg',
description: 'Advanced Audio Coding',
},
{
id: 'flac',
name: 'FLAC',
extension: 'flac',
mimeType: 'audio/flac',
category: 'audio',
converter: 'ffmpeg',
description: 'Free Lossless Audio Codec',
},
// Image formats (ImageMagick)
{
id: 'png',
name: 'PNG',
extension: 'png',
mimeType: 'image/png',
category: 'image',
converter: 'imagemagick',
description: 'Portable Network Graphics',
},
{
id: 'jpg',
name: 'JPG',
extension: 'jpg',
mimeType: 'image/jpeg',
category: 'image',
converter: 'imagemagick',
description: 'JPEG image',
},
{
id: 'webp',
name: 'WebP',
extension: 'webp',
mimeType: 'image/webp',
category: 'image',
converter: 'imagemagick',
description: 'WebP image format',
},
{
id: 'gif',
name: 'GIF',
extension: 'gif',
mimeType: 'image/gif',
category: 'image',
converter: 'imagemagick',
description: 'Graphics Interchange Format',
},
{
id: 'bmp',
name: 'BMP',
extension: 'bmp',
mimeType: 'image/bmp',
category: 'image',
converter: 'imagemagick',
description: 'Bitmap image',
},
{
id: 'tiff',
name: 'TIFF',
extension: 'tiff',
mimeType: 'image/tiff',
category: 'image',
converter: 'imagemagick',
description: 'Tagged Image File Format',
},
{
id: 'svg',
name: 'SVG',
extension: 'svg',
mimeType: 'image/svg+xml',
category: 'image',
converter: 'imagemagick',
description: 'Scalable Vector Graphics',
},
];
/**
* Format presets for common conversions
*/
export const FORMAT_PRESETS: FormatPreset[] = [
{
id: 'web-video',
name: 'Web Video',
description: 'Optimize video for web playback (VP8 for better compatibility)',
category: 'video',
sourceFormats: ['mp4', 'avi', 'mov', 'mkv'],
targetFormat: 'webm',
options: {
videoCodec: 'libvpx',
videoBitrate: '1M',
audioCodec: 'libvorbis',
audioBitrate: '128k',
},
},
{
id: 'web-image',
name: 'Web Image',
description: 'Optimize image for web',
category: 'image',
sourceFormats: ['png', 'jpg', 'bmp', 'tiff'],
targetFormat: 'webp',
options: {
imageQuality: 85,
},
},
{
id: 'audio-compress',
name: 'Compress Audio',
description: 'Reduce audio file size',
category: 'audio',
sourceFormats: ['wav', 'flac'],
targetFormat: 'mp3',
options: {
audioBitrate: '192k',
audioCodec: 'libmp3lame',
},
},
{
id: 'video-gif',
name: 'Video to GIF',
description: 'Convert video to animated GIF',
category: 'video',
sourceFormats: ['mp4', 'webm', 'avi', 'mov'],
targetFormat: 'gif',
options: {
videoFps: 15,
videoResolution: '480x-1',
},
},
];
/**
* Get format by ID
*/
export function getFormatById(id: string): ConversionFormat | undefined {
return SUPPORTED_FORMATS.find((f) => f.id === id);
}
/**
* Get format by extension
*/
export function getFormatByExtension(extension: string): ConversionFormat | undefined {
return SUPPORTED_FORMATS.find((f) => f.extension === extension.toLowerCase());
}
/**
* Get format by MIME type
*/
export function getFormatByMimeType(mimeType: string): ConversionFormat | undefined {
return SUPPORTED_FORMATS.find((f) => f.mimeType === mimeType);
}
/**
* Get all formats by category
*/
export function getFormatsByCategory(category: string): ConversionFormat[] {
return SUPPORTED_FORMATS.filter((f) => f.category === category);
}
/**
* Get compatible output formats for input format
*/
export function getCompatibleFormats(inputFormat: ConversionFormat): ConversionFormat[] {
// Same category and same converter
return SUPPORTED_FORMATS.filter(
(f) => f.category === inputFormat.category && f.converter === inputFormat.converter && f.id !== inputFormat.id
);
}
/**
* Check if conversion is supported
*/
export function isConversionSupported(
inputFormat: ConversionFormat,
outputFormat: ConversionFormat
): boolean {
return (
inputFormat.category === outputFormat.category &&
inputFormat.converter === outputFormat.converter &&
inputFormat.id !== outputFormat.id
);
}

View File

@@ -0,0 +1,188 @@
import type { ConversionOptions } from '@/types/media';
export interface FormatPreset {
id: string;
name: string;
description: string;
icon: string;
category: 'video' | 'audio' | 'image';
outputFormat: string;
options: ConversionOptions;
}
/**
* Predefined format presets for common use cases
*/
export const FORMAT_PRESETS: FormatPreset[] = [
// Video Presets
{
id: 'youtube-video',
name: 'YouTube Video',
description: '1080p MP4, optimized for YouTube',
icon: '🎬',
category: 'video',
outputFormat: 'mp4',
options: {
videoCodec: 'libx264',
videoBitrate: '5M',
videoResolution: '1920x-1',
videoFps: 30,
audioCodec: 'aac',
audioBitrate: '192k',
},
},
{
id: 'instagram-video',
name: 'Instagram Video',
description: 'Square 1:1 format for Instagram',
icon: '📸',
category: 'video',
outputFormat: 'mp4',
options: {
videoCodec: 'libx264',
videoBitrate: '3M',
videoResolution: '1080x-1', // Will be cropped to square
videoFps: 30,
audioCodec: 'aac',
audioBitrate: '128k',
},
},
{
id: 'twitter-video',
name: 'Twitter Video',
description: '720p, optimized for Twitter',
icon: '🐦',
category: 'video',
outputFormat: 'mp4',
options: {
videoCodec: 'libx264',
videoBitrate: '2M',
videoResolution: '1280x-1',
videoFps: 30,
audioCodec: 'aac',
audioBitrate: '128k',
},
},
{
id: 'web-video',
name: 'Web Optimized',
description: 'Small file size for web streaming',
icon: '🌐',
category: 'video',
outputFormat: 'mp4',
options: {
videoCodec: 'libx264',
videoBitrate: '1.5M',
videoResolution: '854x-1',
videoFps: 24,
audioCodec: 'aac',
audioBitrate: '96k',
},
},
// Audio Presets
{
id: 'podcast-audio',
name: 'Podcast',
description: 'MP3, optimized for voice',
icon: '🎙️',
category: 'audio',
outputFormat: 'mp3',
options: {
audioCodec: 'libmp3lame',
audioBitrate: '128k',
audioSampleRate: 44100,
audioChannels: 2,
},
},
{
id: 'music-high-quality',
name: 'High Quality Music',
description: 'MP3, 320kbps for music',
icon: '🎵',
category: 'audio',
outputFormat: 'mp3',
options: {
audioCodec: 'libmp3lame',
audioBitrate: '320k',
audioSampleRate: 48000,
audioChannels: 2,
},
},
{
id: 'audiobook',
name: 'Audiobook',
description: 'Mono, small file size',
icon: '📚',
category: 'audio',
outputFormat: 'mp3',
options: {
audioCodec: 'libmp3lame',
audioBitrate: '64k',
audioSampleRate: 22050,
audioChannels: 1,
},
},
// Image Presets
{
id: 'web-thumbnail',
name: 'Web Thumbnail',
description: 'JPG, 800px width, optimized',
icon: '🖼️',
category: 'image',
outputFormat: 'jpg',
options: {
imageQuality: 85,
imageWidth: 800,
},
},
{
id: 'hd-image',
name: 'HD Image',
description: 'PNG, high quality, lossless',
icon: '🎨',
category: 'image',
outputFormat: 'png',
options: {
imageQuality: 100,
},
},
{
id: 'social-media-image',
name: 'Social Media',
description: 'JPG, 1200px, optimized',
icon: '📱',
category: 'image',
outputFormat: 'jpg',
options: {
imageQuality: 90,
imageWidth: 1200,
},
},
{
id: 'web-optimized-image',
name: 'Web Optimized',
description: 'WebP, small file size',
icon: '⚡',
category: 'image',
outputFormat: 'webp',
options: {
imageQuality: 80,
},
},
];
/**
* Get presets by category
*/
export function getPresetsByCategory(category: 'video' | 'audio' | 'image'): FormatPreset[] {
return FORMAT_PRESETS.filter(preset => preset.category === category);
}
/**
* Get preset by ID
*/
export function getPresetById(id: string): FormatPreset | undefined {
return FORMAT_PRESETS.find(preset => preset.id === id);
}

View File

@@ -0,0 +1,157 @@
import type { FFmpeg } from '@ffmpeg/ffmpeg';
import type { ConverterEngine, WASMModuleState } from '@/types/media';
/**
* WASM module loading state
*/
const moduleState: WASMModuleState = {
ffmpeg: false,
imagemagick: false,
};
/**
* Cached WASM instances
*/
let ffmpegInstance: FFmpeg | null = null;
let imagemagickInstance: any = null;
/**
* Load FFmpeg WASM module
*/
export async function loadFFmpeg(): Promise<FFmpeg> {
if (ffmpegInstance && moduleState.ffmpeg) {
return ffmpegInstance;
}
try {
const { FFmpeg } = await import('@ffmpeg/ffmpeg');
const { toBlobURL } = await import('@ffmpeg/util');
ffmpegInstance = new FFmpeg();
// Load core and dependencies
const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd';
ffmpegInstance.on('log', ({ message }) => {
console.log('[FFmpeg]', message);
});
await ffmpegInstance.load({
coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'),
wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm'),
});
moduleState.ffmpeg = true;
console.log('FFmpeg loaded successfully');
return ffmpegInstance;
} catch (error) {
console.error('Failed to load FFmpeg:', error);
throw new Error('Failed to load FFmpeg WASM module');
}
}
/**
* Load ImageMagick WASM module
*/
export async function loadImageMagick(): Promise<any> {
if (imagemagickInstance && moduleState.imagemagick) {
return imagemagickInstance;
}
try {
const { initializeImageMagick } = await import('@imagemagick/magick-wasm');
// Initialize ImageMagick with WASM file from public directory
// In production (static export), this will be served from /wasm/magick.wasm
const wasmUrl = '/wasm/magick.wasm';
console.log('[ImageMagick] Attempting to load WASM from:', wasmUrl);
// Test fetch the WASM file first to debug
try {
const response = await fetch(wasmUrl);
console.log('[ImageMagick] WASM fetch response:', {
ok: response.ok,
status: response.status,
contentType: response.headers.get('content-type'),
contentLength: response.headers.get('content-length'),
});
if (!response.ok) {
throw new Error(`Failed to fetch WASM file: ${response.status} ${response.statusText}`);
}
const arrayBuffer = await response.arrayBuffer();
console.log('[ImageMagick] WASM file size:', arrayBuffer.byteLength, 'bytes');
if (arrayBuffer.byteLength === 0) {
throw new Error('WASM file is empty');
}
// Now initialize with the buffer directly
await initializeImageMagick(arrayBuffer);
} catch (fetchError) {
console.error('[ImageMagick] Failed to fetch WASM:', fetchError);
throw fetchError;
}
const ImageMagick = await import('@imagemagick/magick-wasm');
imagemagickInstance = ImageMagick;
moduleState.imagemagick = true;
console.log('[ImageMagick] Loaded and initialized successfully');
return imagemagickInstance;
} catch (error) {
console.error('[ImageMagick] Failed to load:', error);
throw new Error(`Failed to load ImageMagick WASM module: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Get loaded module state
*/
export function getModuleState(): WASMModuleState {
return { ...moduleState };
}
/**
* Check if a specific module is loaded
*/
export function isModuleLoaded(engine: ConverterEngine): boolean {
return moduleState[engine];
}
/**
* Load appropriate WASM module for converter engine
*/
export async function loadModule(engine: ConverterEngine): Promise<any> {
switch (engine) {
case 'ffmpeg':
return loadFFmpeg();
case 'imagemagick':
return loadImageMagick();
default:
throw new Error(`Unknown converter engine: ${engine}`);
}
}
/**
* Unload all WASM modules and free memory
*/
export function unloadAll(): void {
if (ffmpegInstance) {
// FFmpeg doesn't have an explicit unload method
// Just null the instance
ffmpegInstance = null;
moduleState.ffmpeg = false;
}
if (imagemagickInstance) {
imagemagickInstance = null;
moduleState.imagemagick = false;
}
console.log('All WASM modules unloaded');
}