Recortes con puntos en las esquinas y captura larga en lectura vertical

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 <noreply@anthropic.com>
This commit is contained in:
2026-11-02 15:52:05 -06:00
co-authored by Claude Opus 5.5
parent 28aa0b4051
commit 52e9edc214
3 changed files with 132 additions and 49 deletions
+2 -2
View File
@@ -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
+119 -46
View File
@@ -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 = `<div class="crop-bar"><span>${icon('scissors')} Arrastra sobre el panel que quieres guardar</span>
ov.innerHTML = `<div class="crop-bar"><span>${icon('scissors')} <em>Arrastra sobre el panel que quieres guardar</em></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>`
<div class="crop-rect" hidden>${['nw', 'ne', 'sw', 'se'].map(h => `<i class="crop-h" data-h="${h}"></i>`).join('')}</div>`
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 = `
<div class="lb-img"><img src="${src}" alt=""></div>
<div class="lb-img${c.height / c.width > 2.5 ? ' long' : ''}"><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>
+11 -1
View File
@@ -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; }