feat: Optimize audiobook export by attempting audio stream copying, adding duration validation, refining chapter duration calculation, and synchronizing export settings with playback controls.

This commit is contained in:
Richard R 2026-02-21 22:57:59 -07:00
parent b2d9ab5395
commit cbd8aa6240
3 changed files with 164 additions and 64 deletions

View file

@ -152,6 +152,46 @@ async function runFFmpeg(args: string[], signal?: AbortSignal): Promise<void> {
});
}
function chapterEncodeArgs(
inputPath: string,
outputPath: string,
format: TTSAudiobookFormat,
postSpeed: number,
titleTag: string,
): string[] {
if (format === 'mp3') {
return [
'-y',
'-i',
inputPath,
...(postSpeed !== 1 ? ['-filter:a', buildAtempoFilter(postSpeed)] : []),
'-c:a',
'libmp3lame',
'-b:a',
'64k',
'-metadata',
`title=${titleTag}`,
outputPath,
];
}
return [
'-y',
'-i',
inputPath,
...(postSpeed !== 1 ? ['-filter:a', buildAtempoFilter(postSpeed)] : []),
'-c:a',
'aac',
'-b:a',
'64k',
'-metadata',
`title=${titleTag}`,
'-f',
'mp4',
outputPath,
];
}
function findChapterFileNameByIndex(fileNames: string[], index: number): { fileName: string; title: string; format: 'mp3' | 'm4b' } | null {
const matches = fileNames
.map((fileName) => {
@ -276,40 +316,36 @@ export async function POST(request: NextRequest) {
await writeFile(inputPath, Buffer.from(new Uint8Array(data.buffer)));
if (format === 'mp3') {
await runFFmpeg(
[
'-y',
'-i',
inputPath,
...(postSpeed !== 1 ? ['-filter:a', buildAtempoFilter(postSpeed)] : []),
'-c:a',
'libmp3lame',
'-b:a',
'64k',
'-metadata',
`title=${titleTag}`,
chapterOutputTempPath,
],
request.signal,
);
const canCopyMp3WithoutReencode = format === 'mp3' && postSpeed === 1;
if (canCopyMp3WithoutReencode) {
try {
await runFFmpeg(
[
'-y',
'-i',
inputPath,
'-c:a',
'copy',
'-map_metadata',
'-1',
'-id3v2_version',
'3',
'-metadata',
`title=${titleTag}`,
chapterOutputTempPath,
],
request.signal,
);
} catch (copyError) {
console.warn('Chapter remux failed; falling back to mp3 re-encode:', copyError);
await runFFmpeg(
chapterEncodeArgs(inputPath, chapterOutputTempPath, format, postSpeed, titleTag),
request.signal,
);
}
} else {
await runFFmpeg(
[
'-y',
'-i',
inputPath,
...(postSpeed !== 1 ? ['-filter:a', buildAtempoFilter(postSpeed)] : []),
'-c:a',
'aac',
'-b:a',
'64k',
'-metadata',
`title=${titleTag}`,
'-f',
'mp4',
chapterOutputTempPath,
],
chapterEncodeArgs(inputPath, chapterOutputTempPath, format, postSpeed, titleTag),
request.signal,
);
}

View file

@ -138,6 +138,13 @@ async function runFFmpeg(args: string[], signal?: AbortSignal): Promise<void> {
});
}
async function ensurePositiveDuration(filePath: string, signal?: AbortSignal): Promise<void> {
const probe = await ffprobeAudio(filePath, signal);
if (!probe.durationSec || probe.durationSec <= 0) {
throw new Error(`Invalid duration for output file: ${filePath}`);
}
}
export async function GET(request: NextRequest) {
let workDir: string | null = null;
try {
@ -230,16 +237,17 @@ export async function GET(request: NextRequest) {
const bytes = await getAudiobookObjectBuffer(bookId, existingBookUserId, chapter.fileName, testNamespace);
await writeFile(localPath, bytes);
let duration = durationByIndex.get(chapter.index) ?? 0;
if (!duration || duration <= 0) {
try {
const probe = await ffprobeAudio(localPath, request.signal);
if (probe.durationSec && probe.durationSec > 0) {
duration = probe.durationSec;
}
} catch {
duration = 0;
let duration = 0;
try {
const probe = await ffprobeAudio(localPath, request.signal);
if (probe.durationSec && probe.durationSec > 0) {
duration = probe.durationSec;
}
} catch {
duration = 0;
}
if (!duration || duration <= 0) {
duration = durationByIndex.get(chapter.index) ?? 0;
}
localChapters.push({
@ -268,31 +276,67 @@ export async function GET(request: NextRequest) {
);
if (format === 'mp3') {
await runFFmpeg(['-f', 'concat', '-safe', '0', '-i', listPath, '-c:a', 'libmp3lame', '-b:a', '64k', outputPath], request.signal);
try {
await runFFmpeg(
['-f', 'concat', '-safe', '0', '-i', listPath, '-map_metadata', '-1', '-c:a', 'copy', outputPath],
request.signal,
);
} catch (copyError) {
console.warn('MP3 concat copy failed; falling back to re-encode:', copyError);
await runFFmpeg(
['-f', 'concat', '-safe', '0', '-i', listPath, '-c:a', 'libmp3lame', '-b:a', '64k', outputPath],
request.signal,
);
}
} else {
await runFFmpeg(
[
'-f',
'concat',
'-safe',
'0',
'-i',
listPath,
'-i',
metadataPath,
'-map_metadata',
'1',
'-c:a',
'aac',
'-b:a',
'64k',
'-f',
'mp4',
outputPath,
],
request.signal,
);
try {
await runFFmpeg(
[
'-f',
'concat',
'-safe',
'0',
'-i',
listPath,
'-i',
metadataPath,
'-map_metadata',
'1',
'-c:a',
'copy',
'-f',
'mp4',
outputPath,
],
request.signal,
);
} catch (copyError) {
console.warn('M4B concat copy failed; falling back to re-encode:', copyError);
await runFFmpeg(
[
'-f',
'concat',
'-safe',
'0',
'-i',
listPath,
'-i',
metadataPath,
'-map_metadata',
'1',
'-c:a',
'aac',
'-b:a',
'64k',
'-f',
'mp4',
outputPath,
],
request.signal,
);
}
}
await ensurePositiveDuration(outputPath, request.signal);
const outputBytes = await readFile(outputPath);
await putAudiobookObject(bookId, existingBookUserId, completeName, outputBytes, chapterFileMimeType(format), testNamespace);

View file

@ -76,6 +76,26 @@ export function AudiobookExportModal({
const hasExistingAudiobook = Boolean(bookId) || chapters.length > 0;
const isLegacyAudiobookMissingSettings = hasExistingAudiobook && savedSettings === null;
useEffect(() => {
// For new audiobooks (no saved settings/chapters), keep generation defaults aligned
// with the current playback controls so users don't need a route remount.
if (!isOpen) return;
if (savedSettings) return;
if (hasExistingAudiobook) return;
setNativeSpeed(voiceSpeed);
setPostSpeed(audioPlayerSpeed);
setAudiobookVoice(configVoice || availableVoices[0] || '');
}, [
isOpen,
savedSettings,
hasExistingAudiobook,
voiceSpeed,
audioPlayerSpeed,
configVoice,
availableVoices,
]);
useEffect(() => {
if (savedSettings) return;
if (audiobookVoice) return;