Users can pre-queue extensions, extend from the library with p2 titles, blend seams with xfade, and a unreadable catalog is no longer replaced with an empty one. Co-authored-by: Cursor <cursoragent@cursor.com>
296 lines
9.6 KiB
TypeScript
296 lines
9.6 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
|
|
|
|
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
|
|
}
|