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
+2 -2
View File
@@ -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 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_URL=https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesrgan-ncnn-vulkan-20220424-ubuntu.zip
ARG ESRGAN_SHA256=e5aa6eb131234b87c0c51f82b89390f5e3e642b7b70f2b9bbe95b6a285a40c96 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 - \ && echo "$ESRGAN_SHA256 /tmp/esr.zip" | sha256sum -c - \
&& mkdir -p /opt/realesrgan/models && cd /tmp && unzip -q esr.zip -d esr \ && mkdir -p /opt/realesrgan/models && cd /tmp && unzip -q esr.zip -d esr \
&& install -m 755 esr/realesrgan-ncnn-vulkan /opt/realesrgan/ \ && 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. # 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. # Debian (no Alpine): Real-ESRGAN necesita glibc y los controladores Vulkan de Mesa.
+5 -1
View File
@@ -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 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 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). 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/` (`<id>.png` original y `<id>-hd.webp`), con nota opcional y descarga.
- **Estadísticas**: rachas, capítulos y páginas por semana, calendario de actividad, géneros - **Estadísticas**: rachas, capítulos y páginas por semana, calendario de actividad, géneros
favoritos y lo más leído. 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`). - 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` | | 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 | | `.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 | | Mangas descargados | `MANGA_DIR_HOST` (aquí `/zdata/media/mangas`) | CBZ + portadas |
Pasos en el sistema nuevo: Pasos en el sistema nuevo:
+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 }
}
+236 -2
View File
@@ -35,6 +35,8 @@ const ICONS = {
x: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>', x: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
scroll: '<rect x="6" y="2" width="12" height="9" rx="1.5"/><rect x="6" y="13" width="12" height="9" rx="1.5"/>', scroll: '<rect x="6" y="2" width="12" height="9" rx="1.5"/><rect x="6" y="13" width="12" height="9" rx="1.5"/>',
pages: '<rect x="2" y="5" width="9" height="14" rx="1.5"/><rect x="13" y="5" width="9" height="14" rx="1.5"/>', pages: '<rect x="2" y="5" width="9" height="14" rx="1.5"/><rect x="13" y="5" width="9" height="14" rx="1.5"/>',
scissors: '<circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M20 4 8.1 15.9M14.5 14.5 20 20M8.1 8.1 12 12"/>',
image: '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-5-5L5 21"/>',
sparkles: '<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z"/><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"/>', sparkles: '<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z"/><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"/>',
} }
const icon = name => `<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICONS[name]}</svg>` const icon = name => `<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICONS[name]}</svg>`
@@ -138,6 +140,7 @@ async function router() {
if (parts[0] === 'history') return await viewHistory() if (parts[0] === 'history') return await viewHistory()
if (parts[0] === 'downloads') return await viewDownloads() if (parts[0] === 'downloads') return await viewDownloads()
if (parts[0] === 'stats') return await viewStats() if (parts[0] === 'stats') return await viewStats()
if (parts[0] === 'clips') return await viewClips(params)
if (parts[0] === 'settings') return viewSettings() if (parts[0] === 'settings') return viewSettings()
return await viewHome(params) return await viewHome(params)
} catch (e) { } catch (e) {
@@ -302,7 +305,7 @@ async function viewManga(id, langOverride) {
if (cleanup) { cleanup(); cleanup = null } if (cleanup) { cleanup(); cleanup = null }
app.innerHTML = '<div class="loading">Cargando…</div>' app.innerHTML = '<div class="loading">Cargando…</div>'
const lang = langOverride || store.get(`lang:${id}`, me.lang) 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 dlSet = new Set(downloaded)
const newSet = new Set(newKeys.map(String)) const newSet = new Set(newKeys.map(String))
let isFollowing = following let isFollowing = following
@@ -336,7 +339,7 @@ async function viewManga(id, langOverride) {
<button class="btn" id="follow"></button> <button class="btn" id="follow"></button>
${menuItems ? `<details class="menu"><summary class="btn" title="Más opciones" aria-label="Más opciones">${icon('dots')}</summary><div class="menu-list">${menuItems}</div></details>` : ''} ${menuItems ? `<details class="menu"><summary class="btn" title="Más opciones" aria-label="Más opciones">${icon('dots')}</summary><div class="menu-list">${menuItems}</div></details>` : ''}
</div> </div>
<div class="facts muted"><span>Fuente: ${esc(SOURCES[manga.source])}</span>${download ? `<span>Carpeta: mangas/${esc(download.folder)}</span>` : ''}</div> <div class="facts muted"><span>Fuente: ${esc(SOURCES[manga.source])}</span>${download ? `<span>Carpeta: mangas/${esc(download.folder)}</span>` : ''}${clipCount ? `<a href="#/clips?manga=${encodeURIComponent(id)}">${icon('scissors')} ${clipCount} recorte${clipCount > 1 ? 's' : ''}</a>` : ''}</div>
${offline ? '<p class="error">Sin conexión con la fuente: mostrando solo los capítulos descargados.</p>' : ''} ${offline ? '<p class="error">Sin conexión con la fuente: mostrando solo los capítulos descargados.</p>' : ''}
<div class="libpanel"><div class="psec" id="dl"></div><div class="psec" id="hd" hidden></div></div> <div class="libpanel"><div class="psec" id="dl"></div><div class="psec" id="hd" hidden></div></div>
</div> </div>
@@ -736,6 +739,7 @@ async function viewReader(mangaId, chapterId, startPage) {
<a class="btn small" href="#/manga/${mangaId}" title="Volver a la ficha">${icon('back')}</a> <a class="btn small" href="#/manga/${mangaId}" title="Volver a la ficha">${icon('back')}</a>
<div class="ttl"><b>${esc(manga.title)}</b></div> <div class="ttl"><b>${esc(manga.title)}</b></div>
<select id="chap" style="max-width:40vw">${chapOpts}</select> <select id="chap" style="max-width:40vw">${chapOpts}</select>
<button class="btn small" id="clipbtn" title="Recortar un panel (se guarda en Recortes y se mejora a HD)">${icon('scissors')}</button>
<button class="btn small" id="modequick"></button> <button class="btn small" id="modequick"></button>
<button class="btn small" id="ropts" title="Opciones de lectura">${icon('settings')}</button> <button class="btn small" id="ropts" title="Opciones de lectura">${icon('settings')}</button>
</div> </div>
@@ -1047,7 +1051,118 @@ async function viewReader(mangaId, chapterId, startPage) {
$('#chap').addEventListener('change', e => { location.hash = readUrl(mangaId, e.target.value) }) $('#chap').addEventListener('change', e => { location.hash = readUrl(mangaId, e.target.value) })
slider.addEventListener('input', () => goTo(Number(slider.value) - 1)) 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 = `<div class="crop-bar"><span>${icon('scissors')} Arrastra sobre el panel que quieres guardar</span>
<button class="btn small" data-a="cancel">${icon('x')} Cancelar</button>
<button class="btn small primary" data-a="save" disabled>${icon('check')} Guardar</button></div>
<div class="crop-rect" hidden></div>`
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 => { const onKey = e => {
if (crop) { if (e.key === 'Escape') endCrop(); return }
if (!isPaged() || e.target.matches('input,select')) return if (!isPaged() || e.target.matches('input,select')) return
const fwdKey = settings.dir === 'ltr' ? 'ArrowRight' : 'ArrowLeft' const fwdKey = settings.dir === 'ltr' ? 'ArrowRight' : 'ArrowLeft'
const backKey = settings.dir === 'ltr' ? 'ArrowLeft' : 'ArrowRight' const backKey = settings.dir === 'ltr' ? 'ArrowLeft' : 'ArrowRight'
@@ -1080,6 +1195,7 @@ async function viewReader(mangaId, chapterId, startPage) {
render() render()
cleanup = () => { cleanup = () => {
endCrop()
if (saveTimer) saveNow() if (saveTimer) saveNow()
observer?.disconnect() observer?.disconnect()
clearTimeout(uiTimer) clearTimeout(uiTimer)
@@ -1091,6 +1207,124 @@ async function viewReader(mangaId, chapterId, startPage) {
} }
} }
// ---------- Recortes ----------
const clipStatus = c => c.status === 'done' ? `<span class="hd" title="Reescalado ×${c.scale}">HD</span>`
: c.status === 'error' ? '<span class="hd pending" title="Falló el reescalado">HD!</span>'
: c.status === 'off' ? '' : '<span class="hd pending" title="Reescalando…">HD…</span>'
async function viewClips(params) {
const mangaId = params.get('manga')
const here = () => location.hash.split('?')[0] === '#/clips'
app.innerHTML = '<div class="loading">Cargando…</div>'
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 = `<h1>${icon('scissors')} Recortes</h1>
${data.mangas.length > 1 || mangaId ? `<div class="tabs">
<button data-m="" class="${mangaId ? '' : 'on'}">Todos (${total})</button>
${data.mangas.map(m => `<button data-m="${esc(m.id)}" class="${m.id === mangaId ? 'on' : ''}">${esc(m.title)} (${m.n})</button>`).join('')}</div>` : ''}
<div id="cgrid" class="clips"></div>
<div id="lb" class="lightbox" hidden></div>`
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) => `
<figure class="clip" data-i="${i}">
<img src="${c.hd || c.img}" alt="" loading="lazy" style="aspect-ratio:${c.width}/${c.height}">
<figcaption><b>${esc(c.mangaTitle)}</b><span>${esc((c.chapterLabel || '').split(' — ')[0])} · p. ${c.page + 1} ${clipStatus(c)}</span>
${c.note ? `<i>${esc(c.note)}</i>` : ''}</figcaption>
</figure>`).join('')
: `<p class="muted">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.</p>`
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 = `
<div class="lb-img"><img src="${src}" alt=""></div>
<div class="lb-side">
<div class="row" style="justify-content:space-between">
<span class="muted">${i + 1} / ${data.clips.length}</span>
<span class="row" style="gap:6px">
<button class="btn small" data-a="prev" ${i ? '' : 'disabled'} title="Anterior">${icon('prev')}</button>
<button class="btn small" data-a="next" ${i < data.clips.length - 1 ? '' : 'disabled'} title="Siguiente">${icon('next')}</button>
<button class="btn small" data-a="close" title="Cerrar">${icon('x')}</button>
</span>
</div>
<h2 style="margin:4px 0 0"><a href="#/manga/${c.mangaId}">${esc(c.mangaTitle)}</a></h2>
<div class="muted">${esc(c.chapterLabel || '')} · página ${c.page + 1}</div>
<div class="muted" style="font-size:.85rem">${size} · ${timeAgo(c.createdAt)} ${clipStatus(c)}</div>
${c.status === 'error' ? `<p class="error" style="font-size:.85rem">${esc(c.error || 'Error al reescalar')}</p>` : ''}
${c.status === 'off' ? '<p class="muted" style="font-size:.85rem">Reescalado no disponible en este servidor.</p>' : ''}
<input id="note" placeholder="Nota (opcional)" maxlength="1000" value="${esc(c.note || '')}">
<a class="btn primary" href="${readUrl(c.mangaId, c.chapterId, c.page)}">${icon('book')} Ir a la página</a>
${c.hd ? `<label class="row muted" style="font-size:.85rem"><input type="checkbox" id="showhd" ${showHd ? 'checked' : ''}> Ver versión HD</label>` : ''}
<div class="row" style="gap:6px;flex-wrap:wrap">
<a class="btn small" href="${(c.hd && showHd ? c.hd + '&' : c.img + '?')}dl=1" download>${icon('download')} Descargar</a>
${c.status === 'error' || (c.status === 'off' && data.available) ? `<button class="btn small" data-a="retry">${icon('refresh')} Reintentar HD</button>` : ''}
<button class="btn small danger" data-a="del">${icon('trash')} Eliminar</button>
</div>
</div>`
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 ---------- // ---------- Historial ----------
async function viewHistory() { async function viewHistory() {
app.innerHTML = '<div class="loading">Cargando…</div>' app.innerHTML = '<div class="loading">Cargando…</div>'
+2 -1
View File
@@ -10,11 +10,12 @@
</head> </head>
<body> <body>
<header id="topbar"> <header id="topbar">
<a href="#/" class="brand"><img src="/icon.svg" alt="" width="26" height="26"> Manga Reader</a> <a href="#/" class="brand"><img src="/icon.svg" alt="" width="26" height="26"> <span>Manga Reader</span></a>
<nav> <nav>
<a href="#/" data-icon="home">Inicio</a> <a href="#/" data-icon="home">Inicio</a>
<a href="#/history" data-icon="history">Historial</a> <a href="#/history" data-icon="history">Historial</a>
<a href="#/downloads" data-icon="download">Descargas</a> <a href="#/downloads" data-icon="download">Descargas</a>
<a href="#/clips" data-icon="scissors">Recortes</a>
<a href="#/stats" data-icon="chart">Estadísticas</a> <a href="#/stats" data-icon="chart">Estadísticas</a>
<a href="#/settings" data-icon="settings">Ajustes</a> <a href="#/settings" data-icon="settings">Ajustes</a>
</nav> </nav>
+32
View File
@@ -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 { left: 12px; right: 12px; transform: none; max-width: none; }
#selbar span { flex: 1; white-space: normal; font-size: .82rem; line-height: 1.3; } #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)); }
}
+4 -1
View File
@@ -8,6 +8,7 @@ import { DatabaseSync } from 'node:sqlite'
import { writeCbz, readCbzIndex, sendCbzEntry, imageType } from './cbz.js' import { writeCbz, readCbzIndex, sendCbzEntry, imageType } from './cbz.js'
import { setupFeatures } from './features.js' import { setupFeatures } from './features.js'
import { setupUpscale } from './upscale.js' import { setupUpscale } from './upscale.js'
import { setupClips } from './clips.js'
const PORT = Number(process.env.PORT || 3352) const PORT = Number(process.env.PORT || 3352)
const DATA_DIR = process.env.DATA_DIR || '/app/data' const DATA_DIR = process.env.DATA_DIR || '/app/data'
@@ -732,6 +733,7 @@ async function route(req, res) {
const user = requireUser(req) const user = requireUser(req)
if ((await features.route(p, method, q, req, res, user)) !== false) return if ((await features.route(p, method, q, req, res, user)) !== false) return
if ((await upscale.route(p, method, req, res)) !== 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') { if (p === '/api/settings' && method === 'POST') {
const body = await readBody(req) 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 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 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(() => []) 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') { if ((m = /^\/api\/chapter\/([^/]+)$/.exec(p)) && CHAPTER_ID.test(m[1]) && method === 'GET') {
const hdAvailable = !!upscale.hdFileOf(m[1]) 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 features = setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInfo, HttpError, json, readBody, MANGA_ID })
const upscale = setupUpscale({ db, MANGA_DIR, 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. // 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()) { 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()) {
+5 -5
View File
@@ -7,15 +7,15 @@ import path from 'node:path'
import { spawn } from 'node:child_process' import { spawn } from 'node:child_process'
import { writeCbz, readCbzIndex, readCbzEntry } from './cbz.js' import { writeCbz, readCbzIndex, readCbzEntry } from './cbz.js'
const BIN = process.env.UPSCALER_BIN || '/opt/realesrgan/realesrgan-ncnn-vulkan' export const BIN = process.env.UPSCALER_BIN || '/opt/realesrgan/realesrgan-ncnn-vulkan'
const MODELS = process.env.UPSCALER_MODELS || path.join(path.dirname(BIN), 'models') export const MODELS = process.env.UPSCALER_MODELS || path.join(path.dirname(BIN), 'models')
const MODEL = process.env.UPSCALER_MODEL || 'realesr-animevideov3' export const MODEL = process.env.UPSCALER_MODEL || 'realesr-animevideov3'
const SCALE = Number(process.env.UPSCALER_SCALE || 2) const SCALE = Number(process.env.UPSCALER_SCALE || 2)
const WEBP_QUALITY = Number(process.env.UPSCALER_QUALITY || 85) const WEBP_QUALITY = Number(process.env.UPSCALER_QUALITY || 85)
const HD_DIR = '.hd' const HD_DIR = '.hd'
const UPSCALABLE = /\.(jpe?g|png|webp)$/i // lo demás (gif, avif) se copia tal cual 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) => { return new Promise((resolve, reject) => {
const p = spawn(cmd, args, { stdio: ['ignore', 'ignore', 'pipe'] }) const p = spawn(cmd, args, { stdio: ['ignore', 'ignore', 'pipe'] })
onSpawn?.(p) 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') 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') 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 }
} }