Download lib/openown.js from SharpMeow/unroll: direct link, hf CLI and curl.
- Browser
- Download file 10.1 kB
-
https://huggingface.co/spaces/SharpMeow/unroll/resolve/main/lib/openown.js
- Command line
-
hf download hf://spaces/SharpMeow/unroll/lib/openown.js
-
curl -L -o openown.js https://huggingface.co/spaces/SharpMeow/unroll/resolve/main/lib/openown.js
10.1 kB
| // "Open your own": read a scroll from files, a zip or a URL, entirely in this browser. | |
| // Nothing is uploaded: files are read with the File API, checked, and kept in IndexedDB on this device. | |
| import { validateManifest, LOCAL_LIMITS, ext, MESH_EXT, IMAGE_EXT } from './manifest.js'; | |
| import { readZip } from './formats.js'; | |
| import { findManifest, fileSource, buildMeshScroll, saveLocal, deleteLocal, listLocal } from './scrolls.js'; | |
| import { exampleZip } from './example.js'; | |
| const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); | |
| const MB = (n) => `${(n / 1048576).toFixed(n < 10485760 ? 1 : 0)} MB`; | |
| const OK_EXT = ['json', 'zip', 'txt', 'md', ...MESH_EXT, ...IMAGE_EXT]; | |
| // files from a drop, a file picker or a zip: [{ name, blob }] | |
| export async function gatherFiles(list, lim = LOCAL_LIMITS) { | |
| const out = []; | |
| for (const f of list) { | |
| const name = f.relPath || f.webkitRelativePath || f.name; | |
| const e = ext(name); | |
| if (e === 'zip') { | |
| if (f.size > lim.maxZipBytes) throw new Error(`${f.name} is ${MB(f.size)}; the limit for a zip is ${MB(lim.maxZipBytes)}.`); | |
| out.push(...(await readZip(await f.arrayBuffer(), lim))); | |
| } else if (OK_EXT.includes(e)) { | |
| if (f.size > lim.maxFileBytes) throw new Error(`${name} is ${MB(f.size)}; the limit per file is ${MB(lim.maxFileBytes)}.`); | |
| out.push({ name, blob: f }); | |
| } | |
| } | |
| if (!out.length) throw new Error('None of these files can be used. Open a manifest.json with its OBJ or PLY meshes and PNG, JPG or TIFF images, or a zip of them.'); | |
| const total = out.reduce((a, f) => a + f.blob.size, 0); | |
| if (total > lim.maxTotalBytes) throw new Error(`These files add up to ${MB(total)}; the limit is ${MB(lim.maxTotalBytes)}.`); | |
| return out.filter((f) => !/(^|\/)(__MACOSX|\.)/.test(f.name)); | |
| } | |
| // a manifest or zip at a URL; the manifest's files are fetched from beside it. Needs CORS on that server. | |
| export async function filesFromURL(url, lim = LOCAL_LIMITS, onStep = () => {}) { | |
| let u; | |
| try { u = new URL(url, location.href); } catch (e) { throw new Error('That is not a valid URL.'); } | |
| if (!/^https?:$/.test(u.protocol)) throw new Error('Only http and https URLs can be opened.'); | |
| const get = async (href, max) => { | |
| let r; | |
| try { r = await fetch(href, { mode: 'cors', credentials: 'omit' }); } | |
| catch (e) { throw new Error(`Could not fetch ${href}. The server may not allow cross-origin requests (CORS), or it is unreachable. Download the files and drop them here instead.`); } | |
| if (!r.ok) throw new Error(`${href}: the server answered ${r.status}.`); | |
| const len = +r.headers.get('content-length'); | |
| if (len && len > max) throw new Error(`${href} is ${MB(len)}; the limit is ${MB(max)}.`); | |
| const b = await r.blob(); | |
| if (b.size > max) throw new Error(`${href} is ${MB(b.size)}; the limit is ${MB(max)}.`); | |
| return b; | |
| }; | |
| onStep(`Fetching ${u.href}`); | |
| const first = await get(u.href, lim.maxZipBytes); | |
| const head = new Uint8Array(await first.slice(0, 4).arrayBuffer()); | |
| if (ext(u.pathname) === 'zip' || (head[0] === 0x50 && head[1] === 0x4b)) return readZip(await first.arrayBuffer(), lim); | |
| let m; | |
| try { m = JSON.parse(await first.text()); } catch (e) { throw new Error('The URL is neither a zip nor a JSON manifest.'); } | |
| const v = validateManifest(m, { limits: lim }); | |
| if (!v.ok) throw Object.assign(new Error('The manifest has problems.'), { list: v.errors }); | |
| const files = [{ name: 'manifest.json', blob: first }]; | |
| let total = first.size; | |
| for (const p of v.files) { | |
| onStep(`Fetching ${p}`); | |
| const b = await get(new URL(p, u).href, lim.maxFileBytes); | |
| total += b.size; | |
| if (total > lim.maxTotalBytes) throw new Error(`The files add up to more than ${MB(lim.maxTotalBytes)}.`); | |
| files.push({ name: p, blob: b }); | |
| } | |
| return files; | |
| } | |
| // check everything a scroll needs, parse it all once, and keep it in this browser | |
| export async function checkAndStore(files, onStep = () => {}) { | |
| const { path, manifest } = await findManifest(files); | |
| const src = fileSource(files, path); | |
| const v = validateManifest(manifest, { limits: LOCAL_LIMITS, fileSize: (p) => src.size(p) }); | |
| if (!v.ok) throw Object.assign(new Error(`${path} has ${v.errors.length} problem${v.errors.length > 1 ? 's' : ''}.`), { list: v.errors, warnings: v.warnings }); | |
| onStep('Reading meshes and images'); | |
| await buildMeshScroll(manifest, src, { limits: LOCAL_LIMITS, atlasSide: 1024 }); // a full trial build, small atlas | |
| const keep = v.files.concat(path).map((p) => src.find(p) || { name: p }).filter((f) => f.blob); | |
| const key = `local-${manifest.id}-${Date.now().toString(36)}`; | |
| const size = keep.reduce((a, f) => a + f.blob.size, 0); | |
| // opening the same scroll again replaces the copy kept before | |
| for (const old of await listLocal()) if (old.id === manifest.id) await deleteLocal(old.key).catch(() => {}); | |
| try { await saveLocal({ key, id: manifest.id, name: manifest.name, added: Date.now(), size, files: keep.map((f) => ({ name: f.name, blob: f.blob })) }); } | |
| catch (e) { throw new Error(`This browser would not store the scroll (${e.message || e}). Private browsing or a full disk can cause this.`); } | |
| return { key, warnings: v.warnings }; | |
| } | |
| export function setupOpenOwn({ $, onOpen, currentKey }) { | |
| const sheet = $('openSheet'), drop = $('oDrop'), status = $('oStatus'); | |
| const show = (html, cls = '') => { status.className = `o-status mono ${cls}`; status.innerHTML = html; status.hidden = !html; }; | |
| const fail = (e) => { | |
| const list = e.list ? `<ul>${e.list.map((x) => `<li>${esc(x)}</li>`).join('')}</ul>` : ''; | |
| show(`<b>Could not open it.</b> ${esc(e.message || e)}${list}`, 'err'); | |
| }; | |
| async function run(get) { | |
| show('Reading files...', 'busy'); | |
| try { | |
| const files = await get(); | |
| const { key, warnings } = await checkAndStore(files, (s) => show(esc(s), 'busy')); | |
| show(`Loaded. ${warnings.length ? `${warnings.length} note${warnings.length > 1 ? 's' : ''}: ${esc(warnings.join(' '))}` : ''} Opening...`, 'ok'); | |
| setTimeout(() => onOpen(key), 300); | |
| } catch (e) { console.warn(e); fail(e); } | |
| } | |
| // drag and drop, including folders | |
| const readEntry = (entry, path = '') => new Promise((ok) => { | |
| if (entry.isFile) entry.file((f) => { f.relPath = path + f.name; ok([f]); }, () => ok([])); | |
| else if (entry.isDirectory) { | |
| const r = entry.createReader(), all = []; | |
| const more = () => r.readEntries(async (es) => { | |
| if (!es.length) { ok((await Promise.all(all)).flat()); return; } | |
| for (const e of es) all.push(readEntry(e, `${path}${entry.name}/`)); | |
| more(); | |
| }, () => ok([])); | |
| more(); | |
| } else ok([]); | |
| }); | |
| for (const t of [drop, sheet]) { | |
| t.addEventListener('dragover', (e) => { e.preventDefault(); drop.classList.add('over'); }); | |
| t.addEventListener('dragleave', () => drop.classList.remove('over')); | |
| t.addEventListener('drop', (e) => { | |
| e.preventDefault(); drop.classList.remove('over'); | |
| const items = [...(e.dataTransfer.items || [])].map((i) => i.webkitGetAsEntry && i.webkitGetAsEntry()).filter(Boolean); | |
| run(async () => gatherFiles(items.length ? (await Promise.all(items.map((it) => readEntry(it)))).flat() : [...e.dataTransfer.files])); | |
| }); | |
| } | |
| // pages can be dropped on anywhere: open the sheet when files are dragged over the 3D view | |
| addEventListener('dragenter', (e) => { if (e.dataTransfer && [...e.dataTransfer.types].includes('Files') && sheet.hidden) open(); }); | |
| $('oPick').onchange = (e) => { const fl = [...e.target.files]; e.target.value = ''; run(() => gatherFiles(fl)); }; | |
| $('oPickDir').onchange = (e) => { const fl = [...e.target.files]; e.target.value = ''; run(() => gatherFiles(fl)); }; | |
| drop.onclick = (e) => { if (e.target === drop || e.target.closest('.o-drop-text')) $('oPick').click(); }; | |
| drop.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); $('oPick').click(); } }; | |
| $('oURLForm').onsubmit = (e) => { | |
| e.preventDefault(); | |
| const u = $('oURL').value.trim(); | |
| if (!u) { show('Paste the URL of a manifest.json or a zip first.', 'err'); return; } | |
| run(() => filesFromURL(u, LOCAL_LIMITS, (s) => show(esc(s), 'busy'))); | |
| }; | |
| $('oExample').onclick = async () => { | |
| $('oExample').disabled = true; | |
| try { | |
| const b = await exampleZip(); | |
| const a = document.createElement('a'); | |
| a.href = URL.createObjectURL(b); a.download = 'unroll-synthetic-scroll.zip'; a.click(); | |
| setTimeout(() => URL.revokeObjectURL(a.href), 4000); | |
| } finally { $('oExample').disabled = false; } | |
| }; | |
| $('oLimits').textContent = `Limits: ${MB(LOCAL_LIMITS.maxFileBytes)} per file, ${MB(LOCAL_LIMITS.maxTotalBytes)} in all, ${LOCAL_LIMITS.maxWindings} windings, ${(LOCAL_LIMITS.maxVertices / 1e6).toFixed(0)} million vertices, images up to ${LOCAL_LIMITS.maxTextureSide} px on a side.`; | |
| async function refreshList() { | |
| const L = await listLocal(); | |
| const box = $('oSaved'); | |
| box.hidden = !L.length; | |
| $('oSavedList').innerHTML = L.map((s) => `<li><span>${esc(s.name)} <span class="dim">${esc(s.id)} · ${MB(s.size || 0)}${s.key === currentKey ? ' · open now' : ''}</span></span>` + | |
| `<span><button class="chip" data-open="${esc(s.key)}" aria-label="Open ${esc(s.name)}" data-testid="open-saved-open-${esc(s.key)}" data-api="openScroll('${esc(s.key)}')">Open</button><button class="chip" data-del="${esc(s.key)}" aria-label="Remove ${esc(s.name)} from this browser" data-testid="open-saved-remove-${esc(s.key)}" data-api="open.removeLocal('${esc(s.key)}')">Remove</button></span></li>`).join(''); | |
| for (const b of box.querySelectorAll('[data-open]')) b.onclick = () => onOpen(b.dataset.open); | |
| for (const b of box.querySelectorAll('[data-del]')) b.onclick = async () => { await deleteLocal(b.dataset.del); if (b.dataset.del === currentKey) onOpen(null); else refreshList(); }; | |
| } | |
| function open() { sheet.hidden = false; refreshList(); $('oClose').focus(); } | |
| $('oClose').onclick = () => { sheet.hidden = true; }; | |
| return { open, refreshList, run }; | |
| } | |