refactor: Consolidate format mappings and FFmpeg codec configurations into shared constants and abstract FFmpeg command execution.

This commit is contained in:
2026-01-30 15:21:18 +01:00
parent d0520a4c3c
commit 75887ce9ae
5 changed files with 241 additions and 293 deletions

View File

@@ -10,6 +10,7 @@ import type {
ConversionRequest, ConversionRequest,
AudioFormat, AudioFormat,
} from '../core/types'; } from '../core/types';
import { MIME_TO_FORMAT } from '../core/types';
import { getFormatCategory } from '../core/format-utils'; import { getFormatCategory } from '../core/format-utils';
import type { AudioConverterWorker } from '../workers/audio.worker'; import type { AudioConverterWorker } from '../workers/audio.worker';
@@ -85,20 +86,11 @@ export class FfmpegAdapter implements IFileConverter {
} }
private detectInputFormat(file: File): AudioFormat { private detectInputFormat(file: File): AudioFormat {
const mimeToFormat: Record<string, AudioFormat> = { // Use shared MIME mappings
'audio/mpeg': 'mp3', const format = MIME_TO_FORMAT[file.type];
'audio/mp3': 'mp3', if (format && SUPPORTED_FORMATS.includes(format as AudioFormat)) {
'audio/wav': 'wav', return format as AudioFormat;
'audio/wave': 'wav', }
'audio/ogg': 'ogg',
'audio/mp4': 'm4a',
'audio/x-m4a': 'm4a',
'audio/aac': 'm4a',
'audio/flac': 'flac',
};
const format = mimeToFormat[file.type];
if (format) return format;
// Fallback to extension // Fallback to extension
const ext = file.name.split('.').pop()?.toLowerCase(); const ext = file.name.split('.').pop()?.toLowerCase();

View File

@@ -10,6 +10,7 @@ import type {
ConversionRequest, ConversionRequest,
ImageFormat, ImageFormat,
} from '../core/types'; } from '../core/types';
import { MIME_TO_FORMAT } from '../core/types';
import { getFormatCategory } from '../core/format-utils'; import { getFormatCategory } from '../core/format-utils';
import type { ImageConverterWorker } from '../workers/image.worker'; import type { ImageConverterWorker } from '../workers/image.worker';
@@ -82,17 +83,11 @@ export class ImageMagickAdapter implements IFileConverter {
} }
private detectInputFormat(file: File): ImageFormat { private detectInputFormat(file: File): ImageFormat {
const mimeToFormat: Record<string, ImageFormat> = { // Use shared MIME mappings
'image/jpeg': 'jpeg', const format = MIME_TO_FORMAT[file.type];
'image/png': 'png', if (format && SUPPORTED_FORMATS.includes(format as ImageFormat)) {
'image/webp': 'webp', return format as ImageFormat;
'image/gif': 'gif', }
'image/bmp': 'bmp',
'image/tiff': 'tiff',
};
const format = mimeToFormat[file.type];
if (format) return format;
// Fallback to extension // Fallback to extension
const ext = file.name.split('.').pop()?.toLowerCase(); const ext = file.name.split('.').pop()?.toLowerCase();

79
src/core/constants.ts Normal file
View File

@@ -0,0 +1,79 @@
// =============================================================================
// Shared Constants
// Centralized configuration for format mappings and codec settings
// =============================================================================
import { MagickFormat } from '@imagemagick/magick-wasm';
import type { AudioFormat, ImageFormat, SupportedFormat } from './types';
import { FORMAT_METADATA } from './types';
// -----------------------------------------------------------------------------
// MIME Type Mappings
// -----------------------------------------------------------------------------
/**
* Convert format to MIME type (derived from FORMAT_METADATA for single source of truth)
*/
export const FORMAT_TO_MIME: Record<SupportedFormat, string> = Object.fromEntries(
Object.entries(FORMAT_METADATA).map(([format, meta]) => [format, meta.mimeType])
) as Record<SupportedFormat, string>;
// -----------------------------------------------------------------------------
// Audio Codec Configuration (FFmpeg)
// -----------------------------------------------------------------------------
/**
* Declarative FFmpeg codec arguments per audio format.
* Each function returns the codec arguments for the given bitrate.
*/
export const AUDIO_CODEC_CONFIG: Record<AudioFormat, (bitrate: number) => string[]> = {
mp3: (br) => ['-c:a', 'libmp3lame', '-b:a', `${br}k`],
aac: (br) => ['-c:a', 'aac', '-b:a', `${br}k`],
m4a: (br) => ['-c:a', 'aac', '-b:a', `${br}k`],
m4r: (br) => ['-c:a', 'aac', '-b:a', `${br}k`],
wma: (br) => ['-c:a', 'wmav2', '-b:a', `${br}k`],
opus: (br) => ['-c:a', 'libopus', '-b:a', `${br}k`],
flac: () => ['-c:a', 'flac', '-compression_level', '5'],
wav: () => ['-c:a', 'pcm_s16le'],
aiff: () => ['-c:a', 'pcm_s16be'],
alac: () => ['-c:a', 'alac'],
ogg: () => ['-c:a', 'libvorbis', '-q:a', '4'],
amr: () => ['-c:a', 'libopencore_amrnb', '-ar', '8000', '-ac', '1'],
};
// -----------------------------------------------------------------------------
// Image Format Mapping (ImageMagick)
// -----------------------------------------------------------------------------
/**
* Maps our ImageFormat to ImageMagick's MagickFormat enum
*/
export const IMAGE_FORMAT_MAP: Record<ImageFormat, MagickFormat> = {
jpeg: MagickFormat.Jpeg,
png: MagickFormat.Png,
webp: MagickFormat.WebP,
gif: MagickFormat.Gif,
bmp: MagickFormat.Bmp,
tiff: MagickFormat.Tiff,
heic: MagickFormat.Heic,
ico: MagickFormat.Ico,
avif: MagickFormat.Avif,
jxl: MagickFormat.Jxl,
svg: MagickFormat.Svg,
psd: MagickFormat.Psd,
raw: MagickFormat.Raw,
tga: MagickFormat.Tga,
};
/**
* Formats that support quality settings (lossy compression)
*/
export const LOSSY_IMAGE_FORMATS: ImageFormat[] = ['jpeg', 'webp', 'avif', 'jxl'];
/**
* Get file extension for a format (handles special cases like ALAC)
*/
export function getFileExtension(format: AudioFormat): string {
// ALAC uses m4a container
return format === 'alac' ? 'm4a' : format;
}

View File

@@ -6,11 +6,8 @@
import * as Comlink from 'comlink'; import * as Comlink from 'comlink';
import { FFmpeg } from '@ffmpeg/ffmpeg'; import { FFmpeg } from '@ffmpeg/ffmpeg';
import { toBlobURL } from '@ffmpeg/util'; import { toBlobURL } from '@ffmpeg/util';
import type { AudioFormat } from '../core/types';
// All supported audio formats import { AUDIO_CODEC_CONFIG, FORMAT_TO_MIME, getFileExtension } from '../core/constants';
type AudioFormat =
| 'mp3' | 'wav' | 'ogg' | 'm4a' | 'flac'
| 'aac' | 'aiff' | 'alac' | 'wma' | 'opus' | 'm4r' | 'amr';
let ffmpeg: FFmpeg | null = null; let ffmpeg: FFmpeg | null = null;
let ffmpegLoading: Promise<void> | null = null; let ffmpegLoading: Promise<void> | null = null;
@@ -41,92 +38,61 @@ async function ensureFFmpegLoaded(): Promise<FFmpeg> {
return ffmpeg!; return ffmpeg!;
} }
function getFFmpegArgs( // -----------------------------------------------------------------------------
_inputFormat: AudioFormat, // Command Executor (DRY: Handles Write → Run → Read → Clean lifecycle)
outputFormat: AudioFormat, // -----------------------------------------------------------------------------
bitrate?: number
): string[] {
const args: string[] = [];
const br = bitrate || 192;
// Codec selection based on output format /**
switch (outputFormat) { * Runs an FFmpeg command with guaranteed cleanup.
case 'mp3': * This abstracts the entire file I/O lifecycle.
args.push('-c:a', 'libmp3lame'); */
args.push('-b:a', `${br}k`); async function runFFmpegCommand(
break; inputBuffer: ArrayBuffer,
case 'wav': inputExt: string,
args.push('-c:a', 'pcm_s16le'); outputExt: string,
break; args: string[],
case 'ogg': onProgress?: (progress: number) => void
args.push('-c:a', 'libvorbis'); ): Promise<Uint8Array> {
args.push('-q:a', '4'); const ff = await ensureFFmpegLoaded();
break; const inputFile = `input.${inputExt}`;
case 'm4a': const outputFile = `output.${outputExt}`;
args.push('-c:a', 'aac');
args.push('-b:a', `${br}k`); // Store callback reference for proper cleanup
break; const progressCallback = onProgress
case 'flac': ? ({ progress }: { progress: number }) => onProgress(Math.round(progress * 100))
args.push('-c:a', 'flac'); : null;
args.push('-compression_level', '5');
break; if (progressCallback) {
case 'aac': ff.on('progress', progressCallback);
args.push('-c:a', 'aac');
args.push('-b:a', `${br}k`);
break;
case 'aiff':
args.push('-c:a', 'pcm_s16be');
break;
case 'alac':
// ALAC in M4A container
args.push('-c:a', 'alac');
break;
case 'wma':
args.push('-c:a', 'wmav2');
args.push('-b:a', `${br}k`);
break;
case 'opus':
args.push('-c:a', 'libopus');
args.push('-b:a', `${br}k`);
break;
case 'm4r':
// M4R is just AAC with different extension (iPhone ringtones)
args.push('-c:a', 'aac');
args.push('-b:a', `${br}k`);
break;
case 'amr':
args.push('-c:a', 'libopencore_amrnb');
args.push('-ar', '8000'); // AMR requires 8kHz sample rate
args.push('-ac', '1'); // AMR is mono only
break;
} }
return args; try {
// Write input file to FFmpeg's virtual filesystem
await ff.writeFile(inputFile, new Uint8Array(inputBuffer));
// Execute FFmpeg command
await ff.exec(['-i', inputFile, ...args, '-y', outputFile]);
// Read output file
const data = await ff.readFile(outputFile);
return data as Uint8Array;
} finally {
// Guaranteed cleanup (even if exec crashes)
try {
await ff.deleteFile(inputFile);
await ff.deleteFile(outputFile);
if (progressCallback) {
ff.off('progress', progressCallback);
}
} catch {
// Ignore cleanup errors
}
}
} }
function getExtension(format: AudioFormat): string { // -----------------------------------------------------------------------------
// ALAC uses m4a container // Audio Converter API
if (format === 'alac') return 'm4a'; // -----------------------------------------------------------------------------
return format;
}
function getMimeType(format: AudioFormat): string {
const mimeMap: Record<AudioFormat, string> = {
mp3: 'audio/mpeg',
wav: 'audio/wav',
ogg: 'audio/ogg',
m4a: 'audio/mp4',
flac: 'audio/flac',
aac: 'audio/aac',
aiff: 'audio/aiff',
alac: 'audio/x-m4a',
wma: 'audio/x-ms-wma',
opus: 'audio/opus',
m4r: 'audio/x-m4r',
amr: 'audio/amr',
};
return mimeMap[format];
}
const audioConverter = { const audioConverter = {
/** /**
@@ -149,58 +115,29 @@ const audioConverter = {
arrayBuffer: ArrayBuffer, arrayBuffer: ArrayBuffer,
inputFormat: AudioFormat, inputFormat: AudioFormat,
outputFormat: AudioFormat, outputFormat: AudioFormat,
bitrate?: number, bitrate: number = 192,
onProgress?: (progress: number) => void onProgress?: (progress: number) => void
): Promise<Blob> { ): Promise<Blob> {
const ff = await ensureFFmpegLoaded(); const getArgs = AUDIO_CODEC_CONFIG[outputFormat];
if (!getArgs) {
const inputExt = getExtension(inputFormat); throw new Error(`Unsupported output format: ${outputFormat}`);
const outputExt = getExtension(outputFormat);
const inputFileName = `input.${inputExt}`;
const outputFileName = `output.${outputExt}`;
// Register progress callback if provided
if (onProgress) {
ff.on('progress', ({ progress }) => {
onProgress(Math.round(progress * 100));
});
} }
try { const inputExt = getFileExtension(inputFormat);
// Write input file to FFmpeg's virtual filesystem const outputExt = getFileExtension(outputFormat);
await ff.writeFile(inputFileName, new Uint8Array(arrayBuffer));
// Build FFmpeg command const resultData = await runFFmpegCommand(
const codecArgs = getFFmpegArgs(inputFormat, outputFormat, bitrate); arrayBuffer,
inputExt,
outputExt,
getArgs(bitrate),
onProgress
);
await ff.exec([ // Copy to new buffer to avoid SharedArrayBuffer issues
'-i', inputFileName, const buffer = new ArrayBuffer(resultData.length);
...codecArgs, new Uint8Array(buffer).set(resultData);
'-y', // Overwrite output return new Blob([buffer], { type: FORMAT_TO_MIME[outputFormat] });
outputFileName,
]);
// Read output file
const data = await ff.readFile(outputFileName);
// Cleanup
await ff.deleteFile(inputFileName);
await ff.deleteFile(outputFileName);
// Convert to regular ArrayBuffer to avoid SharedArrayBuffer issues
const buffer = new ArrayBuffer((data as Uint8Array).length);
new Uint8Array(buffer).set(data as Uint8Array);
return new Blob([buffer], { type: getMimeType(outputFormat) });
} catch (error) {
// Attempt cleanup on error
try {
await ff.deleteFile(inputFileName);
await ff.deleteFile(outputFileName);
} catch {
// Ignore cleanup errors
}
throw error;
}
}, },
/** /**
@@ -210,52 +147,30 @@ const audioConverter = {
arrayBuffer: ArrayBuffer, arrayBuffer: ArrayBuffer,
inputExtension: string, inputExtension: string,
outputFormat: AudioFormat, outputFormat: AudioFormat,
bitrate?: number, bitrate: number = 192,
onProgress?: (progress: number) => void onProgress?: (progress: number) => void
): Promise<Blob> { ): Promise<Blob> {
const ff = await ensureFFmpegLoaded(); const getArgs = AUDIO_CODEC_CONFIG[outputFormat];
if (!getArgs) {
const inputFileName = `input.${inputExtension}`; throw new Error(`Unsupported output format: ${outputFormat}`);
const outputExt = getExtension(outputFormat);
const outputFileName = `output.${outputExt}`;
if (onProgress) {
ff.on('progress', ({ progress }) => {
onProgress(Math.round(progress * 100));
});
} }
try { // Add -vn (no video) to the codec args
await ff.writeFile(inputFileName, new Uint8Array(arrayBuffer)); const args = ['-vn', ...getArgs(bitrate)];
const outputExt = getFileExtension(outputFormat);
const codecArgs = getFFmpegArgs('m4a', outputFormat, bitrate); const resultData = await runFFmpegCommand(
arrayBuffer,
inputExtension,
outputExt,
args,
onProgress
);
await ff.exec([ // Copy to new buffer to avoid SharedArrayBuffer issues
'-i', inputFileName, const buffer = new ArrayBuffer(resultData.length);
'-vn', // No video new Uint8Array(buffer).set(resultData);
...codecArgs, return new Blob([buffer], { type: FORMAT_TO_MIME[outputFormat] });
'-y',
outputFileName,
]);
const data = await ff.readFile(outputFileName);
await ff.deleteFile(inputFileName);
await ff.deleteFile(outputFileName);
// Convert to regular ArrayBuffer
const buffer = new ArrayBuffer((data as Uint8Array).length);
new Uint8Array(buffer).set(data as Uint8Array);
return new Blob([buffer], { type: getMimeType(outputFormat) });
} catch (error) {
try {
await ff.deleteFile(inputFileName);
await ff.deleteFile(outputFileName);
} catch {
// Ignore cleanup errors
}
throw error;
}
}, },
}; };

View File

@@ -9,14 +9,11 @@ import * as Comlink from 'comlink';
import { import {
initializeImageMagick, initializeImageMagick,
ImageMagick, ImageMagick,
MagickFormat,
MagickGeometry, MagickGeometry,
type IMagickImage,
} from '@imagemagick/magick-wasm'; } from '@imagemagick/magick-wasm';
import type { ImageFormat } from '../core/types';
// All supported image formats import { IMAGE_FORMAT_MAP, FORMAT_TO_MIME, LOSSY_IMAGE_FORMATS } from '../core/constants';
type ImageFormat =
| 'jpeg' | 'png' | 'webp' | 'gif' | 'bmp' | 'tiff' | 'heic' | 'ico'
| 'avif' | 'jxl' | 'svg' | 'psd' | 'raw' | 'tga';
let magickInitialized = false; let magickInitialized = false;
let initPromise: Promise<void> | null = null; let initPromise: Promise<void> | null = null;
@@ -38,45 +35,50 @@ async function ensureMagickInitialized(): Promise<void> {
return initPromise; return initPromise;
} }
function getMagickFormat(format: ImageFormat): MagickFormat { // -----------------------------------------------------------------------------
const formatMap: Record<ImageFormat, MagickFormat> = { // Image Processor (DRY: Handles Read → Modify → Write lifecycle)
jpeg: MagickFormat.Jpeg, // -----------------------------------------------------------------------------
png: MagickFormat.Png,
webp: MagickFormat.WebP, /**
gif: MagickFormat.Gif, * Generic image processor that handles I/O and accepts an operation callback.
bmp: MagickFormat.Bmp, * The operation callback receives the image for manipulation (resize, quality, etc.)
tiff: MagickFormat.Tiff, */
heic: MagickFormat.Heic, async function processImage(
ico: MagickFormat.Ico, buffer: ArrayBuffer,
avif: MagickFormat.Avif, outputFormat: ImageFormat,
jxl: MagickFormat.Jxl, operation: (image: IMagickImage) => void
svg: MagickFormat.Svg, ): Promise<Blob> {
psd: MagickFormat.Psd, await ensureMagickInitialized();
raw: MagickFormat.Raw, // Note: May need specific raw format handling const inputData = new Uint8Array(buffer);
tga: MagickFormat.Tga, const magickFormat = IMAGE_FORMAT_MAP[outputFormat];
};
return formatMap[format]; if (!magickFormat) {
throw new Error(`Unsupported output format: ${outputFormat}`);
}
return new Promise((resolve, reject) => {
try {
ImageMagick.read(inputData, (image) => {
// Run the specific operation (resize, quality change, etc.)
operation(image);
// Standard output handling
image.write(magickFormat, (data) => {
// Clone buffer to detach from SharedArrayBuffer
const resultBuffer = new ArrayBuffer(data.length);
new Uint8Array(resultBuffer).set(data);
resolve(new Blob([resultBuffer], { type: FORMAT_TO_MIME[outputFormat] }));
});
});
} catch (error) {
reject(error);
}
});
} }
function getMimeType(format: ImageFormat): string { // -----------------------------------------------------------------------------
const mimeMap: Record<ImageFormat, string> = { // Image Converter API
jpeg: 'image/jpeg', // -----------------------------------------------------------------------------
png: 'image/png',
webp: 'image/webp',
gif: 'image/gif',
bmp: 'image/bmp',
tiff: 'image/tiff',
heic: 'image/heic',
ico: 'image/x-icon',
avif: 'image/avif',
jxl: 'image/jxl',
svg: 'image/svg+xml',
psd: 'image/vnd.adobe.photoshop',
raw: 'image/x-raw',
tga: 'image/x-tga',
};
return mimeMap[format];
}
const imageConverter = { const imageConverter = {
/** /**
@@ -101,14 +103,7 @@ const imageConverter = {
outputFormat: ImageFormat, outputFormat: ImageFormat,
quality: number = 85 quality: number = 85
): Promise<Blob> { ): Promise<Blob> {
await ensureMagickInitialized(); return processImage(arrayBuffer, outputFormat, (image) => {
const inputData = new Uint8Array(arrayBuffer);
const outputMagickFormat = getMagickFormat(outputFormat);
return new Promise((resolve, reject) => {
try {
ImageMagick.read(inputData, (image) => {
// ICO format has max size of 256x256 // ICO format has max size of 256x256
if (outputFormat === 'ico' && (image.width > 256 || image.height > 256)) { if (outputFormat === 'ico' && (image.width > 256 || image.height > 256)) {
const geometry = new MagickGeometry(256, 256); const geometry = new MagickGeometry(256, 256);
@@ -117,21 +112,9 @@ const imageConverter = {
} }
// Set quality for lossy formats // Set quality for lossy formats
if (['jpeg', 'webp', 'avif', 'jxl'].includes(outputFormat)) { if (LOSSY_IMAGE_FORMATS.includes(outputFormat)) {
image.quality = quality; image.quality = quality;
} }
image.write(outputMagickFormat, (data) => {
// Copy to regular ArrayBuffer to avoid SharedArrayBuffer issues
const buffer = new ArrayBuffer(data.length);
new Uint8Array(buffer).set(data);
const blob = new Blob([buffer], { type: getMimeType(outputFormat) });
resolve(blob);
});
});
} catch (error) {
reject(error);
}
}); });
}, },
@@ -145,32 +128,16 @@ const imageConverter = {
outputFormat: ImageFormat, outputFormat: ImageFormat,
quality: number = 85 quality: number = 85
): Promise<Blob> { ): Promise<Blob> {
await ensureMagickInitialized(); return processImage(arrayBuffer, outputFormat, (image) => {
// Resize operation
const inputData = new Uint8Array(arrayBuffer);
const outputMagickFormat = getMagickFormat(outputFormat);
return new Promise((resolve, reject) => {
try {
ImageMagick.read(inputData, (image) => {
const geometry = new MagickGeometry(width, height); const geometry = new MagickGeometry(width, height);
geometry.ignoreAspectRatio = false; geometry.ignoreAspectRatio = false;
image.resize(geometry); image.resize(geometry);
if (['jpeg', 'webp', 'avif', 'jxl'].includes(outputFormat)) { // Set quality for lossy formats
if (LOSSY_IMAGE_FORMATS.includes(outputFormat)) {
image.quality = quality; image.quality = quality;
} }
image.write(outputMagickFormat, (data) => {
const buffer = new ArrayBuffer(data.length);
new Uint8Array(buffer).set(data);
const blob = new Blob([buffer], { type: getMimeType(outputFormat) });
resolve(blob);
});
});
} catch (error) {
reject(error);
}
}); });
}, },
}; };