154 lines
6.9 KiB
JavaScript
154 lines
6.9 KiB
JavaScript
/*
|
|||
|
|
* upscaler.cjs — make last-minute, low-resolution pictures look right on the stage TV.
|
||
|
|
*
|
||
|
|
* The problem: the pastor texts pictures minutes before a service. Texted and downloaded images are
|
||
|
|
* typically ~1000px wide, but the portrait TV is 2160x3840 REAL pixels, so every picture gets
|
||
|
|
* stretched about 2x and looks soft. There is no time to fix that by hand, and no file format or CSS
|
||
|
|
* change can invent detail that was never in the file.
|
||
|
|
*
|
||
|
|
* So on upload we run Real-ESRGAN (ncnn/Vulkan) once, on the GPU, fully offline. Measured on the
|
||
|
|
* stage machine itself (Intel UHD, 12th-gen i7, Vulkan 1.4), upscaling a 1000x595 picture:
|
||
|
|
*
|
||
|
|
* realesr-animevideov3 x4 -> 4000px 2.1 s <- chosen
|
||
|
|
* realesrgan-x4plus-anime x4 -> 4000px 13 s
|
||
|
|
* realesrgan-x4plus x4 -> 4000px 41 s
|
||
|
|
*
|
||
|
|
* animevideov3 is a compact model, but on both line art and photographs it sharpened edges as well
|
||
|
|
* as the heavy models AND preserved film grain that the bigger ones smoothed into plastic — while
|
||
|
|
* being the only one fast enough to live in an upload path. Scale is chosen to land near the panel
|
||
|
|
* width instead of blindly 4x, so a 1080px image becomes exactly 2160px rather than a wasteful 4320.
|
||
|
|
*
|
||
|
|
* Safety: the untouched original is copied to an "_originals" folder, and the upscale only replaces
|
||
|
|
* the visible file after it is written and verified bigger. Any failure — missing binary, no Vulkan,
|
||
|
|
* timeout, odd format — leaves the uploaded file exactly as it was. This must never block a service.
|
||
|
|
*/
|
||
|
|
const fs = require('fs');
|
||
|
|
const path = require('path');
|
||
|
|
const { spawn } = require('child_process');
|
||
|
|
|
||
|
|
const TARGET_WIDTH = 2160; // the stage TV's real horizontal pixels
|
||
|
|
const SKIP_ABOVE_WIDTH = 1800; // already close enough: do not burn time or risk artifacts
|
||
|
|
const MODEL = 'realesr-animevideov3';
|
||
|
|
const TIMEOUT_MS = 120000; // one huge picture must not hang an upload forever
|
||
|
|
|
||
|
|
function vendorDir() {
|
||
|
|
const candidates = [
|
||
|
|
path.join(process.resourcesPath || '', 'app.asar.unpacked', 'vendor', 'upscale'),
|
||
|
|
path.join(path.dirname(process.execPath || ''), 'resources', 'app.asar.unpacked', 'vendor', 'upscale'),
|
||
|
|
path.join(__dirname, 'vendor', 'upscale')
|
||
|
|
];
|
||
|
|
for (const d of candidates) {
|
||
|
|
try { if (d && fs.existsSync(path.join(d, 'realesrgan-ncnn-vulkan.exe'))) return d; } catch (_) {}
|
||
|
|
}
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function isAvailable() { return !!vendorDir(); }
|
||
|
|
|
||
|
|
// Dimensions read straight from the file header, so no image library is needed.
|
||
|
|
function imageSize(file) {
|
||
|
|
let b;
|
||
|
|
try { b = fs.readFileSync(file); } catch (_) { return null; }
|
||
|
|
try {
|
||
|
|
if (b.length > 24 && b[0] === 0x89 && b[1] === 0x50) return { w: b.readUInt32BE(16), h: b.readUInt32BE(20) };
|
||
|
|
if (b[0] === 0x47 && b[1] === 0x49) return { w: b.readUInt16LE(6), h: b.readUInt16LE(8) };
|
||
|
|
if (b.slice(0, 4).toString() === 'RIFF' && b.slice(8, 12).toString() === 'WEBP') {
|
||
|
|
const t = b.slice(12, 16).toString();
|
||
|
|
if (t === 'VP8X') return { w: (b.readUIntLE(24, 3) & 0xFFFFFF) + 1, h: (b.readUIntLE(27, 3) & 0xFFFFFF) + 1 };
|
||
|
|
if (t === 'VP8 ') return { w: b.readUInt16LE(26) & 0x3FFF, h: b.readUInt16LE(28) & 0x3FFF };
|
||
|
|
if (t === 'VP8L') { const n = b.readUInt32LE(21); return { w: (n & 0x3FFF) + 1, h: ((n >> 14) & 0x3FFF) + 1 }; }
|
||
|
|
}
|
||
|
|
if (b[0] === 0xFF && b[1] === 0xD8) {
|
||
|
|
let i = 2;
|
||
|
|
while (i < b.length - 9) {
|
||
|
|
if (b[i] !== 0xFF) { i++; continue; }
|
||
|
|
const m = b[i + 1];
|
||
|
|
if (m >= 0xC0 && m <= 0xCF && m !== 0xC4 && m !== 0xC8 && m !== 0xCC) {
|
||
|
|
return { w: b.readUInt16BE(i + 7), h: b.readUInt16BE(i + 5) };
|
||
|
|
}
|
||
|
|
i += 2 + b.readUInt16BE(i + 2);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} catch (_) {}
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function pickScale(width) {
|
||
|
|
const need = Math.ceil(TARGET_WIDTH / width);
|
||
|
|
return Math.max(2, Math.min(4, need)); // animevideov3 ships x2 / x3 / x4
|
||
|
|
}
|
||
|
|
|
||
|
|
/*
|
||
|
|
* Upscale IN PLACE when the picture is too small for the TV. Always resolves with a short report and
|
||
|
|
* never rejects: a failed enhancement must still leave a perfectly usable uploaded file.
|
||
|
|
*/
|
||
|
|
function enhanceIfSmall(filePath) {
|
||
|
|
return new Promise((resolve) => {
|
||
|
|
const done = (status, extra) => resolve(Object.assign({ status: status }, extra || {}));
|
||
|
|
try {
|
||
|
|
const ext = path.extname(filePath).toLowerCase();
|
||
|
|
// Vector art is already resolution-independent, and video is not ours to touch.
|
||
|
|
if (!/^\.(png|jpe?g|jfif|webp|bmp)$/.test(ext)) return done('skipped-format');
|
||
|
|
const dir = vendorDir();
|
||
|
|
if (!dir) return done('unavailable');
|
||
|
|
const size = imageSize(filePath);
|
||
|
|
if (!size || !size.w) return done('unreadable');
|
||
|
|
if (size.w >= SKIP_ABOVE_WIDTH) return done('already-sharp', { width: size.w });
|
||
|
|
|
||
|
|
const scale = pickScale(size.w);
|
||
|
|
// Write to a temp file and swap it in only on success, so a crash mid-run can never leave a
|
||
|
|
// half-written image where the operator expects their picture.
|
||
|
|
const outExt = (ext === '.jpeg' || ext === '.jfif') ? '.jpg' : ext;
|
||
|
|
const tmp = filePath + '.upscaling' + outExt;
|
||
|
|
const exe = path.join(dir, 'realesrgan-ncnn-vulkan.exe');
|
||
|
|
const args = ['-i', filePath, '-o', tmp, '-n', MODEL, '-s', String(scale), '-f', outExt.replace('.', '')];
|
||
|
|
const t0 = Date.now();
|
||
|
|
let finished = false;
|
||
|
|
const child = spawn(exe, args, { cwd: dir, windowsHide: true });
|
||
|
|
const killTimer = setTimeout(() => { try { child.kill(); } catch (_) {} }, TIMEOUT_MS);
|
||
|
|
|
||
|
|
child.on('error', () => {
|
||
|
|
if (finished) return;
|
||
|
|
finished = true;
|
||
|
|
clearTimeout(killTimer);
|
||
|
|
try { fs.unlinkSync(tmp); } catch (_) {}
|
||
|
|
done('spawn-failed');
|
||
|
|
});
|
||
|
|
child.on('close', () => {
|
||
|
|
if (finished) return;
|
||
|
|
finished = true;
|
||
|
|
clearTimeout(killTimer);
|
||
|
|
const ms = Date.now() - t0;
|
||
|
|
let out = null;
|
||
|
|
try { out = fs.existsSync(tmp) ? imageSize(tmp) : null; } catch (_) {}
|
||
|
|
if (!out || !out.w || out.w <= size.w) {
|
||
|
|
try { fs.unlinkSync(tmp); } catch (_) {}
|
||
|
|
return done('failed', { ms: ms });
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
// Keep the untouched original beside the content, in case it is ever wanted back or the
|
||
|
|
// enhancement made an odd call on some picture.
|
||
|
|
const backupDir = path.join(path.dirname(filePath), '_originals');
|
||
|
|
fs.mkdirSync(backupDir, { recursive: true });
|
||
|
|
const backup = path.join(backupDir, path.basename(filePath));
|
||
|
|
if (!fs.existsSync(backup)) fs.copyFileSync(filePath, backup);
|
||
|
|
fs.renameSync(tmp, filePath);
|
||
|
|
} catch (e) {
|
||
|
|
try { fs.unlinkSync(tmp); } catch (_) {}
|
||
|
|
return done('replace-failed', { ms: ms, error: e.message });
|
||
|
|
}
|
||
|
|
done('enhanced', {
|
||
|
|
from: size.w + 'x' + size.h,
|
||
|
|
to: out.w + 'x' + out.h,
|
||
|
|
scale: scale,
|
||
|
|
ms: ms
|
||
|
|
});
|
||
|
|
});
|
||
|
|
} catch (e) {
|
||
|
|
done('error', { error: e.message });
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
module.exports = { isAvailable, imageSize, enhanceIfSmall, TARGET_WIDTH, SKIP_ABOVE_WIDTH, MODEL };
|