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 }
|
||||
}
|
||||
+236
-2
@@ -35,6 +35,8 @@ const ICONS = {
|
||||
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"/>',
|
||||
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"/>',
|
||||
}
|
||||
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] === '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 = '<div class="loading">Cargando…</div>'
|
||||
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) {
|
||||
<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>` : ''}
|
||||
</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>' : ''}
|
||||
<div class="libpanel"><div class="psec" id="dl"></div><div class="psec" id="hd" hidden></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>
|
||||
<div class="ttl"><b>${esc(manga.title)}</b></div>
|
||||
<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="ropts" title="Opciones de lectura">${icon('settings')}</button>
|
||||
</div>
|
||||
@@ -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 = `<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 => {
|
||||
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' ? `<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 ----------
|
||||
async function viewHistory() {
|
||||
app.innerHTML = '<div class="loading">Cargando…</div>'
|
||||
|
||||
@@ -10,11 +10,12 @@
|
||||
</head>
|
||||
<body>
|
||||
<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>
|
||||
<a href="#/" data-icon="home">Inicio</a>
|
||||
<a href="#/history" data-icon="history">Historial</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="#/settings" data-icon="settings">Ajustes</a>
|
||||
</nav>
|
||||
|
||||
@@ -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)); }
|
||||
}
|
||||
|
||||
+4
-1
@@ -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()) {
|
||||
|
||||
+5
-5
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user