Instrument the play flow with 9 content-free custom events (game_start,
art_style_select, style_image_upload, scene_reached, choice_select,
vision_click, tts_toggle, fullscreen_toggle, play_heartbeat) to measure
retention, engagement depth and session duration.
Privacy is enforced by construction, not convention:
- lib/analytics.ts types each event with a discriminated union, so a
payload has no slot for free text — prompts, world guides, uploaded
images and vision output can never reach analytics (compile-time
guarantee, not a comment).
- track() no-ops without window.umami and never throws into the app.
- coarse 30s heartbeat fires only while the tab is visible.
- script stays gated on NEXT_PUBLIC_UMAMI_* env (blank → no script),
honours Do-Not-Track, and locks to an exact data-domains allowlist.
- one-line on-site disclosure with a link, shown only when tracking is on.
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>
The session-id slice shown in the play header was an opaque timestamp
that reads as noise to players. The footer's "Ⅰ · Ⅰ" was a leftover
decorative mark after its sibling controls were moved above the canvas.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
When the Runware CDN download was slow (~10-20s over VPN / strict
networks, vs. the optimistic <2s the existing comment assumed), the
preload's 8s timeout fired and setImageUrl committed before the bytes
were actually decoded. The rendered <img> has w-auto h-auto and no
intrinsic aspect-ratio source — until the image loads the layout
collapses to roughly 1px tall, giving the "等了很久 → 一根线 → 突然
出图" jank.
Two compounding fixes:
app/play/page.tsx IMAGE_PRELOAD_TIMEOUT_MS 8000 → 20000.
Real CDN+decode usually finishes well before
this; pushing the ceiling out just stops the
window where we commit a half-loaded URL.
components/PlayCanvas.tsx Add width={1792} height={1024} HTML attrs
to the scene <img>. Doesn't affect rendered
size (still driven by w-auto h-auto and the
maxWidth/maxHeight in sizeStyle); the
browser uses them purely as an intrinsic
aspect-ratio source, so the placeholder box
reserves a 16:9-ish frame even mid-download.
Together: slow networks now mostly wait through preload; on the rare
genuine timeout the layout still holds shape instead of collapsing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two coordinated additions to the 绘画风格 modal so the user can shape
the styleGuide that ultimately feeds every painter/director agent,
without ever mutating the source-of-truth STYLE_MAP:
1. New "自定义" entry sits right under "自动" — opens an inline
textarea where the user can write a free-form styleGuide (mix of
Chinese / English, sent verbatim to the image model). Stored as
in-memory state on HomePage (customStyleGuide), so refresh clears
it — fits the "one-shot session" semantics of this UI.
2. Every preset card now exposes a small pencil on the right of its
prompt area. Clicking it inlines a textarea pre-filled with the
current effective prompt (override if any, else STYLE_MAP value).
Saving writes to styleOverrides[name] — a separate in-memory
record keyed by preset name. STYLE_MAP is never written to.
start() selects the styleGuide with this priority:
customStyleGuide (when 自动→自定义)
> styleOverrides[artStyle]
> STYLE_MAP[artStyle]
> STYLE_MAP[DEFAULT_STYLE]
UX polish in the same change:
- 标题永远只读 (only the prompt is editable)
- 只读 prompt 行去掉边框/底色,回归纯文字 + 右上铅笔
- 「自动」项无 prompt 可编辑,标题下直接放一行说明
- 编辑态 textarea 用 ember 边框作为"正在编辑"视觉反馈
- 「保存并选用」一并 onPick + close;「还原默认」清除该预设的 override
- 搜索框同时匹配标题/原名/prompt 内容
- 移除「自由输入」标签 (now visually redundant with the pencil affordance)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
m14 (极简杀机) is currently a 14.7KB placeholder while m18 (数据幽灵)
got a real curated cover this round — promote 数据幽灵 into the front
row and demote 极简杀机 back to its original neighborhood so the visible
首屏 only shows finished art.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two coupled changes so the user's preferred male cards (复古未来梦,
社团存亡日, 黄昏归途, 极简杀机, 辐射新娘, 霓虹义体, 月光下的约定,
花魁的刀) actually appear in the visual front row:
1. Add a DISPLAY_ORDER indirection. STORIES, covers (m{i}.webp),
prebaked first-acts (firstact/m{i}.json) and prompts.json are all
keyed on the original array index — renaming them would touch
dozens of static assets. DISPLAY_ORDER instead lets the homepage
iterate cards in a curated order while still resolving each card's
assets via its original index. Editing one line re-shuffles the
gallery.
2. Switch the gallery wrapper from CSS multi-column (columns-N) to
grid (grid-cols-N). columns fills column-first (top-of-col-1, then
bottom-of-col-1, then top-of-col-2...) so the first eight entries
of DISPLAY_ORDER ended up stacked down the leftmost column instead
of across the top row. Grid fills row-first, which is what "visual
front row" actually means. Cards are already fixed at aspect-ratio
4/5 so row heights stay uniform — no masonry effect lost.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related fixes so the home start button actually reflects what the
user sees:
1. Lift the Typewriter's current phrase index up to HomePage so start()
can read which example is on screen right now. When the textarea is
empty, start() now substitutes that phrase as the user's story seed —
"what you see is what you play", instead of the previous behavior
where an empty input produced a generic worldSetting with no plot
direction and the model invented something unrelated.
2. Restructure the worldSetting string so the user prompt (or the
chosen Typewriter phrase) sits at the top, alone, wrapped in a
strong directive ("必须以此为剧情主线,不要偏离"). Before, the seed
was a single line sandwiched between the gender/style/pace boilerplate
and the generic "edit with dramatic tension" tail, which the Architect
tended to skim past when expanding the bible.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After bed4dc5 renamed style keys to include the (Image N参考) suffix,
the home start() still resolved 「自动」 against the legacy bare name
「京阿尼细腻日常」, leaving styleGuide undefined and tripping the
/api/start required-field check on the default click.
Fall back to "Galgame CG 梦幻光影" — a key that actually exists in
STYLE_MAP — so the default path resolves cleanly without changing the
behavior of explicitly selected styles.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Regenerate 60 covers (30 male + 30 female) via FLUX with story-specific
prompts, replacing the prior gender-shared set
- Crop covers to 4:5 (960×1200) via sharp attention cover; matches new
homepage card aspectRatio
- Persist all 60 prompts to public/home/prompts.json so the prebake step
can reuse the cover's exact visual anchor (per-card styleGuide) and the
first-act scene visually carries over from the poster the player clicked
- Restore /play?card= prebaked instant-play path on homepage card click
- Add OpenAI-compatible image route in ai-client for non-Runware endpoints
- Hide Next.js dev indicators globally; tweak F-key fullscreen label
Co-Authored-By: Claude Opus 4.7 (1M context) <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>