- Ficha e Info son una sola página: banner, puntuaciones, sinopsis, obras relacionadas y recomendaciones. - La info se guarda una vez por manga para todos los usuarios y se actualiza en segundo plano; los mangas seguidos la mantienen al día solos. Si una fuente falla, se conserva lo guardado. - Menú lateral (⋯) con seguir, listas, recortes, otras fuentes, descargas y datos de la obra. - Banners reescalados con Real-ESRGAN; si la obra no tiene, se usa un recorte del propio usuario. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
116 lines
5.3 KiB
JavaScript
116 lines
5.3 KiB
JavaScript
// 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 }
|
||
}
|