From 5d82a5e0201741ef3e3eb2077f8499119c90f3fb Mon Sep 17 00:00:00 2001 From: Nino Date: Sat, 31 Oct 2026 15:38:12 -0600 Subject: [PATCH] =?UTF-8?q?Recortes=20de=20paneles=20desde=20el=20lector,?= =?UTF-8?q?=20reescalados=20aparte,=20con=20galer=C3=ADa=20y=20acceso=20di?= =?UTF-8?q?recto=20a=20la=20p=C3=A1gina?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- Dockerfile | 4 +- README.md | 6 +- app/clips.js | 180 ++++++++++++++++++++++++++++++++ app/public/app.js | 238 +++++++++++++++++++++++++++++++++++++++++- app/public/index.html | 3 +- app/public/style.css | 32 ++++++ app/server.js | 5 +- app/upscale.js | 10 +- 8 files changed, 466 insertions(+), 12 deletions(-) create mode 100644 app/clips.js diff --git a/Dockerfile b/Dockerfile index 4ccfbd9..865a419 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -# Real-ESRGAN (ncnn-vulkan) para el reescalado HD. Solo se guarda el modelo de anime ×2. +# Real-ESRGAN (ncnn-vulkan) para el reescalado HD. Solo se guardan los modelos de anime ×2 (capítulos) y ×4 (recortes). FROM debian:trixie-slim AS esrgan ARG ESRGAN_URL=https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesrgan-ncnn-vulkan-20220424-ubuntu.zip ARG ESRGAN_SHA256=e5aa6eb131234b87c0c51f82b89390f5e3e642b7b70f2b9bbe95b6a285a40c96 @@ -7,7 +7,7 @@ RUN curl -fsSL -o /tmp/esr.zip "$ESRGAN_URL" \ && echo "$ESRGAN_SHA256 /tmp/esr.zip" | sha256sum -c - \ && mkdir -p /opt/realesrgan/models && cd /tmp && unzip -q esr.zip -d esr \ && install -m 755 esr/realesrgan-ncnn-vulkan /opt/realesrgan/ \ - && cp esr/models/realesr-animevideov3-x2.* /opt/realesrgan/models/ + && cp esr/models/realesr-animevideov3-x2.* esr/models/realesr-animevideov3-x4.* /opt/realesrgan/models/ # Versión fijada: node:sqlite aún es experimental y una actualización silenciosa podría romperlo. # Debian (no Alpine): Real-ESRGAN necesita glibc y los controladores Vulkan de Mesa. diff --git a/README.md b/README.md index 419442b..52914d2 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,10 @@ Lector web de manga autoalojado, con las dos fuentes de capítulos marca **HD**, el lector usa la versión HD (se puede desactivar en sus ajustes) y desde allí también se reescala un capítulo suelto. En una Intel HD 630 tarda ~13 s por página y cada página ocupa ~2,5× más (WebP calidad 85). +- **Recortes**: en el lector, ✂ y arrastrar sobre un panel lo guarda en "Recortes" con su manga, + capítulo y página ("Ir a la página" lleva directo allí). Se recorta del original y solo el recorte se + reescala con Real-ESRGAN (×4, o ×2 si ya mide más de 1200 px; unos segundos). Se guardan en + `data/clips/` (`.png` original y `-hd.webp`), con nota opcional y descarga. - **Estadísticas**: rachas, capítulos y páginas por semana, calendario de actividad, géneros favoritos y lo más leído. - Las imágenes pasan por el servidor (`/api/img`), solo con URLs firmadas por él (clave en `data/secret`). @@ -54,7 +58,7 @@ El primer usuario puede registrarse siempre; después el registro queda cerrado |---|---|---| | Código | este repositorio | se recrea con `git clone` | | `.env` | raíz del proyecto (no está en git) | rutas, puerto, PUID/PGID, URL pública | -| `data/` | raíz del proyecto (no está en git) | `manga.db` (+ `-wal`/`-shm`): usuarios, historial, estadísticas, seguimiento, ajustes; `secret`: clave del proxy de imágenes | +| `data/` | raíz del proyecto (no está en git) | `manga.db` (+ `-wal`/`-shm`): usuarios, historial, estadísticas, seguimiento, ajustes; `secret`: clave del proxy de imágenes; `clips/`: recortes | | Mangas descargados | `MANGA_DIR_HOST` (aquí `/zdata/media/mangas`) | CBZ + portadas | Pasos en el sistema nuevo: diff --git a/app/clips.js b/app/clips.js new file mode 100644 index 0000000..d3a0505 --- /dev/null +++ b/app/clips.js @@ -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 /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 + + 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 } +} diff --git a/app/public/app.js b/app/public/app.js index 94263f1..dcfcea0 100644 --- a/app/public/app.js +++ b/app/public/app.js @@ -35,6 +35,8 @@ const ICONS = { x: '', scroll: '', pages: '', + scissors: '', + image: '', sparkles: '', } const icon = name => `` @@ -138,6 +140,7 @@ async function router() { if (parts[0] === 'history') return await viewHistory() if (parts[0] === 'downloads') return await viewDownloads() if (parts[0] === 'stats') return await viewStats() + if (parts[0] === 'clips') return await viewClips(params) if (parts[0] === 'settings') return viewSettings() return await viewHome(params) } catch (e) { @@ -302,7 +305,7 @@ async function viewManga(id, langOverride) { if (cleanup) { cleanup(); cleanup = null } app.innerHTML = '
Cargando…
' const lang = langOverride || store.get(`lang:${id}`, me.lang) - const { manga, chapters, reads, progress, chapterLangs, offline, downloaded, download, upscale, following, newKeys = [] } = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`) + const { manga, chapters, reads, progress, chapterLangs, offline, downloaded, download, upscale, following, clips: clipCount = 0, newKeys = [] } = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`) const dlSet = new Set(downloaded) const newSet = new Set(newKeys.map(String)) let isFollowing = following @@ -336,7 +339,7 @@ async function viewManga(id, langOverride) { ${menuItems ? `` : ''} -
Fuente: ${esc(SOURCES[manga.source])}${download ? `Carpeta: mangas/${esc(download.folder)}` : ''}
+
Fuente: ${esc(SOURCES[manga.source])}${download ? `Carpeta: mangas/${esc(download.folder)}` : ''}${clipCount ? `${icon('scissors')} ${clipCount} recorte${clipCount > 1 ? 's' : ''}` : ''}
${offline ? '

Sin conexión con la fuente: mostrando solo los capítulos descargados.

' : ''}
@@ -736,6 +739,7 @@ async function viewReader(mangaId, chapterId, startPage) { ${icon('back')}
${esc(manga.title)}
+ @@ -1047,7 +1051,118 @@ async function viewReader(mangaId, chapterId, startPage) { $('#chap').addEventListener('change', e => { location.hash = readUrl(mangaId, e.target.value) }) slider.addEventListener('input', () => goTo(Number(slider.value) - 1)) + // --- Recortes: se arrastra un rectángulo sobre la página; se recorta del original (no de la versión HD) + // y el servidor lo reescala aparte. El rectángulo se guarda en coordenadas del documento para + // poder desplazarse (rueda) en vertical mientras se recorta. + let crop = null + function endCrop() { + if (!crop) return + crop.ov.remove() + window.removeEventListener('scroll', crop.draw) + crop = null + $('#clipbtn')?.classList.remove('on') + } + function startCrop() { + if (crop) return endCrop() + if (atEnd || !box.querySelector('img')) return toast('Vuelve a una página para recortar') + reader.classList.remove('hide-ui') + $('.rpanel', reader).hidden = true + const ov = document.createElement('div') + ov.className = 'cropov' + ov.innerHTML = `
${icon('scissors')} Arrastra sobre el panel que quieres guardar + +
+ ` + reader.append(ov) + $('#clipbtn').classList.add('on') + const rectEl = $('.crop-rect', ov) + crop = { ov, r: null, start: null } + crop.draw = () => { + const r = crop?.r + if (!r) return + Object.assign(rectEl.style, { left: `${r.l - scrollX}px`, top: `${r.t - scrollY}px`, width: `${r.r - r.l}px`, height: `${r.b - r.t}px` }) + rectEl.hidden = false + } + window.addEventListener('scroll', crop.draw, { passive: true }) + ov.addEventListener('wheel', e => { e.preventDefault(); scrollBy(0, e.deltaY) }, { passive: false }) + ov.addEventListener('pointerdown', e => { + if (e.target.closest('.crop-bar') || (e.pointerType === 'mouse' && e.button !== 0)) return + ov.setPointerCapture(e.pointerId) + crop.start = { x: e.clientX + scrollX, y: e.clientY + scrollY } + crop.r = null + rectEl.hidden = true + $('[data-a=save]', ov).disabled = true + }) + ov.addEventListener('pointermove', e => { + if (!crop.start) return + const x = e.clientX + scrollX, y = e.clientY + scrollY + crop.r = { l: Math.min(x, crop.start.x), t: Math.min(y, crop.start.y), r: Math.max(x, crop.start.x), b: Math.max(y, crop.start.y) } + crop.draw() + }) + const up = () => { + if (!crop?.start) return + crop.start = null + const ok = crop.r && crop.r.r - crop.r.l >= 12 && crop.r.b - crop.r.t >= 12 + if (!ok) { crop.r = null; rectEl.hidden = true } + $('[data-a=save]', ov).disabled = !ok + } + ov.addEventListener('pointerup', up) + ov.addEventListener('pointercancel', up) + $('[data-a=cancel]', ov).addEventListener('click', endCrop) + $('[data-a=save]', ov).addEventListener('click', e => saveCrop(e.target.closest('button'))) + } + // La imagen bajo el rectángulo (la de mayor intersección) y la zona en fracciones de esa imagen. + function cropTarget(r) { + const c = { l: r.l - scrollX, t: r.t - scrollY, r: r.r - scrollX, b: r.b - scrollY } + let best = null + for (const img of box.querySelectorAll('img')) { + if (!img.complete || !img.naturalWidth) continue + const b = img.getBoundingClientRect() + const w = Math.min(c.r, b.right) - Math.max(c.l, b.left), h = Math.min(c.b, b.bottom) - Math.max(c.t, b.top) + if (w > 0 && h > 0 && (!best || w * h > best.area)) best = { img, b, area: w * h } + } + if (!best) return null + const { img, b } = best + const x0 = Math.max(c.l, b.left) - b.left, y0 = Math.max(c.t, b.top) - b.top + const x1 = Math.min(c.r, b.right) - b.left, y1 = Math.min(c.b, b.bottom) - b.top + return { img, page: Number(img.dataset.i), fx: x0 / b.width, fy: y0 / b.height, fw: (x1 - x0) / b.width, fh: (y1 - y0) / b.height } + } + async function saveCrop(btn) { + const t = crop?.r && cropTarget(crop.r) + if (!t) return toast('Marca una zona sobre la página') + btn.disabled = true + try { + const u = new URL(t.img.currentSrc || t.img.src, location.href) + u.searchParams.delete('hd') // recortar del original: el servidor lo reescala él mismo + const src = await new Promise((resolve, reject) => { + const im = new Image() + im.onload = () => resolve(im) + im.onerror = () => reject(new Error('No se pudo cargar la página')) + im.src = u.href + }) + const sx = Math.round(t.fx * src.naturalWidth), sy = Math.round(t.fy * src.naturalHeight) + const sw = Math.max(1, Math.round(t.fw * src.naturalWidth)), sh = Math.max(1, Math.round(t.fh * src.naturalHeight)) + if (sw < 16 || sh < 16) throw new Error('La zona es demasiado pequeña') + const k = Math.min(1, 4000 / Math.max(sw, sh)) + const cv = document.createElement('canvas') + cv.width = Math.round(sw * k); cv.height = Math.round(sh * k) + cv.getContext('2d').drawImage(src, sx, sy, sw, sh, 0, 0, cv.width, cv.height) + const blob = await new Promise(r => cv.toBlob(r, 'image/png')) + const qs = new URLSearchParams({ mangaId, chapterId, page: t.page, mangaTitle: manga.title, chapterLabel: chapLabel(chapter), cover: manga.cover || '' }) + const res = await fetch(`/api/clips?${qs}`, { method: 'POST', headers: { 'Content-Type': 'image/png' }, body: blob }) + const data = await res.json().catch(() => ({})) + if (!res.ok) throw new Error(data.error || `Error ${res.status}`) + endCrop() + toast(data.status === 'off' ? 'Recorte guardado' : 'Recorte guardado: se está mejorando a HD') + } catch (err) { + toast(err.message) + btn.disabled = false + } + } + $('#clipbtn').addEventListener('click', startCrop) + const onKey = e => { + if (crop) { if (e.key === 'Escape') endCrop(); return } if (!isPaged() || e.target.matches('input,select')) return const fwdKey = settings.dir === 'ltr' ? 'ArrowRight' : 'ArrowLeft' const backKey = settings.dir === 'ltr' ? 'ArrowLeft' : 'ArrowRight' @@ -1080,6 +1195,7 @@ async function viewReader(mangaId, chapterId, startPage) { render() cleanup = () => { + endCrop() if (saveTimer) saveNow() observer?.disconnect() clearTimeout(uiTimer) @@ -1091,6 +1207,124 @@ async function viewReader(mangaId, chapterId, startPage) { } } +// ---------- Recortes ---------- +const clipStatus = c => c.status === 'done' ? `HD` + : c.status === 'error' ? 'HD!' + : c.status === 'off' ? '' : 'HD…' +async function viewClips(params) { + const mangaId = params.get('manga') + const here = () => location.hash.split('?')[0] === '#/clips' + app.innerHTML = '
Cargando…
' + let data = await api(`/api/clips${mangaId ? `?manga=${encodeURIComponent(mangaId)}` : ''}`) + if (!here()) return + const total = data.mangas.reduce((a, m) => a + m.n, 0) + app.innerHTML = `

${icon('scissors')} Recortes

+ ${data.mangas.length > 1 || mangaId ? `
+ + ${data.mangas.map(m => ``).join('')}
` : ''} +
+ ` + app.querySelectorAll('.tabs [data-m]').forEach(b => b.addEventListener('click', () => { location.hash = b.dataset.m ? `#/clips?manga=${encodeURIComponent(b.dataset.m)}` : '#/clips' })) + const grid = $('#cgrid'), lb = $('#lb') + let open = -1, showHd = true + + function renderGrid() { + grid.innerHTML = data.clips.length ? data.clips.map((c, i) => ` +
+ +
${esc(c.mangaTitle)}${esc((c.chapterLabel || '').split(' — ')[0])} · p. ${c.page + 1} ${clipStatus(c)} + ${c.note ? `${esc(c.note)}` : ''}
+
`).join('') + : `

Aún no tienes recortes. En el lector, toca ${icon('scissors')} y arrastra sobre un panel para guardarlo aquí; se mejora a HD automáticamente.

` + grid.querySelectorAll('.clip').forEach(f => f.addEventListener('click', () => openClip(Number(f.dataset.i)))) + } + function openClip(i) { + open = i + const c = data.clips[i] + if (!c) return closeClip() + const src = c.hd && showHd ? c.hd : c.img + const size = c.scale ? `${c.width}×${c.height} → ${c.width * c.scale}×${c.height * c.scale} (×${c.scale})` : `${c.width}×${c.height}` + lb.innerHTML = ` +
+
+
+ ${i + 1} / ${data.clips.length} + + + + + +
+

${esc(c.mangaTitle)}

+
${esc(c.chapterLabel || '')} · página ${c.page + 1}
+
${size} · ${timeAgo(c.createdAt)} ${clipStatus(c)}
+ ${c.status === 'error' ? `

${esc(c.error || 'Error al reescalar')}

` : ''} + ${c.status === 'off' ? '

Reescalado no disponible en este servidor.

' : ''} + + ${icon('book')} Ir a la página + ${c.hd ? `` : ''} +
+ ${icon('download')} Descargar + ${c.status === 'error' || (c.status === 'off' && data.available) ? `` : ''} + +
+
` + lb.hidden = false + document.body.classList.add('noscroll') + lb.querySelector('[data-a=prev]').addEventListener('click', () => openClip(open - 1)) + lb.querySelector('[data-a=next]').addEventListener('click', () => openClip(open + 1)) + lb.querySelector('[data-a=close]').addEventListener('click', closeClip) + $('#showhd', lb)?.addEventListener('change', e => { showHd = e.target.checked; openClip(open) }) + $('#note', lb).addEventListener('change', async e => { + try { Object.assign(c, await api(`/api/clips/${c.id}`, { method: 'PATCH', body: { note: e.target.value } })); renderGrid(); toast('Nota guardada') } catch (err) { toast(err.message) } + }) + lb.querySelector('[data-a=retry]')?.addEventListener('click', async () => { + try { Object.assign(c, await api(`/api/clips/${c.id}/retry`, { method: 'POST' })); renderGrid(); openClip(open); poll() } catch (err) { toast(err.message) } + }) + lb.querySelector('[data-a=del]').addEventListener('click', async () => { + if (!confirm('¿Eliminar este recorte?')) return + try { + await api(`/api/clips/${c.id}`, { method: 'DELETE' }) + data.clips.splice(open, 1) + renderGrid() + data.clips.length ? openClip(Math.min(open, data.clips.length - 1)) : closeClip() + } catch (err) { toast(err.message) } + }) + } + function closeClip() { open = -1; lb.hidden = true; lb.innerHTML = ''; document.body.classList.remove('noscroll') } + lb.addEventListener('click', e => { if (e.target === lb || e.target.classList.contains('lb-img')) closeClip() }) + const onKey = e => { + if (open < 0 || e.target.matches('input')) return + if (e.key === 'Escape') closeClip() + else if (e.key === 'ArrowLeft' && open > 0) openClip(open - 1) + else if (e.key === 'ArrowRight' && open < data.clips.length - 1) openClip(open + 1) + } + document.addEventListener('keydown', onKey) + + // Mientras haya recortes en cola, refrescar su estado. + let timer = null + function poll() { + clearTimeout(timer) + if (!data.clips.some(c => c.status === 'queued' || c.status === 'running')) return + timer = setTimeout(async () => { + if (!here()) return + const fresh = await api(`/api/clips${mangaId ? `?manga=${encodeURIComponent(mangaId)}` : ''}`).catch(() => null) + if (!fresh || !here()) return + const key = d => d.clips.map(c => `${c.id}:${c.status}`).join() + if (key(fresh) !== key(data)) { + const openId = data.clips[open]?.id + data = fresh + renderGrid() + if (openId != null) { const j = data.clips.findIndex(c => c.id === openId); j >= 0 ? openClip(j) : closeClip() } + } + poll() + }, 3000) + } + renderGrid() + poll() + cleanup = () => { clearTimeout(timer); document.removeEventListener('keydown', onKey); document.body.classList.remove('noscroll') } +} + // ---------- Historial ---------- async function viewHistory() { app.innerHTML = '
Cargando…
' diff --git a/app/public/index.html b/app/public/index.html index 89502e7..76e17f8 100644 --- a/app/public/index.html +++ b/app/public/index.html @@ -10,11 +10,12 @@
- Manga Reader + Manga Reader diff --git a/app/public/style.css b/app/public/style.css index 532ea53..f7c9a28 100644 --- a/app/public/style.css +++ b/app/public/style.css @@ -271,3 +271,35 @@ input[type=checkbox] { accent-color: var(--accent); width: 16px; height: 16px; m #selbar { left: 12px; right: 12px; transform: none; max-width: none; } #selbar span { flex: 1; white-space: normal; font-size: .82rem; line-height: 1.3; } } + +/* Recortes: herramienta del lector */ +.cropov { position: fixed; inset: 0; z-index: 40; cursor: crosshair; touch-action: none; background: rgba(0, 0, 0, .25); user-select: none; } +.crop-bar { position: fixed; left: 50%; bottom: max(14px, env(safe-area-inset-bottom)); transform: translateX(-50%); z-index: 1; + display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 12px; background: rgba(20, 18, 26, .94); + box-shadow: 0 6px 24px rgba(0,0,0,.5); cursor: default; white-space: nowrap; max-width: calc(100vw - 24px); } +.crop-bar span { font-size: .85rem; color: var(--muted); display: inline-flex; gap: 6px; align-items: center; overflow: hidden; text-overflow: ellipsis; } +.crop-rect { position: fixed; border: 2px solid var(--accent); box-shadow: 0 0 0 9999px rgba(0, 0, 0, .45); pointer-events: none; } +@media (max-width: 560px) { .crop-bar span { display: none; } } + +/* Recortes: galería */ +.clips { columns: 220px; column-gap: 12px; } +.clip { break-inside: avoid; margin: 0 0 12px; background: var(--surface); border-radius: var(--radius); overflow: hidden; cursor: pointer; } +.clip img { display: block; width: 100%; height: auto; background: var(--surface-2); } +.clip figcaption { padding: 6px 10px 8px; display: grid; font-size: .8rem; } +.clip figcaption b { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 600; font-size: .85rem; } +.clip figcaption span { color: var(--muted); } +.clip figcaption i { color: var(--text); opacity: .85; } +.clip:hover { outline: 1px solid var(--accent); } +@media (max-width: 560px) { .clips { columns: 2; column-gap: 8px; } .clip { margin-bottom: 8px; } } +.lightbox { position: fixed; inset: 0; z-index: 45; background: rgba(8, 7, 12, .94); display: grid; grid-template-columns: 1fr 320px; } +.lightbox[hidden] { display: none; } +.lb-img { display: flex; align-items: center; justify-content: center; padding: 16px; min-height: 0; overflow: auto; } +.lb-img img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 6px; } +.lb-side { background: var(--surface); padding: 16px; display: flex; flex-direction: column; gap: 10px; overflow: auto; } +.lb-side h2 a { color: var(--text); } +body.noscroll { overflow: hidden; } +@media (max-width: 760px) { + .lightbox { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) auto; } + .lb-img { padding: 10px; } + .lb-side { max-height: 48vh; padding: 12px 14px max(12px, env(safe-area-inset-bottom)); } +} diff --git a/app/server.js b/app/server.js index fb12ad3..99ae606 100644 --- a/app/server.js +++ b/app/server.js @@ -8,6 +8,7 @@ import { DatabaseSync } from 'node:sqlite' import { writeCbz, readCbzIndex, sendCbzEntry, imageType } from './cbz.js' import { setupFeatures } from './features.js' import { setupUpscale } from './upscale.js' +import { setupClips } from './clips.js' const PORT = Number(process.env.PORT || 3352) const DATA_DIR = process.env.DATA_DIR || '/app/data' @@ -732,6 +733,7 @@ async function route(req, res) { const user = requireUser(req) if ((await features.route(p, method, q, req, res, user)) !== false) return if ((await upscale.route(p, method, req, res)) !== false) return + if (p.startsWith('/api/clips') && (await clips.route(p, method, q, req, res, user)) !== false) return if (p === '/api/settings' && method === 'POST') { const body = await readBody(req) @@ -783,7 +785,7 @@ async function route(req, res) { const reads = db.prepare('SELECT chapter_id, page, total_pages FROM chapter_reads WHERE user_id = ? AND manga_id = ?').all(user.id, m[1]) const progress = db.prepare('SELECT * FROM progress WHERE user_id = ? AND manga_id = ?').get(user.id, m[1]) ?? null const chapterLangs = chapters.length || manga.source === 'lc' || offline ? [] : await getChapterLangs(m[1]).catch(() => []) - return json(res, 200, { manga, chapters, lang, reads, progress, chapterLangs, offline, downloaded, download: downloadInfo(m[1], true), upscale: upscale.info(m[1]), ...features.mangaExtras(user.id, manga) }) + return json(res, 200, { manga, chapters, lang, reads, progress, chapterLangs, offline, downloaded, download: downloadInfo(m[1], true), upscale: upscale.info(m[1]), clips: clips.count(user.id, m[1]), ...features.mangaExtras(user.id, manga) }) } if ((m = /^\/api\/chapter\/([^/]+)$/.exec(p)) && CHAPTER_ID.test(m[1]) && method === 'GET') { const hdAvailable = !!upscale.hdFileOf(m[1]) @@ -881,6 +883,7 @@ async function route(req, res) { const features = setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInfo, HttpError, json, readBody, MANGA_ID }) const upscale = setupUpscale({ db, MANGA_DIR, HttpError, json, readBody, MANGA_ID }) +const clips = setupClips({ db, DATA_DIR, HttpError, json, readBody, MANGA_ID, CHAPTER_ID, upscale }) // Migración: portadas guardadas antes de firmar las URLs del proxy. for (const row of db.prepare("SELECT user_id, manga_id, cover FROM progress WHERE cover LIKE '/api/img?u=%' AND cover NOT LIKE '%&s=%'").all()) { diff --git a/app/upscale.js b/app/upscale.js index 359038d..c6e25ed 100644 --- a/app/upscale.js +++ b/app/upscale.js @@ -7,15 +7,15 @@ import path from 'node:path' import { spawn } from 'node:child_process' import { writeCbz, readCbzIndex, readCbzEntry } from './cbz.js' -const BIN = process.env.UPSCALER_BIN || '/opt/realesrgan/realesrgan-ncnn-vulkan' -const MODELS = process.env.UPSCALER_MODELS || path.join(path.dirname(BIN), 'models') -const MODEL = process.env.UPSCALER_MODEL || 'realesr-animevideov3' +export const BIN = process.env.UPSCALER_BIN || '/opt/realesrgan/realesrgan-ncnn-vulkan' +export const MODELS = process.env.UPSCALER_MODELS || path.join(path.dirname(BIN), 'models') +export const MODEL = process.env.UPSCALER_MODEL || 'realesr-animevideov3' const SCALE = Number(process.env.UPSCALER_SCALE || 2) const WEBP_QUALITY = Number(process.env.UPSCALER_QUALITY || 85) const HD_DIR = '.hd' const UPSCALABLE = /\.(jpe?g|png|webp)$/i // lo demás (gif, avif) se copia tal cual -function run(cmd, args, onSpawn) { +export function run(cmd, args, onSpawn) { return new Promise((resolve, reject) => { const p = spawn(cmd, args, { stdio: ['ignore', 'ignore', 'pipe'] }) onSpawn?.(p) @@ -224,5 +224,5 @@ export function setupUpscale({ db, MANGA_DIR, HttpError, json, readBody, MANGA_I if (!fs.existsSync(BIN)) console.log('HD: Real-ESRGAN no instalado, reescalado desactivado') else if (!fs.existsSync('/dev/dri')) console.log('HD: sin /dev/dri en el contenedor, reescalado desactivado') - return { info, hdFileOf, onChapterDownloaded, cancel, route, pump } + return { info, hdFileOf, onChapterDownloaded, cancel, route, pump, available } }