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:
+180
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user