fix(play): scene image renders progressively from top → CF Worker proxy

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>
This commit is contained in:
DESKTOP-I1T6TF3\Q
2026-06-03 22:50:48 +08:00
parent 347ab297d5
commit b805b1d9c2
4 changed files with 235 additions and 42 deletions
+90
View File
@@ -0,0 +1,90 @@
// ─────────────────────────────────────────────────────────────────────────
// InfiPlot — Runware image proxy (Cloudflare Worker)
//
// Why this exists:
// Chrome's direct fetch of `im.runware.ai` images sometimes fails with
// `ERR_QUIC_PROTOCOL_ERROR` — HTTP/3 stream errors mid-transfer leave the
// browser holding a partial PNG, which it renders progressively
// (the "层层从上往下" visible-decode glitch). Routing the fetch through
// this Worker fixes it in two ways:
//
// 1. Browser ↔ Worker is HTTP/2 over Cloudflare's edge — extremely
// stable, no QUIC fragility.
// 2. Worker ↔ Runware is a server-to-server fetch (Cloudflare's
// backbone) — also reliable, and the Worker buffers the full
// response before streaming it back, so the client never gets
// partial bytes mid-stream.
//
// Bonus side-effects:
// - CORS: Worker adds `Access-Control-Allow-Origin: *` so the client's
// `fetch()` → blob URL path works regardless of Runware's policy.
// - Edge cache: same Runware UUID re-fetched twice in 24h hits the CF
// edge cache, sub-50ms response from anywhere in the world.
//
// Hardening:
// - Only proxies `im.runware.ai` (open proxies invite abuse + quota burn).
// - Only accepts GET / HEAD / OPTIONS.
// ─────────────────────────────────────────────────────────────────────────
const ALLOWED_HOST = "im.runware.ai";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
"Access-Control-Max-Age": "86400",
};
export default {
async fetch(req) {
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
if (req.method !== "GET" && req.method !== "HEAD") {
return new Response("method not allowed", { status: 405, headers: corsHeaders });
}
const reqUrl = new URL(req.url);
const target = reqUrl.searchParams.get("url");
if (!target) {
return new Response("missing ?url=", { status: 400, headers: corsHeaders });
}
let targetUrl;
try {
targetUrl = new URL(target);
} catch {
return new Response("malformed ?url=", { status: 400, headers: corsHeaders });
}
if (targetUrl.hostname !== ALLOWED_HOST) {
return new Response(`only ${ALLOWED_HOST} is allowed`, {
status: 403,
headers: corsHeaders,
});
}
// Fetch upstream. `cf.cacheEverything: true` tells the CF edge to cache
// by URL even though Runware's own cache headers are weak — so a second
// hit on the same UUID lands in edge memory rather than re-touching
// Runware. 1y TTL: image UUIDs are immutable, the bytes never change.
const upstream = await fetch(targetUrl.toString(), {
cf: { cacheTtl: 31536000, cacheEverything: true },
});
// Stream the body through (no buffering — CF Workers' Response can take
// a ReadableStream directly). Rebuild headers to add CORS + strong cache
// hints, preserve content-type / content-length from upstream.
const headers = new Headers(corsHeaders);
headers.set(
"Content-Type",
upstream.headers.get("content-type") ?? "image/png",
);
headers.set("Cache-Control", "public, max-age=31536000, immutable");
const len = upstream.headers.get("content-length");
if (len) headers.set("Content-Length", len);
return new Response(upstream.body, {
status: upstream.status,
headers,
});
},
};
+16
View File
@@ -0,0 +1,16 @@
# Cloudflare Worker — Runware image proxy.
# See worker/src/index.js for what it does and why.
#
# Deploy:
# 1. `npm i -g wrangler` (one-time)
# 2. `wrangler login` (one-time, OAuth flow in browser)
# 3. From this directory: `wrangler deploy`
# 4. wrangler prints the deployed URL, e.g.
# https://infiplot-image-proxy.<your-cf-username>.workers.dev
# 5. Set NEXT_PUBLIC_IMAGE_PROXY_URL=<that URL> in .env.local for dev
# and in Vercel project settings for prod.
name = "infiplot-image-proxy"
main = "src/index.js"
compatibility_date = "2025-01-01"
workers_dev = true