feat: add media converter app and fix compilation errors
This commit is contained in:
224
lib/media/converters/ffmpegService.ts
Normal file
224
lib/media/converters/ffmpegService.ts
Normal 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
|
||||
);
|
||||
}
|
||||
248
lib/media/converters/imagemagickService.ts
Normal file
248
lib/media/converters/imagemagickService.ts
Normal 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;
|
||||
}
|
||||
65
lib/media/hooks/useKeyboardShortcuts.ts
Normal file
65
lib/media/hooks/useKeyboardShortcuts.ts
Normal 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(' + ');
|
||||
}
|
||||
88
lib/media/storage/history.ts
Normal file
88
lib/media/storage/history.ts
Normal 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);
|
||||
}
|
||||
73
lib/media/storage/settings.ts
Normal file
73
lib/media/storage/settings.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
114
lib/media/utils/fileUtils.ts
Normal file
114
lib/media/utils/fileUtils.ts
Normal 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);
|
||||
}
|
||||
272
lib/media/utils/formatMappings.ts
Normal file
272
lib/media/utils/formatMappings.ts
Normal 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
|
||||
);
|
||||
}
|
||||
188
lib/media/utils/formatPresets.ts
Normal file
188
lib/media/utils/formatPresets.ts
Normal 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);
|
||||
}
|
||||
157
lib/media/wasm/wasmLoader.ts
Normal file
157
lib/media/wasm/wasmLoader.ts
Normal 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');
|
||||
}
|
||||
Reference in New Issue
Block a user