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 { function findChapterFileNameByIndex(fileNames: string[], index: number): { fileName: string; title: string; format: 'mp3' | 'm4b' } | null {
const matches = fileNames const matches = fileNames
.map((fileName) => { .map((fileName) => {
@ -276,40 +316,36 @@ export async function POST(request: NextRequest) {
await writeFile(inputPath, Buffer.from(new Uint8Array(data.buffer))); await writeFile(inputPath, Buffer.from(new Uint8Array(data.buffer)));
if (format === 'mp3') { const canCopyMp3WithoutReencode = format === 'mp3' && postSpeed === 1;
if (canCopyMp3WithoutReencode) {
try {
await runFFmpeg( await runFFmpeg(
[ [
'-y', '-y',
'-i', '-i',
inputPath, inputPath,
...(postSpeed !== 1 ? ['-filter:a', buildAtempoFilter(postSpeed)] : []),
'-c:a', '-c:a',
'libmp3lame', 'copy',
'-b:a', '-map_metadata',
'64k', '-1',
'-id3v2_version',
'3',
'-metadata', '-metadata',
`title=${titleTag}`, `title=${titleTag}`,
chapterOutputTempPath, chapterOutputTempPath,
], ],
request.signal, 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 { } else {
await runFFmpeg( await runFFmpeg(
[ chapterEncodeArgs(inputPath, chapterOutputTempPath, format, postSpeed, titleTag),
'-y',
'-i',
inputPath,
...(postSpeed !== 1 ? ['-filter:a', buildAtempoFilter(postSpeed)] : []),
'-c:a',
'aac',
'-b:a',
'64k',
'-metadata',
`title=${titleTag}`,
'-f',
'mp4',
chapterOutputTempPath,
],
request.signal, 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) { export async function GET(request: NextRequest) {
let workDir: string | null = null; let workDir: string | null = null;
try { try {
@ -230,8 +237,7 @@ export async function GET(request: NextRequest) {
const bytes = await getAudiobookObjectBuffer(bookId, existingBookUserId, chapter.fileName, testNamespace); const bytes = await getAudiobookObjectBuffer(bookId, existingBookUserId, chapter.fileName, testNamespace);
await writeFile(localPath, bytes); await writeFile(localPath, bytes);
let duration = durationByIndex.get(chapter.index) ?? 0; let duration = 0;
if (!duration || duration <= 0) {
try { try {
const probe = await ffprobeAudio(localPath, request.signal); const probe = await ffprobeAudio(localPath, request.signal);
if (probe.durationSec && probe.durationSec > 0) { if (probe.durationSec && probe.durationSec > 0) {
@ -240,6 +246,8 @@ export async function GET(request: NextRequest) {
} catch { } catch {
duration = 0; duration = 0;
} }
if (!duration || duration <= 0) {
duration = durationByIndex.get(chapter.index) ?? 0;
} }
localChapters.push({ localChapters.push({
@ -268,8 +276,42 @@ export async function GET(request: NextRequest) {
); );
if (format === 'mp3') { 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 { } else {
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( await runFFmpeg(
[ [
'-f', '-f',
@ -293,6 +335,8 @@ export async function GET(request: NextRequest) {
request.signal, request.signal,
); );
} }
}
await ensurePositiveDuration(outputPath, request.signal);
const outputBytes = await readFile(outputPath); const outputBytes = await readFile(outputPath);
await putAudiobookObject(bookId, existingBookUserId, completeName, outputBytes, chapterFileMimeType(format), testNamespace); 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 hasExistingAudiobook = Boolean(bookId) || chapters.length > 0;
const isLegacyAudiobookMissingSettings = hasExistingAudiobook && savedSettings === null; 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(() => { useEffect(() => {
if (savedSettings) return; if (savedSettings) return;
if (audiobookVoice) return; if (audiobookVoice) return;