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>
This commit is contained in:
yuanzonghao
2026-06-07 00:12:37 +08:00
parent 04b869eed0
commit 95a66d94ed
123 changed files with 107 additions and 24 deletions
+4 -3
View File
@@ -28,9 +28,10 @@ import sharp from "sharp";
const __dirname = dirname(fileURLToPath(import.meta.url));
const WEB_ROOT = resolve(__dirname, "..");
const FIRSTACT_DIR = resolve(WEB_ROOT, "public", "home", "firstact");
const FIRSTSCENE_DIR = resolve(WEB_ROOT, "public", "home", "firstscene");
const PUBLIC_LOCAL_PREFIX = "/home/firstscene/";
const PORTRAIT = process.argv.includes("--portrait");
const FIRSTACT_DIR = resolve(WEB_ROOT, "public", "home", PORTRAIT ? "firstact-portrait" : "firstact");
const FIRSTSCENE_DIR = resolve(WEB_ROOT, "public", "home", PORTRAIT ? "firstscene-portrait" : "firstscene");
const PUBLIC_LOCAL_PREFIX = PORTRAIT ? "/home/firstscene-portrait/" : "/home/firstscene/";
const MAX_EDGE = 1600;
const QUALITY = 80;
+25 -7
View File
@@ -24,27 +24,36 @@ import { existsSync, mkdirSync, writeFileSync, statSync, readFileSync } from "no
const __dirname = dirname(fileURLToPath(import.meta.url));
const WEB_ROOT = resolve(__dirname, "..");
const OUT_DIR = resolve(WEB_ROOT, "public", "home", "firstact");
const PROMPTS_FILE = resolve(WEB_ROOT, "public", "home", "prompts.json");
const PAGE_FILE = resolve(WEB_ROOT, "app", "page.tsx");
const FORCE = process.argv.includes("--force");
const PORTRAIT = process.argv.includes("--portrait");
const ONLY = process.argv.find(a => a.startsWith("--only="))?.split("=")[1]?.split(",") ?? null;
const BASE_URL = process.env.BASE_URL ?? "http://localhost:3000";
const CONCURRENCY = 1;
const OUT_DIR = resolve(
WEB_ROOT, "public", "home",
PORTRAIT ? "firstact-portrait" : "firstact",
);
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
// Dynamically extract STYLE_MAP and STORIES from page.tsx to avoid code duplication
console.log("[prebake] Parsing page.tsx to extract style map and card list...");
const pageContent = readFileSync(PAGE_FILE, "utf8");
// Dynamically extract STYLE_MAP from lib/options.ts and STORIES from page.tsx
console.log("[prebake] Parsing lib/options.ts + page.tsx to extract style map and card list...");
const styleMapMatch = pageContent.match(/const STYLE_MAP: Record<string, string> = (\{[\s\S]*?\n\});/m);
const OPTIONS_FILE = resolve(WEB_ROOT, "lib", "options.ts");
const optionsContent = readFileSync(OPTIONS_FILE, "utf8");
const styleMapMatch = optionsContent.match(/export const STYLE_MAP: Record<string, string> = (\{[\s\S]*?\n\});/m);
if (!styleMapMatch) {
console.error("Could not find STYLE_MAP in page.tsx!");
console.error("Could not find STYLE_MAP in lib/options.ts!");
process.exit(1);
}
const STYLE_MAP = eval("(" + styleMapMatch[1] + ")");
const pageContent = readFileSync(PAGE_FILE, "utf8");
const storiesMatch = pageContent.match(/const STORIES: Record<Gender, StoryContent\[\]> = (\{[\s\S]*?\n\});/m);
if (!storiesMatch) {
console.error("Could not find STORIES in page.tsx!");
@@ -80,6 +89,13 @@ for (const [gender, list] of Object.entries(STORIES)) {
});
}
if (ONLY) {
const keep = new Set(ONLY);
const removed = CARDS.length;
CARDS.splice(0, CARDS.length, ...CARDS.filter(c => keep.has(c.name)));
console.log(`[prebake] --only filter: ${removed}${CARDS.length} cards`);
}
function buildPayload(card) {
const worldSetting = [
`这是一款面向【${card.gender}】观众的 AI 交互剧情游戏,整体走红果短视频式的强戏剧冲突与快速反转。`,
@@ -94,7 +110,9 @@ function buildPayload(card) {
COVER_PROMPTS[card.name] ??
STYLE_MAP[card.style] ??
STYLE_MAP["京阿尼细腻日常"];
return { worldSetting, styleGuide };
const payload = { worldSetting, styleGuide };
if (PORTRAIT) payload.orientation = "portrait";
return payload;
}
async function bakeOne(card) {