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 {
|
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;
|
||||||
await runFFmpeg(
|
if (canCopyMp3WithoutReencode) {
|
||||||
[
|
try {
|
||||||
'-y',
|
await runFFmpeg(
|
||||||
'-i',
|
[
|
||||||
inputPath,
|
'-y',
|
||||||
...(postSpeed !== 1 ? ['-filter:a', buildAtempoFilter(postSpeed)] : []),
|
'-i',
|
||||||
'-c:a',
|
inputPath,
|
||||||
'libmp3lame',
|
'-c:a',
|
||||||
'-b:a',
|
'copy',
|
||||||
'64k',
|
'-map_metadata',
|
||||||
'-metadata',
|
'-1',
|
||||||
`title=${titleTag}`,
|
'-id3v2_version',
|
||||||
chapterOutputTempPath,
|
'3',
|
||||||
],
|
'-metadata',
|
||||||
request.signal,
|
`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 {
|
} 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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,16 +237,17 @@ 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) {
|
duration = probe.durationSec;
|
||||||
duration = probe.durationSec;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
duration = 0;
|
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
duration = 0;
|
||||||
|
}
|
||||||
|
if (!duration || duration <= 0) {
|
||||||
|
duration = durationByIndex.get(chapter.index) ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
localChapters.push({
|
localChapters.push({
|
||||||
|
|
@ -268,31 +276,67 @@ 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 {
|
||||||
await runFFmpeg(
|
try {
|
||||||
[
|
await runFFmpeg(
|
||||||
'-f',
|
[
|
||||||
'concat',
|
'-f',
|
||||||
'-safe',
|
'concat',
|
||||||
'0',
|
'-safe',
|
||||||
'-i',
|
'0',
|
||||||
listPath,
|
'-i',
|
||||||
'-i',
|
listPath,
|
||||||
metadataPath,
|
'-i',
|
||||||
'-map_metadata',
|
metadataPath,
|
||||||
'1',
|
'-map_metadata',
|
||||||
'-c:a',
|
'1',
|
||||||
'aac',
|
'-c:a',
|
||||||
'-b:a',
|
'copy',
|
||||||
'64k',
|
'-f',
|
||||||
'-f',
|
'mp4',
|
||||||
'mp4',
|
outputPath,
|
||||||
outputPath,
|
],
|
||||||
],
|
request.signal,
|
||||||
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);
|
const outputBytes = await readFile(outputPath);
|
||||||
await putAudiobookObject(bookId, existingBookUserId, completeName, outputBytes, chapterFileMimeType(format), testNamespace);
|
await putAudiobookObject(bookId, existingBookUserId, completeName, outputBytes, chapterFileMimeType(format), testNamespace);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue