Files
bible-app/upscaler.cjs
T

154 lines
6.9 KiB
JavaScript
Raw Permalink Normal View History

2026-09-24 13:05:20 -04:00
/*
* 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 };