Commit Graph

25 Commits

Author SHA1 Message Date
yuanzonghao 95a66d94ed feat(web): support portrait preset story cards on mobile
Mobile users clicking preset story cards now get portrait (9:16) scene
images instead of landscape. Previously card paths hardcoded orientation
to "landscape"; now they respect detectOrientation() and load from
firstact-portrait/ with graceful fallback to landscape.

- Add --portrait and --only flags to prebake-firstacts.mjs
- Add --portrait flag to localize-firstact-images.mjs
- Fix prebake STYLE_MAP extraction (moved to lib/options.ts)
- Generate 60 portrait firstact JSONs + firstscene webp assets
- Remove hardcoded "landscape" in play page card path

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-07 00:12:37 +08:00
yuanzonghao 9794a5a329 fix(play): fix CLAUDE.md typo and dialogue history memo anti-pattern
- Fix @AGETNTS.md → @AGENTS.md typo in CLAUDE.md
- Remove ref read inside useMemo (React anti-pattern causing one-frame stale data)
- Simplify buildDialogueHistory to read visitedBeatIds directly from session.history,
  which also fixes incorrect scene-ID matching when the same ID appears multiple times

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-06 21:39:24 +08:00
baizhi958216 5a7daa8452 feat(play): add history dialog
Signed-off-by: baizhi958216 <1475289190@qq.com>
2026-06-06 20:52:10 +08:00
yuanzonghao d646ce8db8 refactor(web): remove client-side BYO API key feature
The BYO (Bring Your Own) API key configuration for LLM and image
generation will be re-implemented via Cloudflare Workers. Remove
the client-side implementation to prepare for that migration.

TTS (text-to-speech) BYO key support is intentionally preserved.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-06 17:42:00 +08:00
yuanzonghao e88e988de3 fix(web): reduce FOT by stripping redundant voice data from transport
Three transport-only optimizations that cut per-session Vercel FOT by ~50-60%:

P0 — Server strips voice.referenceAudioBase64 from already-known characters
in /api/scene and /api/insert-beat responses (defense-in-depth).

P1 — Client strips all voice data from session before sending to
/api/scene, /api/vision, and /api/insert-beat. Voices are retained locally
and re-merged from responses via mergeCharactersPreserveVoice(). The engine
only needs character names + visualDescriptions for scene generation.

P3 — /api/beat-audio returns binary audio (Response with Content-Type)
instead of JSON-wrapped base64, saving ~33% encoding overhead. Client
converts to blob URLs; PlayCanvas accepts a single audioSrc prop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-05 00:24:34 +08:00
Zonghao Yuan c30d11d60b fix(security): harden BYO API header against SSRF and input abuse (#33)
* fix(security): harden BYO API header against SSRF and input abuse

- Add lib/validateUrl.ts with HTTPS-only + public-IP enforcement,
  provider allowlist, IPv6 rejection, and userinfo-in-URL blocking.
- Add lib/byoHeaders.ts — single source of truth for client-side BYO
  header construction (deduplicates app/page.tsx & app/play/page.tsx).
- config.ts: validate BYO endpoints via isPublicUrl(), cap header at
  2 KB, truncate apiKey/model strings, sanitize log output.
- fetchWithRetry: default redirect to "manual" to block 302-to-intranet.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(security): address Copilot review — trim endpoint, strip control chars, drop unused import

- safeEndpoint: trim whitespace before URL validation
- safeString: strip ASCII control characters to prevent header injection
- play/page.tsx: remove unused BYO_STORAGE_KEY import

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-05 00:23:35 +08:00
yuanzonghao ea207e103b fix(play): lock orientation pre-paint to avoid portrait loading flash
Set the session orientation in an isomorphic layout effect so portrait
phones don't flash the landscape loading chrome for a frame before the
bootstrap effect runs. State still inits to "landscape" for SSR-safety;
the correction now lands before first paint (no-op on landscape devices).

Addresses Copilot review on PR #31.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 17:30:55 +08:00
yuanzonghao 9fc83de276 feat(web,engine): portrait-orientation scene images for mobile full-bleed
Thread orientation (portrait|landscape) from client through API, engine,
and image gen. Portrait devices render 1024x1792 (9:16) full-bleed scenes;
desktop/landscape keeps 1792x1024 (16:9). Adds cover-aware click→image
coordinate mapping, session-locked orientation, a shared coerceOrientation
helper, and a choices overflow cap in portrait.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 17:30:54 +08:00
yuanzonghao b0b2e922d3 feat(web): optional bring-your-own Xiaomi MiMo TTS key (browser-side synthesis)
Public users share one server TTS key, so Xiaomi's per-key RPM/TPM limits
cause silent playback under concurrency. This adds an OPTIONAL path: a user
can store their own Xiaomi MiMo key in the browser and synthesize voice
client-side against Xiaomi's CORS-open endpoints. The key lives only in
localStorage and is never sent to or logged by our server; the shared server
key still serves everyone who does not opt in.

- components/TtsKeyModal.tsx: shared key modal (key-family + region picker),
  reused by both the home and play pages
- app/play/page.tsx: silence nudge moved beside the mute toggle; modal opens
  in place instead of redirecting to the home page
- app/page.tsx: home page consumes the shared modal + readStoredTtsConfig
- lib/clientTtsConfig.ts, lib/ttsPresets.ts: browser config + region presets
- app/api/{start,scene,insert-beat}: thread per-request voice; lib/types update
- docs/xiaomi-tts-key.md + README note

Verified with tsc --noEmit (exit 0).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 16:58:55 +08:00
DESKTOP-I1T6TF3\Q 592c82816a Revert "feat(loading): support typewriter story teaser during first scene generation"
This reverts commit 4e4e06ec8a.
2026-06-04 15:13:03 +08:00
DESKTOP-I1T6TF3\Q a00095df66 Revert "fix(image): try fetching image as a blob directly first to avoid progressive rendering"
This reverts commit 676c0f1af8.
2026-06-04 15:13:03 +08:00
DESKTOP-I1T6TF3\Q 676c0f1af8 fix(image): try fetching image as a blob directly first to avoid progressive rendering 2026-06-04 15:08:39 +08:00
DESKTOP-I1T6TF3\Q 4e4e06ec8a feat(loading): support typewriter story teaser during first scene generation 2026-06-04 14:40:35 +08:00
DESKTOP-I1T6TF3\Q e04c51e875 feat(api): support custom BYO API header override on client fetches and backend config 2026-06-04 13:49:46 +08:00
Zonghao Yuan af155ac107 Merge pull request #24 from zonghaoyuan/fix/optional-image-proxy
fix(play): make scene-image proxy opt-in (default direct-connect)
2026-06-04 11:25:11 +08:00
yuanzonghao 4bc47d8210 fix(play): bound preloadImage decode by the timeout; clarify proxy env docs
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>
2026-06-04 11:04:16 +08:00
yuanzonghao e095650944 refactor(web): enforce content-free Umami fields at compile time
Address the Copilot review on #26.

#1 The game_start / art_style_select payload fields were typed as bare
   `string`, so free text could still slip through despite the "content-free
   by construction" claim. Add lib/options.ts as the single source of truth
   for the selector option sets (`as const` → literal-union types), have the
   home OPTS render from those arrays, and type the analytics fields from the
   derived unions (gender/art_style/plot_style/pacing/style) plus a template
   type for `card`. Free text now fails to compile; no casts at call sites.

#2 The /play heartbeat scheduled its 30s interval unconditionally. Gate the
   effect on the same NEXT_PUBLIC_UMAMI_* env used for script injection, so
   nothing is scheduled when the tracker is off (visibility check kept — a
   hidden tab still never emits).

#3 choice_select no longer emits a -1 choice_index: skip the event when the
   index can't be resolved instead of polluting the index distribution.

Verified with tsc (exit 0) and a throwaway negative test: free text in any
of the six fields raises TS2322, valid enum/template values compile.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 10:59:31 +08:00
yuanzonghao 4bf05f6784 feat(web): add privacy-friendly Umami custom events
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>
2026-06-04 10:14:08 +08:00
yuanzonghao 4347e5bfdf fix(play): make scene-image proxy opt-in — default deployers connect direct
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>
2026-06-04 09:57:29 +08:00
DESKTOP-I1T6TF3\Q b805b1d9c2 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>
2026-06-03 22:50:48 +08:00
DESKTOP-I1T6TF3\Q 347ab297d5 feat(web,engine): custom style — image upload, AI-extract prompt, painter ref
自定义画风入口里加上传按钮:客户端把图缩到 512px webp(base64),传到新
路由 /api/parse-style-image,vision LLM 解析成英文 style prompt 回填 textarea;
图本身随 sessionStorage → /api/start → Session.styleReferenceImage 透传,
painter.collectReferenceImages 把它置于 slot 0,整局每一幕都作为 reference
图锚定画风(brush / color / mood),比 priorScene 优先级更高。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 19:15:19 +08:00
yuanzonghao 3fa3da5378 chore(play): remove session-id readout and decorative footer mark
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>
2026-06-03 16:00:16 +08:00
DESKTOP-I1T6TF3\Q b5f73d8082 fix(play): scene image renders as 1px sliver while CDN bytes still arrive
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>
2026-06-03 07:24:42 +08:00
DESKTOP-I1T6TF3\Q bed4dc5a8f feat(web): gender-differentiated 4:5 covers + per-card styleGuide prebake
- 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>
2026-06-03 02:26:35 +08:00
Zonghao Yuan dc5ecd60f6 refactor: flatten monorepo to single web package (#12)
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>
2026-06-03 00:55:45 +08:00