import FfmpegWorker from './ffmpeg.worker.js?worker'; import coreJs from '!/@ffmpeg/core/dist/esm/ffmpeg-core.js?blob-url'; import coreWasm from '!/@ffmpeg/core/dist/esm/ffmpeg-core.wasm?blob-url'; import { FfmpegProgress } from './ffmpeg.types'; /** * @typedef {import('./ffmpeg.types.ts').FfmpegProgress} FfmpegProgress */ class FfmpegError extends Error { constructor(message) { super(message); this.name = 'FfmpegError'; this.code = 'FFMPEG_FAILED'; } } export function loadFfmpeg() { return ( loadFfmpeg.promise || (loadFfmpeg.promise = (async () => { const data = { coreURL: await coreJs(), wasmURL: await coreWasm(), }; return data; })()) ); } /** * * @param {Blob} audioBlob * @param {string[]} args * @param {string} outputName * @param {string} outputMime * @param {(progress: FfmpegProgress) => void} onProgress * @param {AbortSignal|null} signal * @param {Array<{name: string, data: ArrayBuffer | Uint8Array}>} extraFiles * @param {Boolean} logConsole - Whether to log FFmpeg output to the console * @returns {Promise} Encoded audio blob */ async function ffmpegWorker( audioBlob, args = [], outputName = 'output', outputMime = 'application/octet-stream', onProgress = null, signal = null, extraFiles = [], logConsole = true, rawArgs = false ) { const audioData = audioBlob ? await audioBlob.arrayBuffer() : null; const assets = loadFfmpeg(); return new Promise((resolve, reject) => { let endCategory = null; const worker = new FfmpegWorker(); // Handle abort signal const abortHandler = () => { worker.terminate(); endCategory?.(); reject(new FfmpegError('FFMPEG aborted')); }; if (signal) { if (signal.aborted) { abortHandler(); return; } signal.addEventListener('abort', abortHandler); } worker.onmessage = (e) => { const { type, blob, message, stage, progress, command } = e.data; if (type === 'complete') { if (signal) signal.removeEventListener('abort', abortHandler); worker.terminate(); endCategory?.(); resolve(blob); } else if (type === 'error') { if (signal) signal.removeEventListener('abort', abortHandler); worker.terminate(); endCategory?.(); reject(new FfmpegError(message)); } else if (type === 'progress' && message) { onProgress?.(new FfmpegProgress(stage, progress || 0, message)); } else if (type === 'progress' && stage != 'loading' && progress !== null) { onProgress?.(new FfmpegProgress(stage, progress || 0, message)); } else if (type === 'command') { if (logConsole) { const consoleCategory = `ffmpeg ${command?.join(' ')}`; // eslint-disable-next-line no-console console.groupCollapsed(consoleCategory); // eslint-disable-next-line no-console endCategory = () => console.groupEnd(); } } else if (type === 'log') { onProgress?.(new FfmpegProgress('stdout', 0, message)); if (logConsole) { console.log('[FFmpeg]', message); } } }; worker.onerror = (error) => { if (signal) signal.removeEventListener('abort', abortHandler); worker.terminate(); endCategory?.(); reject(new FfmpegError('Worker failed: ' + error.message)); }; void (async () => { const transferables = []; if (audioData) transferables.push(audioData); for (const f of extraFiles) { if (f.data instanceof ArrayBuffer) { transferables.push(f.data); } else if (f.data.buffer instanceof ArrayBuffer) { transferables.push(f.data.buffer); } } worker.postMessage( { audioData, extraFiles, ...(rawArgs ? { rawArgs: args } : { args }), output: { name: outputName, mime: outputMime, }, loadOptions: await assets, }, transferables ); })(); }); } /** * Encodes audio using FFmpeg via Web Worker * @async * @param {Blob} audioBlob - The audio blob to encode * @param {Object} [opts] - Options for FFmpeg encoding * @param {string[]} [opts.args=[]] - FFmpeg command-line arguments * @param {string} [opts.outputName='output'] - Name of the output file * @param {string} [opts.outputMime='application/octet-stream'] - MIME type of the output * @param {(progress: FfmpegProgress) => void} [opts.onProgress=null] - Optional callback for progress updates * @param {AbortSignal|null} [opts.signal=null] - Optional abort signal to cancel encoding * @param {Array} [opts.extraFiles=[]] - Additional files to provide to FFmpeg * @param {Boolean} [opts.logConsole=true] - Whether to log FFmpeg output to the console * @param {string[]} [opts.rawArgs=[]] - Whether to pass args as raw command line (without default input/output) * @returns {Promise} Encoded audio blob * @throws {FfmpegError} If Web Workers are not available * @throws {Error} If FFmpeg encoding fails */ export async function ffmpeg( audioBlob, { args = [], outputName = 'output', outputMime = 'application/octet-stream', onProgress = null, signal = null, extraFiles = [], logConsole = true, rawArgs = null, } = {} ) { try { // Use Web Worker for non-blocking FFmpeg encoding if (typeof Worker !== 'undefined') { return await ffmpegWorker( audioBlob, rawArgs || args, outputName, outputMime, onProgress, signal, extraFiles, logConsole, !!rawArgs ); } throw new FfmpegError('Web Workers are required for FFMPEG'); } catch (error) { console.error('FFMPEG failed:', error); throw error; } } /** * Retrieves information about an audio blob using FFmpeg * @param {Blob} audioBlob - The audio blob to analyze * @param {Object} [options] - Options for FFmpeg info extraction * @param {((progress: FfmpegProgress) => void) | null} [options.onProgress] - Callback function to track conversion progress * @param {AbortSignal|null} [options.signal] - AbortSignal for cancelling the operation * @returns {Promise} A promise that resolves to an array of output lines */ export async function ffmpegInfo(audioBlob, { onProgress = null, signal = null } = {}) { const outputLines = []; try { await ffmpeg(audioBlob, { args: ['-t', '0.01'], outputName: 'output.wav', onProgress: (progress) => { if (progress.stage === 'stdout' && progress.message) { outputLines.push(progress.message); } onProgress?.(progress); }, signal, logConsole: false, }); } catch (err) { if (err instanceof FfmpegError && !err.message.startsWith('Failed to delete')) { console.warn('FFmpeg info extraction failed:', err); } } return outputLines; } /** * Creates a new FFmpeg container with copied codec and stripped metadata. * @param {Blob} audioBlob - The audio blob to process * @param {string} outputExtension - The extension for the output file * @param {string} outputMime - The MIME type for the output blob * @param {((progress: FfmpegProgress) => void) | null} onProgress - Callback function to track conversion progress * @param {AbortSignal} signal - AbortSignal for cancelling the operation * @returns {Promise} A promise that resolves to the processed data blob */ export async function ffmpegNewContainer(audioBlob, outputExtension, outputMime, onProgress, signal) { return await ffmpeg(audioBlob, { args: ['-map_metadata', '-1', '-c', 'copy', '-strict', '-2'], outputName: `output.${outputExtension}`, outputMime: outputMime, onProgress, signal: signal, }); } export { FfmpegError };