import { existsSync, writeFileSync, readFileSync, unlinkSync, statSync, openSync, readSync, closeSync, copyFileSync } 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, "'\\''") } function assertFrameFile(path: string, kind: string) { if (!existsSync(path) || statSync(path).size < 64) { throw new Error(`Could not extract the ${kind}`) } } export async function extractLastFrame(inputPath: string, outputPath: string) { if (!existsSync(inputPath)) { throw new Error('Source video is missing') } const attempts: string[][] = [] try { const duration = await probeDuration(inputPath) const seek = Math.max(0, duration - 0.08) attempts.push(['-y', '-ss', seek.toFixed(3), '-i', inputPath, '-frames:v', '1', '-an', '-q:v', '1', outputPath]) } catch { // Truncated files often have no duration index; fall through to decode attempts. } attempts.push( ['-y', '-sseof', '-0.04', '-i', inputPath, '-update', '1', '-frames:v', '1', '-an', '-q:v', '1', outputPath], ['-y', '-i', inputPath, '-update', '1', '-an', '-q:v', '1', outputPath] ) for (const args of attempts) { try { await runFfmpeg(args, 60000) if (existsSync(outputPath) && statSync(outputPath).size >= 64) return } catch { // Try the next seek strategy. } } throw new Error('Could not extract the last frame. The video looks incomplete — interrupting Comfy mid-encode usually causes this. Earlier saved parts are still in the library.') } export async function extractFirstFrame(inputPath: string, outputPath: string) { if (!existsSync(inputPath)) { throw new Error('Source video is missing') } await runFfmpeg([ '-y', '-i', inputPath, '-frames:v', '1', '-update', '1', '-an', '-q:v', '4', outputPath ], 20000) assertFrameFile(outputPath, 'first frame') } export async function extractFrameAt(inputPath: string, outputPath: string, seconds: number) { if (!existsSync(inputPath)) { throw new Error('Source video is missing') } const seek = Math.max(0, seconds) await runFfmpeg([ '-y', '-ss', seek.toFixed(3), '-i', inputPath, '-frames:v', '1', '-update', '1', '-an', '-q:v', '2', outputPath ], 60000) assertFrameFile(outputPath, 'frame') } export async function extractFirstFrameFromBuffer(video: Buffer, tmpDir: string) { if (!video.length) throw new Error('Could not extract the first frame') const input = join(tmpDir, `seg_head_${Date.now()}.mp4`) const output = join(tmpDir, `seg_first_${Date.now()}.jpg`) writeFileSync(input, video) try { await extractFirstFrame(input, output) return readFileSync(output) } finally { try { unlinkSync(input) } catch { /* ignore */ } try { unlinkSync(output) } catch { /* ignore */ } } } 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 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 } export async function probeDuration(filePath: string) { const raw = await runFfprobe([ '-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=duration,nb_frames,r_frame_rate,avg_frame_rate', '-show_entries', 'format=duration', '-of', 'json', filePath ]) 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) { 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 } } function mp4MoovIsAtStart(filePath: string) { const fd = openSync(filePath, 'r') try { let offset = 0 const header = Buffer.alloc(16) for (let i = 0; i < 12; i++) { if (readSync(fd, header, 0, 8, offset) < 8) return false let size = header.readUInt32BE(0) const type = header.toString('ascii', 4, 8) if (size === 1) { if (readSync(fd, header, 8, 8, offset + 8) < 8) return false size = Number(header.readBigUInt64BE(8)) } if (!size || size < 8) return false if (type === 'moov') return true if (type === 'mdat') return false offset += size } return false } finally { closeSync(fd) } } export async function remuxFaststart(inputPath: string) { if (!existsSync(inputPath)) return try { if (mp4MoovIsAtStart(inputPath)) return } catch { // Fall through and try a remux anyway. } const tmp = `${inputPath}.faststart.tmp.mp4` try { await runFfmpeg([ '-y', '-i', inputPath, '-c', 'copy', '-map', '0', '-movflags', '+faststart', tmp ], 120000) if (!existsSync(tmp) || statSync(tmp).size < 32) { try { unlinkSync(tmp) } catch { /* ignore */ } return } copyFileSync(tmp, inputPath) try { unlinkSync(tmp) } catch { /* ignore */ } } catch { try { unlinkSync(tmp) } catch { /* ignore */ } } } function encodeArgs(outputPath: string, withAudio: boolean) { const args = [ '-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 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', '[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]', ...encodeArgs(outputPath, true) ], 300000) } else { await runFfmpeg([ '-y', '-i', part1Path, '-i', part2Path, '-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]', ...encodeArgs(outputPath, false) ], 300000) } if (!existsSync(outputPath)) { 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) { 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', '-movflags', '+faststart', outputPath], 180000) await assertStitchLength(part1Path, part2Path, outputPath) } catch { await concatReencode(part1Path, part2Path, outputPath) } } export function buildSourceAssembly(durations: number[], width: number, height: number, fps: number, audio: boolean, blend: boolean) { const filters: string[] = [] durations.forEach((_, index) => { filters.push(`[${index}:v]${index ? "select='gte(n,1)'," : ''}setpts=PTS-STARTPTS,scale=${width}:${height}:flags=lanczos,fps=${fps},format=yuv420p,settb=AVTB[v${index}]`) if (audio) filters.push(`[${index}:a]aresample=48000,asetpts=PTS-STARTPTS[a${index}]`) }) let duration = durations[0] let video = 'v0', sound = 'a0' for (let index = 1; index < durations.length; index++) { const fade = blend ? Math.min(XFADE_DURATION, Math.max(0.04, duration - 0.08), Math.max(0.04, durations[index] - 0.08)) : 0 const nextVideo = `joinv${index}`, nextAudio = `joina${index}` const offset = Math.max(0, duration - fade) filters.push(`[${video}][v${index}]` + (blend ? `xfade=transition=fade:duration=${fade.toFixed(6)}:offset=${offset.toFixed(6)}` : 'concat=n=2:v=1:a=0') + `[${nextVideo}]`) if (audio) filters.push(`[${sound}][a${index}]` + (blend ? `acrossfade=d=${fade.toFixed(6)}:c1=tri:c2=tri` : 'concat=n=2:v=0:a=1') + `[${nextAudio}]`) duration += durations[index] - 1 / fps - fade video = nextVideo; sound = nextAudio } return { filter: filters.join(';'), video, sound, duration } } async function assembleOriginalSources(paths: string[], outputPath: string, blend: boolean) { const [durations, size, audioFlags, frameRate] = await Promise.all([ Promise.all(paths.map(probeDuration)), probeSize(paths[0]), Promise.all(paths.map(probeHasAudio)), runFfprobe(['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=r_frame_rate', '-of', 'default=noprint_wrappers=1:nokey=1', paths[0]]) ]) const [numerator, denominator = 1] = frameRate.split('/').map(Number) const fps = numerator / denominator if (!Number.isFinite(fps) || fps <= 0) throw new Error('Cannot read the source video frame rate') const audio = audioFlags.every(Boolean) const graph = buildSourceAssembly(durations, size.width, size.height, fps, audio, blend) await runFfmpeg(['-y', ...paths.flatMap(path => ['-i', path]), '-filter_complex_threads', '1', '-filter_complex', graph.filter, '-map', `[${graph.video}]`, ...(audio ? ['-map', `[${graph.sound}]`] : []), ...encodeArgs(outputPath, audio)], 600000) const actual = await probeDuration(outputPath) if (Math.abs(actual - graph.duration) > Math.max(0.5, graph.duration * 0.02)) throw new Error('Assembled video length does not match the original segments') } export async function stitchExtension(params: { part1Path: string; part2: Buffer; tmpDir: string; sourcePaths?: string[] }) { const part2Path = join(params.tmpDir, `part2_${Date.now()}.mp4`) const finalPath = join(params.tmpDir, 'final_stitched_video.mp4') writeFileSync(part2Path, params.part2) if (!existsSync(part2Path) || !params.part2.length) { throw new Error('Extension segment was missing before stitch') } const sources = [...(params.sourcePaths?.length ? params.sourcePaths : [params.part1Path]), part2Path] try { await assembleOriginalSources(sources, finalPath, true) } catch (error) { console.error('[stitch] source crossfade failed; joining the same original sources:', (error as Error).message) await assembleOriginalSources(sources, finalPath, false) } const stitched = readFileSync(finalPath) for (const extra of [part2Path, join(params.tmpDir, 'concat_list.txt'), join(params.tmpDir, 'last_frame.png')]) { try { unlinkSync(extra) } catch { /* ignore */ } } return stitched }