feat(web): InfiPlot low-fi homepage with AI-generated cards + gender-reactive hero + audio toggle fix

Rebuilds the landing page from the prototype: 1900px scale-to-fit hero with
hand-drawn SVG-jitter frames, typewriter input + start button, 5 horizontal
collapsible category selectors (with style-picker modal), 7 scattered hero
cards over a 16-card masonry gallery, and project intro panel.

Each card is filled with a Runware FLUX.2 image, pre-generated and stored as
WebP (~2 MB total for 30 cards). Hero card content + image switches by
性向 (男性向 / 女性向); gallery stays shared.

Hover overlay on every card shows title + outline in a bottom-up dark
gradient, matching the prior homepage's interaction style.

Bug fixes uncovered by tracing the form-state → engine pipeline:
- 「语音配音:关闭」was previously stuffed into styleGuide (consumed only by
  FLUX, ignored by TTS). Now serialized as audioEnabled boolean in the
  sessionStorage payload; play page's fetchBeatAudio early-returns when
  false, so no /api/beat-audio request fires.
- 「绘画风格:自动」used to pass the literal Chinese phrase "由模型根据
  prompt 自动判断画风" to FLUX, which painted it as text. Now maps to the
  二次元/galgame default prompt.

Adds reusable scripts under apps/web/scripts/:
- generate-home-images.mjs — Runware FLUX.2 idempotent batch generator
- optimize-home-images.mjs — sharp WebP downscale + recompress

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
DESKTOP-I1T6TF3\Q
2026-06-01 16:55:55 +08:00
parent 774f3734fd
commit 136ceff69f
36 changed files with 1709 additions and 218 deletions
+373
View File
@@ -0,0 +1,373 @@
#!/usr/bin/env node
/**
* One-off generator: produces 23 AI cards (7 hero + 16 gallery) for the
* InfiPlot homepage via Runware FLUX.2 and writes them as PNGs under
* apps/web/public/home/.
*
* Reads IMAGE_BASE_URL / IMAGE_API_KEY / IMAGE_MODEL from apps/web/.env.local.
*
* Run once:
* node apps/web/scripts/generate-home-images.mjs
*
* Idempotent: skips any PNG that already exists. Pass --force to regenerate.
*/
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import { readFileSync, existsSync, mkdirSync, writeFileSync, statSync } from "node:fs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const WEB_ROOT = resolve(__dirname, "..");
const ENV_FILE = resolve(WEB_ROOT, ".env.local");
const OUT_DIR = resolve(WEB_ROOT, "public", "home");
const FORCE = process.argv.includes("--force");
/* ---------- env loading (tiny .env parser) ---------- */
function loadEnv(path) {
const txt = readFileSync(path, "utf8");
const env = {};
for (const raw of txt.split(/\r?\n/)) {
const line = raw.trim();
if (!line || line.startsWith("#")) continue;
const eq = line.indexOf("=");
if (eq < 0) continue;
const k = line.slice(0, eq).trim();
let v = line.slice(eq + 1).trim();
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
v = v.slice(1, -1);
}
env[k] = v;
}
return env;
}
const env = loadEnv(ENV_FILE);
const BASE_URL = env.IMAGE_BASE_URL;
const API_KEY = env.IMAGE_API_KEY;
const MODEL = env.IMAGE_MODEL;
if (!BASE_URL || !API_KEY || !MODEL) {
console.error("Missing IMAGE_BASE_URL / IMAGE_API_KEY / IMAGE_MODEL in", ENV_FILE);
process.exit(2);
}
if (!BASE_URL.includes("runware.ai")) {
console.error("This script assumes Runware. Got:", BASE_URL);
process.exit(2);
}
/* ---------- prompts ---------- */
const BASE_QUALITY =
"masterpiece, best quality, highly detailed, cinematic lighting, soft warm color grading, intricate background, no text, no watermark";
// 7 hero cards — varied flagship moods that showcase the platform's range
const HERO = [
{
name: "hero0",
prompt:
"anime visual novel cover art, two high school students standing under cherry blossom petals at dusk, warm golden sunset light, soft watercolor texture, japanese galgame illustration, widescreen composition",
w: 1024,
h: 640,
},
{
name: "hero1",
prompt:
"post-apocalyptic wasteland anime, lone scavenger silhouette against rusted mecha mountain, golden dust storm sweeping across the dunes, cinematic widescreen, anime concept art, dramatic backlight",
w: 1024,
h: 640,
},
{
name: "hero2",
prompt:
"anime xianxia cultivator boy in flowing white robes standing on a floating mountain peak above a sea of clouds, vermillion banners fluttering, vertical poster composition, chinese mythology, galgame illustration",
w: 768,
h: 1024,
},
{
name: "hero3",
prompt:
"anime visual novel scene, southern chinese small town in june rain, a transfer student looking back from a rainy classroom window, ceiling fan in background, soft warm afternoon tones, slice of life galgame illustration",
w: 1024,
h: 832,
},
{
name: "hero4",
prompt:
"cyberpunk anime portrait, amnesiac detective standing in neon-soaked rainy alley of an east-asian metropolis in 2087, holographic signs reflecting on wet pavement, vertical composition, blade runner palette, anime illustration",
w: 768,
h: 1024,
},
{
name: "hero5",
prompt:
"anime mystery scene, late-night high school library underground chamber, flickering candlelight, a class president kneeling before a glowing rune circle on the stone floor, gothic galgame style, mysterious teal-green glow",
w: 1024,
h: 640,
},
{
name: "hero6",
prompt:
"anime isekai cathedral scene, silver-haired holy maiden with tearful eyes kneeling before a glowing magic summoning circle, golden cathedral light streaming through stained glass, summoned hero just appearing in modern school uniform, warm galgame illustration",
w: 1024,
h: 640,
},
];
// 7 female-oriented hero cards — same slot aspect ratios as HERO above,
// otome / josei / xianxia / cyberpunk romance angles
const HERO_F = [
{
name: "hero0_f",
prompt:
"anime josei otome game illustration, beautiful female protagonist in ornate eastern hanfu silk robes, behind her a tall stoic regent prince in dark embroidered robes leaning down to clasp a red jade bracelet on her wrist, ancient chinese palace interior, soft candlelight, romantic widescreen composition",
w: 1024,
h: 640,
},
{
name: "hero1_f",
prompt:
"anime modern romance scene, young woman in pajamas sitting on a bed at dawn, golden light through curtains, looking at her phone in shock as if she has just been pulled back in time, soft warm tones, melancholic otome illustration, widescreen",
w: 1024,
h: 640,
},
{
name: "hero2_f",
prompt:
"anime villainess otome game character, beautiful young noblewoman with elaborate golden ringlet hair and crimson ballgown, standing alone in a baroque royal academy ballroom while other noble girls glare from the background, dramatic chandelier light, vertical poster composition, otome game cover art",
w: 768,
h: 1024,
},
{
name: "hero3_f",
prompt:
"anime visual novel scene, female high school transfer student standing on a rainy southern chinese town rooftop, sharing her umbrella with a moody boy reading poetry on the railing, soft warm afternoon palette, slice of life otome illustration",
w: 1024,
h: 832,
},
{
name: "hero4_f",
prompt:
"anime josei coronation scene, beautiful young empress in ornate ceremonial robes seated on a high eastern throne, head turned to glance at a handsome attendant standing in the shadowed pillars below, vertical composition, opulent silks and gold, otome game illustration",
w: 768,
h: 1024,
},
{
name: "hero5_f",
prompt:
"anime wuxia swordswoman in flowing light hanfu, jade hairpin, white sword raised mid-stance, cherry blossoms swirling around her, mountain pavilion in the background at golden hour, dynamic widescreen otome wuxia illustration",
w: 1024,
h: 640,
},
{
name: "hero6_f",
prompt:
"anime visual novel scene, female high school student standing on a sunset rooftop looking up at a tall handsome senior in school uniform, warm orange sky, golden hour, romantic galgame otome cover art, widescreen",
w: 1024,
h: 640,
},
];
// 16 gallery cards — broader sweep of genres / moods showcased by the platform
const GALLERY = [
{
name: "gallery0",
prompt:
"anime girl in summer yukata watching fireworks at a japanese festival night, warm bokeh lanterns, vertical composition, soft watercolor, slice of life galgame",
w: 768,
h: 1024,
},
{
name: "gallery1",
prompt:
"cyberpunk neon city skyline at rainy night, flying vehicles, holographic billboards in chinese characters, anime widescreen, cinematic",
w: 1024,
h: 640,
},
{
name: "gallery2",
prompt:
"anime two students standing on empty rural train platform after school, golden hour, slice of life galgame illustration, cinematic widescreen, warm tones",
w: 1024,
h: 832,
},
{
name: "gallery3",
prompt:
"anime mage girl in star-embroidered robes casting starlight spell, ancient fantasy library, vertical composition, magical particles, painterly illustration",
w: 768,
h: 1024,
},
{
name: "gallery4",
prompt:
"anime mecha pilot girl strapped in cockpit, holographic interfaces around her, dramatic red emergency lighting, intense expression, mecha anime style",
w: 1024,
h: 640,
},
{
name: "gallery5",
prompt:
"anime detective girl in long trench coat under a flickering streetlamp at midnight, noir mood, vertical composition, rain mist, cinematic anime",
w: 768,
h: 1024,
},
{
name: "gallery6",
prompt:
"anime cyberpunk couple sharing a quiet moment in a neon-lit rainy alley, holographic umbrella, electric blue and pink reflections, romantic galgame illustration",
w: 1024,
h: 832,
},
{
name: "gallery7",
prompt:
"anime sword duel between two xianxia cultivators in a bamboo grove, motion blur on swords, falling bamboo leaves, dynamic action composition",
w: 1024,
h: 640,
},
{
name: "gallery8",
prompt:
"anime princess in ornate eastern gown seated on an ancient carved throne, candlelight, intricate background tapestries, vertical poster composition, fantasy galgame",
w: 768,
h: 1024,
},
{
name: "gallery9",
prompt:
"anime classroom afternoon, sun streaming through windows onto empty desks, a single uniformed student writing in a notebook, slice of life watercolor, nostalgic",
w: 1024,
h: 640,
},
{
name: "gallery10",
prompt:
"anime girl reading a folded letter under a cherry blossom tree, melancholic expression, petals drifting, soft warm watercolor, slice of life galgame",
w: 1024,
h: 832,
},
{
name: "gallery11",
prompt:
"anime moon goddess descending from a starlit sky, silver hair flowing, ethereal aurora glow, dreamy painterly illustration, vertical composition",
w: 768,
h: 1024,
},
{
name: "gallery12",
prompt:
"anime samurai standing alone under a blood red full moon, sakura petals carried on the wind, katana drawn, dramatic backlight, cinematic widescreen",
w: 1024,
h: 640,
},
{
name: "gallery13",
prompt:
"anime witch girl brewing a glowing potion in a candlelit forest hut, hanging dried herbs, magical sparks rising from the cauldron, vertical composition",
w: 768,
h: 1024,
},
{
name: "gallery14",
prompt:
"anime beach summer scene, two girlfriends sitting on the sand watching a pink-orange sunset, gentle waves, slice of life galgame illustration",
w: 1024,
h: 640,
},
{
name: "gallery15",
prompt:
"anime hacker girl in a dim apartment surrounded by glowing screens, neon cyan reflections on her face, intense focus, cyberpunk galgame style",
w: 1024,
h: 832,
},
];
const ALL = [...HERO, ...HERO_F, ...GALLERY];
/* ---------- Runware caller ---------- */
async function generate({ prompt, w, h }) {
const body = [
{
taskType: "imageInference",
taskUUID: crypto.randomUUID(),
model: MODEL,
positivePrompt: `${prompt}, ${BASE_QUALITY}`,
width: w,
height: h,
steps: 4,
CFGScale: 3.5,
numberResults: 1,
outputType: "base64Data",
outputFormat: "PNG",
},
];
const res = await fetch(BASE_URL.replace(/\/$/, ""), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`,
},
body: JSON.stringify(body),
});
const text = await res.text();
let json;
try {
json = JSON.parse(text);
} catch {
throw new Error(`HTTP ${res.status}: ${text.slice(0, 300)}`);
}
if (json.errors?.length) {
const e = json.errors[0];
throw new Error(`Runware [${e.code ?? "?"}]: ${e.message ?? "no msg"}`);
}
const b64 = json.data?.[0]?.imageBase64Data;
if (!b64) throw new Error(`No image data: ${text.slice(0, 200)}`);
return Buffer.from(b64, "base64");
}
/* ---------- main loop ---------- */
if (!existsSync(OUT_DIR)) mkdirSync(OUT_DIR, { recursive: true });
const total = ALL.length;
let done = 0;
let skipped = 0;
let failed = 0;
const t0 = Date.now();
console.log(`[gen] ${total} cards → ${OUT_DIR}`);
for (const card of ALL) {
const out = resolve(OUT_DIR, `${card.name}.png`);
const webpOut = resolve(OUT_DIR, `${card.name}.webp`);
if (!FORCE && (existsSync(out) || existsSync(webpOut))) {
const path = existsSync(out) ? out : webpOut;
const size = statSync(path).size;
if (size > 1024) {
skipped++;
done++;
console.log(`[${done}/${total}] skip ${card.name} (${size} B)`);
continue;
}
}
const label = `[${++done}/${total}] ${card.name}`;
process.stdout.write(`${label}`);
const t = Date.now();
try {
const buf = await generate(card);
writeFileSync(out, buf);
process.stdout.write(`ok ${buf.length} B in ${Math.round((Date.now() - t) / 100) / 10}s\n`);
} catch (e) {
failed++;
process.stdout.write(`FAIL: ${e.message}\n`);
}
}
console.log(
`\n[gen] done in ${Math.round((Date.now() - t0) / 1000)}s — generated ${
done - skipped - failed
} / skipped ${skipped} / failed ${failed}`,
);
process.exit(failed ? 1 : 0);
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env node
/**
* Compresses the freshly generated apps/web/public/home/*.png into much
* smaller .webp files alongside them, then deletes the originals.
* Output webps target ~1200px on the long edge and quality 78.
*/
import { readdirSync, statSync, unlinkSync } from "node:fs";
import { resolve, dirname, extname, basename } from "node:path";
import { fileURLToPath } from "node:url";
import sharp from "sharp";
const __dirname = dirname(fileURLToPath(import.meta.url));
const DIR = resolve(__dirname, "..", "public", "home");
const MAX_EDGE = 1200;
const QUALITY = 78;
const files = readdirSync(DIR).filter((f) => f.toLowerCase().endsWith(".png"));
let totalIn = 0;
let totalOut = 0;
for (const f of files) {
const inPath = resolve(DIR, f);
const outPath = resolve(DIR, basename(f, extname(f)) + ".webp");
const inSize = statSync(inPath).size;
totalIn += inSize;
const img = sharp(inPath);
const meta = await img.metadata();
const longEdge = Math.max(meta.width ?? 0, meta.height ?? 0);
const resized = longEdge > MAX_EDGE ? img.resize({ width: meta.width >= meta.height ? MAX_EDGE : undefined, height: meta.height > meta.width ? MAX_EDGE : undefined }) : img;
await resized.webp({ quality: QUALITY, effort: 5 }).toFile(outPath);
const outSize = statSync(outPath).size;
totalOut += outSize;
console.log(`${f.padEnd(16)} ${(inSize / 1024).toFixed(0).padStart(5)} KB → ${(outSize / 1024).toFixed(0).padStart(4)} KB`);
unlinkSync(inPath);
}
console.log(
`\nTotal: ${(totalIn / 1024 / 1024).toFixed(1)} MB → ${(totalOut / 1024 / 1024).toFixed(2)} MB`,
);