import { existsSync, writeFileSync, readFileSync, unlinkSync } from 'node:fs' import { dirname, join } from 'node:path' import { spawn } from 'node:child_process' function collectError(stderr: string, fallback: string) { const text = stderr.replace(/\r/g, '').trim() const lines = text.split('\n').map(line => line.trim()).filter(Boolean) return lines.slice(-6).join(' ') || fallback } export function runFfmpeg(args: string[], timeoutMs = 120000) { return new Promise((resolve, reject) => { const proc = spawn('ffmpeg', args, { stdio: ['ignore', 'ignore', 'pipe'] }) let stderr = '' proc.stderr?.on('data', (chunk: Buffer | string) => { stderr += String(chunk) }) const timer = setTimeout(() => { try { proc.kill('SIGKILL') } catch { /* ignore */ } reject(new Error(`FFmpeg timed out after ${Math.round(timeoutMs / 1000)}s`)) }, timeoutMs) proc.on('error', (error) => { clearTimeout(timer) const message = (error as NodeJS.ErrnoException).code === 'ENOENT' ? 'FFmpeg is not installed on this server' : error.message reject(new Error(message)) }) proc.on('close', (code) => { clearTimeout(timer) if (code === 0) resolve() else reject(new Error(collectError(stderr, `FFmpeg exited with code ${code}`))) }) }) } function runFfprobe(args: string[], timeoutMs = 15000) { return new Promise((resolve, reject) => { const proc = spawn('ffprobe', args, { stdio: ['ignore', 'pipe', 'pipe'] }) let out = '' let err = '' proc.stdout?.on('data', (chunk: Buffer | string) => { out += String(chunk) }) proc.stderr?.on('data', (chunk: Buffer | string) => { err += String(chunk) }) const timer = setTimeout(() => { try { proc.kill('SIGKILL') } catch { /* ignore */ } reject(new Error('ffprobe timed out')) }, timeoutMs) proc.on('error', (error) => { clearTimeout(timer) reject(error) }) proc.on('close', (code) => { clearTimeout(timer) if (code === 0) resolve(out.trim()) else reject(new Error(collectError(err, `ffprobe exited with code ${code}`))) }) }) } function concatPath(filePath: string) { return filePath.replace(/\\/g, '/').replace(/'/g, "'\\''") } export async function extractLastFrame(inputPath: string, outputPath: string) { if (!existsSync(inputPath)) { throw new Error('Source video is missing') } await runFfmpeg([ '-y', '-sseof', '-1', '-i', inputPath, '-update', '1', '-frames:v', '1', '-q:v', '1', outputPath ], 30000) if (!existsSync(outputPath)) { throw new Error('FFmpeg did not write the last frame') } } export async function probeHasAudio(filePath: string) { try { const out = await runFfprobe([ '-v', 'error', '-select_streams', 'a:0', '-show_entries', 'stream=codec_type', '-of', 'csv=p=0', filePath ]) return out.includes('audio') } catch { return false } } 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 } async function probeFps(filePath: string) { const out = await runFfprobe([ '-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=avg_frame_rate,r_frame_rate', '-of', 'default=nokey=1:noprint_wrappers=1', 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 } async function probeSize(filePath: string) { const out = await runFfprobe([ '-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height', '-of', 'csv=p=0', filePath ]) const [width, height] = out.split(',').map(Number) return { width: Number.isFinite(width) && width > 0 ? width : 1344, height: Number.isFinite(height) && height > 0 ? height : 768 } } async function trimLeadingFrame(inputPath: string, outputPath: string) { const fps = await probeFps(inputPath) const hasAudio = await probeHasAudio(inputPath) 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 (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]`, '-map', '[v]', '-map', '[a]', ...encode, '-c:a', 'aac', outputPath ], 300000) } else { await runFfmpeg([ '-y', '-i', part1Path, '-i', part2Path, '-filter_complex', videoFilter, '-map', '[v]', ...encode, outputPath ], 300000) } if (!existsSync(outputPath)) { throw new Error('FFmpeg did not write the blended video') } } export async function concatMp4(part1Path: string, part2Path: string, outputPath: string) { const listPath = join(dirname(outputPath), 'concat_list.txt') 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') } } 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}).`) } } const stitched = readFileSync(finalPath) for (const extra of [part2Path, trimmedPath, join(params.tmpDir, 'concat_list.txt'), join(params.tmpDir, 'last_frame.png')]) { try { unlinkSync(extra) } catch { /* ignore */ } } return stitched }