refactor(downloads): abstract ffmpeg worker logic

This refactors the ffmpeg worker logic and abstracts it into its own script file for easier use elsewhere.
This commit is contained in:
Daniel 2026-02-27 20:24:20 +00:00 committed by GitHub
parent 4aaffd2c22
commit 52ad38c080
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 126 additions and 77 deletions

96
js/ffmpeg.js Normal file
View file

@ -0,0 +1,96 @@
class FfmpegError extends Error {
constructor(message) {
super(message);
this.name = 'FfmpegError';
this.code = 'FFMPEG_FAILED';
}
}
async function ffmpegWorker(
audioBlob,
args = {},
outputName = 'output',
outputMime = 'application/octet-stream',
onProgress = null,
signal = null
) {
const audioData = await audioBlob.arrayBuffer();
return new Promise((resolve, reject) => {
const worker = new Worker(new URL('./ffmpeg.worker.js', import.meta.url), { type: 'module' });
// Handle abort signal
const abortHandler = () => {
worker.terminate();
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 } = e.data;
if (type === 'complete') {
if (signal) signal.removeEventListener('abort', abortHandler);
worker.terminate();
resolve(blob);
} else if (type === 'error') {
if (signal) signal.removeEventListener('abort', abortHandler);
worker.terminate();
reject(new FfmpegError(message));
} else if (type === 'progress' && onProgress) {
onProgress({ stage, message, progress });
} else if (type === 'log') {
console.log('[FFmpeg]', message);
}
};
worker.onerror = (error) => {
if (signal) signal.removeEventListener('abort', abortHandler);
worker.terminate();
reject(new FfmpegError('Worker failed: ' + error.message));
};
// Transfer audio data to worker
worker.postMessage(
{
audioData,
...args,
output: {
name: outputName,
mime: outputMime,
},
},
[audioData]
);
});
}
export async function ffmpeg(
audioBlob,
args = {},
outputName = 'output',
outputMime = 'application/octet-stream',
onProgress = null,
signal = null
) {
try {
// Use Web Worker for non-blocking FFmpeg encoding
if (typeof Worker !== 'undefined') {
return await ffmpegWorker(audioBlob, args, outputName, outputMime, onProgress, signal);
}
throw new FfmpegError('Web Workers are required for FFMPEG');
} catch (error) {
console.error('FFMPEG failed:', error);
throw error;
}
}
export { FfmpegError };

View file

@ -36,39 +36,41 @@ async function loadFFmpeg() {
}
self.onmessage = async (e) => {
const { audioData } = e.data;
const {
audioData,
args = [],
output = {
name: 'output',
mime: 'application/octet-stream',
},
encodeStartMessage = 'Encoding...',
encodeEndMessage = 'Finalizing...',
} = e.data;
try {
await loadFFmpeg();
self.postMessage({ type: 'progress', stage: 'encoding', message: 'Encoding to MP3 320kbps...' });
self.postMessage({ type: 'progress', stage: 'encoding', message: encodeStartMessage });
try {
// Write input file to FFmpeg virtual filesystem
await ffmpeg.writeFile('input', new Uint8Array(audioData));
// Encode to MP3 with 320kbps CBR, strip source metadata to avoid duplicate ID3 tags
await ffmpeg.exec([
'-i',
'input',
'-map_metadata',
'-1',
'-c:a',
'libmp3lame',
'-b:a',
'320k',
'-ar',
'44100',
'output.mp3',
]);
const ffmpegArgs = ['-i', 'input', ...args, output.name];
self.postMessage({ type: 'progress', stage: 'finalizing', message: 'Finalizing MP3...' });
// Log the exact FFmpeg command being run for debugging.
self.postMessage({ type: 'log', message: `Running with args: ${ffmpegArgs.join(' ')}` });
// Run FFMPEG with the provided arguments.
await ffmpeg.exec(ffmpegArgs);
self.postMessage({ type: 'progress', stage: 'finalizing', message: encodeEndMessage });
// Read output file - use Uint8Array directly to avoid extra bytes from ArrayBuffer
const data = await ffmpeg.readFile('output.mp3');
const mp3Blob = new Blob([data], { type: 'audio/mpeg' });
const data = await ffmpeg.readFile(output.name);
const outputBlob = new Blob([data], { type: output.mime });
self.postMessage({ type: 'complete', blob: mp3Blob });
self.postMessage({ type: 'complete', blob: outputBlob });
} finally {
// Always cleanup virtual filesystem files
try {
@ -77,7 +79,7 @@ self.onmessage = async (e) => {
// File may not exist if writeFile failed
}
try {
await ffmpeg.deleteFile('output.mp3');
await ffmpeg.deleteFile(output.name);
} catch {
// File may not exist if exec failed
}

View file

@ -1,3 +1,5 @@
import { ffmpeg } from './ffmpeg';
class MP3EncodingError extends Error {
constructor(message) {
super(message);
@ -6,71 +8,20 @@ class MP3EncodingError extends Error {
}
}
async function encodeToMp3Worker(audioBlob, onProgress = null, signal = null) {
const audioData = await audioBlob.arrayBuffer();
return new Promise((resolve, reject) => {
const worker = new Worker(new URL('./mp3-encoder.worker.js', import.meta.url), { type: 'module' });
// Handle abort signal
const abortHandler = () => {
worker.terminate();
reject(new MP3EncodingError('MP3 encoding aborted'));
};
if (signal) {
if (signal.aborted) {
abortHandler();
return;
}
signal.addEventListener('abort', abortHandler);
}
worker.onmessage = (e) => {
const { type, blob, message, stage, progress } = e.data;
if (type === 'complete') {
if (signal) signal.removeEventListener('abort', abortHandler);
worker.terminate();
resolve(blob);
} else if (type === 'error') {
if (signal) signal.removeEventListener('abort', abortHandler);
worker.terminate();
reject(new MP3EncodingError(message));
} else if (type === 'progress' && onProgress) {
onProgress({ stage, message, progress });
} else if (type === 'log') {
console.log('[FFmpeg]', message);
}
};
worker.onerror = (error) => {
if (signal) signal.removeEventListener('abort', abortHandler);
worker.terminate();
reject(new MP3EncodingError('Worker failed: ' + error.message));
};
// Transfer audio data to worker
worker.postMessage(
{
audioData,
},
[audioData]
);
});
}
export async function encodeToMp3(audioBlob, onProgress = null, signal = null) {
try {
// Use Web Worker for non-blocking FFmpeg encoding
if (typeof Worker !== 'undefined') {
return await encodeToMp3Worker(audioBlob, onProgress, signal);
const args = ['-map_metadata', '-1', '-c:a', 'libmp3lame', '-b:a', '320k', '-ar', '44100'];
return await ffmpeg(audioBlob, { args }, 'output.mp3', 'audio/mpeg', onProgress, signal);
}
throw new MP3EncodingError('Web Workers are required for MP3 encoding');
} catch (error) {
console.error('MP3 encoding failed:', error);
throw error;
throw new MP3EncodingError(error?.message ?? error);
}
}