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
- `
+
${['nw', 'ne', 'sw', 'se'].map(h => ``).join('')}
`
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 = `
-