Files
aigen/server/utils/ffmpeg.ts
T
towsty d78c3fee7d Keep extension chains alive and save each finished segment.
The overall bar made a long queue look stuck at ~9% during the first clip, and nothing was written until the last step. Save checkpoints, use per-step progress, and stop treating a short Comfy history lag as a dropped job.
2026-08-26 02:21:55 -05:00

270 lines
9.4 KiB
TypeScript

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<void>((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<string>((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
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 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 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 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', '[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', outputPath], 180000)
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_${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')
}
try {
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, join(params.tmpDir, 'concat_list.txt'), join(params.tmpDir, 'last_frame.png')]) {
try { unlinkSync(extra) } catch { /* ignore */ }
}
return stitched
}