// Recortes: trozos de página (un panel, una viñeta) que el usuario guarda desde el lector, con la // referencia al manga/capítulo/página de donde salen. Cada recorte se reescala solo (Real-ESRGAN ×4, // o ×2 si ya es grande) en segundo plano. Los archivos van a /clips/: .png (original // tal como se recortó) y -hd.webp (reescalado). import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import { BIN, MODELS, MODEL, run } from './upscale.js' const MAX_BYTES = 25 * 1024 * 1024 const MAX_SIDE = 4000 const X4_MAX_SIDE = 1200 // a partir de aquí ×2: ×4 daría imágenes enormes sin ganar detalle const WEBP_QUALITY = 90 // Ancho y alto de un PNG (cabecera IHDR), o null si no es un PNG. function pngSize(buf) { if (buf.length < 24 || buf.readUInt32BE(0) !== 0x89504e47 || buf.toString('latin1', 12, 16) !== 'IHDR') return null return { w: buf.readUInt32BE(16), h: buf.readUInt32BE(20) } } export function setupClips({ db, DATA_DIR, HttpError, json, readBody, MANGA_ID, CHAPTER_ID, upscale }) { const DIR = path.join(DATA_DIR, 'clips') fs.mkdirSync(DIR, { recursive: true }) db.exec(` CREATE TABLE IF NOT EXISTS clips ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, manga_id TEXT NOT NULL, manga_title TEXT NOT NULL, cover TEXT, chapter_id TEXT NOT NULL, chapter_label TEXT, page INTEGER NOT NULL, -- 0-based width INTEGER NOT NULL, height INTEGER NOT NULL, scale INTEGER, -- 2 o 4 cuando ya está reescalado note TEXT, status TEXT NOT NULL, -- queued | running | done | error | off (sin GPU) error TEXT, created_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_clips_user ON clips(user_id, created_at); `) db.prepare("UPDATE clips SET status = 'queued' WHERE status = 'running'").run() // reinicio a mitad const file = (id, hd) => path.join(DIR, hd ? `${id}-hd.webp` : `${id}.png`) const hasX4 = () => fs.existsSync(path.join(MODELS, `${MODEL}-x4.bin`)) async function upscaleClip(clip) { const scale = Math.max(clip.width, clip.height) <= X4_MAX_SIDE && hasX4() ? 4 : 2 const work = fs.mkdtempSync(path.join(os.tmpdir(), 'clip-')) try { const out = path.join(work, 'out.png') await run(BIN, ['-i', file(clip.id), '-o', out, '-m', MODELS, '-n', MODEL, '-s', String(scale), '-f', 'png']) const tmp = path.join(DIR, `${clip.id}-hd.tmp.webp`) await run('cwebp', ['-quiet', '-q', String(WEBP_QUALITY), '-m', '4', '-mt', out, '-o', tmp]) if (!db.prepare('SELECT 1 FROM clips WHERE id = ?').get(clip.id)) return fs.rmSync(tmp, { force: true }) // borrado mientras tanto fs.renameSync(tmp, file(clip.id, true)) db.prepare("UPDATE clips SET status = 'done', scale = ?, error = NULL WHERE id = ?").run(scale, clip.id) } finally { fs.rmSync(work, { recursive: true, force: true }) } } let pumping = false async function pump() { if (pumping || !upscale.available()) return pumping = true try { for (;;) { const clip = db.prepare("SELECT * FROM clips WHERE status = 'queued' ORDER BY id LIMIT 1").get() if (!clip) break db.prepare("UPDATE clips SET status = 'running' WHERE id = ?").run(clip.id) const t = Date.now() try { await upscaleClip(clip) console.log(`Recorte ${clip.id} (${clip.width}×${clip.height}) reescalado en ${((Date.now() - t) / 1000).toFixed(1)} s`) } catch (e) { console.warn('Recorte', clip.id, e.message) db.prepare("UPDATE clips SET status = 'error', error = ? WHERE id = ?").run(e.message.slice(0, 500), clip.id) } } } finally { pumping = false } } setTimeout(pump, 3000) // tras arrancar (cwebp se comprueba de forma asíncrona) const view = c => ({ id: c.id, mangaId: c.manga_id, mangaTitle: c.manga_title, cover: c.cover, chapterId: c.chapter_id, chapterLabel: c.chapter_label, page: c.page, width: c.width, height: c.height, scale: c.scale, note: c.note, status: c.status, error: c.error, createdAt: c.created_at, img: `/api/clips/${c.id}/img`, hd: c.status === 'done' ? `/api/clips/${c.id}/img?hd=1` : null, }) const own = (userId, id) => { const c = db.prepare('SELECT * FROM clips WHERE id = ? AND user_id = ?').get(id, userId) if (!c) throw new HttpError(404, 'Recorte no encontrado') return c } const count = (userId, mangaId) => db.prepare('SELECT COUNT(*) n FROM clips WHERE user_id = ? AND manga_id = ?').get(userId, mangaId).n // Banner de la ficha cuando la obra no tiene: un recorte del propio usuario, el más apaisado (es el que mejor // llena la franja) y, si los hay, de entre los ya reescalados. function bannerFor(userId, mangaId) { const c = db.prepare(`SELECT * FROM clips WHERE user_id = ? AND manga_id = ? ORDER BY status = 'done' DESC, CAST(width AS REAL) / height DESC, id DESC LIMIT 1`).get(userId, mangaId) if (!c) return null const v = view(c) return v.hd || v.img } async function readImage(req) { let size = 0 const chunks = [] for await (const c of req) { size += c.length if (size > MAX_BYTES) throw new HttpError(413, 'El recorte es demasiado grande') chunks.push(c) } return Buffer.concat(chunks) } async function route(p, method, q, req, res, user) { if (p === '/api/clips' && method === 'GET') { const mangaId = q.get('manga') const rows = mangaId ? db.prepare('SELECT * FROM clips WHERE user_id = ? AND manga_id = ? ORDER BY id DESC').all(user.id, mangaId) : db.prepare('SELECT * FROM clips WHERE user_id = ? ORDER BY id DESC').all(user.id) const mangas = db.prepare(`SELECT manga_id id, manga_title title, COUNT(*) n, MAX(id) last FROM clips WHERE user_id = ? GROUP BY manga_id ORDER BY last DESC`).all(user.id) return json(res, 200, { clips: rows.map(view), mangas, available: upscale.available() }), true } if (p === '/api/clips' && method === 'POST') { // Imagen PNG en el cuerpo; la referencia va en la query. const mangaId = q.get('mangaId') || '', chapterId = q.get('chapterId') || '', page = Number(q.get('page')) if (!MANGA_ID.test(mangaId) || !CHAPTER_ID.test(chapterId) || !Number.isInteger(page) || page < 0 || page > 5000) throw new HttpError(400, 'Referencia inválida') const buf = await readImage(req) const dim = pngSize(buf) if (!dim) throw new HttpError(400, 'El recorte debe ser PNG') if (dim.w < 16 || dim.h < 16 || dim.w > MAX_SIDE || dim.h > MAX_SIDE) throw new HttpError(400, `El recorte debe medir entre 16 y ${MAX_SIDE} px por lado`) const cover = (q.get('cover') || '').slice(0, 2000) const r = db.prepare(`INSERT INTO clips (user_id, manga_id, manga_title, cover, chapter_id, chapter_label, page, width, height, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run( user.id, mangaId, (q.get('mangaTitle') || '').slice(0, 300) || 'Sin título', cover.startsWith('/api/') ? cover : null, chapterId, (q.get('chapterLabel') || '').slice(0, 300), page, dim.w, dim.h, upscale.available() ? 'queued' : 'off', Date.now()) const id = Number(r.lastInsertRowid) fs.writeFileSync(file(id), buf) pump() return json(res, 200, view(own(user.id, id))), true } let m if ((m = /^\/api\/clips\/(\d+)\/img$/.exec(p)) && method === 'GET') { const c = own(user.id, Number(m[1])) const hd = q.get('hd') === '1' && c.status === 'done' const f = file(c.id, hd) if (!fs.existsSync(f)) throw new HttpError(404, 'Imagen no encontrada') const name = `${c.manga_title} - ${c.chapter_label || 'cap'} - p${c.page + 1}${hd ? ' HD' : ''}`.replace(/[\\/:*?"<>|]+/g, '_') res.writeHead(200, { 'Content-Type': hd ? 'image/webp' : 'image/png', 'Cache-Control': 'private, max-age=31536000, immutable', ...(q.get('dl') === '1' ? { 'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(name)}.${hd ? 'webp' : 'png'}` } : {}), }) fs.createReadStream(f).pipe(res) return true } if ((m = /^\/api\/clips\/(\d+)$/.exec(p))) { const c = own(user.id, Number(m[1])) if (method === 'GET') return json(res, 200, view(c)), true if (method === 'PATCH') { const b = await readBody(req) if (b.note !== undefined) db.prepare('UPDATE clips SET note = ? WHERE id = ?').run(String(b.note).slice(0, 1000) || null, c.id) return json(res, 200, view(own(user.id, c.id))), true } if (method === 'DELETE') { db.prepare('DELETE FROM clips WHERE id = ?').run(c.id) fs.rmSync(file(c.id), { force: true }) fs.rmSync(file(c.id, true), { force: true }) return json(res, 200, { ok: true }), true } } if ((m = /^\/api\/clips\/(\d+)\/retry$/.exec(p)) && method === 'POST') { const c = own(user.id, Number(m[1])) if (!upscale.available()) throw new HttpError(503, 'El reescalado no está disponible (falta Real-ESRGAN o la GPU en el contenedor)') if (c.status === 'error' || c.status === 'off') db.prepare("UPDATE clips SET status = 'queued', error = NULL WHERE id = ?").run(c.id) pump() return json(res, 200, view(own(user.id, c.id))), true } return false } const queueInfo = userId => db.prepare(`SELECT SUM(status = 'queued') queued, SUM(status = 'running') running, SUM(status = 'error') errors FROM clips WHERE user_id = ?`).get(userId) return { route, count, bannerFor, pump, queueInfo } }