refactor(ffmpeg): add ffmpegInfo function and refactor ffmpeg usage
This commit is contained in:
parent
69d71b99d1
commit
9f7b0ff525
7 changed files with 122 additions and 87 deletions
17
js/api.js
17
js/api.js
|
|
@ -14,7 +14,6 @@ import { trackDateSettings } from './storage.js';
|
|||
import { APICache } from './cache.js';
|
||||
import { DashDownloader } from './dash-downloader.ts';
|
||||
import { HlsDownloader } from './hls-downloader.js';
|
||||
import { MP3EncodingError } from './mp3-encoder.js';
|
||||
import { loadFfmpeg, FfmpegError, ffmpeg } from './ffmpeg.js';
|
||||
import { triggerDownload, applyAudioPostProcessing } from './download-utils.ts';
|
||||
import { isCustomFormat } from './ffmpegFormats.ts';
|
||||
|
|
@ -1877,7 +1876,15 @@ export class LosslessAPI {
|
|||
try {
|
||||
if (isVideo) {
|
||||
blob = new File(
|
||||
[await ffmpeg(blob, ['-c', 'copy'], 'output.mp4', 'video/mp4', onProgress, options.signal)],
|
||||
[
|
||||
await ffmpeg(blob, {
|
||||
args: ['-c', 'copy'],
|
||||
outputName: 'output.mp4',
|
||||
outputMime: 'video/mp4',
|
||||
onProgress,
|
||||
signal: options.signal,
|
||||
}),
|
||||
],
|
||||
'output.mp4',
|
||||
{ type: 'video/mp4' }
|
||||
);
|
||||
|
|
@ -1908,11 +1915,7 @@ export class LosslessAPI {
|
|||
throw error;
|
||||
}
|
||||
console.error('Download failed:', error);
|
||||
if (
|
||||
error instanceof MP3EncodingError ||
|
||||
error instanceof FfmpegError ||
|
||||
error.code === 'MP3_ENCODING_FAILED'
|
||||
) {
|
||||
if (error instanceof FfmpegError || error.code === 'MP3_ENCODING_FAILED') {
|
||||
throw error;
|
||||
}
|
||||
if (error.message === RATE_LIMIT_ERROR_MESSAGE) {
|
||||
|
|
|
|||
|
|
@ -980,11 +980,32 @@ function createBulkDownloadNotification(type, name, _totalItems) {
|
|||
return notifEl;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {HTMLElement} notifEl
|
||||
* @param {number} current
|
||||
* @param {number} total
|
||||
* @param {string} currentItem
|
||||
* @param {FfmpegProgress | ProgressMessage | null} progress
|
||||
* @returns
|
||||
*/
|
||||
function updateBulkDownloadProgress(notifEl, current, total, currentItem, progress = null) {
|
||||
/** @type {HTMLElement | null} */
|
||||
const progressFill = notifEl.querySelector('.download-progress-fill');
|
||||
|
||||
/** @type {HTMLElement | null} */
|
||||
const statusEl = notifEl.querySelector('.download-status');
|
||||
|
||||
if (!progressFill || !statusEl) {
|
||||
console.log('Progress elements not found in notification');
|
||||
return;
|
||||
}
|
||||
|
||||
if (progress instanceof FfmpegProgress) {
|
||||
if (progress.stage == 'stdout') {
|
||||
return;
|
||||
}
|
||||
|
||||
const percent = progress.progress || 0;
|
||||
progressFill.style.width = `${percent}%`;
|
||||
progressFill.style.background = '#3b82f6'; // Blue for encoding
|
||||
|
|
|
|||
96
js/ffmpeg.js
96
js/ffmpeg.js
|
|
@ -38,6 +38,7 @@ export function loadFfmpeg() {
|
|||
* @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<Blob>} Encoded audio blob
|
||||
*/
|
||||
async function ffmpegWorker(
|
||||
|
|
@ -47,7 +48,8 @@ async function ffmpegWorker(
|
|||
outputMime = 'application/octet-stream',
|
||||
onProgress = null,
|
||||
signal = null,
|
||||
extraFiles = []
|
||||
extraFiles = [],
|
||||
logConsole = true
|
||||
) {
|
||||
const audioData = audioBlob ? await audioBlob.arrayBuffer() : null;
|
||||
const assets = loadFfmpeg();
|
||||
|
|
@ -85,7 +87,10 @@ async function ffmpegWorker(
|
|||
} else if (type === 'progress' && stage != 'loading' && progress !== null) {
|
||||
onProgress?.(new FfmpegProgress(stage, progress || 0, message));
|
||||
} else if (type === 'log') {
|
||||
console.log('[FFmpeg]', message);
|
||||
onProgress?.(new FfmpegProgress('stdout', 0, message));
|
||||
if (logConsole) {
|
||||
console.log('[FFmpeg]', message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -127,29 +132,43 @@ async function ffmpegWorker(
|
|||
* Encodes audio using FFmpeg via Web Worker
|
||||
* @async
|
||||
* @param {Blob} audioBlob - The audio blob to encode
|
||||
* @param {string[]} [args=[]] - FFmpeg command-line arguments
|
||||
* @param {string} [outputName='output'] - Name of the output file
|
||||
* @param {string} [outputMime='application/octet-stream'] - MIME type of the output
|
||||
* @param {(progress: FfmpegProgress) => void} [onProgress=null] - Optional callback for progress updates
|
||||
* @param {AbortSignal|null} [signal=null] - Optional abort signal to cancel encoding
|
||||
* @param {Array} [extraFiles=[]] - Additional files to provide to FFmpeg
|
||||
* @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
|
||||
* @returns {Promise<Blob>} 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 = []
|
||||
{
|
||||
args = [],
|
||||
outputName = 'output',
|
||||
outputMime = 'application/octet-stream',
|
||||
onProgress = null,
|
||||
signal = null,
|
||||
extraFiles = [],
|
||||
logConsole = true,
|
||||
} = {}
|
||||
) {
|
||||
try {
|
||||
// Use Web Worker for non-blocking FFmpeg encoding
|
||||
if (typeof Worker !== 'undefined') {
|
||||
return await ffmpegWorker(audioBlob, args, outputName, outputMime, onProgress, signal, extraFiles);
|
||||
return await ffmpegWorker(
|
||||
audioBlob,
|
||||
args,
|
||||
outputName,
|
||||
outputMime,
|
||||
onProgress,
|
||||
signal,
|
||||
extraFiles,
|
||||
logConsole
|
||||
);
|
||||
}
|
||||
|
||||
throw new FfmpegError('Web Workers are required for FFMPEG');
|
||||
|
|
@ -159,24 +178,55 @@ export async function ffmpeg(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string[]>} 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);
|
||||
},
|
||||
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 {Function} onProgress - Callback function to track conversion progress
|
||||
* @param {((progress: FfmpegProgress) => void) | null} onProgress - Callback function to track conversion progress
|
||||
* @param {AbortSignal} signal - AbortSignal for cancelling the operation
|
||||
* @returns {Promise<Blob>} A promise that resolves to the processed data blob
|
||||
*/
|
||||
export async function ffmpegNewContainer(audioBlob, outputExtension, outputMime, onProgress, signal) {
|
||||
return await ffmpeg(
|
||||
audioBlob,
|
||||
['-map_metadata', '-1', '-c', 'copy', '-strict', '-2'],
|
||||
`output.${outputExtension}`,
|
||||
outputMime,
|
||||
return await ffmpeg(audioBlob, {
|
||||
args: ['-map_metadata', '-1', '-c', 'copy', '-strict', '-2'],
|
||||
outputName: `output.${outputExtension}`,
|
||||
outputMime: outputMime,
|
||||
onProgress,
|
||||
signal
|
||||
);
|
||||
signal: signal,
|
||||
});
|
||||
}
|
||||
|
||||
export { FfmpegError };
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
export class FfmpegProgress implements MonochromeProgress {
|
||||
constructor(
|
||||
public readonly stage: 'loading' | 'encoding' | 'finalizing',
|
||||
public readonly stage: 'loading' | 'encoding' | 'finalizing' | 'stdout',
|
||||
public readonly progress: number,
|
||||
public readonly message?: string
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ self.onmessage = async (e) => {
|
|||
await ffmpeg.writeFile(file.name, new Uint8Array(file.data));
|
||||
}
|
||||
|
||||
const ffmpegArgs = ['-i', 'input', ...args, output.name];
|
||||
const ffmpegArgs = ['-i', 'input', ...args, ...(output.name ? [output.name] : [])];
|
||||
self.postMessage({ type: 'log', message: `FFmpeg command: ffmpeg ${ffmpegArgs.join(' ')}` });
|
||||
|
||||
const exitCode = await ffmpeg.exec(ffmpegArgs);
|
||||
|
|
@ -134,7 +134,7 @@ self.onmessage = async (e) => {
|
|||
|
||||
self.postMessage({ type: 'progress', stage: 'finalizing', message: encodeEndMessage, progress: 100.0 });
|
||||
|
||||
const data = await ffmpeg.readFile(output.name);
|
||||
const data = output.name ? await ffmpeg.readFile(output.name) : [];
|
||||
const outputBlob = new Blob([data], { type: output.mime });
|
||||
|
||||
self.postMessage({ type: 'complete', blob: outputBlob });
|
||||
|
|
@ -152,7 +152,9 @@ self.onmessage = async (e) => {
|
|||
}
|
||||
}
|
||||
try {
|
||||
await ffmpeg.deleteFile(output.name);
|
||||
if (output.name) {
|
||||
await ffmpeg.deleteFile(output.name);
|
||||
}
|
||||
} catch {
|
||||
self.postMessage({ type: 'log', message: `Failed to delete ${output.name} from FFmpeg FS.` });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -194,15 +194,14 @@ export async function transcodeWithCustomFormat(
|
|||
signal: AbortSignal | null = null,
|
||||
extraFiles: any[] = []
|
||||
): Promise<Blob> {
|
||||
return ffmpeg(
|
||||
audioBlob,
|
||||
format.ffmpegArgs,
|
||||
format.outputFilename,
|
||||
format.outputMime,
|
||||
return ffmpeg(audioBlob, {
|
||||
args: format.ffmpegArgs,
|
||||
outputName: format.outputFilename,
|
||||
outputMime: format.outputMime,
|
||||
onProgress,
|
||||
signal,
|
||||
extraFiles
|
||||
);
|
||||
extraFiles,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -216,13 +215,12 @@ export async function transcodeWithContainerFormat(
|
|||
signal: AbortSignal | null = null,
|
||||
extraFiles: any[] = []
|
||||
): Promise<Blob> {
|
||||
return ffmpeg(
|
||||
audioBlob,
|
||||
format.ffmpegArgs,
|
||||
format.outputFilename,
|
||||
format.outputMime,
|
||||
return ffmpeg(audioBlob, {
|
||||
args: format.ffmpegArgs,
|
||||
outputName: format.outputFilename,
|
||||
outputMime: format.outputMime,
|
||||
onProgress,
|
||||
signal,
|
||||
extraFiles
|
||||
);
|
||||
extraFiles,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
import { ffmpeg } from './ffmpeg';
|
||||
|
||||
/**
|
||||
* @typedef {import('./ffmpeg.types.ts').FfmpegProgress} FfmpegProgress
|
||||
*/
|
||||
|
||||
class MP3EncodingError extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = 'MP3EncodingError';
|
||||
this.code = 'MP3_ENCODING_FAILED';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Blob} audioBlob
|
||||
* @param {(progress: FfmpegProgress) => void} [onProgress=null]
|
||||
* @param {AbortSignal|null} [signal=null]
|
||||
* @returns {Promise<Blob>} Encoded MP3 audio blob
|
||||
*/
|
||||
export async function encodeToMp3(audioBlob, onProgress = null, signal = null) {
|
||||
try {
|
||||
// Use Web Worker for non-blocking FFmpeg encoding
|
||||
if (typeof Worker !== 'undefined') {
|
||||
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 new MP3EncodingError(error?.message ?? error);
|
||||
}
|
||||
}
|
||||
|
||||
export { MP3EncodingError };
|
||||
Loading…
Reference in a new issue