refactor: Consolidate format mappings and FFmpeg codec configurations into shared constants and abstract FFmpeg command execution.
This commit is contained in:
@@ -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();
|
||||||
|
|||||||
@@ -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
79
src/core/constants.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
await ff.exec([
|
outputExt,
|
||||||
'-i', inputFileName,
|
getArgs(bitrate),
|
||||||
...codecArgs,
|
onProgress
|
||||||
'-y', // Overwrite output
|
);
|
||||||
outputFileName,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Read output file
|
// Copy to new buffer to avoid SharedArrayBuffer issues
|
||||||
const data = await ff.readFile(outputFileName);
|
const buffer = new ArrayBuffer(resultData.length);
|
||||||
|
new Uint8Array(buffer).set(resultData);
|
||||||
// Cleanup
|
return new Blob([buffer], { type: FORMAT_TO_MIME[outputFormat] });
|
||||||
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,
|
||||||
await ff.exec([
|
inputExtension,
|
||||||
'-i', inputFileName,
|
outputExt,
|
||||||
'-vn', // No video
|
args,
|
||||||
...codecArgs,
|
onProgress
|
||||||
'-y',
|
);
|
||||||
outputFileName,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const data = await ff.readFile(outputFileName);
|
// Copy to new buffer to avoid SharedArrayBuffer issues
|
||||||
|
const buffer = new ArrayBuffer(resultData.length);
|
||||||
await ff.deleteFile(inputFileName);
|
new Uint8Array(buffer).set(resultData);
|
||||||
await ff.deleteFile(outputFileName);
|
return new Blob([buffer], { type: FORMAT_TO_MIME[outputFormat] });
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,36 +103,17 @@ const imageConverter = {
|
|||||||
outputFormat: ImageFormat,
|
outputFormat: ImageFormat,
|
||||||
quality: number = 85
|
quality: number = 85
|
||||||
): Promise<Blob> {
|
): Promise<Blob> {
|
||||||
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);
|
// Set quality for lossy formats
|
||||||
const outputMagickFormat = getMagickFormat(outputFormat);
|
if (LOSSY_IMAGE_FORMATS.includes(outputFormat)) {
|
||||||
|
image.quality = quality;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -145,31 +128,15 @@ 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 geometry = new MagickGeometry(width, height);
|
||||||
|
geometry.ignoreAspectRatio = false;
|
||||||
|
image.resize(geometry);
|
||||||
|
|
||||||
const inputData = new Uint8Array(arrayBuffer);
|
// Set quality for lossy formats
|
||||||
const outputMagickFormat = getMagickFormat(outputFormat);
|
if (LOSSY_IMAGE_FORMATS.includes(outputFormat)) {
|
||||||
|
image.quality = quality;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user