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:
+59
-33
@@ -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 → 用户自定义 prompt,sessionStorage 取 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,
|
||||
|
||||
Reference in New Issue
Block a user