Files

116 lines
5.3 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.
// Portadas en HD: cada portada que se sirve (de la fuente o la cover.jpg de una descarga) se encola para
// reescalarla con Real-ESRGAN (×4 si es pequeña, ×2 si no). Mientras tanto se sirve la original con
// caché corta; en cuanto existe la versión HD se sirve esa. Caché en <DATA_DIR>/covers/<hash>.webp.
// Los banners de la ficha pasan por lo mismo, pero se reescalan siempre: se ven a todo el ancho de la pantalla.
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import crypto from 'node:crypto'
import { BIN, MODELS, MODEL, run } from './upscale.js'
const MAX_QUEUE = 300
const X4_MAX_SIDE = 400
const MAX_SIDE = 1600 // más grande ya no hace falta reescalarla
const WEBP_QUALITY = 88
const BANNER_X4_MAX_WIDTH = 1000
// Ancho/alto de un JPEG, PNG o WebP sin decodificarlo (null si no se reconoce).
export function imageSize(b) {
if (b.length > 24 && b.readUInt32BE(0) === 0x89504e47) return { type: 'png', w: b.readUInt32BE(16), h: b.readUInt32BE(20) }
if (b.length > 30 && b.toString('latin1', 0, 4) === 'RIFF' && b.toString('latin1', 8, 12) === 'WEBP') {
const f = b.toString('latin1', 12, 16)
if (f === 'VP8 ') return { type: 'webp', w: b.readUInt16LE(26) & 0x3fff, h: b.readUInt16LE(28) & 0x3fff }
if (f === 'VP8L') { const n = b.readUInt32LE(21); return { type: 'webp', w: (n & 0x3fff) + 1, h: ((n >> 14) & 0x3fff) + 1 } }
if (f === 'VP8X') return { type: 'webp', w: b.readUIntLE(24, 3) + 1, h: b.readUIntLE(27, 3) + 1 }
return null
}
if (b[0] === 0xff && b[1] === 0xd8) {
for (let i = 2; i + 9 < b.length;) {
if (b[i] !== 0xff) { i++; continue }
const marker = b[i + 1]
if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd7) || marker === 0x01 || marker === 0xff) { i += marker === 0xff ? 1 : 2; continue }
const len = b.readUInt16BE(i + 2)
if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) return { type: 'jpg', w: b.readUInt16BE(i + 7), h: b.readUInt16BE(i + 5) }
i += 2 + len
}
}
return null
}
export function setupCovers({ DATA_DIR, upscale }) {
const DIR = path.join(DATA_DIR, 'covers')
fs.mkdirSync(DIR, { recursive: true })
const fileOf = key => path.join(DIR, `${crypto.createHash('sha256').update(key).digest('hex').slice(0, 32)}.webp`)
const queue = [] // { key, buf }
const pending = new Set()
const failed = new Set() // no reintentar hasta reiniciar
// Si ya hay versión HD, la envía y devuelve true.
function sendCached(res, key) {
if (!upscale.available()) return false
const f = fileOf(key)
if (!fs.existsSync(f)) return false
res.writeHead(200, { 'Content-Type': 'image/webp', 'Cache-Control': 'private, max-age=2592000, immutable' })
fs.createReadStream(f).pipe(res)
return true
}
// Cabecera de caché para la original mientras no hay HD (corta, para que el navegador pida luego la HD).
// Un banner no se guarda: en la siguiente visita ya se ve el reescalado.
const pendingCache = (banner = false) => (!upscale.available() ? 'private, max-age=604800, immutable' : banner ? 'no-store' : 'private, max-age=300')
// ¿Falta generar esta imagen? (para no descargarla si no hace falta)
const wants = key => upscale.available() && !pending.has(key) && !failed.has(key) && queue.length < MAX_QUEUE && !fs.existsSync(fileOf(key))
function enqueue(key, buf, banner = false) {
if (!wants(key)) return
pending.add(key)
queue.push({ key, buf, banner })
pump()
}
async function processOne({ key, buf, banner }) {
const size = imageSize(buf)
if (!size) throw new Error('formato no reconocido')
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'cover-'))
try {
const out = path.join(work, 'out.png')
const tmp = `${fileOf(key)}.tmp`
if (!banner && Math.max(size.w, size.h) > MAX_SIDE) {
// Ya es grande: solo se recomprime para no volver a intentarlo.
const inp = path.join(work, `in.${size.type}`)
fs.writeFileSync(inp, buf)
if (size.type === 'webp') fs.copyFileSync(inp, tmp)
else await run('cwebp', ['-quiet', '-q', String(WEBP_QUALITY), inp, '-o', tmp])
} else {
const inp = path.join(work, `in.${size.type}`)
fs.writeFileSync(inp, buf)
const small = banner ? size.w < BANNER_X4_MAX_WIDTH : Math.max(size.w, size.h) <= X4_MAX_SIDE
const scale = small && fs.existsSync(path.join(MODELS, `${MODEL}-x4.bin`)) ? 4 : 2
await run(BIN, ['-i', inp, '-o', out, '-m', MODELS, '-n', MODEL, '-s', String(scale), '-f', 'png'])
await run('cwebp', ['-quiet', '-q', String(WEBP_QUALITY), '-m', '4', '-mt', out, '-o', tmp])
}
fs.renameSync(tmp, fileOf(key))
} finally {
fs.rmSync(work, { recursive: true, force: true })
}
}
let pumping = false
async function pump() {
if (pumping) return
pumping = true
try {
let n = 0
const t = Date.now()
while (queue.length) {
const job = queue.shift()
try { await processOne(job); n++ } catch (e) { failed.add(job.key); console.warn('Portada HD:', e.message) } finally { pending.delete(job.key) }
}
if (n) console.log(`Portadas HD: ${n} en ${Math.round((Date.now() - t) / 1000)} s`)
} finally {
pumping = false
}
}
return { sendCached, wants, enqueue, pendingCache, pendingCount: () => pending.size }
}