Preserve extension quality with original segments and reliable refinement

This commit is contained in:
Towsty
2026-09-08 06:25:40 -05:00
parent c2b6032e49
commit 7665285a0d
16 changed files with 222 additions and 98 deletions
+47 -46
View File
@@ -284,48 +284,6 @@ async function assertStitchLength(part1Path: string, part2Path: string, outputPa
}
}
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)
@@ -366,18 +324,61 @@ export async function concatMp4(part1Path: string, part2Path: string, outputPath
}
}
export async function stitchExtension(params: { part1Path: string; part2: Buffer; tmpDir: string }) {
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 xfadeStitch(params.part1Path, part2Path, finalPath)
await assembleOriginalSources(sources, finalPath, true)
} catch (error) {
console.error('[stitch] xfade failed, concatenating:', (error as Error).message)
await concatReencode(params.part1Path, part2Path, finalPath)
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')]) {