Reject extension stitches that are still the original clip.
Xfade and concat copy could exit 0 while writing only part 1; require a longer output and fall back to a re-encode concat of both originals.
This commit is contained in:
+101
-130
@@ -94,33 +94,36 @@ export async function probeHasAudio(filePath: string) {
|
||||
|
||||
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
|
||||
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 probeFps(filePath: string) {
|
||||
const out = await runFfprobe([
|
||||
async function probeDuration(filePath: string) {
|
||||
const raw = await runFfprobe([
|
||||
'-v', 'error',
|
||||
'-select_streams', 'v:0',
|
||||
'-show_entries', 'stream=avg_frame_rate,r_frame_rate',
|
||||
'-of', 'default=nokey=1:noprint_wrappers=1',
|
||||
'-show_entries', 'stream=duration,nb_frames,r_frame_rate,avg_frame_rate',
|
||||
'-show_entries', 'format=duration',
|
||||
'-of', 'json',
|
||||
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
|
||||
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) {
|
||||
@@ -138,84 +141,100 @@ async function probeSize(filePath: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function trimLeadingFrame(inputPath: string, outputPath: string) {
|
||||
const fps = await probeFps(inputPath)
|
||||
const hasAudio = await probeHasAudio(inputPath)
|
||||
function encodeArgs(outputPath: string, withAudio: boolean) {
|
||||
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 (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', `${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]`,
|
||||
'-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]',
|
||||
...encode,
|
||||
'-c:a', 'aac',
|
||||
outputPath
|
||||
...encodeArgs(outputPath, true)
|
||||
], 300000)
|
||||
} else {
|
||||
await runFfmpeg([
|
||||
'-y',
|
||||
'-i', part1Path,
|
||||
'-i', part2Path,
|
||||
'-filter_complex', videoFilter,
|
||||
'-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]',
|
||||
...encode,
|
||||
outputPath
|
||||
...encodeArgs(outputPath, false)
|
||||
], 300000)
|
||||
}
|
||||
if (!existsSync(outputPath)) {
|
||||
throw new Error('FFmpeg did not write the blended video')
|
||||
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) {
|
||||
@@ -223,72 +242,24 @@ export async function concatMp4(part1Path: string, part2Path: string, outputPath
|
||||
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')
|
||||
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.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}).`)
|
||||
}
|
||||
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, trimmedPath, join(params.tmpDir, 'concat_list.txt'), join(params.tmpDir, 'last_frame.png')]) {
|
||||
for (const extra of [part2Path, join(params.tmpDir, 'concat_list.txt'), join(params.tmpDir, 'last_frame.png')]) {
|
||||
try { unlinkSync(extra) } catch { /* ignore */ }
|
||||
}
|
||||
return stitched
|
||||
|
||||
Reference in New Issue
Block a user