Addresses two GitHub Copilot review comments on PR #24:
- preloadImage cleared the 20s timeout in onload, before awaiting
img.decode(), leaving the decode phase unguarded — a hung decode could
keep the promise pending forever and stall the play loop. Move
clearTimeout into a single idempotent done() so the timeout stays armed
through decode() too, matching the stated "timeouts resolve quietly"
intent.
- .env.example said to leave BOTH proxy vars blank, but shipped
NEXT_PUBLIC_IMAGE_PROXY_ALLOWED_HOSTS=im.runware.ai. Only
NEXT_PUBLIC_IMAGE_PROXY_URL gates the feature; the allowlist is inert
until the URL is set. Corrected the wording, kept the self-documenting
default value.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
b805b1d routed every scene <img> through fetch → Blob → createObjectURL to
kill QUIC progressive-paint, but in doing so added an *unconditional*
dependency on a CORS-adding proxy. That breaks the default deployment:
im.runware.ai sends no Access-Control-Allow-Origin, so a direct
fetch().blob() throws and the scene image silently fails to load for anyone
who hasn't stood up the Cloudflare Worker.
Restore the pre-b805b1d behavior as the *default* and make the proxy
strictly opt-in:
- Direct path (no env set): preloadImage() warms the HTTP cache + decodes,
then <img> uses the original https://im.runware.ai URL — as before
b805b1d. No fetch().blob(), no CORS dependency: a fresh clone just works.
- Proxy path (NEXT_PUBLIC_IMAGE_PROXY_URL set): fetch the proxied URL →
Blob → createObjectURL, exactly as b805b1d, gaining the QUIC-immune
HTTP/2 edge + atomic paint.
shouldProxy(url) gates the two paths: proxy only when a base is configured
AND the host is in NEXT_PUBLIC_IMAGE_PROXY_ALLOWED_HOSTS (default
im.runware.ai). data: / non-http / unknown-host URLs always take the direct
path. blobUrlCache + revoke logic is unchanged and safe for both paths
(revoke is a no-op on non-blob: URLs).
The Cloudflare Worker moves out of this repo into a standalone, one-click-
deployable project (infiplot-image-proxy) so the optional infra isn't
carried by every clone; .env.example and the READMEs link to it.
restore: preloadImage() helper deleted by b805b1d
add: NEXT_PUBLIC_IMAGE_PROXY_ALLOWED_HOSTS (default im.runware.ai)
remove: worker/ (moved to standalone repo)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Symptom: in Chrome on certain networks the scene <img> renders row-by-row
from top to bottom — "层层加载" — instead of appearing atomically.
Root cause (confirmed via DevTools):
- Chrome opportunistically opens HTTP/3 (QUIC) to im.runware.ai.
- QUIC streams to Runware sometimes error mid-transfer:
net::ERR_QUIC_PROTOCOL_ERROR
HTTP-level status stays 200 (response headers received), but bytes are
truncated. The browser paints whatever PNG bytes it has so far → visible
row-by-row decode.
- The earlier preloadImage()+decode() trick can't fix this — neither
HTTP-cache reuse nor sync decode helps when the bytes themselves were
never fully delivered.
Two-tier fix:
1. Client: fetch → Blob → URL.createObjectURL() (app/play/page.tsx)
- <img src> only ever points to a blob: URL whose bytes are 100%
resident in the JS heap. No network-backed src = no possibility of
progressive paint.
- Module-level blobUrlCache keys by original URL so speculative
prefetch + the eventual commit share one fetch.
- Old blobs are URL.revokeObjectURL()'d on scene swap + unmount to
release memory.
2. Network: optional Cloudflare Worker proxy (worker/)
- Browser ↔ Worker is HTTP/2 over CF edge (extremely stable).
- Worker ↔ Runware is a server-to-server fetch (no QUIC fragility,
Cloudflare's backbone handles transit).
- Worker buffers the full upstream response → client never sees a
half-stream.
- Bonus: CF edge cache (cacheEverything, 1y TTL) on Runware UUIDs;
Access-Control-Allow-Origin: * so client fetch() can't hit CORS.
- Hardened: only proxies im.runware.ai, only GET/HEAD/OPTIONS, all
other hosts/methods → 403/405.
Wired via NEXT_PUBLIC_IMAGE_PROXY_URL (inlined at build). Empty → no proxy
→ direct fetch (which still uses the blob path, just exposed to QUIC).
──────────────────────────────────────────────────────────────────────
Deploy steps (one-time, do this AFTER pulling this commit):
1. Install wrangler globally:
npm i -g wrangler
2. Log in to Cloudflare (opens browser for OAuth):
wrangler login
3. From the worker/ directory, deploy:
cd worker
wrangler deploy
wrangler will print the deployed URL, e.g.
https://infiplot-image-proxy.<your-cf-username>.workers.dev
4. Paste that URL into .env.local for local dev:
NEXT_PUBLIC_IMAGE_PROXY_URL=https://infiplot-image-proxy.<...>.workers.dev
…and into Vercel project settings (Environment Variables) for prod.
NEXT_PUBLIC_ vars are inlined at build time, so the URL bakes into
the bundle on the next deploy/dev-server restart.
5. Restart dev server (pnpm dev) so the new env baked in. Generate a
scene; Network tab should show requests going to *.workers.dev
instead of im.runware.ai, no ERR_QUIC_PROTOCOL_ERROR, image renders
atomically.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Cookieless, env-gated page-view tracking via Umami. The <Analytics />
component injects the script only when NEXT_PUBLIC_UMAMI_SRC and
NEXT_PUBLIC_UMAMI_WEBSITE_ID are both set, so local dev and forks send
nothing to our instance. Adds .env.example docs (section 6) and a
homepage footer privacy disclosure. No Cookie consent banner needed.
Flatten the pnpm monorepo (apps/web + packages/*) into a single web package at the repo root.
- Move app/lib/components/scripts/public to root; drop apps/web and packages/* wrappers
- Rewrite tsconfig paths (@infiplot/*) to ./lib/*; turbopack.root = __dirname
- Update Vercel (no root-directory) and Cloudflare (pnpm build:cf at root) deploy paths
- Regenerate pnpm-lock.yaml to drop stale workspace importers
- Bump engines.node to >=22 to match wrangler
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Engine
- Split /api/vision out from /api/interact so client can drive
prefetch + cache lookup independently of click interpretation
- Image client switched to chat-completions+modalities API (OpenRouter/
provider style), supporting markdown image URL responses
- annotateClick now resizes to 768w before composite to keep vision
payloads small and avoid CDN timeouts
- Prompts updated to mention "JSON" in user messages (required by
Gemini's strict JSON mode)
- Shared fetchWithRetry helper: 2 retries for chat/image, 0 for vision
(with 60s hard timeout)
Client
- Parallel prefetch of all three choice branches on each new frame
- Effect deliberately excludes phase from deps so user-click doesn't
abort in-flight prefetches
- Cache hit/miss/free-form fallback handled in handleClick
- PlayCanvas reads img naturalWidth/Height and adapts container to
whatever aspect AI returns (no more cropped third choice)
- max-width raised to 560px, max-height calc(100dvh - 200px)
Misc
- README env-path corrected to apps/web/.env.local
- users.md: BGM/TTS idea note
- .env.example moved into apps/web alongside next config
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>