Files
aigen/scripts/upscale-worker.mjs

85 lines
6.5 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { spawn } from 'node:child_process'
import { readFileSync, mkdirSync, readdirSync, rmSync, existsSync, writeFileSync } from 'node:fs'
import { join, dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { upscaleOptions, upscaleDimensions } from '../shared/video-upscale.mjs'
export async function upscaleVideo(requestPath, { runCommand, report } = {}) {
const request = JSON.parse(readFileSync(requestPath, 'utf8'))
const root = request.root, dir = dirname(requestPath), source = join(dir, 'input.mp4')
const options = upscaleOptions(request)
const started = Date.now()
let child
function event(message, extra = {}) { if (report) report({ message, ...extra }); else process.stdout.write(`AIGEN_EVENT ${JSON.stringify({ message, ...extra })}\n`) }
function execute(exe, args, cwd = dir) {
return new Promise((resolve, reject) => {
child = spawn(exe, args, { cwd, windowsHide: true, shell: false, stdio: ['ignore', 'pipe', 'pipe'] })
let out = '', tail = ''
child.stdout.on('data', b => { out = (out + b).slice(-2_000_000) })
child.stderr.on('data', b => { tail = (tail + b).slice(-6000) })
child.once('error', reject)
child.once('close', code => { child = null; code === 0 ? resolve(out) : reject(new Error(/out of memory|allocate|vkAllocateMemory/i.test(tail) ? 'GPU out of memory. Try 2x with Keep original FPS.' : `${exe.split(/[\\/]/).pop()} failed: ${tail.slice(-1500)}`)) })
})
}
const run = runCommand || execute
// Host cancellation kills this entire tree. Also stop descendants if the host disappears.
const watchdog = setInterval(() => {
try { process.kill(Number(request.parentPid), 0) } catch {
spawn('taskkill', ['/PID', String(process.pid), '/T', '/F'], { windowsHide: true })
}
}, 2000)
try {
event('Checking local tools and weights', { progress: 1 })
await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', fileURLToPath(new URL('./setup-upscale.ps1', import.meta.url)), '-Root', root])
const ffmpeg = join(root, 'ffmpeg', 'ffmpeg.exe'), ffprobe = join(root, 'ffmpeg', 'ffprobe.exe')
const probe = JSON.parse(await run(ffprobe, ['-v', 'error', '-show_streams', '-show_format', '-of', 'json', source]))
const video = probe.streams.find(s => s.codec_type === 'video')
if (!video) throw new Error('Input has no video stream.')
const rate = String(video.avg_frame_rate).split('/').map(Number)
const fps = rate[0] / (rate[1] || 1), duration = Number(video.duration || probe.format.duration)
if (!Number.isFinite(fps) || fps <= 0 || fps > 240 || !Number.isFinite(duration) || duration <= 0) throw new Error('Cannot read source frame rate or duration.')
const target = upscaleDimensions(video.width, video.height, options)
const outputFps = options.fps === 'keep' ? fps : options.fps
const interpolate = options.fps !== 'keep'
const multiplier = interpolate ? Math.max(2, Math.ceil(outputFps / fps)) : 1
const totalFrames = Math.max(1, Math.round(duration * fps))
const chunkFrames = Math.max(2, Math.round(fps * 2))
const rifeDir = join(root, 'rife')
const rifeModel = readdirSync(rifeDir).filter(n => /^rife-v4/.test(n)).sort().at(-1)
const info = { sourceWidth: video.width, sourceHeight: video.height, ...target, duration, fps: outputFps, engine: 'Real-ESRGAN' + (interpolate ? ' + RIFE' : '') }
event('Upscaling video', { ...info, progress: 3 })
const segments = []
for (let start = 0, index = 0; start < totalFrames; start += chunkFrames, index++) {
const count = Math.min(chunkFrames, totalFrames - start)
const work = join(dir, `chunk-${index}`)
for (const folder of ['in', 'esrgan', 'sized', 'rife']) mkdirSync(join(work, folder), { recursive: true })
const pattern = folder => join(work, folder, '%08d.png')
await run(ffmpeg, ['-v', 'error', '-ss', String(start / fps), '-i', source, '-an', '-vf', `fps=${fps},tpad=stop_mode=clone:stop_duration=1`, '-frames:v', String(count + 1), '-start_number', '1', pattern('in')])
event(`Upscaling ${Math.round(start / fps)}–${Math.min(Math.round((start + count) / fps), Math.ceil(duration))}s`, { progress: 3 + 90 * start / totalFrames })
await run(join(root, 'esrgan', 'realesrgan-ncnn-vulkan.exe'), ['-i', join(work, 'in'), '-o', join(work, 'esrgan'), '-m', join(root, 'esrgan', 'models'), '-n', 'realesrgan-x4plus', '-s', '4', '-t', String(process.env.UPSCALE_TILE || 128), '-g', process.env.UPSCALE_GPU_ID || '0', '-j', '1:1:1', '-f', 'png'])
await run(ffmpeg, ['-v', 'error', '-i', pattern('esrgan'), '-vf', `scale=${target.width}:${target.height}:flags=lanczos${options.enhance === 'sharpen' ? ',unsharp=5:5:0.25:5:5:0' : ''}`, '-start_number', '1', pattern('sized')])
if (interpolate) {
event(`Smoothing motion at ${Math.round(start / fps)}s`, { progress: 3 + 90 * (start + count / 2) / totalFrames })
await run(join(rifeDir, 'rife-ncnn-vulkan.exe'), ['-i', join(work, 'sized'), '-o', join(work, 'rife'), '-m', join(rifeDir, rifeModel), '-n', String((count + 1) * multiplier), '-g', process.env.UPSCALE_GPU_ID || '0', '-j', '1:1:1', '-u', '-f', '%08d.png'], rifeDir)
}
const outputCount = Math.round((start + count) / fps * outputFps) - Math.round(start / fps * outputFps)
const segment = `part-${index}.mp4`
await run(ffmpeg, ['-v', 'error', '-framerate', String(fps * multiplier), '-i', pattern(interpolate ? 'rife' : 'sized'), '-vf', `fps=${outputFps},setsar=1`, '-frames:v', String(outputCount), '-an', '-c:v', 'libx264', '-preset', 'medium', '-crf', '17', '-pix_fmt', 'yuv420p', '-video_track_timescale', '90000', join(dir, segment)])
segments.push(`file '${segment}'`)
rmSync(work, { recursive: true, force: true }) // Fixed job-local directory, never an input path.
}
writeFileSync(join(dir, 'concat.txt'), segments.join('\n'))
event('Joining video and preserving original audio', { progress: 96 })
await run(ffmpeg, ['-v', 'error', '-f', 'concat', '-safe', '1', '-i', join(dir, 'concat.txt'), '-i', source, '-map', '0:v:0', '-map', '1:a?', '-c:v', 'copy', '-c:a', 'copy', '-map_metadata', '1', '-t', String(duration), '-movflags', '+faststart', join(dir, 'output.mp4')])
if (!existsSync(join(dir, 'output.mp4'))) throw new Error('Upscaler did not produce a video.')
event('Upscale complete', { ...info, progress: 100, elapsedMs: Date.now() - started })
} catch (error) {
event(error.message, { error: error.message, elapsedMs: Date.now() - started })
throw error
} finally { clearInterval(watchdog) }
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
upscaleVideo(process.argv[2]).catch(() => { process.exitCode = 1 })
}