From 75887ce9aeb5bdd651d0dd5ec99c0ba9cce80396 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Fri, 30 Jan 2026 15:21:18 +0100 Subject: [PATCH] refactor: Consolidate format mappings and FFmpeg codec configurations into shared constants and abstract FFmpeg command execution. --- src/adapters/ffmpeg-adapter.ts | 20 +-- src/adapters/image-magic-adapter.ts | 17 +- src/core/constants.ts | 79 +++++++++ src/workers/audio.worker.ts | 259 ++++++++++------------------ src/workers/image.worker.ts | 159 +++++++---------- 5 files changed, 241 insertions(+), 293 deletions(-) create mode 100644 src/core/constants.ts diff --git a/src/adapters/ffmpeg-adapter.ts b/src/adapters/ffmpeg-adapter.ts index a8fcbef..f72bac4 100644 --- a/src/adapters/ffmpeg-adapter.ts +++ b/src/adapters/ffmpeg-adapter.ts @@ -10,6 +10,7 @@ import type { ConversionRequest, AudioFormat, } from '../core/types'; +import { MIME_TO_FORMAT } from '../core/types'; import { getFormatCategory } from '../core/format-utils'; import type { AudioConverterWorker } from '../workers/audio.worker'; @@ -85,20 +86,11 @@ export class FfmpegAdapter implements IFileConverter { } private detectInputFormat(file: File): AudioFormat { - const mimeToFormat: Record = { - 'audio/mpeg': 'mp3', - 'audio/mp3': 'mp3', - 'audio/wav': 'wav', - '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; + // Use shared MIME mappings + const format = MIME_TO_FORMAT[file.type]; + if (format && SUPPORTED_FORMATS.includes(format as AudioFormat)) { + return format as AudioFormat; + } // Fallback to extension const ext = file.name.split('.').pop()?.toLowerCase(); diff --git a/src/adapters/image-magic-adapter.ts b/src/adapters/image-magic-adapter.ts index 5798582..3547d20 100644 --- a/src/adapters/image-magic-adapter.ts +++ b/src/adapters/image-magic-adapter.ts @@ -10,6 +10,7 @@ import type { ConversionRequest, ImageFormat, } from '../core/types'; +import { MIME_TO_FORMAT } from '../core/types'; import { getFormatCategory } from '../core/format-utils'; import type { ImageConverterWorker } from '../workers/image.worker'; @@ -82,17 +83,11 @@ export class ImageMagickAdapter implements IFileConverter { } private detectInputFormat(file: File): ImageFormat { - const mimeToFormat: Record = { - 'image/jpeg': 'jpeg', - 'image/png': 'png', - 'image/webp': 'webp', - 'image/gif': 'gif', - 'image/bmp': 'bmp', - 'image/tiff': 'tiff', - }; - - const format = mimeToFormat[file.type]; - if (format) return format; + // Use shared MIME mappings + const format = MIME_TO_FORMAT[file.type]; + if (format && SUPPORTED_FORMATS.includes(format as ImageFormat)) { + return format as ImageFormat; + } // Fallback to extension const ext = file.name.split('.').pop()?.toLowerCase(); diff --git a/src/core/constants.ts b/src/core/constants.ts new file mode 100644 index 0000000..bf6d754 --- /dev/null +++ b/src/core/constants.ts @@ -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 = Object.fromEntries( + Object.entries(FORMAT_METADATA).map(([format, meta]) => [format, meta.mimeType]) +) as Record; + +// ----------------------------------------------------------------------------- +// 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 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 = { + 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; +} diff --git a/src/workers/audio.worker.ts b/src/workers/audio.worker.ts index cc39454..64fc92a 100644 --- a/src/workers/audio.worker.ts +++ b/src/workers/audio.worker.ts @@ -6,11 +6,8 @@ import * as Comlink from 'comlink'; import { FFmpeg } from '@ffmpeg/ffmpeg'; import { toBlobURL } from '@ffmpeg/util'; - -// All supported audio formats -type AudioFormat = - | 'mp3' | 'wav' | 'ogg' | 'm4a' | 'flac' - | 'aac' | 'aiff' | 'alac' | 'wma' | 'opus' | 'm4r' | 'amr'; +import type { AudioFormat } from '../core/types'; +import { AUDIO_CODEC_CONFIG, FORMAT_TO_MIME, getFileExtension } from '../core/constants'; let ffmpeg: FFmpeg | null = null; let ffmpegLoading: Promise | null = null; @@ -41,92 +38,61 @@ async function ensureFFmpegLoaded(): Promise { return ffmpeg!; } -function getFFmpegArgs( - _inputFormat: AudioFormat, - outputFormat: AudioFormat, - bitrate?: number -): string[] { - const args: string[] = []; - const br = bitrate || 192; +// ----------------------------------------------------------------------------- +// Command Executor (DRY: Handles Write → Run → Read → Clean lifecycle) +// ----------------------------------------------------------------------------- - // Codec selection based on output format - switch (outputFormat) { - case 'mp3': - args.push('-c:a', 'libmp3lame'); - args.push('-b:a', `${br}k`); - break; - case 'wav': - args.push('-c:a', 'pcm_s16le'); - break; - case 'ogg': - args.push('-c:a', 'libvorbis'); - args.push('-q:a', '4'); - break; - case 'm4a': - args.push('-c:a', 'aac'); - args.push('-b:a', `${br}k`); - break; - case 'flac': - args.push('-c:a', 'flac'); - args.push('-compression_level', '5'); - break; - case 'aac': - 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; +/** + * Runs an FFmpeg command with guaranteed cleanup. + * This abstracts the entire file I/O lifecycle. + */ +async function runFFmpegCommand( + inputBuffer: ArrayBuffer, + inputExt: string, + outputExt: string, + args: string[], + onProgress?: (progress: number) => void +): Promise { + const ff = await ensureFFmpegLoaded(); + const inputFile = `input.${inputExt}`; + const outputFile = `output.${outputExt}`; + + // Store callback reference for proper cleanup + const progressCallback = onProgress + ? ({ progress }: { progress: number }) => onProgress(Math.round(progress * 100)) + : null; + + if (progressCallback) { + ff.on('progress', progressCallback); } - 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 - if (format === 'alac') return 'm4a'; - return format; -} - -function getMimeType(format: AudioFormat): string { - const mimeMap: Record = { - 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]; -} +// ----------------------------------------------------------------------------- +// Audio Converter API +// ----------------------------------------------------------------------------- const audioConverter = { /** @@ -149,58 +115,29 @@ const audioConverter = { arrayBuffer: ArrayBuffer, inputFormat: AudioFormat, outputFormat: AudioFormat, - bitrate?: number, + bitrate: number = 192, onProgress?: (progress: number) => void ): Promise { - const ff = await ensureFFmpegLoaded(); - - const inputExt = getExtension(inputFormat); - 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)); - }); + const getArgs = AUDIO_CODEC_CONFIG[outputFormat]; + if (!getArgs) { + throw new Error(`Unsupported output format: ${outputFormat}`); } - try { - // Write input file to FFmpeg's virtual filesystem - await ff.writeFile(inputFileName, new Uint8Array(arrayBuffer)); + const inputExt = getFileExtension(inputFormat); + const outputExt = getFileExtension(outputFormat); - // Build FFmpeg command - const codecArgs = getFFmpegArgs(inputFormat, outputFormat, bitrate); - - await ff.exec([ - '-i', inputFileName, - ...codecArgs, - '-y', // Overwrite output - outputFileName, - ]); + const resultData = await runFFmpegCommand( + arrayBuffer, + inputExt, + outputExt, + getArgs(bitrate), + onProgress + ); - // 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; - } + // Copy to new buffer to avoid SharedArrayBuffer issues + const buffer = new ArrayBuffer(resultData.length); + new Uint8Array(buffer).set(resultData); + return new Blob([buffer], { type: FORMAT_TO_MIME[outputFormat] }); }, /** @@ -210,52 +147,30 @@ const audioConverter = { arrayBuffer: ArrayBuffer, inputExtension: string, outputFormat: AudioFormat, - bitrate?: number, + bitrate: number = 192, onProgress?: (progress: number) => void ): Promise { - const ff = await ensureFFmpegLoaded(); - - const inputFileName = `input.${inputExtension}`; - const outputExt = getExtension(outputFormat); - const outputFileName = `output.${outputExt}`; - - if (onProgress) { - ff.on('progress', ({ progress }) => { - onProgress(Math.round(progress * 100)); - }); + const getArgs = AUDIO_CODEC_CONFIG[outputFormat]; + if (!getArgs) { + throw new Error(`Unsupported output format: ${outputFormat}`); } - try { - await ff.writeFile(inputFileName, new Uint8Array(arrayBuffer)); + // Add -vn (no video) to the codec args + const args = ['-vn', ...getArgs(bitrate)]; + const outputExt = getFileExtension(outputFormat); - const codecArgs = getFFmpegArgs('m4a', outputFormat, bitrate); - - await ff.exec([ - '-i', inputFileName, - '-vn', // No video - ...codecArgs, - '-y', - outputFileName, - ]); + const resultData = await runFFmpegCommand( + arrayBuffer, + inputExtension, + outputExt, + args, + onProgress + ); - 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; - } + // Copy to new buffer to avoid SharedArrayBuffer issues + const buffer = new ArrayBuffer(resultData.length); + new Uint8Array(buffer).set(resultData); + return new Blob([buffer], { type: FORMAT_TO_MIME[outputFormat] }); }, }; diff --git a/src/workers/image.worker.ts b/src/workers/image.worker.ts index df0b696..38c1fc8 100644 --- a/src/workers/image.worker.ts +++ b/src/workers/image.worker.ts @@ -9,14 +9,11 @@ import * as Comlink from 'comlink'; import { initializeImageMagick, ImageMagick, - MagickFormat, MagickGeometry, + type IMagickImage, } from '@imagemagick/magick-wasm'; - -// All supported image formats -type ImageFormat = - | 'jpeg' | 'png' | 'webp' | 'gif' | 'bmp' | 'tiff' | 'heic' | 'ico' - | 'avif' | 'jxl' | 'svg' | 'psd' | 'raw' | 'tga'; +import type { ImageFormat } from '../core/types'; +import { IMAGE_FORMAT_MAP, FORMAT_TO_MIME, LOSSY_IMAGE_FORMATS } from '../core/constants'; let magickInitialized = false; let initPromise: Promise | null = null; @@ -38,45 +35,50 @@ async function ensureMagickInitialized(): Promise { return initPromise; } -function getMagickFormat(format: ImageFormat): MagickFormat { - const formatMap: Record = { - 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, // Note: May need specific raw format handling - tga: MagickFormat.Tga, - }; - return formatMap[format]; +// ----------------------------------------------------------------------------- +// Image Processor (DRY: Handles Read → Modify → Write lifecycle) +// ----------------------------------------------------------------------------- + +/** + * Generic image processor that handles I/O and accepts an operation callback. + * The operation callback receives the image for manipulation (resize, quality, etc.) + */ +async function processImage( + buffer: ArrayBuffer, + outputFormat: ImageFormat, + operation: (image: IMagickImage) => void +): Promise { + await ensureMagickInitialized(); + const inputData = new Uint8Array(buffer); + const magickFormat = IMAGE_FORMAT_MAP[outputFormat]; + + 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 = { - 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]; -} +// ----------------------------------------------------------------------------- +// Image Converter API +// ----------------------------------------------------------------------------- const imageConverter = { /** @@ -101,36 +103,17 @@ const imageConverter = { outputFormat: ImageFormat, quality: number = 85 ): Promise { - await ensureMagickInitialized(); + return processImage(arrayBuffer, outputFormat, (image) => { + // ICO format has max size of 256x256 + if (outputFormat === 'ico' && (image.width > 256 || image.height > 256)) { + const geometry = new MagickGeometry(256, 256); + geometry.ignoreAspectRatio = false; + image.resize(geometry); + } - 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 - if (outputFormat === 'ico' && (image.width > 256 || image.height > 256)) { - const geometry = new MagickGeometry(256, 256); - geometry.ignoreAspectRatio = false; - image.resize(geometry); - } - - // Set quality for lossy formats - if (['jpeg', 'webp', 'avif', 'jxl'].includes(outputFormat)) { - 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); + // Set quality for lossy formats + if (LOSSY_IMAGE_FORMATS.includes(outputFormat)) { + image.quality = quality; } }); }, @@ -145,31 +128,15 @@ const imageConverter = { outputFormat: ImageFormat, quality: number = 85 ): Promise { - await ensureMagickInitialized(); + return processImage(arrayBuffer, outputFormat, (image) => { + // Resize operation + const geometry = new MagickGeometry(width, height); + geometry.ignoreAspectRatio = false; + image.resize(geometry); - 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); - geometry.ignoreAspectRatio = false; - image.resize(geometry); - - if (['jpeg', 'webp', 'avif', 'jxl'].includes(outputFormat)) { - 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); + // Set quality for lossy formats + if (LOSSY_IMAGE_FORMATS.includes(outputFormat)) { + image.quality = quality; } }); },