From 52e9edc214502e7f6f5fff3a131bbe2772a092f5 Mon Sep 17 00:00:00 2001 From: Nino Date: Mon, 2 Nov 2026 15:52:05 -0600 Subject: [PATCH] Recortes con puntos en las esquinas y captura larga en lectura vertical MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit En lectura vertical la zona sigue creciendo al llegar al borde y une varias páginas en una sola imagen. El servidor admite hasta 8000 px y 64 MB, y la galería y el visor se adaptan a los recortes largos. La animación de la hoja dura 30 ms más. Co-Authored-By: Claude Opus 5.5 --- app/clips.js | 4 +- app/public/app.js | 165 +++++++++++++++++++++++++++++++------------ app/public/style.css | 12 +++- 3 files changed, 132 insertions(+), 49 deletions(-) diff --git a/app/clips.js b/app/clips.js index f2f3c36..4666fb6 100644 --- a/app/clips.js +++ b/app/clips.js @@ -7,8 +7,8 @@ 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 MAX_BYTES = 64 * 1024 * 1024 // un recorte largo de lectura vertical en PNG +const MAX_SIDE = 8000 // a ×2 cabe en el máximo de WebP (16383 px) const X4_MAX_SIDE = 1200 // a partir de aquí ×2: ×4 daría imágenes enormes sin ganar detalle const WEBP_QUALITY = 90 diff --git a/app/public/app.js b/app/public/app.js index 9544da0..a33537d 100644 --- a/app/public/app.js +++ b/app/public/app.js @@ -1969,7 +1969,7 @@ async function viewReader(mangaId, chapterId, startPage) { const lift = curl.set(g, reverse, (spineLeft ? 1 : -1) * (reverse ? -1 : 1) * GRAB_TILT_Z * k, GRAB_TILT_X * k) drop.style.opacity = (lift * 0.8).toFixed(3) } - dur = double ? 720 : 580 + dur = double ? 750 : 610 auto = t => g0 + (g1 - g0) * t // El borde libre va donde lo lleva el dedo: de su sitio al lomo (una página) o al otro lado (doble); // hacia atrás en una página, del lomo a su sitio. @@ -2295,12 +2295,16 @@ async function viewReader(mangaId, chapterId, startPage) { }) 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. + // --- Recortes: se arrastra un rectángulo sobre la página y se ajusta con los puntos de sus esquinas. 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: al llevar un punto al borde de la pantalla la lectura se desplaza y la zona sigue creciendo, y + // en lectura vertical puede abarcar varias páginas, que se unen en una sola imagen. + const CROP_MAX_SIDE = 8000 // el mismo límite que en el servidor (clips.js) + const CROP_MAX_AREA = 16e6 // por encima, Safari en iOS puede no generar el canvas let crop = null function endCrop() { if (!crop) return + cancelAnimationFrame(crop.raf) crop.ov.remove() window.removeEventListener('scroll', crop.draw) crop = null @@ -2313,86 +2317,155 @@ async function viewReader(mangaId, chapterId, startPage) { $('.rpanel', reader).hidden = true const ov = document.createElement('div') ov.className = 'cropov' - ov.innerHTML = `
${icon('scissors')} Arrastra sobre el panel que quieres guardar + 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 } + const rectEl = $('.crop-rect', ov), saveBtn = $('[data-a=save]', ov), hint = $('.crop-bar em', ov) + crop = { ov, r: null, start: null, prev: null, pan: null, pid: null, off: { x: 0, y: 0 }, last: null, raf: 0 } 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 } + const canScroll = () => document.documentElement.scrollHeight > innerHeight + 1 + // Lleva la esquina que se arrastra hasta el dedo; la opuesta (crop.start) se queda fija. + const stretch = () => { + const x = crop.last.x + scrollX + crop.off.x, y = crop.last.y + scrollY + crop.off.y + 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() + } + // Con el dedo cerca del borde de arriba o de abajo, la lectura se desplaza y la zona sigue creciendo. + const EDGE = 80, EDGE_SPEED = 22 + const autoScroll = () => { + if (!crop?.start) return + const y = crop.last.y + const v = y < EDGE ? -(EDGE - y) / EDGE : y > innerHeight - EDGE ? (y - innerHeight + EDGE) / EDGE : 0 + if (v && crop.r) { + scrollBy(0, v * EDGE_SPEED) + stretch() + } + crop.raf = requestAnimationFrame(autoScroll) + } 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 + if (e.target.closest('.crop-bar') || (e.pointerType === 'mouse' && e.button !== 0) || crop.pid != null) return + const x = e.clientX + scrollX, y = e.clientY + scrollY, r = crop.r + const handle = e.target.closest('.crop-h')?.dataset.h + crop.pid = e.pointerId 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 + // Con el dedo la lectura no se puede desplazar de otro modo: deslizar dentro de la zona la mueve, para llegar + // a las esquinas que quedan fuera de la pantalla. + if (!handle && e.pointerType !== 'mouse' && r && canScroll() && x >= r.l && x <= r.r && y >= r.t && y <= r.b) { + crop.pan = e.clientY + return + } + if (handle) { + const cx = handle[1] === 'w' ? r.l : r.r, cy = handle[0] === 'n' ? r.t : r.b + crop.start = { x: handle[1] === 'w' ? r.r : r.l, y: handle[0] === 'n' ? r.b : r.t } + crop.off = { x: cx - x, y: cy - y } + crop.prev = r + } else { + crop.start = { x, y } + crop.off = { x: 0, y: 0 } + crop.prev = null + crop.r = null + rectEl.hidden = true + saveBtn.disabled = true + } + crop.last = { x: e.clientX, y: e.clientY } + crop.raf = requestAnimationFrame(autoScroll) }) ov.addEventListener('pointermove', e => { + if (e.pointerId !== crop.pid) return + if (crop.pan != null) { + scrollBy(0, crop.pan - e.clientY) + crop.pan = e.clientY + return + } 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() + crop.last = { x: e.clientX, y: e.clientY } + stretch() }) - const up = () => { - if (!crop?.start) return + const up = e => { + if (!crop || e.pointerId !== crop.pid) return + crop.pid = null + crop.pan = null + if (!crop.start) return + cancelAnimationFrame(crop.raf) 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 + // Demasiado pequeña: al ajustar vuelve a como estaba; al marcarla de cero, no queda nada. + if (!crop.r || crop.r.r - crop.r.l < 12 || crop.r.b - crop.r.t < 12) crop.r = crop.prev + if (crop.r) crop.draw() + else rectEl.hidden = true + saveBtn.disabled = !crop.r + if (crop.r) hint.textContent = canScroll() ? 'Mueve los puntos; llévalos al borde para seguir' : 'Ajusta la zona con los puntos' } 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'))) + saveBtn.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) { + // Las imágenes bajo el rectángulo y la zona de cada una, en píxeles de pantalla desde su esquina. Por páginas, solo + // la de mayor intersección; en vertical, todas las que toca, de arriba abajo. + function cropParts(r) { const c = { l: r.l - scrollX, t: r.t - scrollY, r: r.r - scrollX, b: r.b - scrollY } - let best = null + const hits = [] 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 (w <= 0 || h <= 0) continue + hits.push({ img, b, area: w * h, x0: Math.max(c.l, b.left) - b.left, y0: Math.max(c.t, b.top) - b.top, x1: Math.min(c.r, b.right) - b.left, y1: Math.min(c.b, b.bottom) - b.top }) } - 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 } + const ready = hit => hit.img.complete && hit.img.naturalWidth > 0 + if (isPaged()) { + const loaded = hits.filter(ready) + return loaded.length ? [loaded.reduce((a, h) => (h.area > a.area ? h : a))] : [] + } + if (!hits.every(ready)) throw new Error('Espera a que carguen las páginas de la zona') + return hits } 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 parts = crop?.r ? cropParts(crop.r) : [] + if (!parts.length) throw new Error('Marca una zona sobre la página') + // Del original: el servidor lo reescala él mismo. + const sources = await Promise.all(parts.map(({ img }) => new Promise((resolve, reject) => { + const u = new URL(img.currentSrc || img.src, location.href) + u.searchParams.delete('hd') 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)) + }))) + // Misma escala para todas (la de la página con más detalle), una debajo de otra y sin los huecos de la lectura. + const scale = Math.max(...parts.map((h, i) => sources[i].naturalWidth / h.b.width)) + const left = Math.min(...parts.map(h => h.b.left + h.x0)), right = Math.max(...parts.map(h => h.b.left + h.x1)) + const fullW = (right - left) * scale, fullH = parts.reduce((sum, h) => sum + h.y1 - h.y0, 0) * scale + if (fullW < 16 || fullH < 16) throw new Error('La zona es demasiado pequeña') + const k = scale * Math.min(1, CROP_MAX_SIDE / Math.max(fullW, fullH), Math.sqrt(CROP_MAX_AREA / (fullW * fullH))) 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) + cv.width = Math.round((right - left) * k) + cv.height = Math.round(parts.reduce((sum, h) => sum + h.y1 - h.y0, 0) * k) + const ctx = cv.getContext('2d') + ctx.imageSmoothingQuality = 'high' + if (parts.length > 1) { ctx.fillStyle = '#000'; ctx.fillRect(0, 0, cv.width, cv.height) } // páginas de distinto ancho + let y = 0 + parts.forEach((h, i) => { + const src = sources[i], fx = src.naturalWidth / h.b.width, fy = src.naturalHeight / h.b.height + const top = Math.round(y * k) + y += h.y1 - h.y0 + ctx.drawImage(src, Math.round(h.x0 * fx), Math.round(h.y0 * fy), Math.max(1, Math.round((h.x1 - h.x0) * fx)), Math.max(1, Math.round((h.y1 - h.y0) * fy)), + Math.round((h.b.left + h.x0 - left) * k), top, Math.round((h.x1 - h.x0) * k), Math.round(y * k) - top) + }) 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 || '' }) + if (!blob) throw new Error('La zona es demasiado grande para este dispositivo') + const qs = new URLSearchParams({ mangaId, chapterId, page: parts[0].img.dataset.i, 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}`) @@ -2490,7 +2563,7 @@ async function viewClips(params) { 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} diff --git a/app/public/style.css b/app/public/style.css index 82e2c5f..32566ca 100644 --- a/app/public/style.css +++ b/app/public/style.css @@ -300,12 +300,19 @@ input[type=checkbox] { accent-color: var(--accent); width: 16px; height: 16px; m 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; } +.crop-h { position: absolute; width: 44px; height: 44px; pointer-events: auto; touch-action: none; } +.crop-h::after { content: ''; position: absolute; left: 15px; top: 15px; width: 14px; height: 14px; box-sizing: border-box; border-radius: 50%; background: var(--accent); border: 2px solid #fff; box-shadow: 0 1px 4px rgba(0,0,0,.6); } +.crop-h[data-h="nw"] { left: -23px; top: -23px; cursor: nwse-resize; } +.crop-h[data-h="ne"] { right: -23px; top: -23px; cursor: nesw-resize; } +.crop-h[data-h="sw"] { left: -23px; bottom: -23px; cursor: nesw-resize; } +.crop-h[data-h="se"] { right: -23px; bottom: -23px; cursor: nwse-resize; } +.crop-bar em { font-style: normal; } @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 img { display: block; width: 100%; height: auto; max-height: 520px; object-fit: cover; object-position: top; 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); } @@ -316,6 +323,9 @@ input[type=checkbox] { accent-color: var(--accent); width: 16px; height: 16px; m .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; } +/* Recorte largo de lectura vertical: a su ancho y con desplazamiento; entero en pantalla sería una tira ilegible. */ +.lb-img.long { align-items: flex-start; } +.lb-img.long img { max-height: none; height: auto; flex: none; } .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; }