402 lines
14 KiB
TypeScript
402 lines
14 KiB
TypeScript
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<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, "'\\''")
|
|
}
|
|
|
|
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 maxTotalTimeMs = 15000
|
|
const start = Date.now()
|
|
let delayMs = 200
|
|
|
|
while (Date.now() - start < maxTotalTimeMs) {
|
|
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 next strategy
|
|
}
|
|
}
|
|
|
|
const elapsed = Date.now() - start
|
|
if (elapsed >= maxTotalTimeMs) break
|
|
|
|
const wait = Math.min(delayMs, maxTotalTimeMs - elapsed)
|
|
await new Promise(resolve => setTimeout(resolve, wait))
|
|
delayMs = Math.min(delayMs * 2, maxTotalTimeMs - (Date.now() - start))
|
|
}
|
|
|
|
throw new Error('Could not extract the last frame within the timeout. The video may still be encoding or is incomplete.')
|
|
}
|
|
|
|
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
|
|
}
|