diff --git a/src/app/api/audiobook/chapter/route.ts b/src/app/api/audiobook/chapter/route.ts index 0a75004..15d7450 100644 --- a/src/app/api/audiobook/chapter/route.ts +++ b/src/app/api/audiobook/chapter/route.ts @@ -152,6 +152,46 @@ async function runFFmpeg(args: string[], signal?: AbortSignal): Promise { }); } +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, ); } diff --git a/src/app/api/audiobook/route.ts b/src/app/api/audiobook/route.ts index e6abc47..afeae62 100644 --- a/src/app/api/audiobook/route.ts +++ b/src/app/api/audiobook/route.ts @@ -138,6 +138,13 @@ async function runFFmpeg(args: string[], signal?: AbortSignal): Promise { }); } +async function ensurePositiveDuration(filePath: string, signal?: AbortSignal): Promise { + 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); diff --git a/src/components/AudiobookExportModal.tsx b/src/components/AudiobookExportModal.tsx index d3d9fb8..14be830 100644 --- a/src/components/AudiobookExportModal.tsx +++ b/src/components/AudiobookExportModal.tsx @@ -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;