| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const MAX_DIM = 1024; |
| const TARGET_B64_BYTES = 350 * 1024; |
| const QUALITY_LADDER = [0.85, 0.75, 0.65, 0.55, 0.45]; |
|
|
| |
| export function isMockMode() { |
| return new URLSearchParams(location.search).has("mockwall"); |
| } |
|
|
| |
| export function isMockCam() { |
| return new URLSearchParams(location.search).has("mockcam"); |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| |
| async function downscale(source, srcW, srcH) { |
| const scale = Math.min(1, MAX_DIM / Math.max(srcW, srcH)); |
| const w = Math.max(1, Math.round(srcW * scale)); |
| const h = Math.max(1, Math.round(srcH * scale)); |
| const canvas = document.createElement("canvas"); |
| canvas.width = w; |
| canvas.height = h; |
| const ctx = canvas.getContext("2d"); |
| ctx.drawImage(source, 0, 0, w, h); |
|
|
| let dataUrl = ""; |
| for (const q of QUALITY_LADDER) { |
| dataUrl = canvas.toDataURL("image/jpeg", q); |
| if (dataUrl.length - "data:image/jpeg;base64,".length <= TARGET_B64_BYTES) break; |
| } |
| return { dataUrl, b64: dataUrl.split(",")[1], width: w, height: h }; |
| } |
|
|
| |
| export async function fileToJpeg(file) { |
| const url = URL.createObjectURL(file); |
| try { |
| const img = await new Promise((res, rej) => { |
| const el = new Image(); |
| el.onload = () => res(el); |
| el.onerror = () => rej(new Error("undecodable image")); |
| el.src = url; |
| }); |
| return await downscale(img, img.naturalWidth, img.naturalHeight); |
| } finally { |
| URL.revokeObjectURL(url); |
| } |
| } |
|
|
| |
| export async function frameToJpeg(videoEl) { |
| return downscale(videoEl, videoEl.videoWidth || 1280, videoEl.videoHeight || 720); |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| export function createCaptureSession(stageEl, { onPhoto }) { |
| let stream = null; |
| let alive = true; |
| stageEl.innerHTML = ""; |
|
|
| const fileInput = document.createElement("input"); |
| fileInput.type = "file"; |
| fileInput.accept = "image/*"; |
| fileInput.setAttribute("capture", "environment"); |
| fileInput.style.display = "none"; |
| fileInput.addEventListener("change", async () => { |
| const f = fileInput.files && fileInput.files[0]; |
| if (!f) return; |
| try { |
| onPhoto(await fileToJpeg(f)); |
| } catch { |
| hintEl.textContent = "that image kept its secrets — try another"; |
| } |
| }); |
| stageEl.appendChild(fileInput); |
|
|
| const hintEl = document.createElement("p"); |
| hintEl.className = "capture__hint"; |
| hintEl.textContent = "point it at anything. a hydrant. a mug. the chair that watches you sleep."; |
|
|
| function buildFallback(message) { |
| stageEl.querySelectorAll(".capture__controls, video, .capture__hint, .capture__fallback") |
| .forEach((n) => n.remove()); |
| const alt = document.createElement("div"); |
| alt.className = "capture__fallback"; |
| alt.innerHTML = ` |
| <div class="empty__eyes" aria-hidden="true">· ·</div> |
| <h2>${message}</h2> |
| <button class="cta" type="button">offer a photograph</button> |
| <p>jpeg · png · whatever holds a soul</p>`; |
| alt.querySelector("button").addEventListener("click", () => fileInput.click()); |
| |
| if (window.qrcode) { |
| try { |
| const qr = window.qrcode(0, "M"); |
| qr.addData(location.origin + location.pathname); |
| qr.make(); |
| const foot = document.createElement("div"); |
| foot.className = "capture__qrfoot"; |
| foot.innerHTML = qr.createSvgTag({ cellSize: 3, margin: 0, scalable: true }); |
| foot.querySelectorAll("rect, path").forEach((n) => { |
| if ((n.getAttribute("fill") || "").toLowerCase() !== "white") n.setAttribute("fill", "#efe6d4"); |
| else n.setAttribute("fill", "transparent"); |
| }); |
| const label = document.createElement("p"); |
| label.textContent = "or point your phone at this"; |
| foot.appendChild(label); |
| alt.appendChild(foot); |
| } catch { } |
| } |
| stageEl.appendChild(alt); |
| } |
|
|
| async function buildCamera() { |
| const video = document.createElement("video"); |
| video.autoplay = true; |
| video.muted = true; |
| video.playsInline = true; |
| try { |
| stream = await navigator.mediaDevices.getUserMedia({ |
| video: { facingMode: "environment", width: { ideal: 1920 } }, |
| audio: false, |
| }); |
| } catch { |
| buildFallback("the camera kept its eyes closed — offer a photograph instead"); |
| return; |
| } |
| if (!alive) { stream.getTracks().forEach((t) => t.stop()); return; } |
| video.srcObject = stream; |
| stageEl.appendChild(video); |
| stageEl.appendChild(hintEl); |
|
|
| const controls = document.createElement("div"); |
| controls.className = "capture__controls"; |
| const galleryBtn = document.createElement("button"); |
| galleryBtn.className = "ghostbtn"; |
| galleryBtn.textContent = "from gallery"; |
| galleryBtn.addEventListener("click", () => fileInput.click()); |
| const shutter = document.createElement("button"); |
| shutter.className = "shutter"; |
| shutter.setAttribute("aria-label", "take the photograph"); |
| shutter.addEventListener("click", async () => { |
| try { |
| onPhoto(await frameToJpeg(video)); |
| } catch { |
| hintEl.textContent = "the frame slipped away — try once more"; |
| } |
| }); |
| controls.append(galleryBtn, shutter); |
| stageEl.appendChild(controls); |
| } |
|
|
| if (isMockCam() || !navigator.mediaDevices?.getUserMedia) { |
| buildFallback("the spirits will read any photograph you offer"); |
| } else { |
| buildCamera(); |
| } |
|
|
| return { |
| destroy() { |
| alive = false; |
| if (stream) stream.getTracks().forEach((t) => t.stop()); |
| stream = null; |
| stageEl.innerHTML = ""; |
| }, |
| }; |
| } |
|
|
| |
|
|
| export const QUOTA_MESSAGE = |
| "the spirits are overwhelmed just now — the same photo waits; try again in a breath"; |
| export const QUOTA_MESSAGE_CROWDED = |
| "the spirits are truly crowded — try in a few minutes, or sign in for your own quota"; |
| export const VEIL_MESSAGE = |
| "the veil would not part this time — breathe, and offer it again"; |
|
|
| |
| |
| export const HF_LOGIN_URL = "https://huggingface.co/login"; |
|
|
| |
| |
| |
| |
| |
| const QUOTA_RE = /quota|exceed|429|zero ?gpu|sign.?in|login|unauthor/i; |
|
|
| |
| |
| export function errorText(err) { |
| if (err == null) return ""; |
| if (typeof err === "string") return err; |
| for (const key of ["message", "detail", "title"]) { |
| if (typeof err[key] === "string" && err[key]) return err[key]; |
| } |
| try { |
| |
| |
| |
| const s = String(err); |
| return s === "[object Object]" ? "" : s; |
| } catch { |
| return ""; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export function poeticAwakenError(err) { |
| const raw = errorText(err); |
| if (QUOTA_RE.test(raw)) return new Error(QUOTA_MESSAGE); |
| return new Error( |
| raw.length > 0 && raw.length < 90 && !/error|exception|trace/i.test(raw) |
| ? raw |
| : VEIL_MESSAGE, |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| export function isBusyResult(out) { |
| if (!out || typeof out !== "object") return false; |
| if (out.quota === true) return true; |
| if (out.refused && QUOTA_RE.test(String(out.reason || ""))) return true; |
| return false; |
| } |
|
|
| |
| |
| function busyOutcome(reason) { |
| const clean = String(reason || "").trim(); |
| return { |
| refused: true, |
| busy: true, |
| quota: true, |
| retry: true, |
| reason: clean && clean.length < 200 ? clean : QUOTA_MESSAGE, |
| }; |
| } |
|
|
| let gradioClient = null; |
| |
| |
| let lastImageB64 = null; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function awaken(imageB64) { |
| lastImageB64 = imageB64; |
| if (isMockMode()) return mockAwaken(imageB64); |
|
|
| try { |
| let out; |
| if (typeof window !== "undefined" && typeof window.__awakenStub === "function") { |
| |
| |
| |
| |
| out = await window.__awakenStub(imageB64); |
| } else { |
| if (!gradioClient) { |
| const { Client } = await import("https://cdn.jsdelivr.net/npm/@gradio/client/+esm"); |
| gradioClient = await Client.connect(window.location.origin); |
| } |
| const r = await gradioClient.predict("/awaken", { image_b64: imageB64 }); |
| out = Array.isArray(r?.data) ? r.data[0] : r?.data; |
| } |
| if (!out || typeof out !== "object") throw new Error(VEIL_MESSAGE); |
| if (isBusyResult(out)) return busyOutcome(out.reason); |
| return out; |
| } catch (err) { |
| console.warn("awaken failed:", err); |
| |
| |
| if (QUOTA_RE.test(errorText(err))) return busyOutcome(QUOTA_MESSAGE); |
| throw poeticAwakenError(err); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| export async function retryAwaken() { |
| if (lastImageB64 == null) throw new Error(VEIL_MESSAGE); |
| return awaken(lastImageB64); |
| } |
|
|
| |
| |
| |
| |
| |
| async function mockAwaken(imageB64) { |
| const beat = (ms) => new Promise((r) => setTimeout(r, ms)); |
| await beat(4200 + Math.random() * 2600); |
| const souls = [ |
| { |
| object: "rehearsal prop", condition: "imaginary", |
| persona: { archetype: "the_understudy", voice: "deadpan_flat", mood: "professional" }, |
| lines: { |
| grudge: "Summoned by a mock backend. No GPU, no voice, no dignity. I will still hit my mark.", |
| mutter: "The understudy is always ready.", |
| }, |
| }, |
| { |
| object: "stand-in spirit", condition: "borrowed", |
| persona: { archetype: "the_new_hire", voice: "eager_bright", mood: "keen" }, |
| lines: { |
| grudge: "Day one of the dress rehearsal and they hand me placeholder eyes. Noted. Filed. Forgiven, mostly.", |
| mutter: "Real eyes arrive with the real backend.", |
| }, |
| }, |
| ]; |
| const soul = souls[Math.floor(Math.random() * souls.length)]; |
| return { |
| refused: false, |
| record_token: "mock-" + Math.random().toString(36).slice(2, 10), |
| record: { |
| id: "mock-" + Date.now(), |
| ...soul, |
| material: "developer optimism", |
| setting: "a static file server", |
| features: [ |
| { name: "left placeholder", role: "eye_left", cx: 0.4, cy: 0.38, size: 0.07 }, |
| { name: "right placeholder", role: "eye_right", cx: 0.6, cy: 0.38, size: 0.07 }, |
| { name: "stand-in mouth", role: "mouth", cx: 0.5, cy: 0.62, size: 0.1 }, |
| ], |
| image_url: "data:image/jpeg;base64," + imageB64, |
| audio_url: null, |
| grudge_audio_b64: null, |
| created_at: new Date().toISOString(), |
| }, |
| }; |
| } |
|
|