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:
parent
b2d9ab5395
commit
cbd8aa6240
3 changed files with 164 additions and 64 deletions
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue