From 66502409fe220950a69b8c61c5fa0ed2f529d824 Mon Sep 17 00:00:00 2001 From: Nino Date: Wed, 4 Nov 2026 21:28:50 -0600 Subject: [PATCH] =?UTF-8?q?Modo=20libro=203D,=20tomos=20con=20cubiertas=20?= =?UTF-8?q?reales=20y=20ocultar=20cr=C3=A9ditos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Libro 3D (app/public/book3d.js): lector WebGL2 con mesa de madera, cubierta, hojas que giran, cámara real y giroscopio, ahora con más recorrido alrededor del libro. Tomos (app/volumes.js): agrupa los capítulos en un solo libro con portada y contraportada reales. El reparto sale de Wikipedia, MangaDex, M440 y MangaUpdates, contrastados entre sí. Lo que ninguna fuente sitúa se estima, de modo que solo quedan sueltos los capítulos del final, y los fuera de orden se recolocan. Manhwa y manhua no tienen tomos. Ocultar créditos (app/credits.js): botón por obra en la ficha. Al leer por tomos oculta, sin borrarlas, la página cero y las dos últimas de cada capítulo si se repiten en otros capítulos de la obra. HANDOFF-LIBRO3D.md recoge el estado, las decisiones y las pruebas. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 7 + app/credits.js | 92 ++++ app/meta.js | 28 +- app/public/app.js | 497 ++++++++++++++++--- app/public/book3d.js | 1065 +++++++++++++++++++++++++++++++++++++++++ app/public/index.html | 1 + app/public/style.css | 31 ++ app/public/sw.js | 2 +- app/server.js | 59 ++- app/volumes.js | 706 +++++++++++++++++++++++++++ 10 files changed, 2430 insertions(+), 58 deletions(-) create mode 100644 app/credits.js create mode 100644 app/public/book3d.js create mode 100644 app/volumes.js diff --git a/.gitignore b/.gitignore index ff4375d..6e81f30 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,14 @@ data/* # Descargas cuando se usan dentro del proyecto (MANGA_DIR_HOST por defecto) mangas/* !mangas/.keep +# Configuración real de cada servidor (la plantilla sí se versiona) .env .env.* !.env.example node_modules/ +# Documentos internos de traspaso (se guardan solo en el servidor) +HANDOFF-LIBRO3D.md +# Basura del sistema y registros +*.log +.DS_Store +Thumbs.db diff --git a/app/credits.js b/app/credits.js new file mode 100644 index 0000000..60fc640 --- /dev/null +++ b/app/credits.js @@ -0,0 +1,92 @@ +// Créditos de los traductores: cada grupo repite casi la misma página en sus capítulos, y las de historia no se repiten. +// El navegador resume la página 0 y las 2 últimas de cada capítulo (el servidor no sabe decodificar imágenes) y aquí se +// comparan con las del resto de la obra. Solo se ocultan al leer por tomos, y nunca se borra nada. +const SIDE = 16 +const SIG_BYTES = SIDE * SIDE +const SAME = 0.03 // diferencia media: los créditos quedan por debajo del 2% y la historia muy por encima +const SAME_SHAPE = 0.03 +const MIN_PAGES = 5 +const STRIP = 0.45 // tira larga: los créditos vienen pegados a la historia y no se puede quitar uno sin la otra +const PLAIN = 0.03 // página casi lisa (en blanco, negra): no son créditos, aunque se repitan + +const decode = s => Buffer.from(String(s || ''), 'base64') +const spread = b => { + const mean = b.reduce((a, x) => a + x, 0) / b.length + return Math.sqrt(b.reduce((a, x) => a + (x - mean) ** 2, 0) / b.length) / 255 +} +const difference = (a, b) => { + let sum = 0 + for (let i = 0; i < SIG_BYTES; i++) sum += Math.abs(a[i] - b[i]) + return sum / SIG_BYTES / 255 +} + +/** Qué páginas repetidas tiene cada capítulo pedido. rows: [{ id, n, pages: { índice: { s, ar } } }], de toda la obra. */ +export function repeatedPages(rows, ids) { + const all = rows.flatMap(r => Object.entries(r.pages).map(([i, p]) => ({ id: r.id, i: Number(i), ar: p.ar, sig: decode(p.s) }))) + .filter(p => p.sig.length === SIG_BYTES) + const out = {} + for (const r of rows) { + if (!ids.includes(r.id) || r.n < MIN_PAGES) continue + const hidden = [] + for (const p of all) { + if (p.id !== r.id || p.ar < STRIP || spread(p.sig) < PLAIN) continue + if (![0, r.n - 2, r.n - 1].includes(p.i)) continue + if (all.some(o => o.id !== r.id && Math.abs(o.ar - p.ar) < SAME_SHAPE && difference(o.sig, p.sig) < SAME)) hidden.push(p.i) + } + if (hidden.length) out[r.id] = hidden.sort((a, b) => a - b) + } + return out +} + +export function setupCredits({ db, HttpError, json, readBody, MANGA_ID, CHAPTER_ID }) { + db.exec(` + CREATE TABLE IF NOT EXISTS page_sigs ( + manga_id TEXT NOT NULL, + chapter_id TEXT NOT NULL, + n INTEGER NOT NULL, + pages TEXT NOT NULL, + PRIMARY KEY (manga_id, chapter_id) + ); + CREATE TABLE IF NOT EXISTS hide_credits ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + manga_id TEXT NOT NULL, + PRIMARY KEY (user_id, manga_id) + );`) + const enabled = (userId, mangaId) => !!db.prepare('SELECT 1 FROM hide_credits WHERE user_id = ? AND manga_id = ?').get(userId, mangaId) + + function validPages(pages, n) { + const entries = Object.entries(pages || {}) + if (!entries.length || entries.length > 3) return false + return entries.every(([i, p]) => [0, n - 2, n - 1].includes(Number(i)) && typeof p?.ar === 'number' && p.ar > 0 && decode(p.s).length === SIG_BYTES) + } + + async function route(p, method, req, res, user) { + let m + if ((m = /^\/api\/credits\/([^/]+)\/option$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') { + const { on } = await readBody(req) + if (on) db.prepare('INSERT OR IGNORE INTO hide_credits (user_id, manga_id) VALUES (?, ?)').run(user.id, m[1]) + else db.prepare('DELETE FROM hide_credits WHERE user_id = ? AND manga_id = ?').run(user.id, m[1]) + json(res, 200, { hideCredits: !!on }) + return true + } + if ((m = /^\/api\/credits\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') { + const body = await readBody(req) + const wanted = Array.isArray(body.chapters) ? body.chapters.slice(0, 60) : [] + if (!wanted.every(c => CHAPTER_ID.test(c?.id || '') && Number.isInteger(c?.n) && c.n > 0 && c.n < 100000)) throw new HttpError(400, 'Capítulos inválidos') + const save = db.prepare('INSERT OR REPLACE INTO page_sigs (manga_id, chapter_id, n, pages) VALUES (?, ?, ?, ?)') + for (const c of wanted) { + const pages = body.sigs?.[c.id] + if (pages && validPages(pages, c.n)) save.run(m[1], c.id, c.n, JSON.stringify(pages)) + } + const rows = db.prepare('SELECT chapter_id AS id, n, pages FROM page_sigs WHERE manga_id = ?').all(m[1]).map(r => ({ id: r.id, n: r.n, pages: JSON.parse(r.pages) })) + const known = new Map(rows.map(r => [r.id, r.n])) + // Un capítulo vuelto a subir puede tener otras páginas: su resumen ya no vale. + const missing = wanted.filter(c => known.get(c.id) !== c.n).map(c => c.id) + const current = rows.filter(r => wanted.every(c => c.id !== r.id || c.n === r.n)) + json(res, 200, { missing, hidden: repeatedPages(current, wanted.map(c => c.id).filter(id => !missing.includes(id))) }) + return true + } + return false + } + return { route, enabled } +} diff --git a/app/meta.js b/app/meta.js index 355827b..c5e7234 100644 --- a/app/meta.js +++ b/app/meta.js @@ -12,6 +12,7 @@ // Todo se guarda en la base de datos, una vez por manga y para todos los usuarios: lo guardado se sirve sin // consultar nada y, si es antiguo, se actualiza en segundo plano (si una fuente falla, se queda lo que había). // Los mangas seguidos por alguien se mantienen siempre al día, aunque nadie abra su ficha. +import { parseMuVolumes, workTitle } from './volumes.js' const DAY = 24 * 3600e3 const INFO_TTL = v => (v?.found ? 30 * DAY : 3 * DAY) const SYNC_EVERY = 3600e3 @@ -119,6 +120,31 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n }, wait) } + // Las entradas de MangaDex con el título de la obra, en el orden en que las devuelve (primero la más parecida). + function mdMatches(mangaId) { + return stored(`mdworks:${mangaId}`, v => (v.length ? 30 * DAY : 3 * DAY), async () => { + const { manga } = await fetchMangaFull(mangaId, 'es') + const title = workTitle(manga.title) + const r = await md('/manga', { title, limit: 10, 'contentRating[]': MD_RATINGS }, 6 * 3600e3) + return (r?.data || []).filter(m => mdTitles(m).some(t => similar(t, title))).map(m => m.id) + }, true) + } + + // Capítulo -> tomo según las publicaciones de MangaUpdates. Sirve para las obras licenciadas, que MangaDex deja sin capítulos. + async function muVolumes(mangaId) { + const l = await links(mangaId, true) + if (!l.mu) return {} + return stored(`mumap:${mangaId}`, v => (Object.keys(v).length ? 7 * DAY : 3 * DAY), async () => { + const rows = [] + for (let page = 1; page <= 20; page++) { + const r = await mu('/releases/search', { search: String(l.mu), search_type: 'series', perpage: 100, page }) + rows.push(...(r?.results || []).map(x => x.record)) + if (!r || page * 100 >= r.total_hits) break + } + return parseMuVolumes(rows) + }, true) + } + // ---------- Ficha ---------- const AL_FIELDS = `id idMal siteUrl title{romaji english native} synonyms description(asHtml:true) format status startDate{year month day} endDate{year month day} chapters volumes countryOfOrigin isAdult @@ -287,5 +313,5 @@ export function setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, n return true } - return { route, links, info, cachedInfo, dates } + return { route, links, mdMatches, muVolumes, similar, stored, info, cachedInfo, dates } } diff --git a/app/public/app.js b/app/public/app.js index 17496bb..44e5cad 100644 --- a/app/public/app.js +++ b/app/public/app.js @@ -681,7 +681,7 @@ async function viewManga(id, langOverride) { if (cleanup) { cleanup(); cleanup = null } app.innerHTML = '
Cargando…
' const lang = langOverride || store.get(`lang:${id}`, me.lang) - const { manga, chapters, reads, progress, chapterLangs, offline, downloaded, download, upscale, following, clips: clipCount = 0, lists: inLists = [], newKeys = [], info: savedInfo = null, clipBanner = null } = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`) + const { manga, chapters, reads, progress, chapterLangs, offline, downloaded, hideCredits: creditsOn, download, upscale, following, clips: clipCount = 0, lists: inLists = [], newKeys = [], info: savedInfo = null, clipBanner = null } = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`) const dlSet = new Set(downloaded) const newSet = new Set(newKeys.map(String)) let isFollowing = following @@ -732,6 +732,7 @@ async function viewManga(id, langOverride) {
Opciones
+ ${offline ? '' : ``} ${clipCount ? `${icon('scissors')} ${clipCount} recorte${clipCount > 1 ? 's' : ''}` : ''} ${menuItems} @@ -1172,6 +1173,23 @@ async function viewManga(id, langOverride) { b.hidden = offline || isBook } renderFollow() + // Solo con tomos: es donde se ocultan. Manhwa y manhua no tienen, y se leen como siempre. + let hideCredits = !!creditsOn + function renderCredits() { + const b = $('#hidecred') + if (!b) return + b.innerHTML = `${icon(hideCredits ? 'check' : 'scissors')} Ocultar créditos` + b.classList.toggle('on', hideCredits) + b.setAttribute('aria-pressed', hideCredits) + b.title = 'Al leer por tomos, salta las páginas de créditos repetidas (la primera y las 2 últimas de cada capítulo). No se borra nada.' + b.hidden = offline || !vols.length + } + $('#hidecred').addEventListener('click', async () => { + const r = await api(`/api/credits/${id}/option`, { method: 'POST', body: { on: !hideCredits } }) + hideCredits = r.hideCredits + renderCredits() + toast(hideCredits ? 'Se ocultarán los créditos al leer por tomos' : 'Se mostrarán todas las páginas') + }) $('#follow').addEventListener('click', async () => { const r = await api(`/api/follow/${id}`, isFollowing ? { method: 'DELETE' } : { method: 'POST', body: { title: manga.title, cover: manga.cover, lang } }) isFollowing = r.following @@ -1251,13 +1269,59 @@ async function viewManga(id, langOverride) { const d = new Date(e.t).toISOString().slice(0, 10) return `${e.approx ? '≈ ' : ''}${fmtDate(d)}` } + // Tomos: llegan aparte, para no hacer esperar a la ficha. Los plegados se recuerdan mientras se está en ella. + let vols = [], volOf = new Map(), folded = new Set() + if (!isBook) api(`/api/volumes/${id}?lang=${encodeURIComponent(lang)}`).then(r => { + if (!r.volumes?.length || !ul?.isConnected) return + vols = r.volumes + volOf = new Map(vols.flatMap(v => v.chapters.map(c => [c, v]))) + const open = volOf.get(progress?.chapter_id) || vols[0] + folded = new Set(vols.filter(v => v !== open).map(v => v.vol)) + renderList() + renderCredits() + }, () => {}) + const volHead = v => { + if (!v) return '
  • Sin tomo
  • ' + const inVol = chapters.filter(c => volOf.get(c.id) === v) + const isDone = c => { const r = readMap.get(c.id); return r && r.total_pages && r.page >= r.total_pages - 1 } + const read = inVol.filter(isDone).length, from = inVol.find(c => !isDone(c)) || inVol[0], r = readMap.get(from.id) + return `
  • ${icon('book')} Leer tomo
  • ` + } + ul?.addEventListener('click', e => { + const b = e.target.closest('button.voltoggle') + if (!b) return + folded.has(b.dataset.vol) ? folded.delete(b.dataset.vol) : folded.add(b.dataset.vol) + renderList() + }) function renderList() { if (!ul) return const list = desc ? [...chapters].reverse() : chapters const picking = sel?.mode === 'pick' - const pickable = new Set(picking ? KINDS[sel.kind].candidates().map(c => c.id) : []) + pickable = new Set(picking ? KINDS[sel.kind].candidates().map(c => c.id) : []) ul.classList.toggle('picking', picking) - ul.innerHTML = list.map((c, i) => { + // Cada tomo, junto: si un capítulo quedó en medio de otros, no se repite la cabecera. + const seen = new Set() + const grouped = !vols.length ? list : list.flatMap(c => { + const v = volOf.get(c.id) + if (!v) return [c] + if (seen.has(v)) return [] + seen.add(v) + return list.filter(x => volOf.get(x.id) === v) + }) + let curVol + ul.innerHTML = grouped.map((c, i) => { + const v = volOf.get(c.id) || null + const head = vols.length && v !== curVol ? volHead(v) : '' + curVol = v + if (v && !picking && folded.has(v.vol)) return head + return head + chapterItem(c, i) + }).join('') + } + let pickable = new Set() + function chapterItem(c, i) { + const picking = sel?.mode === 'pick' const r = readMap.get(c.id) const done = r && r.total_pages && r.page >= r.total_pages - 1 if (picking) { @@ -1268,7 +1332,6 @@ async function viewManga(id, langOverride) { } const cls = progress?.chapter_id === c.id ? 'current' : done ? 'read' : '' return `
  • ${esc(chapLabel(c))}${chapMeta(c, r, done)}
  • ` - }).join('') } // Selección en la lista (Mayús + clic marca el tramo desde el último marcado). ul?.addEventListener('click', e => { @@ -1295,6 +1358,7 @@ async function viewManga(id, langOverride) { } // ---------- Lector ---------- +const B3D_OK = matchMedia('(pointer: coarse)').matches && 'WebGL2RenderingContext' in window const READ_MODES = { paged: 'Una página', double: 'Doble página', vertical: 'Vertical' } const PAGE_ANIMS = { flip: 'Pasar hoja', slide: 'Deslizar', fade: 'Fundido', none: 'Sin animación' } // ---------- Sonido al pasar página ---------- @@ -1804,6 +1868,130 @@ async function createBook(text, mangaId, size) { return book } +// Resumen de una página para compararla con las de otros capítulos: 16×16 en grises y su proporción. +async function pageSig(url) { + const res = await fetch(url) + if (!res.ok) throw new Error('Sin imagen') + const bmp = await createImageBitmap(await res.blob()) + const ar = bmp.width / bmp.height + // A la mitad cada vez, hasta 64×64: una reducción grande de golpe da resultados distintos en Safari y en Chrome. + let from = bmp, w = bmp.width, h = bmp.height, cv, cx + while (w > 64 || h > 64 || w !== h) { + w = Math.max(64, w >> 1) + h = Math.max(64, h >> 1) + cv = document.createElement('canvas') + cv.width = w + cv.height = h + cx = cv.getContext('2d', { willReadFrequently: w === 64 && h === 64 }) + cx.imageSmoothingQuality = 'high' + cx.drawImage(from, 0, 0, w, h) + from = cv + } + bmp.close() + const d = cx.getImageData(0, 0, 64, 64).data + const out = new Uint8Array(256) + for (let y = 0; y < 16; y++) { + for (let x = 0; x < 16; x++) { + let sum = 0 + for (let j = 0; j < 4; j++) { + for (let i = 0; i < 4; i++) { + const k = ((y * 4 + j) * 64 + x * 4 + i) * 4 + sum += 0.299 * d[k] + 0.587 * d[k + 1] + 0.114 * d[k + 2] + } + } + out[y * 16 + x] = Math.round(sum / 16) + } + } + return { s: btoa(String.fromCharCode(...out)), ar: Math.round(ar * 1000) / 1000 } +} + +// Qué páginas de cada capítulo del tomo son créditos: la 0 y las 2 últimas, si se repiten en otros capítulos de la obra. +async function creditPages(mangaId, parts) { + const chapters = parts.filter(x => x.urls.length >= 5).map(x => ({ id: x.id, n: x.urls.length })) + if (!chapters.length) return {} + const ask = sigs => api(`/api/credits/${mangaId}`, { method: 'POST', body: { chapters, sigs } }) + const first = await ask() + if (!first.missing.length) return first.hidden + const jobs = parts.filter(x => first.missing.includes(x.id)) + const sigs = {} + let next = 0 + await Promise.all(Array.from({ length: 3 }, async () => { + while (next < jobs.length) { + const x = jobs[next++], n = x.urls.length + try { + const pages = {} + for (const i of [0, n - 2, n - 1]) pages[i] = await pageSig(x.urls[i]) + sigs[x.id] = pages + } catch {} // se volverá a intentar la próxima vez + } + })) + return (await ask(sigs)).hidden +} + +// Las páginas de todos los capítulos del tomo de chapterId, o null si no tiene tomo o no se puede saber a tiempo. +async function loadTome(mangaId, lang, chapters, chapterId, chap, settings, hideCredits) { + const within = (p, ms) => Promise.race([p, new Promise(r => setTimeout(r, ms, null))]) + const r = await within(api(`/api/volumes/${mangaId}?lang=${encodeURIComponent(lang)}`).catch(() => null), 20000) // la primera vez consulta varias fuentes + const vols = r?.volumes || [] + const at = vols.findIndex(v => v.chapters.includes(chapterId)) + if (at < 0) return null + const v = vols[at] + $('.reader .loading', app)?.replaceChildren(`Preparando el tomo ${v.vol}…`) + const query = `?saver=${settings.saver ? 1 : 0}&hd=${settings.hd === false ? 0 : 1}` + const want = chapters.map((c, i) => ({ c, i })).filter(x => v.chapters.includes(x.c.id)) + const parts = new Array(want.length) + let nextJob = 0 + await Promise.all(Array.from({ length: 4 }, async () => { + while (nextJob < want.length) { + const k = nextJob++, { c, i } = want[k] + const data = c.id === chapterId ? chap : await api(`/api/chapter/${c.id}${query}`).catch(() => null) + if (data?.pages?.length) parts[k] = { i, id: c.id, local: !!data.local, urls: data.pages } + } + })) + const ok = parts.filter(Boolean) + if (!ok.some(x => x.id === chapterId)) return null + const cover = v.front ? await within(new Promise(res => { + const im = new Image() + im.onload = () => res(im.naturalWidth > 0) + im.onerror = () => res(false) + im.src = v.front + }), 15000) : false + // Si tarda, esta vez se leen enteros; los resúmenes quedan guardados para la próxima. + const hidden = hideCredits ? (await within(creditPages(mangaId, ok).catch(() => null), 15000)) || {} : {} + const pages = cover ? [v.front] : [] + const starts = new Set() + for (const x of ok) { + const out = new Set(hidden[x.id] || []) + x.keep = x.urls.map((_, j) => j).filter(j => !out.has(j)) + x.start = pages.length + x.count = x.keep.length + starts.add(x.start) + pages.push(...x.keep.map(j => x.urls[j])) + } + const partOf = c => ok.find(x => x.i === c) + const tome = { + vol: v.vol, back: v.back, cover: !!cover, parts: ok, pages, starts, + has: c => !!partOf(c), + chapterAt: p => { let k = ok[0]; for (const x of ok) if (p >= x.start) k = x; return k.i }, + chapterStart: c => partOf(c).start, + chapterPages: c => partOf(c).count, + // El progreso, la ruta y los recortes usan el número real de la página, con los créditos contados. Acabar la última + // visible es acabar el capítulo. + rawCount: c => partOf(c).urls.length, + rawPage: (c, local) => { const x = partOf(c); return local >= x.count - 1 ? x.urls.length - 1 : x.keep[local] }, + localPage: (c, raw) => { const at = partOf(c).keep.findIndex(j => j >= raw); return at < 0 ? partOf(c).count - 1 : at }, + // Las URL de MangaDex caducan a los 15 minutos, y un tomo se lee durante más tiempo. + async refresh(c) { + for (const x of ok) { + if (x.local || x.i < c || x.i > c + 1) continue + const data = await api(`/api/chapter/${x.id}${query}`).catch(() => null) + if (data?.pages?.length === x.urls.length) for (let j = 0; j < x.count; j++) pages[x.start + j] = data.pages[x.keep[j]] + } + }, + } + return { tome, volOf: id => vols.find(x => x.chapters.includes(id)) || null } +} + async function viewReader(mangaId, chapterId, startPage) { document.body.classList.add('reading') app.innerHTML = '
    Cargando capítulo…
    ' @@ -1823,9 +2011,9 @@ async function viewReader(mangaId, chapterId, startPage) { const { manga, chapters } = info const settings = { ...globalSettings, ...defaultPrefs(manga), ...(info.prefs || {}) } const idx = chapters.findIndex(c => c.id === chapterId) - const chapter = chapters[idx] || { id: chapterId, num: info.progress?.chapter_num, title: '' } - const prev = idx > 0 ? chapters[idx - 1] : null - const next = idx >= 0 && idx < chapters.length - 1 ? chapters[idx + 1] : null + let chapter = chapters[idx] || { id: chapterId, num: info.progress?.chapter_num, title: '' } + let prev = idx > 0 ? chapters[idx - 1] : null + let next = idx >= 0 && idx < chapters.length - 1 ? chapters[idx + 1] : null const isPaged = () => settings.mode !== 'vertical' const isDouble = () => settings.mode === 'double' && window.innerWidth >= 700 && window.innerWidth > window.innerHeight // Libro: las páginas salen de maquetar el texto para esta pantalla (con forma de página, para que nunca cuenten como anchas). @@ -1844,7 +2032,23 @@ async function viewReader(mangaId, chapterId, startPage) { if (location.hash !== opening) { book.release(); return } book.busy = () => turning } - const pages = chap.pages + // Tomo: sus capítulos, uno detrás de otro, forman un solo libro con su portada delante. La ruta y el progreso siguen + // siendo por capítulo. Sin datos del tomo, se lee el capítulo suelto. + let tome = null, nextVol = null + if (!isBook && settings.tomes !== false && idx >= 0) { + const opening = location.hash + const loaded = await loadTome(mangaId, lang, chapters, chapterId, chap, globalSettings, info.hideCredits) + if (location.hash !== opening) return + if (loaded) { + tome = loaded.tome + const first = tome.parts[0].i, last = tome.parts.at(-1).i + prev = first > 0 ? chapters[first - 1] : null + next = last < chapters.length - 1 ? chapters[last + 1] : null + nextVol = next && loaded.volOf(next.id) // lo que sigue puede ser un capítulo sin tomo + } + } + const seq = book || tome // las páginas cruzan varios capítulos + const pages = tome ? tome.pages : chap.pages const pageSrc = book ? i => book.src(i) : i => pages[i] const total = book ? book.count : pages.length let wanted = startPage >= 0 ? startPage : chap.savedPage || 0 @@ -1858,6 +2062,10 @@ async function viewReader(mangaId, chapterId, startPage) { if (bookResume?.mangaId === mangaId) wanted = book.pageAtWord(bookResume.word) bookResume = null } + if (tome) { + const local = tome.localPage(idx, Math.max(wanted, 0)) + wanted = tome.cover && idx === tome.parts[0].i && local === 0 ? 0 : tome.chapterStart(idx) + local + } let page = Math.min(Math.max(wanted, 0), total - 1) let shown = [page] // páginas visibles (2 en doble página) const lastShown = () => (isPaged() ? shown.at(-1) : page) @@ -1866,12 +2074,13 @@ async function viewReader(mangaId, chapterId, startPage) { let saveTimer = null const bookPayload = (c, local, count) => ({ mangaId, chapterId: chapters[c].id, mangaTitle: manga.title, cover: manga.cover, chapterNum: chapters[c].num ?? null, - chapterTitle: chapters[c].title, lang: manga.languages?.[0] || 'es', page: local, totalPages: count, isLast: c === chapters.length - 1, + chapterTitle: chapters[c].title, lang: tome ? (manga.source !== 'md' ? 'es' : chapters[c].lang || lang) : manga.languages?.[0] || 'es', page: local, totalPages: count, isLast: c === chapters.length - 1, }) const payload = () => { - if (!book) return mangaPayload() - const p = lastShown(), c = book.chapterAt(p) - return bookPayload(c, p - book.chapterStart(c), book.chapterPages(c)) + if (!seq) return mangaPayload() + const p = lastShown(), c = seq.chapterAt(p) + const local = Math.max(0, p - seq.chapterStart(c)) + return tome ? bookPayload(c, tome.rawPage(c, local), tome.rawCount(c)) : bookPayload(c, local, seq.chapterPages(c)) } const mangaPayload = () => ({ mangaId, chapterId, mangaTitle: manga.title, cover: manga.cover, chapterNum: chapter.num ?? null, @@ -1886,7 +2095,9 @@ async function viewReader(mangaId, chapterId, startPage) { window.addEventListener('pagehide', beacon) saveNow() - let curChap = book ? book.chapterAt(page) : idx + let curChap = seq ? seq.chapterAt(page) : idx + const tomeTimer = tome ? setInterval(() => tome.refresh(curChap), 10 * 60e3) : 0 + const routePage = () => (tome ? tome.rawPage(curChap, Math.max(0, lastShown() - tome.chapterStart(curChap))) : lastShown()) const chapOpts = chapters.map((c, i) => ``).join('') app.innerHTML = `
    @@ -1905,6 +2116,8 @@ async function viewReader(mangaId, chapterId, startPage) { + ${B3D_OK ? '' : ''} + ${isBook ? '' : ''} ${manga.source !== 'md' || chap.local ? '' : ''} ${chap.hdAvailable ? '' @@ -1916,16 +2129,18 @@ async function viewReader(mangaId, chapterId, startPage) { ${OFFLINE_OK && !isBook ? `` : ''}

    ${isBook ? 'Modo y dirección se recuerdan para este libro; la letra y el papel, en este dispositivo.' : 'Modo, ajuste y dirección se recuerdan para este manga.'} ${icon('scroll')} en la barra de arriba cambia entre páginas y vertical. Zoom: pellizca o toca dos veces en el centro (Ctrl+rueda o + / − en PC).

    +
    - ${book ? `${icon('prev')} Cap. ant.` : prev ? `${icon('prev')} Cap. ant.` : ''} + ${seq ? `${icon('prev')} Cap. ant.` : prev ? `${icon('prev')} Cap. ant.` : ''} - ${book ? `Cap. sig. ${icon('next')}` : next ? `Cap. sig. ${icon('next')}` : ''} + ${seq ? `Cap. sig. ${icon('next')}` : next ? `Cap. sig. ${icon('next')}` : ''}
    ` const reader = $('.reader', app) + let b3d = null, b3dLoading = false // el libro 3D, si está activo const box = $('#pages', app) const slider = $('#slider', app) $('#mode').value = settings.mode @@ -1944,14 +2159,15 @@ async function viewReader(mangaId, chapterId, startPage) { const applyFilter = () => { const f = [FILTERS[settings.filter]?.[1] || '', settings.brightness != 100 ? `brightness(${settings.brightness / 100})` : ''].join(' ').trim() box.style.setProperty('--page-filter', f || 'none') + b3d?.setFilter(f) reader.classList.toggle('bluelight', settings.filter === 'blue') } applyFilter() if ($('#saver')) $('#saver').checked = settings.saver if ($('#hd')) $('#hd').checked = settings.hd !== false const endBlock = book ? `` : `

    Fin del capítulo ${esc(chapter.num ?? '')}

    - ${next ? `${icon('next')} Siguiente: ${esc(chapLabel(next))}` : '

    No hay más capítulos disponibles.

    '} + ${icon('back')} Volver a la ficha
    ` : `

    ${tome ? `Fin del tomo ${esc(tome.vol)}` : `Fin del capítulo ${esc(chapter.num ?? '')}`}

    + ${next ? `${icon('next')} Siguiente: ${nextVol ? `Tomo ${esc(nextVol.vol)}` : esc(chapLabel(next))}` : '

    No hay más capítulos disponibles.

    '} ${icon('back')} Volver a la ficha
    ` if (!info.prefs && !store.get(`autoPrefs:${mangaId}`)) { @@ -1969,28 +2185,31 @@ async function viewReader(mangaId, chapterId, startPage) { } const syncBookChapter = () => { const p = lastShown() - book.trim(p) - if (!isPaged()) { - box.querySelectorAll('img[src]').forEach(im => { - if (!book.near(Number(im.dataset.i), p)) { im.removeAttribute('src'); im.dataset.lazy = '1' } - }) + if (book) { + book.trim(p) + if (!isPaged()) { + box.querySelectorAll('img[src]').forEach(im => { + if (!book.near(Number(im.dataset.i), p)) { im.removeAttribute('src'); im.dataset.lazy = '1' } + }) + } } - const c = book.chapterAt(p) + const c = seq.chapterAt(p) if (c !== curChap) { if (c > curChap && Math.abs(p - lastBookPage) <= 2) { for (let j = curChap; j < c; j++) { - const count = book.chapterPages(j) + const count = tome ? tome.rawCount(j) : seq.chapterPages(j) api('/api/progress', { method: 'POST', body: bookPayload(j, count - 1, count) }).catch(() => {}) } } curChap = c + if (tome) { chapterId = chapters[c].id; chapter = chapters[c] } history.replaceState(null, '', readUrl(mangaId, chapters[c].id)) } lastBookPage = p syncBookNav() } const setPageLabel = () => { - if (book) syncBookChapter() + if (seq) syncBookChapter() $('#pnum').textContent = `${shown.length > 1 ? `${shown[0] + 1}-${shown[1] + 1}` : lastShown() + 1} / ${total}` slider.value = lastShown() + 1 } @@ -2015,7 +2234,8 @@ async function viewReader(mangaId, chapterId, startPage) { // ancha ocupa las dos caras. function spreadOf(i) { for (let s = 0; s < total;) { - const g = s === 0 || wide.has(s) || wide.has(s + 1) || s + 1 >= total ? [s] : [s, s + 1] + // En un tomo, la primera página de cada capítulo no empieza pareja: así las parejas de dentro son las de siempre. + const g = s === 0 || tome?.starts.has(s) || wide.has(s) || wide.has(s + 1) || s + 1 >= total ? [s] : [s, s + 1] if (g.includes(i)) return g s += g.length } @@ -2054,7 +2274,7 @@ async function viewReader(mangaId, chapterId, startPage) { const spread = spreadOf(i) return !isDouble() && spread.length === 2 && spread[0] === i ? (rtl ? 'l' : 'r') : rtl ? 'r' : 'l' } - const refreshGutters = () => box.querySelectorAll('.stage:not(.leaving) img').forEach((im, n) => { im.dataset.gutter = gutterOf(Number(im.dataset.i), n) }) + const refreshGutters = () => { box.querySelectorAll('.stage:not(.leaving) img').forEach((im, n) => { im.dataset.gutter = gutterOf(Number(im.dataset.i), n) }); if (b3dOn()) b3d?.show(shown) } // Vista de una página con «pasar hoja»: entre las dos caras de una pareja no se gira la hoja, se recorre con la vista. function isPan(old) { const imgs = old.querySelectorAll('img') @@ -2077,7 +2297,7 @@ async function viewReader(mangaId, chapterId, startPage) { stage.classList.toggle('two', shown.length === 2) stage.innerHTML = shown.map((i, n) => ``).join('') const imgs = [...stage.querySelectorAll('img')] - if ($('.stage', box) && dir && !wantDrag && !imgs.every(imgReady)) { + if ($('.stage', box) && dir && !wantDrag && !imgs.every(imgReady) && !b3dOn()) { // el libro 3D no espera: la página llega a su malla cuando esté waitTimer = setTimeout(() => box.classList.add('waiting'), 200) const touchedAt = grabY // el toque ya habrá acabado cuando llegue la página const arrived = Promise.all(imgs.map(im => (im.decode ? im.decode() : new Promise(r => { im.onload = im.onerror = r })).catch(() => {}))) @@ -2110,8 +2330,11 @@ async function viewReader(mangaId, chapterId, startPage) { }) stage.classList.toggle('loading', pending.length > 0) // Al arrastrar, el sonido sale al soltar (si la página llega a pasar). - if (old && dir && !wantDrag && !isPan(old)) turnSound(settings.sfx, turnPan(dir)) - if (old && dir && settings.anim !== 'none' && zoom.s === 1) turnPage(old, stage, dir, wantDrag) + if (dir && !wantDrag && (old ? !isPan(old) : b3dOn())) turnSound(settings.sfx, turnPan(dir)) + if (b3dOn()) { + box.replaceChildren(stage) + syncB3d(settings.anim === 'flip' && zoom.s === 1 ? dir : 0, wantDrag) + } else if (old && dir && settings.anim !== 'none' && zoom.s === 1) turnPage(old, stage, dir, wantDrag) else { box.replaceChildren(stage) } stage.querySelectorAll('img').forEach(img => img.addEventListener('load', () => { const i = Number(img.dataset.i) @@ -2372,7 +2595,7 @@ async function viewReader(mangaId, chapterId, startPage) { // giro daría un tirón. Una página que no llegó a subirse se sube al girar. function scheduleLeafTextures(delay = 300) { clearTimeout(leafTimer) - if (!leafGLOff && settings.anim === 'flip' && isPaged() && !box.classList.contains('fit-width')) leafTimer = setTimeout(nextLeafTexture, delay) + if (!leafGLOff && settings.anim === 'flip' && isPaged() && !box.classList.contains('fit-width') && !b3dWanted()) leafTimer = setTimeout(nextLeafTexture, delay) } function nextLeafTexture() { if (turning) return scheduleLeafTextures(400) @@ -2794,7 +3017,7 @@ async function viewReader(mangaId, chapterId, startPage) { turning = { anims, cleanup } anims[0].finished.then(() => { if (turning?.anims === anims) { turning = null; cleanup() } }, () => {}) } - function showEnd() { + function showEnd(dir = 0) { finishTurn() showSeq++ // descarta una página que estuviera esperando a cargar clearTimeout(waitTimer) @@ -2802,12 +3025,14 @@ async function viewReader(mangaId, chapterId, startPage) { atEnd = true resetZoom() box.innerHTML = endBlock - reader.classList.remove('hide-ui') + if (!b3dWanted()) reader.classList.remove('hide-ui') // en el libro 3D el final se ve en la contraportada, sin barras encima + else if (dir && !wantDrag && settings.anim === 'flip') turnSound(settings.sfx, turnPan(dir)) + syncB3d(settings.anim === 'flip' ? dir : 0, wantDrag) } function goTo(n, fromScroll = false, force = false, dir = 0) { if (isPaged()) { - if (n >= total) return showEnd() + if (n >= total) return showEnd(dir) n = Math.min(Math.max(n, 0), total - 1) const g = groupOf(n) const changed = atEnd || g[0] !== shown[0] || g.length !== shown.length @@ -2816,7 +3041,7 @@ async function viewReader(mangaId, chapterId, startPage) { shown = g page = g[0] setPageLabel() - if (changed || force || !$('.stage img', box)) showPaged(changed && !wasEnd ? dir : 0) + if (changed || force || !$('.stage img', box)) showPaged(changed && (!wasEnd || b3dWanted()) ? dir : 0) if (changed) scheduleSave() return } @@ -2830,7 +3055,7 @@ async function viewReader(mangaId, chapterId, startPage) { } const forward = () => { if (!atEnd) goTo(shown.at(-1) + 1, false, false, 1) } const backward = () => { - if (atEnd) { box.innerHTML = ''; goTo(total - 1, false, true); return } + if (atEnd) { box.innerHTML = ''; goTo(total - 1, false, true, -1); return } if (page > 0) goTo(groupOf(page - 1)[0], false, false, -1) } @@ -2841,6 +3066,67 @@ async function viewReader(mangaId, chapterId, startPage) { $('.rbar.bottom').classList.toggle('rtl', rtl) } + // --- Libro 3D: otro módulo dibuja el libro; el lector sigue llevando la página, el progreso y los gestos. + const b3dWanted = () => B3D_OK && !!settings.book3d && isPaged() && settings.fit !== 'width' + const b3dOn = () => b3dWanted() && !atEnd + const b3dPaper = () => { + const hex = book ? (BOOK_PAPERS[bookSettings().paper] || BOOK_PAPERS.light).bg : '#efeadf' + return [1, 3, 5].map(n => parseInt(hex.slice(n, n + 2), 16) / 255) + } + // El final del capítulo va sobre la contraportada, con sus colores, y la sigue cuando el libro se mueve. + function placeEnd(at, colors) { + const el = $('.chapter-end', box) + if (!el) return + el.classList.toggle('on', !!at) + if (!at) return + const o = box.getBoundingClientRect() + el.style.width = `${at.width}px` + el.style.height = `${at.height}px` + el.style.transform = `translate(${at.x - o.left}px, ${at.y - o.top}px)` + if (colors) for (const [k, v] of Object.entries(colors)) el.style.setProperty(`--end-${k}`, v) + } + function syncB3d(dir = 0, drag = false) { + const on = b3dWanted(), host = $('#b3d', reader) + if (!host) return + reader.classList.toggle('b3d', on) + host.hidden = !on + if (!b3dWanted()) { + b3d?.destroy() + b3d = null + return + } + // Las hojas del lector normal no se usan aquí: que no ocupen memoria dos veces. + clearTimeout(leafTimer) + for (const tex of leafTextures.values()) leafGL?.gl.deleteTexture(tex) + leafTextures.clear() + if (b3d) { + if (!on) return + const ctl = atEnd ? b3d.showBack(dir, drag) : b3d.show(shown, dir, drag) + if (ctl) turning = ctl + return + } + if (b3dLoading) return + b3dLoading = true + import(`/book3d.js?v=${document.querySelector('meta[name=b3d-version]')?.content || ''}`).then(m => { + b3dLoading = false + if (!b3dWanted() || !box.isConnected) return + b3d = m.createBook3D(host, { + total, src: pageSrc, whenReady: book ? (i, cb) => book.whenReady(i, cb) : null, spreadOf, isWide: i => wide.has(i), + dir: () => settings.dir, paper: b3dPaper, still: reduceMotion, table: me?.tableV ? `/api/table?v=${me.tableV}` : null, back: tome?.back || null, double: isDouble, onTurnEnd: ctl => { if (turning === ctl) turning = null }, onBack: placeEnd, + }) + if (!b3d) throw new Error('sin WebGL2') + applyFilter() + syncB3d() + }).catch(err => { + console.warn('Libro 3D:', err) + b3dLoading = false + settings.book3d = false + saveGlobal() + if ($('#b3dfx', reader)) $('#b3dfx', reader).checked = false + syncB3d() + toast('El libro 3D no está disponible en este dispositivo') + }) + } function render() { observer?.disconnect() const keep = lastShown() @@ -2849,6 +3135,7 @@ async function viewReader(mangaId, chapterId, startPage) { box.className = isPaged() ? `paged ${settings.fit === 'width' ? 'fit-width' : ''} ${isDouble() ? 'double' : ''}` : `vertical fit-${settings.fit}` renderModeQuick() box.dataset.dir = settings.dir + syncB3d() // En der → izq la barra de progreso también avanza de derecha a izquierda (en vertical no aplica). syncDirUi() $('#dir').hidden = !isPaged() @@ -2898,6 +3185,7 @@ async function viewReader(mangaId, chapterId, startPage) { const st = stageEl() if (st) st.style.transform = zoom.s === 1 ? '' : `translate(${zoom.x}px, ${zoom.y}px) scale(${zoom.s})` box.classList.toggle('zoomed', zoom.s > 1) + if (b3dWanted()) b3d?.setZoom(zoom.s, zoom.x, zoom.y) $('#zoomreset').hidden = zoom.s === 1 } function clampPan() { @@ -2930,7 +3218,7 @@ async function viewReader(mangaId, chapterId, startPage) { lastTap = dbl ? { t: 0, x: 0, y: 0 } : { t: now, x: cx, y: cy } const xr = cx / window.innerWidth const center = xr > 0.3 && xr < 0.7 - if (dbl && (center || zoom.s > 1)) { + if (dbl && !atEnd && (center || zoom.s > 1)) { clearTimeout(uiTimer) if (zoom.s > 1) resetZoom() else zoomAt(2.5, ...local(cx, cy)) @@ -2943,15 +3231,24 @@ async function viewReader(mangaId, chapterId, startPage) { try { (xr >= 0.7) === (settings.dir === 'ltr') ? forward() : backward() } finally { grabY = null } } box.addEventListener('pointerdown', e => { - if (!isPaged() || atEnd || e.target.closest('a,button,select,input')) return + if (!isPaged() || (atEnd && !b3dWanted()) || e.target.closest('a,button,select,input')) return + // Un primer dedo con otros aún apuntados: sus «levantar» nunca llegaron. Sin esto, cada toque contaría como pellizco para siempre. + if (e.isPrimary && pts.size) { + pts.clear() + tap = null + if (gesture?.type === 'turn') finishTurn() + gesture = null + } if (gesture?.type === 'turn') return // otro dedo mientras se pasa la página if (e.pointerType === 'mouse' && e.button !== 0) return if (settings.sfx !== 'off') sfxUnlock() pts.set(e.pointerId, { x: e.clientX, y: e.clientY }) if (pts.size === 1) { - tap = { x: e.clientX, y: e.clientY, t: Date.now(), moved: false } + tap = { x: e.clientX, y: e.clientY, t: e.timeStamp, moved: false } // la hora del evento: un tirón no debe alargar el toque gesture = zoom.s > 1 ? { type: 'pan', x: e.clientX, y: e.clientY } : null if (gesture) box.setPointerCapture?.(e.pointerId) + } else if (pts.size === 2 && atEnd) { + tap = null // la contraportada no se amplía } else if (pts.size === 2) { const [a, b] = [...pts.values()] gesture = { type: 'pinch', d: Math.hypot(a.x - b.x, a.y - b.y), mx: (a.x + b.x) / 2, my: (a.y + b.y) / 2 } @@ -2976,14 +3273,14 @@ async function viewReader(mangaId, chapterId, startPage) { const dx = e.clientX - tap.x, dy = e.clientY - tap.y if (Math.abs(dx) > 14 && Math.abs(dx) > Math.abs(dy) * 1.3) { const fwd = (dx < 0) === (settings.dir === 'ltr') - if (fwd ? shown.at(-1) + 1 < total : page > 0) { - const prev = [...shown] + if (fwd ? !atEnd && (shown.at(-1) + 1 < total || b3dWanted()) : page > 0 || atEnd) { + const prev = [...shown], wasEnd = atEnd finishTurn() wantDrag = true grabY = tap.y try { fwd ? forward() : backward() } finally { wantDrag = false; grabY = null } if (turning?.move) { - gesture = { type: 'turn', ctl: turning, prev, dir: fwd ? 1 : -1, x0: tap.x, k: dx < 0 ? -1 : 1, d: Math.abs(dx), v: 0, t: e.timeStamp } + gesture = { type: 'turn', ctl: turning, prev, wasEnd, dir: fwd ? 1 : -1, x0: tap.x, k: dx < 0 ? -1 : 1, d: Math.abs(dx), v: 0, t: e.timeStamp } turning.move(gesture.d / turning.range) box.setPointerCapture?.(e.pointerId) } @@ -3024,6 +3321,9 @@ async function viewReader(mangaId, chapterId, startPage) { page = g.prev[0] setPageLabel() scheduleSave() + if (g.wasEnd) return showEnd() // se quedó en la contraportada + atEnd = false + if (b3dOn()) showPaged() // el lector vuelve a la página de antes, sin animación }) return } @@ -3031,11 +3331,11 @@ async function viewReader(mangaId, chapterId, startPage) { const [p] = [...pts.values()] gesture = { type: 'pan', x: p.x, y: p.y } } else if (pts.size === 0) { - if (e.type === 'pointerup' && tap && !tap.moved && Date.now() - tap.t < 350) onTap(e.clientX, e.clientY) + if (e.type === 'pointerup' && tap && !tap.moved && e.timeStamp - tap.t < 350) onTap(e.clientX, e.clientY) else if (e.type === 'pointerup' && tap?.moved && zoom.s === 1 && e.pointerType !== 'mouse') { // Deslizar el dedo en horizontal pasa página (hacia la izquierda = adelante en izq→der). const dx = e.clientX - tap.x, dy = e.clientY - tap.y - if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.5 && Date.now() - tap.t < 800) ((dx < 0) === (settings.dir === 'ltr') ? forward : backward)() + if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.5 && e.timeStamp - tap.t < 800) ((dx < 0) === (settings.dir === 'ltr') ? forward : backward)() } gesture = null tap = null @@ -3056,7 +3356,7 @@ async function viewReader(mangaId, chapterId, startPage) { $('#zoomreset').addEventListener('click', resetZoom) $('#ropts').addEventListener('click', () => { const p = $('.rpanel', reader); p.hidden = !p.hidden; $('#ropts').classList.toggle('on', !p.hidden) }) - const saveGlobal = () => store.set('readerSettings', { saver: settings.saver, filter: settings.filter, brightness: settings.brightness, hd: settings.hd !== false, anim: settings.anim, sfx: settings.sfx }) + const saveGlobal = () => store.set('readerSettings', { tomes: settings.tomes !== false, saver: settings.saver, filter: settings.filter, brightness: settings.brightness, hd: settings.hd !== false, anim: settings.anim, sfx: settings.sfx, book3d: !!settings.book3d }) const savePrefs = () => { return api(`/api/prefs/${mangaId}`, { method: 'POST', body: { mode: settings.mode, fit: settings.fit, dir: settings.dir } }) .then(() => toast(`Guardado para este ${noun}`)).catch(err => toast(err.message)) @@ -3083,26 +3383,42 @@ async function viewReader(mangaId, chapterId, startPage) { $('#sfx').addEventListener('change', e => { settings.sfx = e.target.value; saveGlobal(); if (settings.sfx !== 'off') turnSound(settings.sfx) }) $('#bright').addEventListener('input', e => { settings.brightness = Number(e.target.value); saveGlobal(); applyFilter() }) $('#bright').addEventListener('dblclick', e => { e.target.value = settings.brightness = 100; saveGlobal(); applyFilter() }) - $('#saver')?.addEventListener('change', e => { settings.saver = e.target.checked; saveGlobal(); history.replaceState(null, '', readUrl(mangaId, chapterId, lastShown())); router() }) - $('#hd')?.addEventListener('change', e => { settings.hd = e.target.checked; saveGlobal(); history.replaceState(null, '', readUrl(mangaId, chapterId, lastShown())); router() }) + $('#saver')?.addEventListener('change', e => { settings.saver = e.target.checked; saveGlobal(); history.replaceState(null, '', readUrl(mangaId, chapterId, routePage())); router() }) + $('#hd')?.addEventListener('change', e => { settings.hd = e.target.checked; saveGlobal(); history.replaceState(null, '', readUrl(mangaId, chapterId, routePage())); router() }) $('#hdone')?.addEventListener('click', async e => { e.target.closest('button').disabled = true try { await api(`/api/upscale/${mangaId}`, { method: 'POST', body: { chapterId } }); toast('En cola: estará en HD en unos minutos') } catch (err) { toast(err.message); e.target.closest('button').disabled = false } }) + if ($('#tomefx')) { + $('#tomefx').checked = settings.tomes !== false + $('#tomefx').addEventListener('change', e => { settings.tomes = e.target.checked; saveGlobal(); history.replaceState(null, '', readUrl(mangaId, chapterId, routePage())); router() }) + } + // Un capítulo de este libro se alcanza pasando páginas; uno de otro tomo se abre aparte. + const inSeq = c => !!book || !!tome?.has(c) $('#chap').addEventListener('change', e => { - if (book) goTo(book.chapterStart(chapters.findIndex(c => c.id === e.target.value))) + const c = chapters.findIndex(x => x.id === e.target.value) + if (seq && inSeq(c)) goTo(seq.chapterStart(c)) else location.hash = readUrl(mangaId, e.target.value) }) - if (book) { - $('#bnext').addEventListener('click', e => { e.preventDefault(); goTo(book.chapterStart(curChap + 1)) }) + if (seq) { + $('#bnext').addEventListener('click', e => { + e.preventDefault() + if (inSeq(curChap + 1)) goTo(seq.chapterStart(curChap + 1)) + else location.hash = readUrl(mangaId, chapters[curChap + 1].id) + }) $('#bprev').addEventListener('click', e => { e.preventDefault() // Al inicio del capítulo actual o, si ya se está ahí, al anterior que empiece en otra página. let c = curChap - while (c > 0 && book.chapterStart(c) >= (shown[0] ?? page)) c-- - goTo(book.chapterStart(c)) + while (c > 0 && inSeq(c - 1) && seq.chapterStart(c) >= (shown[0] ?? page)) c-- + if (seq.chapterStart(c) < (shown[0] ?? page)) goTo(seq.chapterStart(c)) + else if (!book && tome.cover && c === tome.parts[0].i && (shown[0] ?? page) > 0) goTo(0) + else if (c > 0 && !inSeq(c - 1)) location.hash = readUrl(mangaId, chapters[c - 1].id) + else goTo(seq.chapterStart(c)) }) - book.onChange = i => { box.querySelectorAll(`img[data-i="${i}"]`).forEach(im => { im.src = book.src(i) }) } + } + if (book) { + book.onChange = i => { box.querySelectorAll(`img[data-i="${i}"]`).forEach(im => { im.src = book.src(i) }); if (b3dOn()) b3d?.invalidate(i) } } $('#offone')?.addEventListener('click', e => { const b = e.target.closest('button') @@ -3127,11 +3443,13 @@ async function viewReader(mangaId, chapterId, startPage) { crop.ov.remove() window.removeEventListener('scroll', crop.draw) crop = null + reader.classList.remove('b3dcrop') $('#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.add('b3dcrop') // para recortar se ve la página plana, como en el lector normal reader.classList.remove('hide-ui') $('.rpanel', reader).hidden = true const ov = document.createElement('div') @@ -3284,7 +3602,8 @@ async function viewReader(mangaId, chapterId, startPage) { }) const blob = await new Promise(r => cv.toBlob(r, 'image/png')) 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 at = Number(parts[0].img.dataset.i), from = tome ? chapters[tome.chapterAt(at)] : chapter + const qs = new URLSearchParams({ mangaId, chapterId: from.id, page: tome ? tome.rawPage(tome.chapterAt(at), Math.max(0, at - tome.chapterStart(tome.chapterAt(at)))) : at, mangaTitle: manga.title, chapterLabel: chapLabel(from), 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}`) @@ -3305,6 +3624,7 @@ async function viewReader(mangaId, chapterId, startPage) { const [cx, cy] = [box.clientWidth / 2, Math.min(box.clientHeight, window.innerHeight) / 2] if (e.key === fwdKey || e.key === ' ') { e.preventDefault(); forward() } else if (e.key === backKey) backward() + else if (atEnd) return else if (e.key === '+' || e.key === '=') zoomAt(zoom.s * 1.25, cx, cy) else if (e.key === '-') zoomAt(zoom.s / 1.25, cx, cy) else if (e.key === '0') resetZoom() @@ -3340,9 +3660,22 @@ async function viewReader(mangaId, chapterId, startPage) { }) } } + if ($('#b3dfx')) { + $('#b3dfx').checked = !!settings.book3d + $('#b3dfx').addEventListener('change', e => { + settings.book3d = e.target.checked + saveGlobal() + // iOS solo pide el permiso del sensor desde un toque, y este lo es. El resultado lo recoge el módulo al arrancar. + if (e.target.checked) (window.DeviceMotionEvent?.requestPermission || window.DeviceOrientationEvent?.requestPermission)?.call(window.DeviceMotionEvent?.requestPermission ? DeviceMotionEvent : DeviceOrientationEvent)?.catch?.(() => {}) + render() + }) + } render() cleanup = () => { + b3d?.destroy() + b3d = null book?.release() + clearInterval(tomeTimer) freeLeafGL() endCrop() if (saveTimer) saveNow() @@ -3877,6 +4210,24 @@ async function viewStats() { } // ---------- Ajustes ---------- +// Cualquier imagen que el navegador sepa abrir, reducida a un tamaño que la tableta pueda usar como textura. +async function tableJpeg(file) { + const url = URL.createObjectURL(file) + try { + const im = new Image() + await new Promise((ok, fail) => { im.onload = ok; im.onerror = () => fail(new Error('Este dispositivo no puede abrir esa imagen')); im.src = url }) + const k = Math.min(1, 2048 / Math.max(im.naturalWidth, im.naturalHeight)) + const c = Object.assign(document.createElement('canvas'), { width: Math.max(1, Math.round(im.naturalWidth * k)), height: Math.max(1, Math.round(im.naturalHeight * k)) }) + const g = c.getContext('2d') + g.fillStyle = '#4a2e19' // lo transparente queda del color de la mesa + g.fillRect(0, 0, c.width, c.height) + g.imageSmoothingQuality = 'high' + g.drawImage(im, 0, 0, c.width, c.height) + const blob = await new Promise(r => c.toBlob(r, 'image/jpeg', 0.88)) + if (!blob) throw new Error('No se pudo preparar la imagen') + return blob + } finally { URL.revokeObjectURL(url) } +} function viewSettings() { app.innerHTML = `

    Ajustes

    @@ -3887,6 +4238,13 @@ function viewSettings() {
    ${icon('book')} Qué mostrar

    Elige qué aparece en el inicio y en tu librería. No se borra nada: lo oculto vuelve al cambiar este ajuste.

    +
    ${icon('book')} Libro 3D: fondo de la mesa +

    Pon cualquier imagen en lugar de la madera. Se guarda en tu cuenta y se usa en todos tus dispositivos.

    + +
    + + +
    Cambiar contraseña
    @@ -3921,6 +4279,37 @@ function viewSettings() {
    ` + const showTable = () => { + const prev = $('#tableprev') + if (!prev) return + prev.hidden = !me.tableV + if (me.tableV) prev.src = `/api/table?v=${me.tableV}` + else prev.removeAttribute('src') + $('#tabledel').hidden = !me.tableV + } + showTable() + $('#tablefile').addEventListener('change', async e => { + const f = e.target.files[0] + e.target.value = '' + if (!f) return + try { + const blob = await tableJpeg(f) + const res = await fetch('/api/table', { method: 'POST', headers: { 'Content-Type': blob.type }, body: blob }) + const data = await res.json().catch(() => ({})) + if (!res.ok) throw new Error(data.error || `Error ${res.status}`) + me.tableV = data.tableV + showTable() + toast('Fondo guardado') + } catch (err) { toast(err.message) } + }) + $('#tabledel').addEventListener('click', async () => { + try { + await api('/api/table', { method: 'DELETE' }) + me.tableV = 0 + showTable() + toast('La mesa vuelve a ser de madera') + } catch (err) { toast(err.message) } + }) api('/api/notify').then(n => { const f = $('#notify') if (!f) return diff --git a/app/public/book3d.js b/app/public/book3d.js new file mode 100644 index 0000000..f7cc9d4 --- /dev/null +++ b/app/public/book3d.js @@ -0,0 +1,1065 @@ +// Modo libro 3D: un solo canvas WebGL2 con la mesa, el libro y una cámara de verdad. Hojas y cantos salen de la misma +// función de altura, así que no pueden separarse. El lector solo le dice qué páginas se ven. +// Unidades: la página mide 1 de alto. x cruza el libro (la costura en 0), y va hacia arriba de la pantalla, z sale de la mesa. + +export const BOOK = { THICK: 0.075, BOARD: 0.008, OVER: 0.004, CROWN: 0.05, FALL: 0.02, DIP: 0.06, SPINE: 0.03 } +const dipAt = t => Math.exp(-t / BOOK.DIP) +// Lo que se levanta la hoja sobre un bloque plano. t: 0 en la costura, 1 en el borde exterior. open: 0 cerrado, 1 abierto. +export const arch = (t, open = 1) => open * (BOOK.CROWN * (1 - dipAt(t)) - BOOK.FALL * t * t) +// Altura de la hoja a profundidad v (0: la de arriba; 1: la de abajo). La curva se atenúa hasta la mitad en la última. +// k: 1 con el bloque entero; 0 sin hojas (la mitad sobre la que aún no ha aterrizado la cubierta). +export function sheetZ(t, v = 0, open = 1, k = 1) { + return BOOK.BOARD + k * (BOOK.THICK * (1 - v) * (1 - 0.45 * dipAt(t) * open) + arch(t, open) * (1 - v / 2)) +} +export const surface = (t, open = 1, k = 1) => sheetZ(t, 0, open, k) +const LID_Z = BOOK.BOARD * 2 + BOOK.THICK // la cara de arriba de la cubierta con el libro cerrado +const smooth = (a, b, x) => { const t = Math.max(0, Math.min(1, (x - a) / (b - a))); return t * t * (3 - 2 * t) } +const easeIO = t => (t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2) +// La mitad sobre la que aterriza la cubierta: hasta que está casi abajo no hay nada, solo la mesa. Después la cubierta ya la tapa. +export const landWidth = a => (a < 0.97 ? 0 : Math.max(0, -Math.cos(a * Math.PI))) +// Una hoja a media vuelta sobre la costura. a: 0 tumbada en su lado, 1 tumbada en el otro. rigid: la cubierta, que no se dobla. +// Parte de la hoja en reposo y vuelve a ella: al empezar y al acabar coincide con la superficie del libro, sin saltos. +export function sheetCurve(a, side, W, rigid = false, cols = 30, ext = 0) { + const L = rigid ? 0 : 0.38, pts = [] + const hinge = rigid ? LID_Z + (surface(0) - LID_Z) * a : surface(0) + 0.0015 + const rest = t => (rigid ? LID_Z + (surface(t) - LID_Z) * a : surface(t) + 0.0015) + let x = 0, z = 0, prev = 0 + for (let c = 0; c <= cols; c++) { + const t = (c / cols) ** 2, phi = Math.PI * (rigid ? a : smooth(0, 1, a * (1 + L) - L * t)) + x += Math.cos(phi) * (t - prev) * (W + ext) + z += Math.sin(phi) * (t - prev) * (W + ext) + prev = t + pts.push({ t, phi, x: side * x, z: hinge + z + (rest(t) - hinge) * Math.cos(phi) ** 2 }) + } + pts.forEach((p, c) => { + const q = pts[Math.min(cols, c + 1)], o = pts[Math.max(0, c - 1)], tx = q.x - o.x, tz = q.z - o.z, l = Math.hypot(tx, tz) || 1 + p.nx = (side * -tz) / l + p.nz = (side * tx) / l + }) + return pts +} +// Matriz de color de un filtro CSS (filas: r, g, b, desplazamiento), para aplicarlo a las páginas y no a la mesa. +export function filterMatrix(css) { + let M = [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0]] + const then = N => { M = N.map(r => [0, 1, 2, 3].map(c => r[0] * M[0][c] + r[1] * M[1][c] + r[2] * M[2][c] + (c === 3 ? r[3] : 0))) } + for (const [, name, val] of String(css || '').matchAll(/([a-z-]+)\(([-\d.]+)(?:deg|%)?\)/g)) { + const v = Number(val), i = 1 - v + if (name === 'brightness') then([[v, 0, 0, 0], [0, v, 0, 0], [0, 0, v, 0]]) + else if (name === 'contrast') { const o = (1 - v) / 2; then([[v, 0, 0, o], [0, v, 0, o], [0, 0, v, o]]) } + else if (name === 'invert') { const k = 1 - 2 * v; then([[k, 0, 0, v], [0, k, 0, v], [0, 0, k, v]]) } + else if (name === 'grayscale') then([[0.2126 + 0.7874 * i, 0.7152 - 0.7152 * i, 0.0722 - 0.0722 * i, 0], [0.2126 - 0.2126 * i, 0.7152 + 0.2848 * i, 0.0722 - 0.0722 * i, 0], [0.2126 - 0.2126 * i, 0.7152 - 0.7152 * i, 0.0722 + 0.9278 * i, 0]]) + else if (name === 'sepia') then([[0.393 + 0.607 * i, 0.769 - 0.769 * i, 0.189 - 0.189 * i, 0], [0.349 - 0.349 * i, 0.686 + 0.314 * i, 0.168 - 0.168 * i, 0], [0.272 - 0.272 * i, 0.534 - 0.534 * i, 0.131 + 0.869 * i, 0]]) + else if (name === 'saturate') then([[0.213 + 0.787 * v, 0.715 - 0.715 * v, 0.072 - 0.072 * v, 0], [0.213 - 0.213 * v, 0.715 + 0.285 * v, 0.072 - 0.072 * v, 0], [0.213 - 0.213 * v, 0.715 - 0.715 * v, 0.072 + 0.928 * v, 0]]) + else if (name === 'hue-rotate') { + const c = Math.cos((v * Math.PI) / 180), n = Math.sin((v * Math.PI) / 180) + then([[0.213 + c * 0.787 - n * 0.213, 0.715 - c * 0.715 - n * 0.715, 0.072 - c * 0.072 + n * 0.928, 0], [0.213 - c * 0.213 + n * 0.143, 0.715 + c * 0.285 + n * 0.14, 0.072 - c * 0.072 - n * 0.283, 0], [0.213 - c * 0.213 - n * 0.787, 0.715 - c * 0.715 + n * 0.715, 0.072 + c * 0.928 + n * 0.072, 0]]) + } + } + return M +} + +const norm = v => { const l = Math.hypot(...v) || 1; return v.map(n => n / l) } +const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]] +const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +// Perspectiva cerrada e inclinación mayor que medio campo de visión: así la cámara queda al sur del libro y se ve el canto de abajo. +export const FOV = (14 * Math.PI) / 180, TILT = (14 * Math.PI) / 180, NEAR = 0.5, FAR = 40 + +// Matriz de la cámara, por columnas. tilt: inclinación hacia el sur, para ver el libro con algo de ángulo. +export function cameraMatrix(target, dist, aspect, tilt = TILT, yaw = 0) { + const eye = [target[0] + dist * Math.sin(yaw) * Math.cos(tilt), target[1] - dist * Math.sin(tilt), target[2] + dist * Math.cos(yaw) * Math.cos(tilt)] + const f = norm([target[0] - eye[0], target[1] - eye[1], target[2] - eye[2]]), s = norm(cross(f, [0, 1, 0])), u = cross(s, f) + const k = 1 / Math.tan(FOV / 2), A = (FAR + NEAR) / (NEAR - FAR), B = (2 * FAR * NEAR) / (NEAR - FAR) + const row = (v, o) => [v[0], v[1], v[2], o] + const V0 = row(s, -dot(s, eye)), V1 = row(u, -dot(u, eye)), V2 = row(f.map(n => -n), dot(f, eye)) + const m = new Float32Array(16) + for (let c = 0; c < 4; c++) { + m[c * 4] = (k / aspect) * V0[c] + m[c * 4 + 1] = k * V1[c] + m[c * 4 + 2] = A * V2[c] + (c === 3 ? B : 0) + m[c * 4 + 3] = -V2[c] + } + return m +} +export function project(m, p) { + const w = m[3] * p[0] + m[7] * p[1] + m[11] * p[2] + m[15] + return [(m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12]) / w, (m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13]) / w] +} +// Distancia y objetivo para que los puntos llenen la pantalla, centrados y dejando margen (en fracción de la pantalla). +export function fitCamera(points, aspect, marginX, marginY) { + const target = [0, 0, 0] + const n = points.length + for (let k = 0; k < 2; k++) target[k] = points.reduce((a, p) => a + p[k], 0) / n + const limX = 1 - 2 * marginX, limY = 1 - 2 * marginY + let lo = 0.5, hi = 30 + const extent = d => { + for (let pass = 0; pass < 5; pass++) { // recentrar: con perspectiva e inclinación el centro se desplaza + const m = cameraMatrix(target, d, aspect), q = points.map(p => project(m, p)) + const xs = q.map(a => a[0]), ys = q.map(a => a[1]) + const midX = (Math.max(...xs) + Math.min(...xs)) / 2, midY = (Math.max(...ys) + Math.min(...ys)) / 2 + const half = d * Math.tan(FOV / 2) + target[0] += midX * half * aspect + target[1] += midY * half + } + const m = cameraMatrix(target, d, aspect), q = points.map(p => project(m, p)) + const xs = q.map(a => a[0]), ys = q.map(a => a[1]) + return Math.max((Math.max(...xs) - Math.min(...xs)) / 2 / limX, (Math.max(...ys) - Math.min(...ys)) / 2 / limY) + } + for (let i = 0; i < 26; i++) { + const d = (lo + hi) / 2 + if (extent(d) > 1) lo = d; else hi = d + } + extent(hi) + return { target, dist: hi } +} + +// Colores de la portada para la contraportada y sus botones. +const luma = c => { const f = v => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4); return 0.2126 * f(c[0]) + 0.7152 * f(c[1]) + 0.0722 * f(c[2]) } +const contrast = (a, b) => { const x = luma(a), y = luma(b); return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05) } +const inkOn = c => (contrast(c, [1, 1, 1]) >= contrast(c, [0.07, 0.06, 0.05]) ? [1, 1, 1] : [0.07, 0.06, 0.05]) +const mixColor = (a, b, t) => a.map((n, k) => n + (b[k] - n) * t) +const css = c => `rgb(${c.map(n => Math.round(n * 255)).join(' ')})` +// Color dominante y color de acento: el acento es el que más destaca sobre el dominante, sin ser una mota de la imagen. +export function coverPalette(data) { + const boxes = new Map() + for (let p = 0; p < data.length; p += 4) { + const key = ((data[p] >> 4) << 8) | ((data[p + 1] >> 4) << 4) | (data[p + 2] >> 4) + const b = boxes.get(key) || { n: 0, c: [0, 0, 0] } + b.n++ + for (let k = 0; k < 3; k++) b.c[k] += data[p + k] / 255 + boxes.set(key, b) + } + const list = [...boxes.values()].map(b => ({ n: b.n, c: b.c.map(v => v / b.n) })).sort((a, b) => b.n - a.n) + const base = list[0]?.c || [0.2, 0.14, 0.1] + let accent = null, best = 0 + for (const b of list) { + const sat = Math.max(...b.c) - Math.min(...b.c), score = Math.sqrt(b.n) * Math.hypot(b.c[0] - base[0], b.c[1] - base[1], b.c[2] - base[2]) * (0.35 + sat) + if (score > best) { best = score; accent = b.c } + } + accent ||= base + // Si apenas se distingue del dominante, se aclara o se oscurece hasta que se vea. + const away = luma(base) > 0.3 ? [0, 0, 0] : [1, 1, 1] + for (let i = 0; i < 10 && contrast(accent, base) < 2.4; i++) accent = mixColor(accent, away, 0.18) + return { base, accent } +} + +// Madera generada: se repite sin que se note el corte. +function woodCanvas() { + const S = 1024, PLANKS = 5, canvas = Object.assign(document.createElement('canvas'), { width: S, height: S }), g = canvas.getContext('2d') + let seed = 7 + const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647 + for (let p = 0; p < PLANKS; p++) { + const x0 = (p * S) / PLANKS, w = S / PLANKS, light = 34 + rnd() * 7 + g.save() + g.beginPath(); g.rect(x0, 0, w, S); g.clip() + g.fillStyle = `hsl(${24 + rnd() * 5}, 46%, ${light}%)` + g.fillRect(x0, 0, w, S) + const phase = rnd() * 6, swing = 4 + rnd() * 10 + for (let i = 0; i < 170; i++) { + const x = x0 + rnd() * w, dark = rnd() < 0.72 + g.strokeStyle = dark ? `rgba(40,20,8,${0.05 + rnd() * 0.16})` : `rgba(255,214,160,${0.02 + rnd() * 0.07})` + g.lineWidth = 0.5 + rnd() * 2.4 + g.beginPath() + for (let y = 0; y <= S; y += 32) { + const turn = (y / S) * Math.PI * 2 + const dx = Math.sin(turn + phase) * swing + Math.sin(turn * 3 + x * 0.05) * 1.6 + if (y) g.lineTo(x + dx, y); else g.moveTo(x + dx, y) + } + g.stroke() + } + g.restore() + g.fillStyle = 'rgba(20,9,3,.55)' + g.fillRect(x0 - 1, 0, 2, S) + } + return canvas +} + +const VS = `#version 300 es +uniform mat4 uM; +layout(location = 0) in vec3 aPos; +layout(location = 1) in vec3 aNor; +layout(location = 2) in vec2 aUV; +out vec3 vPos; out vec3 vNor; out vec2 vUV; +void main() { vPos = aPos; vNor = aNor; vUV = aUV; gl_Position = uM * vec4(aPos, 1.0); }` +const FS = `#version 300 es +precision highp float; +uniform int uKind; +uniform sampler2D uTex; +uniform vec3 uColor; +uniform vec4 uMap, uRect; +uniform float uSoft, uAlpha, uSeamAt, uHas, uFlip, uMirror; +uniform vec2 uCast; +uniform vec3 uTab; +uniform mat3 uFx; +uniform vec3 uFxO; +in vec3 vPos; in vec3 vNor; in vec2 vUV; +out vec4 color; +const vec3 L = normalize(vec3(-0.16, -0.26, 0.95)); +// Luz: 1 con la hoja plana; se oscurece al inclinarse hacia la costura o en las caras laterales. +float light() { return clamp(mix(0.55, 1.0, max(dot(normalize(vNor) * uFlip, L), 0.0) / L.z), 0.0, 1.06); } +// La sombra de la hoja que gira: desde la costura hasta donde cae la proyección de su borde. +float castShade() { + if (uCast.y <= 0.0 || abs(uCast.x) < 0.001) return 1.0; + float r = vPos.x / uCast.x; + if (r <= 0.0) return 1.0; + // La hoja solo mide lo que mide el libro: su sombra no llega a la mesa de más arriba ni de más abajo. + float inY = 1.0 - smoothstep(0.5, 0.56, abs(vPos.y)); + return 1.0 - uCast.y * inY * (1.0 - smoothstep(0.7, 1.15, r)); +} +vec3 fx(vec3 c) { return clamp(uFx * c + uFxO, 0.0, 1.0); } +// Misma hendidura que el lector normal, para que la página se vea igual. +float gutter(float d) { + if (d < 0.012) return mix(0.45, 0.62, d / 0.012); + if (d < 0.03) return mix(0.62, 0.82, (d - 0.012) / 0.018); + if (d < 0.06) return mix(0.82, 0.94, (d - 0.03) / 0.03); + if (d < 0.1) return mix(0.94, 1.0, (d - 0.06) / 0.04); + return 1.0; +} +void main() { + if (uKind == 0) { // mesa: la luz cae sobre el libro y se apaga hacia fuera + vec3 wood = uTab.z > 0.0 ? texture(uTex, vec2(0.5 + vPos.x * uTab.x, 0.5 - vPos.y * uTab.y), -0.4).rgb : texture(uTex, vUV, -0.4).rgb; + float r = length((vPos.xy - uRect.xy) / uRect.zw); + color = vec4(wood * mix(1.08, 0.62, smoothstep(0.5, 2.1, r)) * castShade(), 1.0); + } else if (uKind == 1) { // sombra en la mesa: un rectángulo difuso + vec2 a = smoothstep(uRect.xy - uSoft, uRect.xy + uSoft, vPos.xy) * (1.0 - smoothstep(uRect.zw - uSoft, uRect.zw + uSoft, vPos.xy)); + color = vec4(0.05, 0.02, 0.0, 1.0) * uAlpha * a.x * a.y; + } else if (uKind == 2) { // tapa + color = vec4(uColor * light() * castShade(), 1.0); + } else if (uKind == 3) { // canto: cada línea es una hoja, y sigue la curva porque la malla la sigue + float v = vUV.y * 15.0, w = fwidth(v); + float line = 1.0 - smoothstep(0.0, max(w, 0.35), abs(fract(v) - 0.5) - 0.12); + vec3 c = uColor * (1.0 - 0.17 * line) * mix(0.82, 1.0, smoothstep(0.0, 0.45, vUV.y)); + color = vec4(fx(c) * min(1.0, light() * 1.3) * castShade(), 1.0); // el papel de los cantos recibe luz rebotada de la mesa + } else if (uKind == 4) { // página + float ux = uMirror > 0.5 ? 1.0 - vUV.x : vUV.x; + vec2 q = vec2(ux * uMap.x + uMap.y, vUV.y * uMap.z + uMap.w); + vec3 c = uColor; + if (uHas > 0.5 && q.x >= 0.0 && q.x <= 1.0 && q.y >= 0.0 && q.y <= 1.0) c = texture(uTex, q, -0.5).rgb; + color = vec4(fx(c) * light() * gutter(uSeamAt < 0.5 ? ux : 1.0 - ux) * castShade(), 1.0); + } else if (uKind == 5) { // portada, plana y sin hendidura + color = vec4(fx(uHas > 0.5 ? texture(uTex, vUV, -0.5).rgb : uColor) * light() * castShade(), 1.0); + } else { // lomo: el color del borde de la portada, de arriba abajo + color = vec4(fx(uHas > 0.5 ? texture(uTex, vec2(uSeamAt, vUV.y)).rgb : uColor) * light() * castShade(), 1.0); + } +}` + +// Junta rejillas en una malla: pos(3) normal(3) uv(2). +class Shape { + constructor() { this.v = []; this.i = [] } + grid(cols, rows, at) { + const base = this.v.length / 8 + for (let c = 0; c <= cols; c++) for (let r = 0; r <= rows; r++) this.v.push(...at(c, r)) + for (let c = 0; c < cols; c++) for (let r = 0; r < rows; r++) { + const a = base + c * (rows + 1) + r, b = a + rows + 1 + this.i.push(a, a + 1, b, b, a + 1, b + 1) + } + return this + } + box(x0, x1, y0, y1, z0, z1) { + const quad = (o, a, b, n) => this.grid(1, 1, (c, r) => [o[0] + a[0] * c + b[0] * r, o[1] + a[1] * c + b[1] * r, o[2] + a[2] * c + b[2] * r, ...n, c, r]) + const dx = x1 - x0, dy = y1 - y0, dz = z1 - z0 + quad([x0, y0, z1], [dx, 0, 0], [0, dy, 0], [0, 0, 1]) + quad([x0, y0, z0], [dx, 0, 0], [0, 0, dz], [0, -1, 0]) + quad([x0, y1, z0], [dx, 0, 0], [0, 0, dz], [0, 1, 0]) + quad([x0, y0, z0], [0, dy, 0], [0, 0, dz], [-1, 0, 0]) + quad([x1, y0, z0], [0, dy, 0], [0, 0, dz], [1, 0, 0]) + return this + } +} +const COLS = 30 +const tAt = c => (c / COLS) ** 2 // más columnas junto a la costura, donde la curva es más cerrada +function slope(t, open, k) { const e = 0.002, a = Math.min(1, t + e), b = Math.max(0, t - e); return (surface(a, open, k) - surface(b, open, k)) / (a - b) } +// Una mitad del libro. side: -1 izquierda, 1 derecha. width: lo que ya se ha extendido, de 0 a 1. +function pageShape(side, W, open, k = 1, width = 1) { + return new Shape().grid(COLS, 1, (c, r) => { + const t = tAt(c), n = norm([(-side * slope(t, open, k)) / W, 0, 1]) + return [side * W * width * t, 0.5 - r, surface(t, open, k), ...n, side > 0 ? t : 1 - t, r] + }) +} +// Los cantos de esa mitad, extruidos desde la misma curva: abajo, arriba y por fuera. +function edgeShape(sh, side, W, open, k = 1, width = 1) { + const RINGS = 5, v = r => (r >= RINGS ? 1 : r / RINGS) + const z = (t, r) => (r > RINGS ? BOOK.BOARD : sheetZ(t, v(r), open, k)) + for (const [y, ny] of [[-0.5, -1], [0.5, 1]]) sh.grid(COLS, RINGS + 1, (c, r) => { const t = tAt(c); return [side * W * width * t, y, z(t, r), 0, ny, 0, t, v(r)] }) + sh.grid(1, RINGS + 1, (c, r) => [side * W * width, c - 0.5, z(1, r), side, 0, 0, c, v(r)]) + return sh +} +function spineShape(outward, top, size) { + const STEPS = 10, mid = top / 2 + return new Shape().grid(STEPS, 1, (c, r) => { + const a = (c / STEPS) * Math.PI, sx = Math.sin(a), cz = Math.cos(a) + return [outward * size * sx, 0.5 + BOOK.OVER - r * (1 + 2 * BOOK.OVER), mid + mid * cz, outward * sx, 0, cz, c / STEPS, r] + }) +} +// La hoja que gira y, en la cubierta, su grosor por el borde de abajo y por fuera. +function sheetShape(pts, y0 = -0.5, y1 = 0.5) { + const s = new Shape(), n = pts.length - 1 + s.grid(n, 1, (c, r) => { const p = pts[c]; return [p.x, y1 + (y0 - y1) * r, p.z, p.nx, 0, p.nz, p.u, r] }) + return s +} +function rimShape(pts, y0, y1, thick) { + const s = new Shape(), n = pts.length - 1, e = pts[n] + s.grid(n, 1, (c, r) => { const p = pts[c]; return [p.x - p.nx * thick * r, y0, p.z - p.nz * thick * r, 0, -1, 0, 0, 0] }) + s.grid(1, 1, (c, r) => [e.x - e.nx * thick * r, y0 + (y1 - y0) * c, e.z - e.nz * thick * r, e.nz * Math.sign(e.x || 1), 0, -e.nx * Math.sign(e.x || 1), 0, 0]) + return s +} + +// El sensor: los ejes de la pantalla a partir de la orientación del dispositivo. La inclinación se desvanece con el tiempo. +// MAX_DEG: lo que hay que girar el dispositivo para llegar al tope. ORBIT_*: cuánto rodea entonces la cámara al libro, en grados. +const MAX_DEG = 15, FADE_S = 2.5, QUIET = 0.6, ORBIT_TILT = 7, ORBIT_YAW = 10, ORBIT_BACK = 0.06 +function createTilt(onChange) { + const target = { x: 0, y: 0 } + let listening = false, gotOrientation = false, lastT = 0, lastOri = null, lastFade = 0, askOnTouch = null + const clamp = v => Math.max(-MAX_DEG, Math.min(MAX_DEG, v)) + function screenAngle() { + let a = (((window.orientation ?? screen.orientation?.angle ?? 0) % 360) + 360) % 360 + const landscape = innerWidth > innerHeight + if (landscape && a % 180 === 0) a = 90 + if (!landscape && a % 180) a = 0 + return (a * Math.PI) / 180 + } + function add(dx, dy) { + const a = screenAngle(), c = Math.cos(a), s = Math.sin(a) + target.x = clamp(target.x + dx * c - dy * s) + target.y = clamp(target.y + dx * s + dy * c) + lastFade = lastFade || performance.now() + onChange() + } + const stepOf = t => { const dt = Math.min(0.1, Math.max(0, (t - lastT) / 1000)); lastT = t; return dt } + const quiet = v => (Math.abs(v) < QUIET ? 0 : v) + function onOrientation(e) { + if (e.beta == null || e.gamma == null) return + gotOrientation = true + const dt = stepOf(e.timeStamp) + if (lastOri) { + const dx = e.beta - lastOri.b, dy = e.gamma - lastOri.g + const still = dt > 0 && Math.hypot(dx, dy) / dt < QUIET + if (!still && Math.abs(dx) < 25 && Math.abs(dy) < 25) add(dx, dy) + } + lastOri = { b: e.beta, g: e.gamma } + } + function onMotion(e) { + const r = e.rotationRate + if (gotOrientation || r?.alpha == null || r?.beta == null) return + const dt = stepOf(e.timeStamp) + add(quiet(r.alpha) * dt, quiet(r.beta) * dt) // especificación: alpha es el eje x y beta el eje y + } + function listen(on) { + if (on === listening) return + listening = on + const method = on ? 'addEventListener' : 'removeEventListener' + window[method]('devicemotion', onMotion) + window[method]('deviceorientation', onOrientation) + target.x = target.y = 0 + lastT = performance.now() + lastOri = null + onChange() + } + const forget = () => { if (askOnTouch) document.removeEventListener('click', askOnTouch); askOnTouch = null } + // iOS pide permiso desde un toque. Con el permiso ya dado se repite sin toque; si no, espera al siguiente. Sin permiso, el libro queda quieto. + function permit() { + if (!isSecureContext) return + const M = window.DeviceMotionEvent, O = window.DeviceOrientationEvent + const asked = M?.requestPermission ? M.requestPermission() : O?.requestPermission ? O.requestPermission() : null + if (!asked) return listen(true) + asked.then(answer => { forget(); if (answer === 'granted' && document.visibilityState === 'visible') listen(true) }, () => { + if (askOnTouch) return + askOnTouch = () => { forget(); permit() } + document.addEventListener('click', askOnTouch) + }) + } + return { + target, + permit, + listen, + get listening() { return listening }, + // Con el dispositivo quieto pueden dejar de llegar lecturas: el regreso al centro va por tiempo. + fade(now) { + const dt = Math.min(0.1, Math.max(0, (now - lastFade) / 1000)), keep = Math.exp(-dt / FADE_S) + lastFade = now + for (const k of ['x', 'y']) target[k] = Math.abs(target[k] * keep) < 0.3 ? 0 : target[k] * keep + }, + stop() { forget(); listen(false) }, + } +} + +export function createBook3D(container, opts) { + const { total, src, whenReady, spreadOf, isWide, still = false } = opts + const canvas = document.createElement('canvas') + canvas.className = 'b3dcanvas' + const gl = canvas.getContext('webgl2', { alpha: false, antialias: true, depth: true, powerPreference: 'high-performance' }) + if (!gl) return null + container.replaceChildren(canvas) + let dead = false, raf = 0, u = {}, prog = null, wood = null, tableMesh = null, aniso = null, maxSize = 2400 + let tableTex = null, tableAspect = 1 + let backTex = null, backColors = null, backSent = '', backFrom = null, backNear = false, backTimer = 0 + let W = 0, coverColor = [0.2, 0.14, 0.1], view = null, statics = null, staticKey = '', dyn = null + let anim = null, camNow = null, camMove = null, zoom = { s: 1, x: 0, y: 0 }, fxM = filterMatrix('') + const textures = new Map(), loading = new Map(), queue = [] + const fitCache = new Map() + let uploadTimer = 0, rect = { width: 1, height: 1 } + const tiltNow = { x: 0, y: 0 } + const tilt = createTilt(() => draw()) + + function makeMesh(sh, usage = gl.STATIC_DRAW) { + const vao = gl.createVertexArray(), vb = gl.createBuffer(), ib = gl.createBuffer() + gl.bindVertexArray(vao) + gl.bindBuffer(gl.ARRAY_BUFFER, vb) + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(sh.v), usage) + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ib) + gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(sh.i), usage) + gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 32, 0) + gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 32, 12) + gl.enableVertexAttribArray(2); gl.vertexAttribPointer(2, 2, gl.FLOAT, false, 32, 24) + gl.bindVertexArray(null) + return { vao, vb, ib, n: sh.i.length } + } + const freeMesh = m => { if (m) { gl.deleteVertexArray(m.vao); gl.deleteBuffer(m.vb); gl.deleteBuffer(m.ib) } } + const drawMesh = m => { gl.bindVertexArray(m.vao); gl.drawElements(gl.TRIANGLES, m.n, gl.UNSIGNED_SHORT, 0) } + // Mallas que cambian en cada fotograma de una animación: se reutilizan los mismos búferes. + function dynMesh(name, sh) { + const m = (dyn[name] ||= makeMesh(new Shape(), gl.DYNAMIC_DRAW)) + gl.bindVertexArray(m.vao) + gl.bindBuffer(gl.ARRAY_BUFFER, m.vb) + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(sh.v), gl.DYNAMIC_DRAW) + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, m.ib) + gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(sh.i), gl.DYNAMIC_DRAW) + gl.bindVertexArray(null) + m.n = sh.i.length + return m + } + + function initGL() { + const shader = (type, text) => { const s = gl.createShader(type); gl.shaderSource(s, text); gl.compileShader(s); return s } + prog = gl.createProgram() + gl.attachShader(prog, shader(gl.VERTEX_SHADER, VS)) + gl.attachShader(prog, shader(gl.FRAGMENT_SHADER, FS)) + gl.linkProgram(prog) + if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog) || 'shader') + gl.useProgram(prog) + u = {} + for (const n of ['uM', 'uKind', 'uTex', 'uColor', 'uMap', 'uRect', 'uSoft', 'uAlpha', 'uSeamAt', 'uHas', 'uFlip', 'uMirror', 'uCast', 'uTab', 'uFx', 'uFxO']) u[n] = gl.getUniformLocation(prog, n) + gl.uniform1i(u.uTex, 0) + gl.uniform1f(u.uFlip, 1) + gl.enable(gl.DEPTH_TEST) + gl.depthFunc(gl.LEQUAL) + gl.clearColor(0.29, 0.18, 0.1, 1) + gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA) + aniso = gl.getExtension('EXT_texture_filter_anisotropic') + maxSize = Math.min(gl.getParameter(gl.MAX_TEXTURE_SIZE), 2400) // más grande no se aprecia y agota la memoria en la tableta + wood = gl.createTexture() + gl.bindTexture(gl.TEXTURE_2D, wood) + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, woodCanvas()) + gl.generateMipmap(gl.TEXTURE_2D) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT) + const TABLE = 14, TILE = 1.7 + tableMesh = makeMesh(new Shape().grid(1, 1, (c, r) => { const x = (c * 2 - 1) * TABLE, y = (r * 2 - 1) * TABLE; return [x, y, 0, 0, 0, 1, x / TILE, y / TILE] })) + dyn = {} + statics = null + staticKey = '' + } + + // ----- Texturas ----- + function makeTexture(source) { + const tex = gl.createTexture() + gl.bindTexture(gl.TEXTURE_2D, tex) + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source) + gl.generateMipmap(gl.TEXTURE_2D) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) + if (aniso) gl.texParameterf(gl.TEXTURE_2D, aniso.TEXTURE_MAX_ANISOTROPY_EXT, Math.min(8, gl.getParameter(aniso.MAX_TEXTURE_MAX_ANISOTROPY_EXT))) + return tex + } + // Fondo elegido por el usuario: una sola imagen fija sobre la mesa, que llena lo que se ve. Si asoma el borde, sigue en espejo, sin corte. + function loadTable() { + if (!opts.table) return + const im = new Image() + im.onload = () => { + if (dead || gl.isContextLost() || !im.naturalWidth) return + tableTex = makeTexture(im) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.MIRRORED_REPEAT) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.MIRRORED_REPEAT) + tableAspect = im.naturalWidth / im.naturalHeight + draw() + } + im.src = opts.table + } + function tableScale() { + let half = 1.5 + if (W > 0) { + const ref = fitOf({ kind: 'open', focus: rect.height > rect.width ? 1 : 0 }) // la vista más abierta de esta pantalla + const hy = ref.dist * 1.3 * Math.tan(FOV / 2) * 1.1 + Math.abs(ref.target[1]), hx = hy * (rect.width / rect.height) + Math.abs(ref.target[0]) + half = Math.max(hy, hx / tableAspect) + } + return 1 / (2 * half) + } + // La contraportada: los colores de la portada, en espejo y muy difuminados para que continúe por el lomo, con un marco de su color de acento. + function buildBack(im) { + try { + const small = Object.assign(document.createElement('canvas'), { width: 48, height: 72 }), sg = small.getContext('2d', { willReadFrequently: true }) + sg.drawImage(im, 0, 0, 48, 72) + const { base, accent } = coverPalette(sg.getImageData(0, 0, 48, 72).data) + const H = 1152, w = Math.round(H * Math.min(0.85, Math.max(0.45, im.naturalWidth / im.naturalHeight))) + const c = Object.assign(document.createElement('canvas'), { width: w, height: H }), g = c.getContext('2d', { willReadFrequently: true }) + g.fillStyle = css(base) + g.fillRect(0, 0, w, H) + const blur = Object.assign(document.createElement('canvas'), { width: 3, height: 4 }), bg = blur.getContext('2d') + bg.translate(3, 0) + bg.scale(-1, 1) + bg.drawImage(small, 0, 0, 3, 4) + g.imageSmoothingEnabled = true + g.imageSmoothingQuality = 'high' + g.globalAlpha = 0.4 + g.drawImage(blur, 0, 0, w, H) + g.globalAlpha = 1 + const shade = g.createRadialGradient(w / 2, H / 2, H * 0.2, w / 2, H / 2, H * 0.72) + shade.addColorStop(0, 'rgba(0,0,0,0)') + shade.addColorStop(1, 'rgba(0,0,0,.28)') + g.fillStyle = shade + g.fillRect(0, 0, w, H) + const inset = w * 0.055 + g.strokeStyle = css(accent) + g.lineWidth = w * 0.009 + g.strokeRect(inset, inset, w - 2 * inset, H - 2 * inset) + g.lineWidth = w * 0.003 + g.strokeRect(inset * 1.35, inset * 1.35, w - 2.7 * inset, H - 2.7 * inset) + const mid = g.getImageData(Math.round(w * 0.2), Math.round(H * 0.3), Math.round(w * 0.6), Math.round(H * 0.4)).data, surface = [0, 0, 0] + for (let p = 0; p < mid.length; p += 4) for (let k = 0; k < 3; k++) surface[k] += mid[p + k] + const area = mid.length / 4 + for (let k = 0; k < 3; k++) surface[k] /= area * 255 + if (backTex) gl.deleteTexture(backTex) + backTex = makeTexture(c) + backColors = { surface: css(surface), ink: css(inkOn(surface)), accent: css(accent), accentInk: css(inkOn(accent)), line: css(mixColor(surface, inkOn(surface), 0.45)) } + backSent = '' + } catch (err) { console.warn('Contraportada:', err) } // sin ella, la tapa queda del color del borde de la portada + } + // Se fabrica una vez y solo cerca del final: al abrir el capítulo no debe retrasar las primeras páginas. + function ensureBack() { + if (!backFrom || !backNear || dead || gl.isContextLost()) return + const im = backFrom + backFrom = null + buildBack(im) + realBack() + draw() + } + // La contraportada original del tomo, si existe, sustituye a la fabricada; los colores de los botones no cambian. + let backImage = null, backAsked = false + function realBack() { + if (backImage) return placeRealBack() + if (!opts.back || backAsked) return + backAsked = true + const im = new Image() + im.onload = () => { + if (dead || !im.naturalWidth) return + backImage = im + placeRealBack() + draw() + } + im.src = opts.back + } + function placeRealBack() { + if (dead || gl.isContextLost() || !backColors) return + let source = backImage + const big = Math.max(source.naturalWidth, source.naturalHeight) + if (big > maxSize) { + const k = maxSize / big, c = Object.assign(document.createElement('canvas'), { width: Math.round(source.naturalWidth * k), height: Math.round(source.naturalHeight * k) }) + c.getContext('2d').drawImage(source, 0, 0, c.width, c.height) + source = c + } + if (backTex) gl.deleteTexture(backTex) + backTex = makeTexture(source) + } + function upload(i, im) { + let source = im, w = im.naturalWidth, h = im.naturalHeight + if (!w || !h) return + if (Math.max(w, h) > maxSize) { // reducir aquí: pasarla entera podría fallar + const k = maxSize / Math.max(w, h), c = Object.assign(document.createElement('canvas'), { width: Math.round(w * k), height: Math.round(h * k) }), g = c.getContext('2d') + g.imageSmoothingQuality = 'high' + g.drawImage(im, 0, 0, c.width, c.height) + source = c + } + if (i === 0) { coverColor = sampleCover(im); backFrom = im; ensureBack() } + const tex = makeTexture(source) + const had = textures.get(i) + if (had) gl.deleteTexture(had.tex) + textures.set(i, { tex, w, h }) + // La forma del libro la da la primera página que no sea ancha. + if (!W && w < h) { W = Math.min(0.85, Math.max(0.45, w / h)); staticKey = ''; fitCache.clear() } + draw() + } + function sampleCover(im) { + try { + const c = Object.assign(document.createElement('canvas'), { width: 8, height: 8 }), g = c.getContext('2d', { willReadFrequently: true }) + g.drawImage(im, 0, im.naturalHeight * 0.92, im.naturalWidth, im.naturalHeight * 0.08, 0, 0, 8, 8) // el canto de abajo, que es lo que se ve + const d = g.getImageData(0, 0, 8, 8).data, sum = [0, 0, 0] + for (let p = 0; p < d.length; p += 4) for (let k = 0; k < 3; k++) sum[k] += d[p + k] + return sum.map(v => (v / 64 / 255) * 0.62) + } catch { return coverColor } + } + // Se sube de una en una y lo que se ve ahora primero: subir una textura grande para un momento el hilo principal. + function pump() { + uploadTimer = 0 + if (dead || !queue.length || gl.isContextLost()) return + if (anim && !anim.dragging && anim.p < 1) { uploadTimer = setTimeout(pump, 120); return } // no durante un giro + const visible = view ? pagesOf(view) : [] + const at = queue.findIndex(q => visible.includes(q.i)) + const [next] = queue.splice(Math.max(0, at), 1) + if (!textures.has(next.i) && wanted(next.i)) upload(next.i, next.im) + if (queue.length) uploadTimer = setTimeout(pump, visible.includes(queue[0]?.i) ? 0 : 90) + } + let keepFrom = 0, keepTo = 0 + let glEpoch = 0 // lo que se pidió antes de perder el contexto ya no vale: su textura nacería muerta y la página quedaría en blanco + const wanted = i => i === 0 || (i >= keepFrom && i <= keepTo) + function want(i) { + if (i == null || i < 0 || i >= total || textures.has(i) || loading.has(i) || gl.isContextLost()) return + if (queue.some(q => q.i === i)) return + loading.set(i, true) + const epoch = glEpoch + const go = url => { + const im = new Image() + im.onload = async () => { + await im.decode?.().catch(() => {}) + if (dead || epoch !== glEpoch) return + loading.delete(i) + queue.push({ i, im }) + if (!uploadTimer) uploadTimer = setTimeout(pump, 0) + } + im.onerror = () => { if (epoch === glEpoch) loading.delete(i) } + im.src = url + } + if (whenReady) whenReady(i, go); else go(src(i)) + } + function trim() { + for (const [i, t] of textures) if (!wanted(i)) { gl.deleteTexture(t.tex); textures.delete(i) } + for (let n = queue.length - 1; n >= 0; n--) if (!wanted(queue[n].i)) queue.splice(n, 1) + } + + // ----- Lo que se ve ----- + const rtl = () => opts.dir() === 'rtl' + function viewOf(group) { + const first = group[0], spread = spreadOf(first) + if (spread.length === 1 && isWide(first)) return { kind: 'wide', page: first, focus: 0, key: `w${first}` } + if (spread.length === 1 && first === 0) return { kind: 'closed', under: rtl() ? -1 : 1, cover: 0, focus: 0, key: 'c' } + const [a, b = null] = spread, right = rtl() ? a : b, left = rtl() ? b : a + return { kind: 'open', right, left, focus: group.length === 1 && !(spread.length === 1 && opts.double?.()) ? (group[0] === right ? 1 : -1) : 0, key: `o${a}` } // a doble página, una suelta se ve con el libro entero + } + const pagesOf = v => (v.kind === 'closed' ? [0] : v.kind === 'wide' ? [v.page] : [v.left, v.right]).filter(i => i != null) + const pageOn = (v, side) => (v.kind === 'wide' ? { i: v.page, wide: true } : v.kind === 'closed' ? { i: null } : { i: side > 0 ? v.right : v.left }) + const hasAll = v => pagesOf(v).every(i => textures.has(i)) + + // Lo que debe llenar la pantalla. A una página, solo esa mitad; la otra sigue ahí, fuera de cuadro. + function frameOf(v) { + const O = BOOK.OVER, y0 = -0.5 - O, y1 = 0.5 + O, h = BOOK.BOARD + BOOK.THICK + BOOK.CROWN + let x0 = -W - O, x1 = W + O + if (v.kind === 'closed') { + x0 = v.under > 0 ? -BOOK.SPINE : -W - O + x1 = v.under > 0 ? W + O : BOOK.SPINE + } else if (v.focus) { + x0 = v.focus > 0 ? 0 : -W - O + x1 = v.focus > 0 ? W + O : 0 + } + return [[x0, y0, 0], [x1, y0, 0], [x0, y1, 0], [x1, y1, 0], [x0, y0, h], [x1, y0, h], [x0, y1, h], [x1, y1, h]] + } + // El encuadre se calcula una vez por vista y tamaño de pantalla: no hace falta repetirlo en cada fotograma. + function fitOf(v) { + const margin = Math.max(14, Math.min(40, Math.min(rect.width, rect.height) * 0.04)) + const key = `${v.kind}:${v.under || 0}:${v.focus}:${W}:${rect.width}x${rect.height}` + let cam = fitCache.get(key) + if (!cam) { + cam = fitCamera(frameOf(v), rect.width / rect.height, margin / rect.width, margin / rect.height) + if (fitCache.size > 12) fitCache.clear() + fitCache.set(key, cam) + } + return cam + } + const mixCam = (a, b, t) => ({ target: a.target.map((n, k) => n + (b.target[k] - n) * t), dist: a.dist + (b.dist - a.dist) * t }) + + // Libro en reposo: la malla se reutiliza. Durante la cubierta se rehace en cada fotograma. + function bookShapes(o, under, land) { + const O = BOOK.OVER, ed = new Shape() + const out = { left: null, right: null } + for (const side of [-1, 1]) { + const isLand = under && side === -under, k = isLand ? land.k : 1, width = isLand ? land.width : 1 + if (!width) continue + out[side > 0 ? 'right' : 'left'] = pageShape(side, W, o, k, width) + edgeShape(ed, side, W, o, k, width) + } + const landOf = side => (under && side === -under ? land.width : 1) + const xL = -W * landOf(-1) - (landOf(-1) ? O : 0), xR = W * landOf(1) + (landOf(1) ? O : 0) + out.edges = ed + out.board = new Shape().box(xL, xR, -0.5 - O, 0.5 + O, 0, BOOK.BOARD) + return out + } + function staticMeshes(v) { + const key = `${v.kind === 'closed' ? `c${v.under}` : 'o'}:${W}` + if (key === staticKey && statics) return statics + if (statics) for (const m of Object.values(statics)) freeMesh(m) + staticKey = key + const sh = v.kind === 'closed' ? bookShapes(0, v.under, { k: 0, width: 0 }) : bookShapes(1, 0, null) + statics = {} + for (const [n, s] of Object.entries(sh)) if (s) statics[n] = makeMesh(s) + return statics + } + + function pageMap(i, side, wide) { + const t = textures.get(i) + if (!t) return null + const region = wide ? 2 * W : W, a = t.w / t.h + let sx = 1, sy = 1 // Fracción del hueco que llena la imagen, sin deformarla + if (a < region) sx = a / region; else sy = region / a + const ox = (1 - sx) / 2, oy = (1 - sy) / 2 + const scale = wide ? 0.5 : 1, start = wide && side > 0 ? 0.5 : 0 + return { tex: t.tex, map: [scale / sx, (start - ox) / sx, 1 / sy, -oy / sy] } + } + function drawPage(mesh, page, side, mirror = false) { + const p = page?.i == null ? null : pageMap(page.i, side, page.wide) + gl.uniform1i(u.uKind, 4) + gl.uniform1f(u.uSeamAt, side > 0 ? 0 : 1) + gl.uniform1f(u.uHas, p ? 1 : 0) + gl.uniform1f(u.uMirror, mirror ? 1 : 0) + if (p) { gl.bindTexture(gl.TEXTURE_2D, p.tex); gl.uniform4fv(u.uMap, p.map) } + gl.uniform3fv(u.uColor, opts.paper()) + drawMesh(mesh) + gl.uniform1f(u.uMirror, 0) + } + // Dibuja las dos caras de una hoja: la de delante en su lado y la de detrás, ya volteada, en el otro. + function drawSheet(mesh, side, front, back, lid = null) { + gl.enable(gl.CULL_FACE) + gl.frontFace(side > 0 ? gl.CCW : gl.CW) + gl.cullFace(gl.BACK) + if (lid) { + gl.uniform1i(u.uKind, 5) + gl.uniform1f(u.uHas, lid.tex ? 1 : 0) + if (lid.tex) gl.bindTexture(gl.TEXTURE_2D, lid.tex) + gl.uniform3fv(u.uColor, coverColor) + drawMesh(mesh) + } else drawPage(mesh, front, side) + gl.cullFace(gl.FRONT) + gl.uniform1f(u.uFlip, -1) + drawPage(mesh, back, -side, true) + gl.uniform1f(u.uFlip, 1) + gl.disable(gl.CULL_FACE) + } + + function drawBook(m, left, right) { + gl.uniform1i(u.uKind, 2) + gl.uniform3fv(u.uColor, coverColor) + drawMesh(m.board) + gl.uniform1i(u.uKind, 3) + gl.uniform3fv(u.uColor, opts.paper()) + drawMesh(m.edges) + if (m.left) drawPage(m.left, left, -1) + if (m.right) drawPage(m.right, right, 1) + } + // Lo que va en la tapa: la portada delante, o la contraportada por detrás del libro. El lomo toma el color de su borde. + const lidOf = isBack => ({ tex: isBack ? backTex : textures.get(0)?.tex }) + function drawSpine(under, top, size, lid) { + gl.uniform1i(u.uKind, 6) + gl.uniform1f(u.uHas, lid.tex ? 1 : 0) + if (lid.tex) gl.bindTexture(gl.TEXTURE_2D, lid.tex) + gl.uniform3fv(u.uColor, coverColor) + gl.uniform1f(u.uSeamAt, under > 0 ? 0.01 : 0.99) + drawMesh(dynMesh('spine', spineShape(-under, top, size))) + } + // La cubierta: una pieza rígida con grosor. a: 0 cerrada, 1 ya abierta. + function drawCover(under, a, backPage, lid) { + const O = BOOK.OVER + const pts = sheetCurve(a, under, W, true, 12, O) + pts.forEach(p => { p.u = under > 0 ? p.t : 1 - p.t }) + const mesh = dynMesh('cover', sheetShape(pts, -0.5 - O, 0.5 + O)) + gl.uniform1i(u.uKind, 2) + gl.uniform3fv(u.uColor, coverColor) + drawMesh(dynMesh('rim', rimShape(pts, -0.5 - O, 0.5 + O, BOOK.BOARD))) + drawSheet(mesh, under, null, backPage, lid) + return pts.at(-1) + } + + function stepTilt(now) { + tilt.fade(now) + const on = !still && zoom.s === 1 + const want = on ? { x: tilt.target.x / MAX_DEG, y: tilt.target.y / MAX_DEG } : { x: 0, y: 0 } + let moving = !!(tilt.target.x || tilt.target.y) && on + for (const k of ['x', 'y']) { + const d = want[k] - tiltNow[k] + if (Math.abs(d) < 0.002) tiltNow[k] = want[k] + else { tiltNow[k] += d * 0.25; moving = true } + } + return moving + } + function stepAnim(now) { + if (!anim) return false + const dt = Math.min(0.12, (now - anim.last) / 1000) // tras un tirón, recuperar sin alargar la animación + anim.last = now + if (anim.dragging) { + const d = anim.goal - anim.p + anim.p += d * 0.35 + return true + } + const before = anim.p + anim.p = anim.back ? Math.max(0, before - dt / anim.dur) : Math.min(1, before + dt / anim.dur) + if ((anim.back && anim.p <= 0) || (!anim.back && anim.p >= 1)) { finishAnim(!anim.back); return !!anim } + return true + } + // Acaba la animación en uno de los dos extremos y deja el libro en reposo. + function finishAnim(commit = true) { + const a = anim + if (!a) return + anim = null + view = commit ? a.to : a.from + camNow = null + camMove = null + opts.onTurnEnd?.(a.ctl) + a.done?.(commit) + draw() + } + + // Dónde queda la contraportada en la pantalla: el lector pone ahí los botones, y siguen al libro cuando se mueve. + function reportBack(M) { + if (!opts.onBack) return + let at = null + if (!anim && view?.back && W > 0) { + const O = BOOK.OVER, far = view.under * (W + O) + const q = [[0, -0.5 - O], [far, -0.5 - O], [0, 0.5 + O], [far, 0.5 + O]].map(([x, y]) => project(M, [x, y, LID_Z])) + const xs = q.map(p => ((p[0] + 1) / 2) * rect.width), ys = q.map(p => ((1 - p[1]) / 2) * rect.height) + at = { x: Math.min(...xs), y: Math.min(...ys), width: Math.max(...xs) - Math.min(...xs), height: Math.max(...ys) - Math.min(...ys) } + } + const key = at ? `${Math.round(at.x)},${Math.round(at.y)},${Math.round(at.width)},${Math.round(at.height)}` : 'off' + if (key === (backSent || 'off')) return + backSent = key + opts.onBack(at, backColors) + } + + function paint(now) { + raf = 0 + if (dead || gl.isContextLost()) return + rect = container.getBoundingClientRect() + if (!rect.width || !rect.height) return + const dpr = Math.min(window.devicePixelRatio || 1, 3) + const cw = Math.round(rect.width * dpr), ch = Math.round(rect.height * dpr) + if (canvas.width !== cw || canvas.height !== ch) { canvas.width = cw; canvas.height = ch } + gl.viewport(0, 0, cw, ch) + gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) + let again = stepTilt(now) + if (stepAnim(now)) again = true + const v = anim ? anim.to : view + const ready = v && W > 0 + const aspect = cw / ch + let cam = { target: [0, 0, 0], dist: 4 } + if (ready) { + cam = fitOf(v) + if (anim) cam = mixCam(anim.camFrom, cam, easeIO(anim.p)) + else if (camMove) { + const t = Math.min(1, (now - camMove.t0) / camMove.dur) + cam = mixCam(camMove.from, cam, easeIO(t)) + if (t >= 1) camMove = null; else again = true + } + camNow = cam + } + // La sombra de la hoja se calcula antes, porque cae también en la mesa. + let castX = 0, castA = 0, cover = null, sheet = null + if (ready && anim) { + const e = anim.dragging ? anim.p : easeIO(anim.p) + if (anim.kind === 'cover') { + cover = { a: anim.opening ? e : 1 - e, under: anim.under } + castX = Math.cos(cover.a * Math.PI) * cover.under * (W + BOOK.OVER) + castA = Math.sin(cover.a * Math.PI) * 0.5 + } else { + sheet = { a: e, side: anim.side } + sheet.pts = sheetCurve(e, anim.side, W) + castX = sheet.pts.at(-1).x + castA = Math.sin(e * Math.PI) * 0.45 + } + } + // Una hoja en alto queda cerca de la cámara: durante el giro, esta se aleja y se pone más vertical para que quepa y no se vea torcida. + const lift = cover ? Math.sin(cover.a * Math.PI) : sheet ? 0.35 * Math.sin(sheet.a * Math.PI) : 0 + if (lift) cam = { target: cam.target, dist: cam.dist * (1 + 0.3 * lift) } + // Al rodear el libro, la cámara se aleja un poco para que el lado que se acerca no se salga de la pantalla. + const round = Math.max(Math.abs(tiltNow.x), Math.abs(tiltNow.y)) + if (round) cam = { target: cam.target, dist: cam.dist * (1 + ORBIT_BACK * round) } + const dTilt = ((-tiltNow.x * ORBIT_TILT - 9 * lift) * Math.PI) / 180, yaw = (-tiltNow.y * ORBIT_YAW * Math.PI) / 180 + const M = cameraMatrix(cam.target, cam.dist, aspect, TILT + dTilt, yaw) + // Zoom del lector y el pequeño desplazamiento del movimiento: se aplican a la imagen final, que se vuelve a dibujar nítida. + const s = zoom.s, ox = s - 1 + (2 * (zoom.x + tiltNow.y * 3)) / rect.width, oy = -(s - 1) - (2 * (zoom.y + tiltNow.x * 3)) / rect.height + for (let c = 0; c < 4; c++) { + M[c * 4] = s * M[c * 4] + ox * M[c * 4 + 3] + M[c * 4 + 1] = s * M[c * 4 + 1] + oy * M[c * 4 + 3] + } + gl.uniformMatrix4fv(u.uM, false, M) + reportBack(M) + gl.uniformMatrix3fv(u.uFx, false, [fxM[0][0], fxM[1][0], fxM[2][0], fxM[0][1], fxM[1][1], fxM[2][1], fxM[0][2], fxM[1][2], fxM[2][2]]) + gl.uniform3f(u.uFxO, fxM[0][3], fxM[1][3], fxM[2][3]) + gl.uniform2f(u.uCast, castX, castA) + // Mesa + gl.depthMask(true) + gl.uniform1i(u.uKind, 0) + gl.bindTexture(gl.TEXTURE_2D, tableTex || wood) + if (tableTex) { const ts = tableScale(); gl.uniform3f(u.uTab, ts / tableAspect, ts, 1) } else gl.uniform3f(u.uTab, 0, 0, 0) + gl.uniform4f(u.uRect, cam.target[0], cam.target[1], Math.max(W, 0.7) * 1.3, 1) + drawMesh(tableMesh) + if (!ready) { if (again) draw(); return } + // Sombras en la mesa: una muy pegada une el libro con la madera, la otra cae a un lado y abajo. + const O = BOOK.OVER + let bx0 = -W - O, bx1 = W + O + if (cover || v.kind === 'closed') { + const under = cover ? cover.under : v.under, reach = cover ? landWidth(cover.a) : 0 + if (under > 0) { bx0 = -(W + O) * reach - BOOK.SPINE * (1 - reach); bx1 = W + O } else { bx0 = -W - O; bx1 = (W + O) * reach + BOOK.SPINE * (1 - reach) } + } + gl.enable(gl.BLEND) + gl.depthMask(false) + gl.disable(gl.DEPTH_TEST) + gl.uniform1i(u.uKind, 1) + for (const [dx, dy, soft, alpha] of [[-0.05, -0.062, 0.1, 0.5], [-0.02, -0.026, 0.04, 0.58], [-0.003, -0.004, 0.012, 0.75]]) { + gl.uniform4f(u.uRect, bx0 + dx, -0.5 - O + dy, bx1 + dx, 0.5 + O + dy) + gl.uniform1f(u.uSoft, soft) + gl.uniform1f(u.uAlpha, alpha) + drawMesh(tableMesh) + } + gl.disable(gl.BLEND) + gl.enable(gl.DEPTH_TEST) + gl.depthMask(true) + // Libro + if (cover) { + // Durante la apertura, la mitad sobre la que aterriza se extiende justo debajo de la cubierta. + const openV = anim.opening ? anim.to : anim.from, a = cover.a, o = smooth(0, 1, a) + const width = landWidth(a), land = { k: smooth(0.97, 1, a), width } + const sh = bookShapes(o, cover.under, land), m = {} + for (const [n, q] of Object.entries(sh)) if (q) m[n] = dynMesh(`b_${n}`, q) + const under = pageOn(openV, cover.under) + const pages = { [cover.under]: under, [-cover.under]: { i: null } } + drawBook(m, pages[-1], pages[1]) + const size = BOOK.SPINE * Math.max(0, 1 - a * 2.5) + const lid = lidOf(anim.from.back || anim.to.back) + if (size > 0) drawSpine(cover.under, LID_Z, size, lid) + drawCover(cover.under, a, pageOn(openV, -cover.under), lid) + } else if (sheet) { + // La hoja lleva por delante la página vieja de su lado y por detrás la nueva del otro. Debajo ya se ve la nueva de su lado. + const m = staticMeshes(v), s0 = sheet.side + const pages = { [s0]: pageOn(anim.to, s0), [-s0]: pageOn(anim.from, -s0) } + drawBook(m, pages[-1], pages[1]) + sheet.pts.forEach(p => { p.u = s0 > 0 ? p.t : 1 - p.t }) + gl.uniform2f(u.uCast, 0, 0) + drawSheet(dynMesh('sheet', sheetShape(sheet.pts)), s0, pageOn(anim.from, s0), pageOn(anim.to, -s0)) + } else if (v.kind === 'closed') { + drawBook(staticMeshes(v), null, null) + const lid = lidOf(v.back) + drawSpine(v.under, LID_Z, BOOK.SPINE, lid) + drawCover(v.under, 0, { i: null }, lid) + } else { + drawBook(staticMeshes(v), pageOn(v, -1), pageOn(v, 1)) + } + if (again) draw() + } + function draw() { if (!raf && !dead) raf = requestAnimationFrame(paint) } + + function startTurn(from, to, dir, drag) { + const fwd = dir > 0, r = rtl() + const ctl = { anims: [{ cancel() {} }], range: Math.max(200, rect.width * (from.focus || to.focus ? 0.9 : 0.5)) } + const shut = from.kind === 'closed' ? from : to.kind === 'closed' ? to : null, cover = !!shut + anim = { + ctl, from, to, p: 0, goal: 0, back: false, dragging: !!drag, last: performance.now(), camFrom: camNow || fitOf(from), + kind: cover ? 'cover' : 'sheet', dur: cover ? 1.3 : 0.75, + opening: to.kind !== 'closed', under: shut?.under ?? (r ? -1 : 1), + side: fwd === r ? -1 : 1, // lado del que sale la hoja: en der → izq, avanzar la levanta de la izquierda + } + ctl.cleanup = () => { if (anim?.ctl === ctl) finishAnim(!anim.back) } + if (drag) { + ctl.move = q => { if (anim?.ctl === ctl) { anim.goal = Math.max(0, Math.min(1, q)); draw() } } + ctl.release = (commit, done) => { + if (anim?.ctl !== ctl) return done?.(commit) + anim.dragging = false + anim.back = !commit + anim.done = done + anim.dur *= 0.7 + // Lo arrastrado ya es lineal: se pasa a la curva de la animación sin saltos. + anim.p = inverseEase(anim.p) + anim.last = performance.now() + draw() + } + } + return ctl + } + const inverseEase = y => (y < 0.5 ? Math.sqrt(y / 2) : 1 - Math.sqrt((1 - y) / 2)) + + const ro = new ResizeObserver(draw) + ro.observe(container) + const onVis = () => { if (document.visibilityState === 'visible') { if (!still) tilt.permit() } else tilt.listen(false) } + document.addEventListener('visibilitychange', onVis) + canvas.addEventListener('webglcontextlost', e => { + e.preventDefault() + glEpoch++ + if (anim) finishAnim(true) + textures.clear(); loading.clear(); queue.length = 0; statics = null; staticKey = ''; backTex = null; tableTex = null + }) + canvas.addEventListener('webglcontextrestored', () => { + initGL() + loadTable() + want(0) + if (view) for (const i of pagesOf(view)) want(i) + draw() + }) + initGL() + loadTable() + if (!still) tilt.permit() + + const api = { + // Devuelve el control del giro si lo hay: el lector lo usa para el arrastre y para acabarlo antes de tiempo. + show(group, dir = 0, drag = false) { + if (!group.length) return null + if (anim) finishAnim(!anim.back) + const next = viewOf(group), first = group[0], last = group.at(-1) + keepFrom = first - 3 + keepTo = last + 4 + pagesOf(next).forEach(want) + want(0) // la portada da el color de la tapa y de la contraportada, aunque se empiece a mitad de capítulo + if (!backNear && last + 4 >= total - 1) { backNear = true; clearTimeout(backTimer); backTimer = setTimeout(ensureBack, 1200) } // cuando acabe el giro + for (let k = 1; k <= 3; k++) { want(last + k); want(first - k) } + trim() + const prev = view + let ctl = null + if (prev && dir && prev.key !== next.key && !(prev.kind === 'closed' && next.kind === 'closed') && W > 0 && hasAll(next)) ctl = startTurn(prev, next, dir, drag) + else if (prev && dir && prev.key === next.key && prev.focus !== next.focus && W > 0 && camNow) camMove = { from: camNow, t0: performance.now(), dur: 450 } // la otra cara de la misma pareja + if (ctl) anim.to = next + else view = next + draw() + return ctl + }, + // Fin del capítulo: la tapa de atrás se cierra sobre las hojas. El libro queda boca abajo y con el lomo al otro lado. + showBack(dir = 0, drag = false) { + if (anim) finishAnim(!anim.back) + want(0) + backNear = true + ensureBack() + const next = { kind: 'closed', back: true, under: rtl() ? 1 : -1, focus: 0, key: 'e' }, prev = view + const ctl = prev && dir && prev.kind !== 'closed' && W > 0 && textures.has(0) ? startTurn(prev, next, dir, drag) : null + if (!ctl) view = next + draw() + return ctl + }, + setZoom(s, x, y) { zoom = { s, x, y }; draw() }, + setFilter(css) { fxM = filterMatrix(css); draw() }, + invalidate(i) { + const t = textures.get(i) + if (t) { gl.deleteTexture(t.tex); textures.delete(i) } + want(i) + }, + draw, + // Para las pruebas. + debug: () => ({ view: view?.key, focus: view?.focus, anim: anim && { kind: anim.kind, p: anim.p, side: anim.side, dragging: anim.dragging }, tilt: { ...tiltNow }, target: { ...tilt.target }, listening: tilt.listening, textures: [...textures.keys()], W, zoom: zoom.s, cam: camNow && { x: camNow.target[0], dist: camNow.dist } }), + destroy() { + dead = true + cancelAnimationFrame(raf) + clearTimeout(uploadTimer) + clearTimeout(backTimer) + ro.disconnect() + tilt.stop() + document.removeEventListener('visibilitychange', onVis) + gl.getExtension('WEBGL_lose_context')?.loseContext() + canvas.remove() + }, + } + canvas.b3d = api // para las pruebas + return api +} diff --git a/app/public/index.html b/app/public/index.html index baef472..4237778 100644 --- a/app/public/index.html +++ b/app/public/index.html @@ -3,6 +3,7 @@ + Manga Reader diff --git a/app/public/style.css b/app/public/style.css index 5bcda77..e4d5e54 100644 --- a/app/public/style.css +++ b/app/public/style.css @@ -79,6 +79,13 @@ input:focus, select:focus { border-color: var(--accent); } .chapters a:hover { background: var(--surface-2); } .chapters a.read { opacity: .5; } .chapters a.current { outline: 1px solid var(--accent); opacity: 1; } +.chapters .volhead { display: flex; align-items: center; gap: 8px; margin-top: 6px; } +.chapters .voltoggle { flex: 1; min-width: 0; display: flex; align-items: center; gap: 12px; padding: 8px; border: 0; border-radius: 8px; background: var(--surface-2); color: inherit; font: inherit; text-align: left; cursor: pointer; } +.chapters .voltoggle.static { cursor: default; padding: 10px 12px; } +.chapters .voltoggle img { width: 44px; height: 63px; object-fit: cover; border-radius: 3px; flex: none; } +.chapters .voltoggle > span { display: grid; gap: 2px; } +.chapters .voltoggle .meta { text-align: left; } +.chapters .volhead > .btn { flex: none; } .chapters .meta { font-size: .8rem; color: var(--muted); text-align: right; flex: none; } .history { display: grid; gap: 10px; } @@ -86,6 +93,8 @@ input:focus, select:focus { border-color: var(--accent); } .settings { max-width: 480px; display: grid; gap: 16px; } .settings section { background: var(--surface); padding: 16px; border-radius: var(--radius); display: grid; gap: 10px; } +.tableprev { width: 100%; max-height: 180px; object-fit: cover; border-radius: var(--radius); } +.tableprev[hidden] { display: none; } .loading { padding: 40px; text-align: center; color: var(--muted); } @@ -450,3 +459,25 @@ body.reading #netbanner { display: none; } .tag.hot { color: var(--accent); } @media (max-width: 640px) { .kv { grid-template-columns: 1fr; gap: 0; } .kv dd { margin-bottom: 8px; } .banner { height: 120px; } } .chapters .meta .cdate.approx { opacity: .7; } +/* Libro 3D: el canvas dibuja el libro y #pages sigue debajo de los dedos, invisible, para los gestos. */ +#b3d { position: fixed; inset: 0; z-index: 0; } +#b3d[hidden] { display: none; } +#b3d canvas { display: block; width: 100%; height: 100%; } +.reader.b3d { background: #4a2e19; } +.reader.b3d > #pages { position: relative; z-index: 1; background: transparent; } +.reader.b3d > #pages img { visibility: hidden; } +/* Los dedos caen siempre en #pages, que no se quita. Si cayeran en la etapa, al pasar de página el «levantar» no llegaría al lector. */ +.reader.b3d > #pages > * { pointer-events: none; } +/* El final del capítulo, sobre la contraportada y con sus colores. */ +.reader.b3d .chapter-end { position: absolute; left: 0; top: 0; padding: 10%; align-content: center; gap: 12px; color: var(--end-ink, #fff); filter: var(--page-filter, none); + opacity: 0; visibility: hidden; transition: opacity .3s, visibility .3s; will-change: transform; } +.reader.b3d .chapter-end.on { opacity: 1; visibility: visible; } +.reader.b3d .chapter-end p { margin: 0; padding: 3px 12px; border-radius: 6px; background: var(--end-surface, transparent); color: var(--end-ink, #fff); font-weight: 600; } +.reader.b3d .chapter-end .btn { pointer-events: auto; width: 100%; max-width: 320px; min-height: 44px; white-space: normal; text-align: center; + background: var(--end-surface, #222); color: var(--end-ink, #fff); border-color: var(--end-line, #888); box-shadow: 0 2px 6px rgba(0, 0, 0, .35); } +.reader.b3d .chapter-end .btn.primary { background: var(--end-accent, #e50); color: var(--end-accentInk, #fff); border-color: var(--end-accent, #e50); } +.reader.b3d .chapter-end .btn:focus-visible { outline: 2px solid var(--end-ink, #fff); outline-offset: 2px; } +/* Para recortar se ve la página plana. */ +.reader.b3d.b3dcrop { background: #000; } +.reader.b3d.b3dcrop > #b3d { display: none; } +.reader.b3d.b3dcrop > #pages img { visibility: visible; } diff --git a/app/public/sw.js b/app/public/sw.js index 4073a16..bc4a48a 100644 --- a/app/public/sw.js +++ b/app/public/sw.js @@ -2,7 +2,7 @@ // dispositivo" (caché OFFLINE, la llena app.js) se leen sin red. const SHELL = 'shell-v1' const OFFLINE = 'offline-v1' -const SHELL_FILES = ['/', '/app.js', '/style.css', '/icon.svg', '/manifest.webmanifest', '/icon-192.png', '/icon-512.png'] +const SHELL_FILES = ['/', '/app.js', '/style.css', '/book3d.js', '/icon.svg', '/manifest.webmanifest', '/icon-192.png', '/icon-512.png'] // Se guarda la portada tal como la sirve el servidor y justo las versiones de app.js y style.css que // enlaza (?v=…); cache: 'reload' para no copiar lo que tenga la caché HTTP (un proxy puede alargarla horas). diff --git a/app/server.js b/app/server.js index 1c6d7a6..6d05d65 100644 --- a/app/server.js +++ b/app/server.js @@ -15,6 +15,8 @@ import { setupSecurity, safeFetch } from './security.js' import { setupM440, MI_BASE } from './m440.js' import { setupConnectivity } from './connectivity.js' import { setupMeta } from './meta.js' +import { setupVolumes } from './volumes.js' +import { setupCredits } from './credits.js' import { setupBooks, isBookId } from './books.js' const PORT = Number(process.env.PORT || 3352) @@ -771,6 +773,45 @@ function json(res, status, body, headers = {}) { res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers }) res.end(JSON.stringify(body)) } +// Fondo de la mesa del libro 3D, por usuario. El navegador lo reduce antes de subirlo; aquí solo se aceptan estos formatos, mirando el contenido. +const TABLE_DIR = path.join(DATA_DIR, 'tables'), TABLE_MAX = 24 * 1024 * 1024 +const tableType = b => { + if (b.length < 16) return null + if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg' + if (b.readUInt32BE(0) === 0x89504e47) return 'image/png' + if (b.toString('latin1', 0, 4) === 'RIFF' && b.toString('latin1', 8, 12) === 'WEBP') return 'image/webp' + return null +} +fs.mkdirSync(TABLE_DIR, { recursive: true }) +const tableFile = id => path.join(TABLE_DIR, `${Number(id)}.img`) +const tableVersion = id => { try { return Math.round(fs.statSync(tableFile(id)).mtimeMs) } catch { return 0 } } +async function tableRoute(method, req, res, user, q) { + const file = tableFile(user.id) + if (method === 'GET') { + let buf + try { buf = fs.readFileSync(file) } catch { throw new HttpError(404, 'Sin fondo') } + res.writeHead(200, { 'Content-Type': tableType(buf) || 'application/octet-stream', 'Content-Length': buf.length, 'Cache-Control': q.get('v') ? 'private, max-age=31536000, immutable' : 'private, no-cache' }) + return res.end(buf) + } + if (method === 'DELETE') { + fs.rmSync(file, { force: true }) + return json(res, 200, { tableV: 0 }) + } + if (method !== 'POST') throw new HttpError(405, 'Método no permitido') + let size = 0 + const chunks = [] + for await (const c of req) { + size += c.length + if (size > TABLE_MAX) throw new HttpError(413, 'La imagen es demasiado grande') + chunks.push(c) + } + const buf = Buffer.concat(chunks) + if (!tableType(buf)) throw new HttpError(400, 'La imagen debe ser JPEG, PNG o WebP') + const tmp = `${file}.tmp` + fs.writeFileSync(tmp, buf) + fs.renameSync(tmp, file) + return json(res, 200, { tableV: tableVersion(user.id) }) +} async function readBody(req) { let size = 0 const chunks = [] @@ -801,6 +842,7 @@ const assetVersion = name => crypto.createHash('sha256').update(fs.readFileSync( const INDEX_HTML = fs.readFileSync(path.join(PUBLIC_DIR, 'index.html'), 'utf8') .replace('src="/app.js"', `src="/app.js?v=${assetVersion('app.js')}"`) .replace('href="/style.css"', `href="/style.css?v=${assetVersion('style.css')}"`) + .replace('', ``) .replace('', ``) function serveStatic(res, pathname) { let file = path.join(PUBLIC_DIR, path.normalize(pathname).replace(/^(\.\.[/\\])+/, '')) @@ -836,7 +878,8 @@ async function route(req, res) { // --- Auth --- if (p === '/api/me' && method === 'GET') { const userCount = db.prepare('SELECT COUNT(*) n FROM users').get().n - return json(res, 200, { user: getUser(req), signup: ALLOW_SIGNUP || userCount === 0 }) + const u = getUser(req) + return json(res, 200, { user: u && { ...u, tableV: tableVersion(u.id) }, signup: ALLOW_SIGNUP || userCount === 0 }) } if ((p === '/api/login' || p === '/api/register') && method === 'POST') { rateLimit(ip) @@ -873,12 +916,14 @@ async function route(req, res) { // Todo lo demás requiere sesión. const user = requireUser(req) + if (p === '/api/table') return tableRoute(method, req, res, user, q) // Un libro subido solo existe para quien lo subió, en cualquier ruta y parámetro que lo nombre. let rawUrl = req.url try { rawUrl = decodeURIComponent(req.url) } catch {} for (const [ub] of rawUrl.matchAll(/ub~[0-9a-f]{32}/g)) if (!books.owns(user.id, ub)) throw new HttpError(404, 'No encontrado') if (p.startsWith('/api/bans') && security.route(p, method, req, res, json) !== false) return if ((await features.route(p, method, q, req, res, user)) !== false) return + if ((await credits.route(p, method, req, res, user)) !== false) return if ((await upscale.route(p, method, req, res)) !== false) { // Pedir el reescalado (o el automático) lo pone en la biblioteca de quien lo pide. if (method === 'POST' && (m = /^\/api\/upscale\/([^/]+)(\/auto)?$/.exec(p)) && db.prepare('SELECT 1 FROM downloads WHERE manga_id = ?').get(m[1])) linkDownload(user.id, m[1]) @@ -948,6 +993,14 @@ async function route(req, res) { } let m + if ((m = /^\/api\/volumes\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'GET') { + return json(res, 200, { volumes: isBookId(m[1]) ? [] : await volumes.list(m[1], lang) }) + } + if ((m = /^\/api\/tome\/([^/]+)\/([^/]+)\/(front|back)$/.exec(p)) && method === 'GET') { + let vol + try { vol = decodeURIComponent(m[2]) } catch { throw new HttpError(404, 'Cubierta no encontrada') } + return volumes.sendFace(res, m[1], vol, m[3]) + } if ((m = /^\/api\/manga\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'GET') { let manga, chapters, offline = false try { @@ -966,7 +1019,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 !== 'md' || 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]), clips: clips.count(user.id, m[1]), lists: library.listsWith(user.id, m[1]), info: isBookId(m[1]) ? null : meta.cachedInfo(m[1]), clipBanner: clips.bannerFor(user.id, m[1]), ...features.mangaExtras(user.id, manga) }) + return json(res, 200, { manga, chapters, lang, reads, progress, chapterLangs, offline, downloaded, hideCredits: credits.enabled(user.id, m[1]), download: downloadInfo(m[1], true), upscale: upscale.info(m[1]), clips: clips.count(user.id, m[1]), lists: library.listsWith(user.id, m[1]), info: isBookId(m[1]) ? null : meta.cachedInfo(m[1]), clipBanner: clips.bannerFor(user.id, m[1]), ...features.mangaExtras(user.id, manga) }) } if (p === '/api/books' && method === 'POST') return json(res, 200, await books.upload(req, user)) if ((m = /^\/api\/books\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && m[1].startsWith('ub~') && method === 'DELETE') { @@ -1116,6 +1169,8 @@ const library = setupLibrary({ db, HttpError, json, readBody, MANGA_ID, CHAPTER_ // Al volver internet: reanudar las descargas en cola. net.whenOnline(() => pumpDownloads()) const meta = setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, net, decode, imgUrl, UA, prepareBanner }) +const credits = setupCredits({ db, HttpError, json, readBody, MANGA_ID, CHAPTER_ID }) +const volumes = setupVolumes({ md, sourceOf, fetchMangaFull, stored: meta.stored, mdMatches: meta.mdMatches, muVolumes: meta.muVolumes, similar: meta.similar, miSearch: q => mi.search(q), imgUrl, DATA_DIR, UA, HttpError, net }) const clips = setupClips({ db, DATA_DIR, HttpError, json, readBody, MANGA_ID, CHAPTER_ID, upscale }) // Migración: lo descargado antes de que hubiera dueños pasa a quien lo lee, lo sigue o lo tiene en una diff --git a/app/volumes.js b/app/volumes.js new file mode 100644 index 0000000..cef66dd --- /dev/null +++ b/app/volumes.js @@ -0,0 +1,706 @@ +// Tomos: qué capítulos forman cada tomo y sus cubiertas originales. +// - Reparto, capítulo a capítulo: lista de Wikipedia, MangaDex (la edición con más datos), MangaUpdates y M440. Ninguna basta: MangaDex +// pierde los capítulos licenciados y M440 mete lo reciente en un tomo cajón. Cada una se limpia y se contrasta con las demás. +// - Portada: /cover de MangaDex, a tamaño original (prefiere la edición japonesa). +// - Contraportada: la Biblioteca Nacional de Japón (NDL) da el ISBN de cada tomo y Amazon sirve la imagen por ISBN. +// Si la edición japonesa no la tiene, se prueba la inglesa (ISBN por Open Library). +// Lo descargado queda en /tomes//-.img. +import fs from 'node:fs' +import path from 'node:path' +import crypto from 'node:crypto' + +const DAY = 24 * 3600e3 +const NDL = 'https://ndlsearch.ndl.go.jp/api/opensearch' +const OPEN_LIBRARY = 'https://openlibrary.org/search.json' +const AMAZON = 'https://m.media-amazon.com/images/P' +const WIKIPEDIA = 'https://en.wikipedia.org/w/api.php' +const MAX_IMAGE = 30 * 1024 * 1024 +const MIN_IMAGE = 8000 // por debajo son el píxel transparente o una miniatura de Amazon +const VOL = /^[\p{L}\p{N}._-]{1,16}$/u +const MD_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ + +// "01" -> "1", "1.50" -> "1.5", "Vol. 3" no es un número y se queda igual. +export const volKey = v => { + const s = String(v ?? '').trim() + if (!s || s.toLowerCase() === 'none') return null + return /^\d+(\.\d+)?$/.test(s) ? String(parseFloat(s)) : s +} +const numKey = n => { const f = parseFloat(n); return Number.isFinite(f) ? String(f) : null } +const volOrder = (a, b) => (parseFloat(a) || Infinity) - (parseFloat(b) || Infinity) || String(a).localeCompare(String(b)) + +// Título comparable: sin signos, espacios ni diferencias de anchura. +export const titleKey = s => String(s || '').normalize('NFKC').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '') +export function ndlVolume(s) { + const m = /^(?:vol\.?|第)?\s*(\d{1,4})\s*巻?$/i.exec(String(s || '').normalize('NFKC').trim()) + return m ? String(parseInt(m[1], 10)) : null +} +export function isbn10(raw) { + const s = String(raw || '').replace(/[^0-9Xx]/g, '').toUpperCase() + if (/^\d{9}[\dX]$/.test(s)) return s + if (!/^978\d{10}$/.test(s)) return null + const body = s.slice(3, 12) + let sum = 0 + for (let i = 0; i < 9; i++) sum += (10 - i) * Number(body[i]) + const c = (11 - (sum % 11)) % 11 + return body + (c === 10 ? 'X' : String(c)) +} +const xmlText = s => String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").trim() + +// De los registros de la NDL, los tomos de esta obra: mismo título exacto, tomo numérico, clasificado como manga y del +// autor con más tomos. Si el primer tomo sale muy lejos del año de la obra, es otra obra con el mismo título. +export function pickIsbns(xmlPages, titles, year) { + const keys = new Set(titles.map(titleKey).filter(Boolean)) + const groups = new Map() + for (const xml of xmlPages) { + for (const [, it] of xml.matchAll(/([\s\S]*?)<\/item>/g)) { + if (!keys.has(titleKey(xmlText(/([\s\S]*?)<\/dc:title>/.exec(it)?.[1])))) continue + const vol = ndlVolume(xmlText(/([\s\S]*?)<\/dcndl:volume>/.exec(it)?.[1])) + if (!vol) continue + const isbns = [...it.matchAll(/xsi:type="dcndl:ISBN">([^<]+) isbn10(m[1])).filter(Boolean) + if (!isbns.length) continue + const author = titleKey(xmlText(/([\s\S]*?)<\/author>/.exec(it)?.[1]).split(/[, ]/)[0]) || '?' + const when = parseInt(/]*>(\d{4})/.exec(it)?.[1], 10) || null + if (!groups.has(author)) groups.set(author, new Map()) + const g = groups.get(author) + if (!g.has(vol)) g.set(vol, { isbns: [], when }) + const e = g.get(vol) + for (const i of isbns) if (!e.isbns.includes(i)) e.isbns.push(i) + if (when && (!e.when || when < e.when)) e.when = when + } + } + const firstYear = g => Math.min(...[...g.values()].map(e => e.when || Infinity)) + const best = [...groups.values()] + .filter(g => !year || !Number.isFinite(firstYear(g)) || Math.abs(firstYear(g) - year) <= 3) + .sort((a, b) => b.size - a.size)[0] + if (!best) return {} + return Object.fromEntries([...best].map(([v, e]) => [v, e.isbns])) +} + +// Open Library: solo el título exacto seguido del tomo («Berserk of Gluttony, Vol. 1» no es Berserk) y solo ISBN en inglés. +// Ediciones de varios tomos y otros libros: su contraportada no es la de este tomo. One Piece "Vol. 1 (Romance Dawn / +// Buggy the Clown / Don't Get Fooled Again)" es en realidad el ómnibus East Blue 1-2-3. +const SEVERAL_VOLUMES = /omnibus|ómnibus|\d\s*-?\s*in\s*-?\s*\d|\d\s*[-–&]\s*\d|\s\/\s|box(ed)? set|collection|coloring|how to draw/ +export function pickEnglishIsbns(docs, titles, vol) { + const out = [] + for (const title of titles) { + const esc = titleLower(title).replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const re = new RegExp(`^${esc}[,:]?\\s*(?:vol(?:ume)?\\.?\\s*)?0*${vol}(?:\\D|$)`) + for (const d of docs) { + if (!re.test(titleLower(d.title)) || SEVERAL_VOLUMES.test(titleLower(d.title))) continue + for (const x of d.isbn || []) { + const i = isbn10(x) + if (i && /^[01]/.test(i) && !out.includes(i)) out.push(i) + } + } + } + return out +} +const titleLower = s => String(s || '').normalize('NFKC').toLowerCase().replace(/\s+/g, ' ').trim() + +// Una por tomo: la japonesa, si no la del idioma de lectura, luego la inglesa y luego cualquiera. +export function pickCovers(records, lang) { + const rank = l => { const i = ['ja', lang, 'en'].indexOf(l); return i < 0 ? 9 : i } + const out = {} + for (const r of records) { + const vol = volKey(r.attributes?.volume), file = r.attributes?.fileName, l = r.attributes?.locale + if (!vol || !file) continue + if (!out[vol] || rank(l) < rank(out[vol].locale)) out[vol] = { file, locale: l } + } + return out +} + +function imageKind(b) { + if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg' + if (b.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return 'image/png' + if (b.subarray(0, 4).toString('latin1') === 'RIFF' && b.subarray(8, 12).toString('latin1') === 'WEBP') return 'image/webp' + return null +} + +/** MangaDex trae errores de quien subió cada capítulo: el 232 en el tomo 1 de Chainsaw Man, por ejemplo. + * Devuelve los números que quedan lejos del resto de su tomo. Con menos de 3 no hay con qué comparar. */ +export function strayNums(nums) { + const known = nums.map(parseFloat).filter(Number.isFinite).sort((a, b) => a - b) + if (known.length < 3) return new Set() + const middle = a => (a[(a.length - 1) >> 1] + a[a.length >> 1]) / 2 + const center = middle(known) + const spread = middle(known.map(n => Math.abs(n - center)).sort((a, b) => a - b)) + const limit = Math.max(4 * spread, 5) + const far = known.filter(n => Math.abs(n - center) > limit) + return new Set(far.length > Math.max(1, known.length * 0.15) ? [] : far) // varios lejos: es la numeración de la obra (Berserk) +} + +/** Un mapa capítulo -> tomo sin lo que no es fiable: capítulos fuera de sitio y tomos cajón, mucho mayores que los demás. */ +export function cleanVolMap(map) { + const byVol = new Map() + for (const [n, v] of Object.entries(map)) byVol.set(v, [...(byVol.get(v) || []), n]) + const whole = nums => new Set(nums.map(n => Math.floor(parseFloat(n)))).size // el 2 y el 2.2 son un capítulo partido + const sizes = [...byVol.values()].map(whole).sort((a, b) => a - b) + const usual = sizes[(sizes.length - 1) >> 1] + const out = {} + for (const [v, nums] of byVol) { + const size = whole(nums) + if (size >= 40 || (size >= 20 && (sizes.length === 1 || size > 3 * usual))) continue + const stray = /^\d/.test(v) ? strayNums(nums) : new Set() + for (const n of nums) if (!stray.has(parseFloat(n))) out[n] = v + } + return out +} + +/** ¿Dos fuentes dan el mismo reparto donde coinciden? Si numeran distinto (Berserk en Wikipedia), no. */ +export function volMapsAgree(a, b) { + const shared = Object.keys(a).filter(n => n in b) + if (shared.length < 5) return true + return shared.filter(n => a[n] === b[n]).length / shared.length >= 0.6 +} + +/** Junta las fuentes por orden de preferencia; cada una solo rellena lo que falta. */ +export function mergeVolMaps({ wiki = {}, md = {}, mu = {}, mi = {} }) { + const merged = {} + // M440 acierta más que las anotaciones de MangaUpdates, salvo si le falta un tomo en medio: suele haberlo unido al anterior. + const shown = new Set(Object.values(mi).map(Number)) + const inMu = new Set(Object.values(mu).map(Number)) + const miHasHole = [...inMu].some(v => !shown.has(v) && v < Math.max(...shown)) + for (const src of [volMapsAgree(wiki, md) ? wiki : {}, md, ...(miHasHole ? [mu, mi] : [mi, mu])]) { + if (!volMapsAgree(src, merged)) continue + for (const [n, v] of Object.entries(src)) if (!(n in merged)) merged[n] = v + } + return merged +} + +/** Publicaciones de MangaUpdates: cada grupo anota, a veces, el tomo de lo que publica ("35a", "12-14"). Si discrepan, gana la mayoría. */ +export function parseMuVolumes(rows) { + const votes = {} + for (const { volume, chapter } of rows) { + const vol = /^\s*0*([1-9]\d{0,3})\s*$/.exec(String(volume ?? ''))?.[1] + if (!vol) continue + const ch = String(chapter ?? '').trim() + const one = /^(\d+(?:\.\d+)?)[a-z]?$/i.exec(ch) + const range = /^(\d+(?:\.\d+)?)[a-z]?\s*-\s*(\d+(?:\.\d+)?)[a-z]?$/i.exec(ch) + const nums = one ? [parseFloat(one[1])] : [] + if (range) { + const [lo, hi] = [parseFloat(range[1]), parseFloat(range[2])] + if (hi >= lo && hi - lo < 50) { + nums.push(lo, hi) + for (let k = Math.ceil(lo); k <= hi; k++) nums.push(k) + } + } + for (const n of new Set(nums)) { + const count = (votes[n] ||= {}) + count[vol] = (count[vol] || 0) + 1 + } + } + const map = {} + for (const [n, count] of Object.entries(votes)) map[n] = Object.keys(count).reduce((a, b) => (count[b] > count[a] ? b : a)) + return map +} + +/** Lo aprovechable de las anotaciones de MangaUpdates: sin lo que rompe el orden de los tomos. Si no incluyen el tomo 1, solo valen + * si cubren al menos una cuarta parte de los capítulos (si no, son anotaciones sueltas). */ +export function orderedMuMap(map, total = 0) { + const nums = Object.keys(map).sort((a, b) => parseFloat(a) - parseFloat(b)) + const vol = nums.map(n => parseInt(map[n], 10)) + // La subsecuencia no decreciente más larga. + const len = vol.map(() => 1), from = vol.map(() => -1) + let end = 0 + vol.forEach((v, i) => { + for (let k = 0; k < i; k++) if (vol[k] <= v && len[k] + 1 > len[i]) { len[i] = len[k] + 1; from[i] = k } + if (len[i] > len[end]) end = i + }) + const out = {} + for (let i = nums.length ? end : -1; i >= 0; i = from[i]) out[nums[i]] = map[nums[i]] + const size = Object.keys(out).length + return Object.values(out).includes('1') || (total && size >= total / 4) ? out : {} +} + +/** MangaUpdates apunta el tomo de unos pocos capítulos. Se completa lo que queda claro entre dos de ellos: + * antes del primero, si es el tomo 1; entre dos del mismo tomo; y tras un extra (5.5, el cierre de su tomo) si después viene el tomo siguiente. */ +export function fillVolGaps(map, nums) { + const anchors = Object.keys(map).map(parseFloat).sort((a, b) => a - b) + const out = { ...map } + for (const key of nums) { + if (key == null || key in map) continue + const n = parseFloat(key) + const prev = anchors.filter(a => a < n).at(-1) + const next = anchors.find(a => a > n) + if (next === undefined) continue + const after = map[next] + if (prev === undefined) { + if (after === '1' && n >= 1) out[key] = after // el capítulo 0 no suele ser parte del tomo + continue + } + const before = map[prev] + if (before === after ? next - prev <= 5 : !Number.isInteger(prev) && parseInt(after, 10) === parseInt(before, 10) + 1 && next - prev <= 15) out[key] = after + } + return out +} + +export const ownVolMap = chapters => { + const map = {} + for (const c of chapters) { + const n = numKey(c.num), v = volKey(c.volume) + if (n && v && !(n in map)) map[n] = v + } + return map +} + +/** Numeración que vuelve a empezar en cada tomo: el número no identifica el capítulo y solo vale su propio tomo. */ +export function numbersRestart(chapters) { + const seen = new Map() + let repeated = 0 + for (const c of chapters) { + const n = numKey(c.num), v = volKey(c.volume) + if (!n || !v) continue + if (seen.has(n) && seen.get(n) !== v) repeated++ + else seen.set(n, v) + } + return repeated >= 3 +} + +/** El tomo de cada capítulo, en el orden de la lista (de menor a mayor). Un extra (7.5) va con el capítulo al que sigue, + * y lo que no tiene tomo entre dos del mismo se queda con ellos, para que el libro no se salte nada. */ +export function assignVolumes(chapters, map = {}) { + const vols = chapters.map(c => map[numKey(c.num)] ?? null) + vols.forEach((v, i) => { + const n = parseFloat(chapters[i].num) + if (!v && i && vols[i - 1] && !Number.isInteger(n) && Math.floor(n) === Math.floor(parseFloat(chapters[i - 1].num))) vols[i] = vols[i - 1] + }) + let last = -1 + vols.forEach((v, i) => { + if (!v) return + if (last >= 0 && vols[last] === v && i - last <= 4) for (let k = last + 1; k < i; k++) vols[k] = v // un hueco grande no se sabe qué tiene + last = i + }) + return vols +} + +/** Quita el tomo a los capítulos mal etiquetados: un tramo corto cuyo tomo rompe el orden, y sin él los de alrededor quedan en orden + * (T14, T13, T15). Si no se arregla al quitarlo, es que la obra numera así: Berserk recorre los tomos 1-5 con el prólogo (0.01-0.2) y vuelve al T1 con el capítulo 1. */ +export function dropMisplaced(vols, maxRun = 3) { + const out = [...vols] + if (!vols.every(v => !v || /^\d+$/.test(v))) return out + const runsOf = () => { + const runs = [] + out.forEach((v, i) => { + if (!v) return + if (runs.at(-1)?.v === parseInt(v, 10)) runs.at(-1).at.push(i) + else runs.push({ v: parseInt(v, 10), at: [i] }) + }) + return runs + } + // De uno en uno y primero el más corto: al quitar un tramo, sus vecinos dejan de parecer mal puestos. + for (;;) { + const runs = runsOf() + const wrong = runs.filter((r, j) => { + if (r.at.length > maxRun) return false + const [a, b] = [runs[j - 1], runs[j + 1]] + if (a && b) return a.v <= b.v && (r.v < a.v || r.v > b.v) + const near = a || b + return near && r.at.length < near.at.length && (a ? r.v < a.v : r.v > b.v) + }) + if (!wrong.length) return out + const hit = wrong.reduce((x, y) => (y.at.length < x.at.length ? y : x)) + for (const i of hit.at) out[i] = null + } +} + +/** Estima el tomo de lo que ninguna fuente sitúa: solo pueden quedar sin tomo los capítulos del final que no quepan en un tomo conocido. + * known: los tomos que se sabe que existen (tienen portada), aunque no se sepa qué capítulos llevan. */ +export function placeLoose(chapters, vols, known = []) { + const out = dropMisplaced(vols) + const isNum = v => /^\d+$/.test(v ?? '') + const floorOf = i => Math.floor(parseFloat(chapters[i].num)) + // Un corte no separa un capítulo de sus partes o su extra (5 y 5.5). + const cut = i => { while (i > 0 && i < chapters.length && floorOf(i) === floorOf(i - 1)) i++; return i } + const spread = (from, to, names) => { + let start = from + names.forEach((name, j) => { + const end = j === names.length - 1 ? to : Math.min(to, cut(from + Math.round((to - from) * (j + 1) / names.length))) + for (let i = start; i < end; i++) out[i] = name + start = Math.max(start, end) + }) + } + const last = known.filter(isNum).reduce((a, v) => Math.max(a, parseInt(v, 10)), 0) + const assigned = out.flatMap((v, i) => (v ? [i] : [])) + if (!assigned.length) { + if (last && chapters.length >= last * 2) spread(0, chapters.length, Array.from({ length: last }, (_, i) => String(i + 1))) + return out + } + const count = {} + for (const i of assigned) count[out[i]] = (count[out[i]] || 0) + 1 + const sizes = Object.values(count).sort((a, b) => a - b) + const usual = Math.max(1, sizes[(sizes.length - 1) >> 1]) + const between = (a, b) => { // los tomos que aún no tienen capítulos en ese tramo de números + const free = [] + for (let k = a + 1; k < b; k++) if (!count[k]) free.push(String(k)) + return free + } + // Antes del primer tomo. + const first = assigned[0] + if (first) { + const head = out[first] + const free = isNum(head) ? between(0, parseInt(head, 10)) : [] + const n = Math.min(free.length, Math.round(first / usual)) + if (n) spread(0, first, free.slice(-n)) + else for (let i = 0; i < first; i++) out[i] = head + } + // Entre dos tomos. + for (let a = 0; a < assigned.length - 1; a++) { + const [l, r] = [assigned[a], assigned[a + 1]] + if (r - l < 2) continue + const [left, right] = [out[l], out[r]] + const free = isNum(left) && isNum(right) ? between(parseInt(left, 10), parseInt(right, 10)) : [] + if (free.length) { + // Se igualan también los dos tomos de los lados: suelen venir de datos parciales (T1 con 4 capítulos, T3 con 1). + const per = (count[left] + count[right] + r - l - 1) / (free.length + 2) + const from = cut(l + 1 + Math.max(0, Math.round(per - count[left]))) + const to = Math.max(from, r - Math.max(0, Math.round(per - count[right]))) + for (let i = l + 1; i < from; i++) out[i] = left + for (let i = to; i < r; i++) out[i] = right + spread(from, to, free) + count[left] += from - l - 1 + count[right] += r - to + continue + } + if (left === right || !isNum(left) || !isNum(right) || parseInt(right, 10) < parseInt(left, 10)) { + for (let i = l + 1; i < r; i++) out[i] = left + continue + } + // Dos tomos seguidos: el corte que más los iguala. Si hay un extra, cierra tomo: se corta justo después. + const whole = [] + for (let i = l + 1; i <= r; i++) if (floorOf(i) !== floorOf(i - 1)) whole.push(i) + if (!whole.length) whole.push(r) // todo es del capítulo del tomo de la izquierda + const closes = whole.filter(i => i > l + 1 && !Number.isInteger(parseFloat(chapters[i - 1].num))) + const score = i => Math.abs((count[left] + i - l - 1) - (count[right] + r - i)) + const at = (closes.length ? closes : whole).reduce((x, y) => (score(y) < score(x) ? y : x)) + for (let i = l + 1; i < r; i++) out[i] = i < at ? left : right + count[left] += at - l - 1 + count[right] += r - at + } + // Tras el último: solo en tomos que existen. Lo que no quepa se queda sin tomo. + const end = assigned.at(-1) + const free = isNum(out[end]) ? between(parseInt(out[end], 10), last + 1) : [] + const rest = chapters.length - end - 1 + if (free.length && rest) { + const fits = rest <= free.length * usual * 1.3 + spread(end + 1, fits ? chapters.length : cut(end + 1 + free.length * usual), free) + } + return out +} + +/** Cuántos elementos tiene {{Numbered list|start=N|a|b|c}}: las barras de primer nivel, sin contar las de sus plantillas. */ +function numberedListCount(text, from) { + let depth = 0, bars = 0 + for (let i = from; i < text.length - 1; i++) { + const two = text.slice(i, i + 2) + if (two === '{{' || two === '[[') { depth++; i++ } else if (two === '}}' || two === ']]') { + if (--depth < 0) break + i++ + } else if (text[i] === '|' && depth === 0) bars++ + } + return bars +} + +/** Listas de capítulos de Wikipedia ({{Graphic novel list}}). Cada página numera a su manera: {{Numbered list|start=N}}, + * "* 001. Título", "*Mission: 1–5" o una lista sin números, que sigue la cuenta. Las obras derivadas, que vuelven a + * empezar por el tomo 1, se ignoran. */ +export function parseWikiVolumes(text) { + const map = {}, seen = new Set() + let next = 1 + for (const block of text.split(/\{\{\s*Graphic novel list(?![\w/ ]*(?:header|\/))/i).slice(1)) { + const vol = volKey(/\|\s*VolumeNumber\s*=\s*([^\n|<]*)/i.exec(block)?.[1]) + if (!vol) continue + if (seen.has(vol)) break + seen.add(vol) + const fields = [...block.matchAll(/\|\s*ChapterList(?:Col\d)?\s*=/gi)] + if (!fields.length) continue + const add = n => { map[String(n)] = vol; next = Math.max(next, n + 1) } + for (const f of fields) { + const start = f.index + f[0].length + const end = block.slice(start).search(/\n\s*\|\s*(?:ChapterList|LicensedTitle|Summary|LineColor|OriginalRelDate|OriginalISBN|LicensedRelDate|LicensedISBN|Volume\w*|Title)\w*\s*=/i) + const body = block.slice(start, end < 0 ? undefined : start + end) + const numbered = [...body.matchAll(/\{\{\s*(?:Numbered|Ordered) list\s*\|\s*start\s*=\s*(\d+)/gi)] + if (numbered.length) { + for (const m of numbered) { + const first = parseInt(m[1], 10) + for (let k = 0, c = numberedListCount(body, m.index + m[0].length); k < c; k++) add(first + k) + } + continue + } + const range = /^\s*\*?\s*(?:[\p{L} ]+:)?\s*(\d+)\s*[–-]\s*(\d+)\s*$/mu.exec(body) + if (range) { + for (let n = parseInt(range[1], 10), e = parseInt(range[2], 10); n <= e && e - n < 500; n++) add(n) + continue + } + for (const line of body.split('\n')) { + if (!/^\s*[*#]/.test(line)) continue + const own = /^\s*[*#]+\s*(?:Chapter\s*)?(\d+)\s*[.:]/i.exec(line) + add(own ? parseInt(own[1], 10) : next) + } + } + } + return map +} + +/** El título de la obra sin la edición: "One Piece Full Color" es One Piece, con los mismos tomos. */ +export function workTitle(title) { + const t = String(title || '').trim() + const base = t + .replace(/\s*[([{]\s*(?:(?:full|fan|official|digital(?:ly)?|a todo|a|todo)\s+)?colou?r(?:ed|ized|eado)?(?:\s+edition)?\s*[)\]}]\s*$/i, '') + .replace(/\s*[-–:]?\s*(?:full|fan|official|digital(?:ly)?)\s+colou?r(?:ed|ized)?(?:\s+edition)?\s*$/i, '') + .replace(/\s*[-–:]?\s*(?:a todo color|a color)\s*$/i, '') + return base.length >= 3 ? base : t +} + +/** Manhwa y manhua no se publican por tomos: se leen por capítulos. */ +export const hasNoVolumes = (type, original) => ['manhwa', 'manhua', 'webtoon'].includes(String(type || '').toLowerCase()) || ['ko', 'zh', 'zh-hk'].includes(original) + +export const wikiKey = t => String(t).toLowerCase().replace(/×/g, 'x').replace(/[^\p{L}\p{N}]/gu, '') + +export function setupVolumes({ md, sourceOf, fetchMangaFull, stored, mdMatches, muVolumes, similar, miSearch, imgUrl, DATA_DIR, UA, HttpError, net }) { + const DIR = path.join(DATA_DIR, 'tomes') + + const fetchImage = async url => { + net.guard() + const r = await fetch(url, { headers: { 'User-Agent': UA }, redirect: 'follow', signal: AbortSignal.timeout(30000) }).catch(() => { net.failed(); return null }) + if (!r) throw new HttpError(502, 'No se pudo descargar la cubierta') + if (r.status >= 500 || r.status === 429) throw new HttpError(502, 'La fuente de la cubierta no responde') + if (!r.ok) return null + const b = Buffer.from(await r.arrayBuffer()) + return b.length >= MIN_IMAGE && b.length <= MAX_IMAGE && imageKind(b) ? b : null + } + + // ---------- Lo que dice MangaDex de la obra ---------- + async function mdVolumes(mdId, lang) { + return stored(`volumes:${mdId}:${lang || ''}`, DAY, async () => { + const [agg, mangaData] = await Promise.all([ + md(`/manga/${mdId}/aggregate`, {}, 30 * 60e3), + md(`/manga/${mdId}`, {}, 30 * 60e3), + ]) + const byNum = {} + const vols = Object.values(agg.volumes || {}).sort((a, b) => volOrder(a.volume, b.volume)) + for (const v of vols) { + const vol = volKey(v.volume) + if (!vol) continue + const inVol = Object.values(v.chapters || {}) + const stray = /^\d/.test(vol) ? strayNums(inVol.map(c => c.chapter)) : new Set() + for (const c of inVol) { + const n = numKey(c.chapter) + if (n && !byNum[n] && !stray.has(parseFloat(n))) byNum[n] = vol + } + } + const records = [] + for (let offset = 0; offset < 1000; offset += 100) { + const r = await md('/cover', { 'manga[]': [mdId], limit: 100, offset, 'order[volume]': 'asc' }, 30 * 60e3) + records.push(...r.data) + if (offset + 100 >= r.total || !r.data.length) break + } + const a = mangaData.data.attributes + const jaTitles = [a.title?.ja, ...(a.altTitles || []).map(t => t.ja)].filter(Boolean) + const enTitles = [a.title?.en, ...(a.altTitles || []).map(t => t.en)].filter(Boolean) + const roTitles = [a.title?.['ja-ro'], ...(a.altTitles || []).map(t => t['ja-ro'])].filter(Boolean) + return { byNum, covers: pickCovers(records, lang), jaTitles, enTitles, roTitles, year: a.year || null, original: a.originalLanguage || null } + }, true) + } + + let gate = Promise.resolve() + const turn = () => { const p = gate.then(() => new Promise(r => setTimeout(r, 1100))); gate = p.catch(() => {}); return p } // la NDL y Wikipedia piden no insistir + + // ---------- Otras fuentes del reparto ---------- + async function wikiPages(titles) { + if (!titles.length) return [] + await turn() // Wikipedia corta a quien va demasiado rápido + net.guard() + const url = `${WIKIPEDIA}?${new URLSearchParams({ action: 'query', prop: 'revisions', rvprop: 'content', rvslots: 'main', redirects: '1', format: 'json', formatversion: '2', titles: titles.join('|') })}` + const get = () => fetch(url, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(30000) }).catch(() => { net.failed(); return null }) + let r = await get() + if (r?.status === 429) { // límite pasajero: se espera una vez; el fallo no se guarda y se reintenta en la siguiente visita + await new Promise(res => setTimeout(res, 3000)) + r = await get() + } + if (!r?.ok) throw new HttpError(502, 'No se pudo consultar Wikipedia') + return ((await r.json()).query?.pages || []).map(p => p.revisions?.[0]?.slots?.main?.content).filter(Boolean) + } + async function wikiSearch(names) { + await turn() + net.guard() + const url = `${WIKIPEDIA}?${new URLSearchParams({ action: 'query', list: 'search', srsearch: `intitle:list intitle:chapters ${names[0]}`, srlimit: '10', format: 'json', formatversion: '2' })}` + const r = await fetch(url, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(30000) }).catch(() => { net.failed(); return null }) + if (!r?.ok) throw new HttpError(502, 'No se pudo consultar Wikipedia') + const wanted = new Set(names.map(wikiKey)) + return ((await r.json()).query?.search || []).map(x => x.title).filter(t => wanted.has(wikiKey(t.replace(/^List of /i, '').replace(/ (?:manga )?(?:chapters|volumes)$/i, '')))).slice(0, 3) + } + function wikiMap(mangaId, names) { + return stored(`wikimap:${mangaId}`, v => (Object.keys(v).length ? 30 * DAY : 7 * DAY), async () => { + const titles = names.slice(0, 8).flatMap(n => [`List of ${n} chapters`, `List of ${n} manga volumes`, `List of ${n} manga chapters`]).filter(t => !/[|#<>[\]{}]/.test(t)) + let best = {} + let pages = await wikiPages(titles) + if (!pages.length) pages = await wikiPages(await wikiSearch(names)) // "SPY×FAMILY" es "Spy × Family" en Wikipedia + for (const text of pages) { + // One Piece y otras largas reparten la lista en subpáginas. + const subs = [...text.matchAll(/\{\{:(List of [^}|]+)\}\}/g)].map(m => m[1]).slice(0, 40) + let found = parseWikiVolumes(text) + for (let i = 0; i < subs.length; i += 10) { + for (const sub of await wikiPages(subs.slice(i, i + 10))) { + const part = parseWikiVolumes(sub) + found = { ...part, ...found } + } + } + if (Object.keys(found).length > Object.keys(best).length) best = found + } + return best + }, true) + } + function m440Map(mangaId, names) { + return stored(`mimap:${mangaId}`, DAY, async () => { + for (const name of names.slice(0, 2)) { + const hit = (await miSearch(name)).find(m => names.some(n => similar(m.title, n))) + if (hit) return ownVolMap((await fetchMangaFull(hit.id, 'es')).chapters) + } + return {} + }, true) + } + + // ---------- ISBN de cada tomo ---------- + async function ndlSearch(title) { + const pages = [] + for (let idx = 1; idx <= 501; idx += 500) { // ONE PIECE tiene más de 600 registros + await turn() + net.guard() + const url = `${NDL}?${new URLSearchParams({ title, mediatype: 'books', cnt: '500', idx: String(idx) })}` + const r = await fetch(url, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(30000) }).catch(() => { net.failed(); return null }) + if (!r?.ok) throw new HttpError(502, 'No se pudo consultar la NDL') + const xml = await r.text() + pages.push(xml) + if (parseInt(/totalResults>(\d+)/.exec(xml)?.[1], 10) <= idx + 499) break + } + return pages + } + async function isbnsOf(mdId) { + return stored(`isbns:${mdId}`, v => (Object.keys(v).length ? 30 * DAY : 7 * DAY), async () => { + const d = await mdVolumes(mdId) + if (d.original !== 'ja' || !d.jaTitles.length) return {} + for (const t of d.jaTitles.slice(0, 3)) { + const found = pickIsbns(await ndlSearch(t), d.jaTitles, d.year) + if (Object.keys(found).length) return found + } + return {} + }, true) + } + + async function englishIsbns(mdId, vol) { + const titles = (await mdVolumes(mdId)).enTitles.slice(0, 3) + if (!titles.length) return [] + await turn() + net.guard() + const url = `${OPEN_LIBRARY}?${new URLSearchParams({ title: `${titles[0]}, Vol. ${vol}`, limit: '10', fields: 'title,isbn' })}` + const r = await fetch(url, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(30000) }).catch(() => { net.failed(); return null }) + if (!r?.ok) throw new HttpError(502, 'No se pudo consultar Open Library') + return pickEnglishIsbns((await r.json()).docs || [], titles, vol).slice(0, 4) + } + + // ---------- Imágenes de un tomo ---------- + async function fetchFace(mdId, vol, side) { + const volNum = /^\d+$/.test(vol) ? vol : null + if (side === 'front') { + const c = (await mdVolumes(mdId)).covers[vol] + if (c) { + const b = await fetchImage(`https://uploads.mangadex.org/covers/${mdId}/${c.file}`) + if (b) return b + } + } + if (!volNum) return null + for (const isbn of (await isbnsOf(mdId))[volNum] || []) { + const b = await fetchImage(`${AMAZON}/${isbn}.01.${side === 'back' ? 'BACK.' : ''}_SCRM_.jpg`) + if (b) return b + } + if (side === 'back') { + for (const isbn of await englishIsbns(mdId, volNum)) { + const b = await fetchImage(`${AMAZON}/${isbn}.01.BACK._SCRM_.jpg`) + if (b) return b + } + } + return null + } + const inflight = new Map() + function face(mdId, vol, side) { + const label = side === 'back' ? 'back2' : side // back2: las anteriores podían ser de un ómnibus + const file = path.join(DIR, mdId, `${crypto.createHash('sha1').update(vol).digest('hex').slice(0, 16)}-${label}.img`) + if (fs.existsSync(file)) return Promise.resolve(file) + const key = `tome:${mdId}:${vol}:${label}` + if (!inflight.has(key)) { + // Lo que no existe se recuerda un tiempo; un fallo de la red no, para reintentar. + inflight.set(key, stored(key, 14 * DAY, async () => { + const b = await fetchFace(mdId, vol, side) + if (!b) return { found: false } + fs.mkdirSync(path.dirname(file), { recursive: true }) + const tmp = `${file}.${process.pid}.tmp` + fs.writeFileSync(tmp, b) + fs.renameSync(tmp, file) + return { found: true } + }, true).then(() => (fs.existsSync(file) ? file : null)).finally(() => inflight.delete(key))) + } + return inflight.get(key) + } + + // ---------- API ---------- + async function list(mangaId, lang) { + const { manga, chapters } = await fetchMangaFull(mangaId, lang) + if (hasNoVolumes(manga.type)) return [] + const source = sourceOf(mangaId) + // Una obra puede estar varias veces en MangaDex (coloreada, por ejemplo): vale la que más datos tiene de estos capítulos. + const found = await mdMatches(mangaId).catch(() => []) + const ids = [...new Set(source === 'md' ? [mangaId, ...found] : found)].slice(0, 4) + const editions = (await Promise.all(ids.map(async id => ({ id, data: await mdVolumes(id, lang).catch(() => null) })))).filter(e => e.data) + const score = e => chapters.filter(c => e.data.byNum[numKey(c.num)]).length + Object.keys(e.data.covers).length + const best = editions.reduce((a, e) => (!a || score(e) > score(a) ? e : a), null) + if (hasNoVolumes(null, best?.data.original)) return [] // la fuente no siempre dice el tipo + const mdId = source === 'md' ? mangaId : best?.id || null + const data = editions.find(e => e.id === mdId)?.data || null + const own = cleanVolMap(ownVolMap(chapters)) + let vols + if (numbersRestart(chapters)) vols = chapters.map(c => volKey(c.volume)) + else { + const names = [...new Set([workTitle(manga.title), manga.title, ...(best?.data.enTitles || []), ...(best?.data.roTitles || [])])] + const [wiki, mi] = await Promise.all([ + wikiMap(mangaId, names).catch(() => ({})), + source === 'mi' ? own : m440Map(mangaId, names).catch(() => ({})), + ]) + const byNum = cleanVolMap(best?.data.byNum || {}) + const main = { wiki: cleanVolMap(wiki), md: source === 'md' ? { ...byNum, ...own } : byNum } + // Con Wikipedia y MangaDex casi completos, lo que falta son los capítulos aún sin tomo: no hace falta preguntar más. + const known = { ...main.wiki, ...main.md } + const missing = chapters.filter(c => !(numKey(c.num) in known)).length + const mu = missing > chapters.length * 0.1 ? fillVolGaps(orderedMuMap(cleanVolMap(await muVolumes(mangaId).catch(() => ({}))), chapters.length), chapters.map(c => numKey(c.num))) : {} + vols = assignVolumes(chapters, mergeVolMaps({ ...main, mu, mi: cleanVolMap(mi) })) + } + vols = placeLoose(chapters, vols, Object.keys(data?.covers || {})) + const groups = new Map() + vols.forEach((vol, i) => { + if (!vol) return + if (!groups.has(vol)) groups.set(vol, []) + groups.get(vol).push(chapters[i].id) + }) + return [...groups.keys()].sort(volOrder).map(vol => { + const cover = data?.covers[vol] + return { + vol, + chapters: groups.get(vol), + thumb: cover ? imgUrl(`https://uploads.mangadex.org/covers/${mdId}/${cover.file}.256.jpg`) : null, + front: mdId && VOL.test(vol) ? `/api/tome/${mdId}/${encodeURIComponent(vol)}/front` : null, + back: mdId && VOL.test(vol) ? `/api/tome/${mdId}/${encodeURIComponent(vol)}/back` : null, + } + }) + } + async function sendFace(res, mdId, vol, side) { + if (!MD_ID.test(mdId) || !VOL.test(vol) || !['front', 'back'].includes(side)) throw new HttpError(404, 'Cubierta no encontrada') + const file = await face(mdId, vol, side) + if (!file) throw new HttpError(404, 'Este tomo no tiene esa cubierta') + const head = Buffer.alloc(16) + const fd = fs.openSync(file, 'r') + fs.readSync(fd, head, 0, 16, 0) + fs.closeSync(fd) + res.writeHead(200, { 'Content-Type': imageKind(head) || 'application/octet-stream', 'Cache-Control': 'private, max-age=2592000', 'X-Content-Type-Options': 'nosniff' }) + fs.createReadStream(file).pipe(res) + } + return { list, sendFace } +}