diff --git a/server/utils/ffmpeg.ts b/server/utils/ffmpeg.ts index d21ed11..32479ad 100644 --- a/server/utils/ffmpeg.ts +++ b/server/utils/ffmpeg.ts @@ -94,33 +94,36 @@ export async function probeHasAudio(filePath: string) { const XFADE_DURATION = 0.12 -async function probeDuration(filePath: string) { - const out = await runFfprobe([ - '-v', 'error', - '-show_entries', 'format=duration', - '-of', 'default=nokey=1:noprint_wrappers=1', - filePath - ]) - const duration = Number(out) - if (!Number.isFinite(duration) || duration <= 0) { - throw new Error('Could not read video duration') - } - return duration +function parseRate(rate: string) { + const [num, den] = String(rate || '').split('/').map(Number) + if (den) return num / den + const value = Number(rate) + return Number.isFinite(value) && value > 0 ? value : NaN } -async function probeFps(filePath: string) { - const out = await runFfprobe([ +async function probeDuration(filePath: string) { + const raw = await runFfprobe([ '-v', 'error', '-select_streams', 'v:0', - '-show_entries', 'stream=avg_frame_rate,r_frame_rate', - '-of', 'default=nokey=1:noprint_wrappers=1', + '-show_entries', 'stream=duration,nb_frames,r_frame_rate,avg_frame_rate', + '-show_entries', 'format=duration', + '-of', 'json', filePath ]) - const line = out.split(/\r?\n/).map(item => item.trim()).find(Boolean) || '' - const [num, den] = line.split('/').map(Number) - if (Number.isFinite(num) && Number.isFinite(den) && den > 0) return num / den - const fps = Number(line) - return Number.isFinite(fps) && fps > 0 ? fps : 24 + const parsed = JSON.parse(raw) as { + format?: { duration?: string } + streams?: Array<{ duration?: string; nb_frames?: string; r_frame_rate?: string; avg_frame_rate?: string }> + } + const stream = parsed.streams?.[0] || {} + const fps = parseRate(stream.avg_frame_rate || '') || parseRate(stream.r_frame_rate || '') + const frames = Number(stream.nb_frames) + const fromFrames = Number.isFinite(frames) && frames > 0 && Number.isFinite(fps) ? frames / fps : NaN + const candidates = [Number(stream.duration), Number(parsed.format?.duration), fromFrames] + .filter(value => Number.isFinite(value) && value > 0) + if (!candidates.length) { + throw new Error('Could not read video duration') + } + return Math.min(...candidates) } async function probeSize(filePath: string) { @@ -138,84 +141,100 @@ async function probeSize(filePath: string) { } } -async function trimLeadingFrame(inputPath: string, outputPath: string) { - const fps = await probeFps(inputPath) - const hasAudio = await probeHasAudio(inputPath) +function encodeArgs(outputPath: string, withAudio: boolean) { const args = [ - '-y', - '-i', inputPath, - '-vf', 'trim=start_frame=1,setpts=PTS-STARTPTS' - ] - if (hasAudio) { - args.push('-af', `atrim=start=${(1 / fps).toFixed(6)},asetpts=PTS-STARTPTS`, '-c:a', 'aac') - } else { - args.push('-an') - } - args.push( - '-c:v', 'libx264', - '-preset', 'fast', - '-crf', '17', - '-pix_fmt', 'yuv420p', - '-movflags', '+faststart', - outputPath - ) - await runFfmpeg(args, 180000) - if (!existsSync(outputPath)) { - throw new Error('FFmpeg did not write the trimmed extension segment') - } -} - -async function xfadeStitch(part1Path: string, part2Path: string, outputPath: string) { - const [duration1, duration2, fps, size, audio1, audio2] = await Promise.all([ - probeDuration(part1Path), - probeDuration(part2Path), - probeFps(part1Path), - probeSize(part1Path), - probeHasAudio(part1Path), - probeHasAudio(part2Path) - ]) - const fade = Math.min( - XFADE_DURATION, - Math.max(0.04, duration1 - 0.05), - Math.max(0.04, duration2 - 0.05) - ) - const offset = Math.max(0, Number((duration1 - fade).toFixed(3))) - const fpsLabel = Number.isInteger(fps) ? String(fps) : fps.toFixed(3) - const prepare = `fps=${fpsLabel},scale=${size.width}:${size.height}:flags=bicubic,format=yuv420p,setpts=PTS-STARTPTS` - const videoFilter = `[0:v]${prepare}[v0];[1:v]${prepare}[v1];[v0][v1]xfade=transition=fade:duration=${fade.toFixed(3)}:offset=${offset},setpts=PTS-STARTPTS[v]` - const encode = [ '-c:v', 'libx264', '-preset', 'fast', '-crf', '17', '-pix_fmt', 'yuv420p', '-movflags', '+faststart' ] + if (withAudio) args.push('-c:a', 'aac') + args.push(outputPath) + return args +} + +async function assertStitchLength(part1Path: string, part2Path: string, outputPath: string, fade = 0) { + const [duration1, duration2, output] = await Promise.all([ + probeDuration(part1Path), + probeDuration(part2Path), + probeDuration(outputPath) + ]) + const expected = duration1 + duration2 - fade + if (output < expected * 0.75 || output < duration1 + 0.75) { + throw new Error(`Stitched video was ${output.toFixed(2)}s, expected about ${expected.toFixed(2)}s`) + } +} + +async function xfadeStitch(part1Path: string, part2Path: string, outputPath: string) { + const [duration1, duration2, size, audio1, audio2] = await Promise.all([ + probeDuration(part1Path), + probeDuration(part2Path), + probeSize(part1Path), + probeHasAudio(part1Path), + probeHasAudio(part2Path) + ]) + const fade = Math.min(XFADE_DURATION, Math.max(0.04, duration1 - 0.08), Math.max(0.04, duration2 - 0.08)) + const offset = Math.max(0, Number((duration1 - fade - 0.04).toFixed(3))) + const prepare1 = `scale=${size.width}:${size.height}:flags=bicubic,format=yuv420p,setpts=PTS-STARTPTS` + const prepare2 = `select='gte(n,1)',setpts=PTS-STARTPTS,scale=${size.width}:${size.height}:flags=bicubic,format=yuv420p` + const videoFilter = `[0:v]${prepare1}[v0];[1:v]${prepare2}[v1];[v0][v1]xfade=transition=fade:duration=${fade.toFixed(3)}:offset=${offset},setpts=PTS-STARTPTS[v]` + const runVideo = async (withAudio: boolean) => { + const args = [ + '-y', + '-i', part1Path, + '-i', part2Path, + '-filter_complex', withAudio + ? `${videoFilter};[0:a]asetpts=PTS-STARTPTS[a0];[1:a]asetpts=PTS-STARTPTS[a1];[a0][a1]acrossfade=d=${fade.toFixed(3)}:c1=tri:c2=tri[a]` + : videoFilter, + '-map', '[v]', + ...(withAudio ? ['-map', '[a]'] : []), + ...encodeArgs(outputPath, withAudio) + ] + await runFfmpeg(args, 300000) + } + if (audio1 && audio2) { + try { + await runVideo(true) + } catch { + await runVideo(false) + } + } else { + await runVideo(false) + } + if (!existsSync(outputPath)) { + throw new Error('FFmpeg did not write the blended video') + } + await assertStitchLength(part1Path, part2Path, outputPath, fade) +} + +async function concatReencode(part1Path: string, part2Path: string, outputPath: string) { + const audio1 = await probeHasAudio(part1Path) + const audio2 = await probeHasAudio(part2Path) if (audio1 && audio2) { await runFfmpeg([ '-y', '-i', part1Path, '-i', part2Path, - '-filter_complex', `${videoFilter};[0:a]asetpts=PTS-STARTPTS[a0];[1:a]asetpts=PTS-STARTPTS[a1];[a0][a1]acrossfade=d=${fade.toFixed(3)}:c1=tri:c2=tri[a]`, + '-filter_complex', '[0:v]setpts=PTS-STARTPTS[v0];[1:v]select=\'gte(n,1)\',setpts=PTS-STARTPTS[v1];[0:a]asetpts=PTS-STARTPTS[a0];[1:a]asetpts=PTS-STARTPTS[a1];[v0][a0][v1][a1]concat=n=2:v=1:a=1[v][a]', '-map', '[v]', '-map', '[a]', - ...encode, - '-c:a', 'aac', - outputPath + ...encodeArgs(outputPath, true) ], 300000) } else { await runFfmpeg([ '-y', '-i', part1Path, '-i', part2Path, - '-filter_complex', videoFilter, + '-filter_complex', '[0:v]setpts=PTS-STARTPTS[v0];[1:v]select=\'gte(n,1)\',setpts=PTS-STARTPTS[v1];[v0][v1]concat=n=2:v=1:a=0[v]', '-map', '[v]', - ...encode, - outputPath + ...encodeArgs(outputPath, false) ], 300000) } if (!existsSync(outputPath)) { - throw new Error('FFmpeg did not write the blended video') + throw new Error('FFmpeg did not write the stitched video') } + await assertStitchLength(part1Path, part2Path, outputPath) } export async function concatMp4(part1Path: string, part2Path: string, outputPath: string) { @@ -223,72 +242,24 @@ export async function concatMp4(part1Path: string, part2Path: string, outputPath writeFileSync(listPath, `file '${concatPath(part1Path)}'\nfile '${concatPath(part2Path)}'\n`) try { await runFfmpeg(['-y', '-f', 'concat', '-safe', '0', '-i', listPath, '-c', 'copy', outputPath], 180000) - } catch (copyError) { - const audio1 = await probeHasAudio(part1Path) - const audio2 = await probeHasAudio(part2Path) - try { - if (audio1 && audio2) { - await runFfmpeg([ - '-y', - '-i', part1Path, - '-i', part2Path, - '-filter_complex', '[0:v][0:a][1:v][1:a]concat=n=2:v=1:a=1[v][a]', - '-map', '[v]', - '-map', '[a]', - '-c:v', 'libx264', - '-preset', 'fast', - '-crf', '17', - '-pix_fmt', 'yuv420p', - '-c:a', 'aac', - '-movflags', '+faststart', - outputPath - ], 300000) - } else { - await runFfmpeg([ - '-y', - '-i', part1Path, - '-i', part2Path, - '-filter_complex', '[0:v][1:v]concat=n=2:v=1:a=0[v]', - '-map', '[v]', - '-c:v', 'libx264', - '-preset', 'fast', - '-crf', '17', - '-pix_fmt', 'yuv420p', - '-movflags', '+faststart', - outputPath - ], 300000) - } - } catch (encodeError) { - const copyMessage = copyError instanceof Error ? copyError.message : String(copyError) - const encodeMessage = encodeError instanceof Error ? encodeError.message : String(encodeError) - throw new Error(`FFmpeg concat failed (${copyMessage}). Re-encode also failed (${encodeMessage}).`) - } - } - if (!existsSync(outputPath)) { - throw new Error('FFmpeg did not write the stitched video') + await assertStitchLength(part1Path, part2Path, outputPath) + } catch { + await concatReencode(part1Path, part2Path, outputPath) } } export async function stitchExtension(params: { part1Path: string; part2: Buffer; tmpDir: string }) { const part2Path = join(params.tmpDir, 'part2.mp4') - const trimmedPath = join(params.tmpDir, 'part2_trim.mp4') const finalPath = join(params.tmpDir, 'final_stitched_video.mp4') writeFileSync(part2Path, params.part2) try { - await trimLeadingFrame(part2Path, trimmedPath) - await xfadeStitch(params.part1Path, trimmedPath, finalPath) - } catch (blendError) { - try { - const trimmedReady = existsSync(trimmedPath) - await concatMp4(params.part1Path, trimmedReady ? trimmedPath : part2Path, finalPath) - } catch (concatError) { - const blendMessage = blendError instanceof Error ? blendError.message : String(blendError) - const concatMessage = concatError instanceof Error ? concatError.message : String(concatError) - throw new Error(`Seam blend failed (${blendMessage}). Concat fallback also failed (${concatMessage}).`) - } + await xfadeStitch(params.part1Path, part2Path, finalPath) + } catch (error) { + console.error('[stitch] xfade failed, concatenating:', (error as Error).message) + await concatReencode(params.part1Path, part2Path, finalPath) } const stitched = readFileSync(finalPath) - for (const extra of [part2Path, trimmedPath, join(params.tmpDir, 'concat_list.txt'), join(params.tmpDir, 'last_frame.png')]) { + for (const extra of [part2Path, join(params.tmpDir, 'concat_list.txt'), join(params.tmpDir, 'last_frame.png')]) { try { unlinkSync(extra) } catch { /* ignore */ } } return stitched