feat(web): 红果-style homepage + instant-play prebaked first acts

Rewrites all 64 homepage cards (32 男性向 + 32 女性向) as short-drama hook
stories (战神归来 / 重生分手前夜 / 系统选妃 / 穿成乙游男配 / 末世异能 / 民国
谍战 / 修真渡劫 …) and regenerates each cover via FLUX in its assigned art
style (12 styles spread across 64 cards) at 832×1024 ≈4:5.

Click-to-play path: cards now jump straight to /play?card=<name> and hydrate
Session from /home/firstact/<name>.json — the engine pipeline (Architect +
Writer + CharacterDesigner + Painter) has been pre-run for 44/64 cards. The
remaining 20 (m14/m29/f14..f31) are pending an LLM credit top-up; their
clicks fall through to live /api/start for now.

Runware-hosted first-scene images are downloaded into /home/firstscene/
and the JSONs are rewritten to point at the local webp, so click → first
image is bounded by local-disk decode (~100ms) instead of CDN round-trip.

Scripts:
- scripts/generate-home-images.mjs  — rewrites all 64 cover prompts, per-card
  styles baked into prompts, 832×1024 dims to match StoryCard aspect
- scripts/prebake-firstacts.mjs     — POST /api/start × 64 with concurrency
  4, saves StartResponse to public/home/firstact/<name>.json
- scripts/localize-firstact-images.mjs — downloads each prebaked imageUrl
  to public/home/firstscene/<name>.webp (q80, ≤1600px) and rewrites JSON

README: adds Screenshots section (3×3 gallery) to README.md / README.zh-CN.md,
9 in-game shots compressed to docs/screenshots/*.webp (7.5MB → 680KB).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
DESKTOP-I1T6TF3\Q
2026-06-02 17:20:34 +08:00
parent 9ae91dd3ed
commit d93c16d836
168 changed files with 680 additions and 544 deletions
+59 -33
View File
@@ -485,48 +485,74 @@ function PlayInner() {
if (startedRef.current) return;
startedRef.current = true;
let payload: { worldSetting: string; styleGuide: string } | null = null;
// 三条进入路径:
// ?card=<m0..f31> → 首页精选卡,直接从 /home/firstact/{name}.json
// 静态文件加载(已在构建期 prebake,免一切引擎调用)
// ?preset=<id> → 内置 PRESETS(仍走 /api/start 现场生成)
// ?custom=1 → 用户自定义 promptsessionStorage 取 ws/sg
// 后走 /api/start 现场生成
const cardName = params.get("card");
const presetId = params.get("preset");
const isCustom = params.get("custom") === "1";
if (presetId) {
const p = PRESETS.find((x) => x.id === presetId);
if (p) payload = { worldSetting: p.worldSetting, styleGuide: p.styleGuide };
} else if (params.get("custom") === "1") {
const stored = sessionStorage.getItem("infiplot:custom");
if (stored) {
try {
const parsed = JSON.parse(stored) as {
worldSetting: string;
styleGuide: string;
audioEnabled?: boolean;
};
payload = { worldSetting: parsed.worldSetting, styleGuide: parsed.styleGuide };
// audioEnabled 已在 useState 初始化时反向投射到 muted;这里无需再额外存。
} catch {
payload = null;
let livePayload: { worldSetting: string; styleGuide: string } | null = null;
if (!cardName) {
if (presetId) {
const p = PRESETS.find((x) => x.id === presetId);
if (p) livePayload = { worldSetting: p.worldSetting, styleGuide: p.styleGuide };
} else if (isCustom) {
const stored = sessionStorage.getItem("infiplot:custom");
if (stored) {
try {
const parsed = JSON.parse(stored) as {
worldSetting: string;
styleGuide: string;
audioEnabled?: boolean;
};
livePayload = { worldSetting: parsed.worldSetting, styleGuide: parsed.styleGuide };
// audioEnabled 已在 useState 初始化时反向投射到 muted;这里无需再额外存。
} catch {
livePayload = null;
}
}
}
}
if (!payload) {
if (!cardName && !livePayload) {
router.replace("/");
return;
}
const finalPayload = payload;
type PrebakedFirstAct = StartResponse & {
worldSetting: string;
styleGuide: string;
cardName?: string;
cardTitle?: string;
cardGender?: string;
};
fetch("/api/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(finalPayload),
})
.then(async (r) => {
if (!r.ok) {
const j = (await r.json().catch(() => ({}))) as { error?: string };
throw new Error(j.error ?? r.statusText);
}
return (await r.json()) as StartResponse;
})
const fetchStart: Promise<PrebakedFirstAct> = cardName
? fetch(`/home/firstact/${encodeURIComponent(cardName)}.json`).then(
async (r) => {
if (!r.ok) throw new Error(`找不到精选剧情:${cardName}`);
return (await r.json()) as PrebakedFirstAct;
},
)
: fetch("/api/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(livePayload),
}).then(async (r) => {
if (!r.ok) {
const j = (await r.json().catch(() => ({}))) as { error?: string };
throw new Error(j.error ?? r.statusText);
}
const data = (await r.json()) as StartResponse;
// Live /api/start doesn't echo ws/sg back — splice in what we sent.
return { ...data, worldSetting: livePayload!.worldSetting, styleGuide: livePayload!.styleGuide };
});
fetchStart
.then(async (data) => {
// Decode the Runware image in memory before committing to state, so
// the <img> renders instantly when it mounts (same rationale as the
@@ -536,8 +562,8 @@ function PlayInner() {
const initial: Session = {
id: data.sessionId,
createdAt: Date.now(),
worldSetting: finalPayload.worldSetting,
styleGuide: finalPayload.styleGuide,
worldSetting: data.worldSetting,
styleGuide: data.styleGuide,
history: [
{
scene: data.scene,