Recortes de paneles desde el lector, reescalados aparte, con galería y acceso directo a la página

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 15:38:12 -06:00
co-authored by Claude Opus 5.5
parent d48378aa49
commit 5d82a5e020
8 changed files with 466 additions and 12 deletions
+180
View File
@@ -0,0 +1,180 @@
// 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 <DATA_DIR>/clips/: <id>.png (original
// tal como se recortó) y <id>-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
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
}
return { route, count, pump }
}