diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css
index b15dc09..43faabb 100644
--- a/apps/web/app/globals.css
+++ b/apps/web/app/globals.css
@@ -2,67 +2,650 @@
@tailwind components;
@tailwind utilities;
+/* ==================== InfiPlot — low-fi prototype tokens ==================== */
+:root {
+ --ink: #3a3a38;
+ --ink-soft: #6f6e69;
+ --ink-faint: #a9a7a0;
+ --line: #cfccc4;
+ --paper: #f3f1ec;
+ --paper-2: #e9e6df;
+ --fill: #ddd9d0;
+ --accent: #d4824a;
+ --jit: 1;
+ --sketch-filter: url(#s2);
+}
+
@layer base {
html {
font-feature-settings: "ss01", "kern", "liga";
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
+ background: var(--paper);
}
body {
- background-image:
- radial-gradient(rgba(133, 79, 37, 0.025) 1px, transparent 1px),
- radial-gradient(rgba(133, 79, 37, 0.018) 1px, transparent 1px);
- background-size: 28px 28px, 38px 38px;
- background-position: 0 0, 14px 19px;
+ background:
+ repeating-linear-gradient(0deg, transparent 0 38px, rgba(0, 0, 0, 0.018) 38px 39px),
+ var(--paper);
+ color: var(--ink);
+ font-family: "Noto Sans SC", "PingFang SC", system-ui, sans-serif;
}
::selection {
- background-color: rgb(217 122 46 / 0.28);
- color: #2d1810;
- }
-
- textarea::placeholder {
- color: rgb(168 105 59 / 0.45);
+ background-color: rgb(212 130 74 / 0.28);
+ color: var(--ink);
}
}
@layer utilities {
- .hairline {
- background-image: linear-gradient(
- to right,
- transparent,
- rgba(45, 24, 16, 0.18) 18%,
- rgba(45, 24, 16, 0.18) 82%,
- transparent
- );
- height: 1px;
- }
-
- .hairline-full {
- height: 1px;
- background: rgba(45, 24, 16, 0.14);
- }
-
- .num {
- font-variant-numeric: tabular-nums lining-nums;
- }
-
- .smallcaps {
- text-transform: uppercase;
- letter-spacing: 0.32em;
+ .latin {
+ font-family: "Patrick Hand", "Caveat", "Cormorant Garamond", cursive;
}
}
-@keyframes yume-ripple {
- 0% {
- width: 14px;
- height: 14px;
- opacity: 0.95;
- }
- 100% {
- width: 110px;
- height: 110px;
- opacity: 0;
- }
+/* ==================== hand-drawn frame ==================== */
+.frame {
+ position: absolute;
+ inset: 0;
+ border: 2px solid var(--ink);
+ border-radius: 12px;
+ filter: var(--sketch-filter);
+ background: transparent;
+ pointer-events: none;
+}
+.frame.soft {
+ border-color: var(--ink-soft);
+}
+
+/* ==================== logo ==================== */
+.ip-logo {
+ display: inline-flex;
+ align-items: center;
+ gap: 12px;
+}
+.ip-logo .mark {
+ position: relative;
+ width: 30px;
+ height: 30px;
+}
+.ip-logo .mark .frame {
+ border-radius: 50%;
+}
+.ip-logo .mark span {
+ position: absolute;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ font-size: 15px;
+ color: var(--ink-soft);
+}
+.ip-logo .word {
+ font-family: "Patrick Hand", "Caveat", cursive;
+ font-size: 24px;
+ letter-spacing: 2px;
+ color: var(--ink);
+ font-weight: 400;
+}
+
+/* ==================== input bar (prompt + start) ==================== */
+.ip-tagline {
+ font-size: clamp(22px, 2.6vw, 33px);
+ color: var(--ink);
+ font-weight: 500;
+ letter-spacing: 1px;
+ text-align: center;
+}
+.ip-bar {
+ display: flex;
+ gap: 16px;
+ align-items: stretch;
+ width: min(1100px, 92vw);
+ height: 68px;
+}
+.ip-field {
+ position: relative;
+ flex: 1;
+}
+.ip-field .frame {
+ border-radius: 16px;
+}
+.ip-field input {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ padding: 0 28px;
+ background: transparent;
+ border: none;
+ outline: none;
+ font: inherit;
+ font-size: 20px;
+ color: var(--ink);
+ z-index: 1;
+}
+.ip-field input::placeholder {
+ color: transparent;
+}
+.ip-field .ph {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ padding: 0 28px;
+ font-size: 20px;
+ color: var(--ink-faint);
+ font-weight: 300;
+ white-space: nowrap;
+ overflow: hidden;
+ pointer-events: none;
+ z-index: 0;
+}
+.ip-cursor {
+ display: inline-block;
+ width: 2px;
+ height: 23px;
+ background: var(--ink-faint);
+ margin: 0 1px 0 3px;
+ vertical-align: -4px;
+ animation: ip-blink 1.1s steps(1) infinite;
+}
+@keyframes ip-blink {
+ 50% { opacity: 0; }
+}
+
+.ip-start {
+ position: relative;
+ width: 176px;
+ flex: none;
+ display: grid;
+ place-items: center;
+ cursor: pointer;
+ border: none;
+ background: transparent;
+ padding: 0;
+}
+.ip-start .frame {
+ border-radius: 16px;
+ background: var(--accent);
+ border-color: var(--accent);
+ z-index: 0;
+}
+.ip-start span {
+ position: relative;
+ z-index: 1;
+ color: #fff;
+ font-size: 22px;
+ letter-spacing: 6px;
+ font-weight: 500;
+ padding-left: 6px;
+}
+.ip-start:disabled {
+ opacity: 0.55;
+ cursor: not-allowed;
+}
+
+/* ==================== collapsible category pills ==================== */
+.ip-cat {
+ position: relative;
+}
+.ip-catbtn {
+ position: relative;
+ height: 42px;
+ padding: 0 16px;
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ cursor: pointer;
+ white-space: nowrap;
+ border: none;
+ background: transparent;
+}
+.ip-catbtn .frame {
+ border-radius: 21px;
+ border-color: var(--line);
+}
+.ip-catname {
+ position: relative;
+ z-index: 2;
+ font-size: 12.5px;
+ color: var(--ink-faint);
+}
+.ip-catval {
+ position: relative;
+ z-index: 2;
+ font-size: 15px;
+ color: var(--ink);
+ font-weight: 600;
+}
+.ip-caret {
+ position: relative;
+ z-index: 2;
+ font-size: 11px;
+ color: var(--ink-soft);
+ transition: transform 0.15s;
+}
+.ip-cat.open .ip-caret {
+ transform: rotate(180deg);
+}
+.ip-cat.open .ip-catbtn .frame {
+ border-color: var(--accent);
+}
+.ip-cat.open .ip-catval {
+ color: var(--accent);
+}
+.ip-catmenu {
+ position: absolute;
+ top: 50px;
+ left: 0;
+ min-width: calc(100% + 8px);
+ padding: 7px;
+ z-index: 20;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ background: var(--paper);
+}
+.ip-catmenu .frame {
+ border-radius: 12px;
+ border-color: var(--ink-soft);
+}
+.ip-catopt {
+ position: relative;
+ z-index: 1;
+ padding: 8px 16px;
+ border-radius: 8px;
+ font-size: 14px;
+ color: var(--ink-soft);
+ cursor: pointer;
+ white-space: nowrap;
+ background: transparent;
+ border: none;
+ text-align: left;
+ font: inherit;
+}
+.ip-catopt:hover {
+ background: var(--paper-2);
+}
+.ip-catopt.on {
+ color: var(--accent);
+ font-weight: 600;
+}
+.ip-catopt.on::after {
+ content: "\2713";
+ margin-left: 8px;
+ font-size: 12px;
+}
+
+/* ==================== scattered story cards ==================== */
+.ip-card {
+ position: absolute;
+ cursor: pointer;
+ transition: transform 0.25s ease;
+}
+.ip-card:hover {
+ transform: rotate(0deg) translateY(-4px) !important;
+}
+.ip-card .inner {
+ position: absolute;
+ inset: 0;
+ border-radius: 12px;
+ overflow: hidden;
+}
+.ip-card .img {
+ position: absolute;
+ inset: 0;
+ background: var(--fill);
+ display: grid;
+ place-items: center;
+}
+.ip-card .img svg {
+ width: 40%;
+ max-width: 120px;
+ opacity: 0.55;
+}
+.ip-card .img img.card-photo {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+}
+.ip-card .frame {
+ z-index: 3;
+}
+/* hover overlay: bottom-up dark gradient with title + outline */
+.ip-hover {
+ position: absolute;
+ inset: 0;
+ z-index: 2;
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-end;
+ padding: 16px 18px 16px;
+ color: #fff;
+ background: linear-gradient(to top, rgba(0, 0, 0, 0.9), rgba(0, 0, 0, 0.45) 45%, rgba(0, 0, 0, 0) 100%);
+ opacity: 0;
+ transition: opacity 0.3s ease;
+ user-select: none;
+ border-radius: 12px;
+}
+.ip-card:hover .ip-hover {
+ opacity: 1;
+}
+.ip-hover-title {
+ margin: 0 0 6px;
+ font-size: 16px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ color: #fff;
+}
+.ip-hover-outline {
+ margin: 0;
+ font-size: 12px;
+ line-height: 1.55;
+ color: rgba(231, 226, 215, 0.92);
+ font-style: italic;
+ display: -webkit-box;
+ -webkit-line-clamp: 4;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+/* ==================== gallery masonry ==================== */
+.ip-gallery {
+ width: min(1640px, 96vw);
+ margin: 14px auto 0;
+ column-count: 4;
+ column-gap: 24px;
+}
+@media (max-width: 1100px) {
+ .ip-gallery { column-count: 3; }
+}
+@media (max-width: 780px) {
+ .ip-gallery { column-count: 2; }
+}
+@media (max-width: 480px) {
+ .ip-gallery { column-count: 1; }
+}
+.ip-card.gcard {
+ position: relative;
+ width: 100%;
+ display: inline-block;
+ margin: 0 0 24px;
+ break-inside: avoid;
+ transform: rotate(calc(var(--gr, 0deg) * var(--jit)));
+}
+
+.ip-sectionnote {
+ width: 100%;
+ margin: 34px auto 6px;
+ text-align: center;
+ font-family: "Patrick Hand", "Caveat", cursive;
+ font-size: 19px;
+ color: var(--accent);
+}
+.ip-sectionnote .arr {
+ display: block;
+ font-size: 22px;
+ line-height: 1;
+ margin-bottom: 2px;
+}
+
+/* ==================== project intro ==================== */
+.ip-intro {
+ position: relative;
+ width: min(1500px, 94vw);
+ margin: 70px auto 110px;
+ padding: 58px clamp(28px, 5vw, 80px) 64px;
+}
+.ip-intro .frame {
+ border-radius: 16px;
+ border-color: var(--ink-soft);
+}
+.ip-intro .imark {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ margin-bottom: 6px;
+}
+.ip-intro .imark .gl {
+ width: 34px;
+ height: 34px;
+ display: grid;
+ place-items: center;
+ font-size: 18px;
+ color: var(--ink-soft);
+ border: 2px solid var(--ink-soft);
+ border-radius: 50%;
+}
+.ip-intro h2 {
+ position: relative;
+ z-index: 1;
+ margin: 0;
+ font-family: "Patrick Hand", "Caveat", cursive;
+ font-size: 38px;
+ letter-spacing: 1px;
+ color: var(--ink);
+ font-weight: 400;
+}
+.ip-intro .kicker {
+ position: relative;
+ z-index: 1;
+ font-size: 15px;
+ color: var(--ink-soft);
+ letter-spacing: 3px;
+ margin: 2px 0 26px;
+ font-family: "Patrick Hand", "Caveat", cursive;
+}
+.ip-intro p {
+ position: relative;
+ z-index: 1;
+ max-width: 1180px;
+ font-size: 18px;
+ line-height: 1.9;
+ color: var(--ink-soft);
+ margin: 0 0 18px;
+ font-weight: 300;
+ text-wrap: pretty;
+}
+.ip-intro .label {
+ position: relative;
+ z-index: 1;
+ font-size: 13px;
+ color: var(--ink-faint);
+ letter-spacing: 2px;
+ margin: 26px 0 8px;
+ font-weight: 500;
+}
+.ip-intro b {
+ color: var(--ink);
+ font-weight: 600;
+}
+.ip-intro .mail {
+ color: var(--accent);
+ font-weight: 500;
+}
+
+/* ==================== style picker modal ==================== */
+.ip-modal-ov {
+ position: fixed;
+ inset: 0;
+ z-index: 60;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 40px;
+ background: rgba(40, 38, 34, 0);
+ backdrop-filter: blur(0px);
+ -webkit-backdrop-filter: blur(0px);
+ transition: background 0.28s ease, backdrop-filter 0.28s ease, -webkit-backdrop-filter 0.28s ease;
+}
+.ip-modal-ov.show {
+ background: rgba(40, 38, 34, 0.34);
+ backdrop-filter: blur(7px);
+ -webkit-backdrop-filter: blur(7px);
+}
+.ip-modal {
+ position: relative;
+ width: 1120px;
+ max-width: 94vw;
+ max-height: 88vh;
+ display: flex;
+ flex-direction: column;
+ background: var(--paper);
+ border-radius: 16px;
+ overflow: hidden;
+ box-shadow: 0 30px 80px rgba(0, 0, 0, 0.32);
+ transform: scale(0.92);
+ opacity: 0;
+ transition: transform 0.3s cubic-bezier(0.2, 0.82, 0.25, 1), opacity 0.24s ease;
+}
+.ip-modal-ov.show .ip-modal {
+ transform: scale(1);
+ opacity: 1;
+}
+.ip-modal .frame {
+ border-radius: 16px;
+ border-color: var(--ink-soft);
+ z-index: 0;
+}
+.ip-modal-hd {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ align-items: center;
+ gap: 20px;
+ padding: 22px 26px;
+ border-bottom: 1.5px dashed var(--line);
+}
+.ip-modal-ttl {
+ display: flex;
+ flex-direction: column;
+ font-size: 22px;
+ font-weight: 600;
+ color: var(--ink);
+ white-space: nowrap;
+}
+.ip-modal-sub {
+ font-size: 13px;
+ font-weight: 400;
+ color: var(--ink-faint);
+ margin-top: 3px;
+}
+.ip-modal-search {
+ position: relative;
+ margin-left: auto;
+ width: 320px;
+ max-width: 46vw;
+}
+.ip-modal-search input {
+ width: 100%;
+ height: 42px;
+ padding: 0 40px 0 16px;
+ border-radius: 21px;
+ border: 1.5px solid var(--line);
+ background: var(--paper-2);
+ font: inherit;
+ font-size: 15px;
+ color: var(--ink);
+ outline: none;
+}
+.ip-modal-search input::placeholder {
+ color: var(--ink-faint);
+}
+.ip-modal-search .si {
+ position: absolute;
+ right: 15px;
+ top: 50%;
+ transform: translateY(-50%);
+ color: var(--ink-faint);
+ font-size: 18px;
+ pointer-events: none;
+}
+.ip-modal-x {
+ font-size: 28px;
+ color: var(--ink-soft);
+ cursor: pointer;
+ line-height: 1;
+ padding: 0 2px;
+ background: transparent;
+ border: none;
+}
+.ip-modal-grid {
+ position: relative;
+ z-index: 1;
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 18px;
+ padding: 24px 26px 28px;
+ overflow-y: auto;
+}
+@media (max-width: 780px) {
+ .ip-modal-grid { grid-template-columns: repeat(2, 1fr); }
+}
+.ip-scard {
+ position: relative;
+ cursor: pointer;
+}
+.ip-scard .sthumb {
+ position: relative;
+ height: 160px;
+ background: var(--fill);
+ display: grid;
+ place-items: center;
+ border-radius: 11px;
+ overflow: hidden;
+}
+.ip-scard .sthumb svg {
+ width: 32%;
+ max-width: 84px;
+ opacity: 0.5;
+}
+.ip-scard .sthumb::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ border-radius: 11px;
+ border: 2px solid transparent;
+}
+.ip-scard.on .sthumb::after {
+ border-color: var(--accent);
+}
+.ip-scard .sname {
+ text-align: center;
+ padding: 10px 4px 2px;
+ font-size: 15px;
+ color: var(--ink);
+}
+.ip-scard.on .sname {
+ color: var(--accent);
+ font-weight: 600;
+}
+.ip-noresult {
+ grid-column: 1 / -1;
+ text-align: center;
+ color: var(--ink-faint);
+ padding: 48px 0;
+ font-size: 15px;
+}
+
+/* ==================== avatar (bottom-left of hero) ==================== */
+.ip-avatar {
+ position: relative;
+ width: 46px;
+ height: 46px;
+}
+.ip-avatar .frame {
+ border-radius: 50%;
+ border-color: var(--ink-soft);
+}
+.ip-avatar span {
+ position: absolute;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ font-family: "Patrick Hand", "Caveat", cursive;
+ font-size: 22px;
+ color: var(--ink-soft);
}
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx
index 9bb1cdc..e7ae148 100644
--- a/apps/web/app/layout.tsx
+++ b/apps/web/app/layout.tsx
@@ -1,25 +1,9 @@
import type { Metadata } from "next";
-import { Cormorant_Garamond, Inter } from "next/font/google";
import "./globals.css";
-const cormorant = Cormorant_Garamond({
- subsets: ["latin"],
- weight: ["300", "400", "500", "600"],
- style: ["normal", "italic"],
- variable: "--font-serif",
- display: "swap",
-});
-
-const inter = Inter({
- subsets: ["latin"],
- weight: ["300", "400", "500"],
- variable: "--font-sans",
- display: "swap",
-});
-
export const metadata: Metadata = {
- title: "云梦 — AI 视觉小说",
- description: "一部由 AI 实时绘制每一帧的开源视觉小说。",
+ title: "InfiPlot — AI 实时交互剧情游戏",
+ description: "InfiPlot 是一款用 AI 实时生成图片、语音与剧情分支的交互式剧情游戏 Demo。",
};
export default function RootLayout({
@@ -28,18 +12,31 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
-
+
+
+
-
+
+ {/* Hand-drawn jitter filters used by every .frame element */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{children}
diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx
index 46fa03e..3b96ae4 100644
--- a/apps/web/app/page.tsx
+++ b/apps/web/app/page.tsx
@@ -1,156 +1,640 @@
-import Link from "next/link";
-import { PRESETS } from "@/lib/presets";
-import { PresetCard } from "@/components/PresetCard";
+"use client";
-const ORDINALS = ["Ⅰ", "Ⅱ", "Ⅲ", "Ⅳ"];
+import { useRouter } from "next/navigation";
+import { useEffect, useLayoutEffect, useRef, useState } from "react";
-export default function LandingPage() {
+/* ============================================================================
+ InfiPlot · 低保真原型首页
+ - 1900px 设计画布 + 等比缩放至视口宽度,最大程度还原原型版式
+ - 顶部 Hero 浮动散落卡片;下方瀑布流;尾部项目介绍
+ ========================================================================== */
+
+const HERO_CANVAS_W = 1900;
+const HERO_CANVAS_H = 980;
+
+const EXAMPLE_PHRASES: Record<"男性向" | "女性向", string[]> = {
+ 男性向: [
+ "从小一起长大的青梅竹马,突然红着脸向我告白",
+ "一觉醒来,班上的女生好像都偷偷喜欢上了我",
+ "三年之期已到,原来我是富家公子,报仇时机已到",
+ "我带着无限 Token 穿越回了互联网诞生前夕……",
+ ],
+ 女性向: [
+ "穿越成将军府的废物嫡女,冷面摄政王却独宠我一人",
+ "重生回到分手前夜,这一次换我先放手",
+ "一觉醒来成了乙游里的恶役千金,要躲开所有死亡结局",
+ ],
+};
+
+type Opt = {
+ label: string;
+ items: string[];
+ defaultIndex?: number;
+ modal?: boolean;
+};
+
+const OPTS: Opt[] = [
+ { label: "性向", items: ["男性向", "女性向"] },
+ {
+ label: "绘画风格",
+ modal: true,
+ items: [
+ "自动",
+ "二次元",
+ "吉卜力",
+ "真实系",
+ "超写实",
+ "水彩",
+ "像素风",
+ "日系动画",
+ "3D 渲染",
+ "蒸汽朋克",
+ "玄幻",
+ "国风水墨",
+ "赛博朋克",
+ ],
+ },
+ { label: "剧情风格", items: ["平铺直叙", "多线转折", "悬疑烧脑", "治愈日常"], defaultIndex: 1 },
+ { label: "语音配音", items: ["关闭", "开启"], defaultIndex: 1 },
+ { label: "内容节奏", items: ["慢热细腻", "紧凑爽快"], defaultIndex: 1 },
+];
+
+/* Hero slot geometry — 7 fixed positions, contents switch by 性向 */
+const HERO_SLOTS = [
+ { x: 55, y: 470, w: 330, h: 196, rot: -1.6 },
+ { x: 55, y: 690, w: 330, h: 196, rot: 1.3 },
+ { x: 418, y: 566, w: 286, h: 352, rot: -1.1 },
+ { x: 765, y: 642, w: 326, h: 258, rot: 1.1 },
+ { x: 1130, y: 570, w: 326, h: 352, rot: 1.4 },
+ { x: 1492, y: 478, w: 358, h: 200, rot: 1.6 },
+ { x: 1492, y: 688, w: 358, h: 200, rot: -1.3 },
+] as const;
+
+type HeroContent = { title: string; outline: string };
+
+const HERO_CONTENT: Record<"男性向" | "女性向", HeroContent[]> = {
+ 男性向: [
+ { title: "樱の约定", outline: "樱花纷飞的黄昏,他终于鼓起勇气,向并肩走过六年的青梅竹马说出那句话……" },
+ { title: "锈色边境", outline: "漫天黄沙的废土,机械心脏在胸腔中沉重轰鸣。我从钢铁山中挖出一个完好的休眠舱……" },
+ { title: "云海仙踪", outline: "凡骨少年偶得神秘残碑,登顶云海仙山,神魔同修之路自此开启。" },
+ { title: "六月雨季", outline: "南方县城的多雨六月,转学第一天,注意到那个总在天台读诗的同学。雨水打湿了未送出的伞……" },
+ { title: "雨夜霓虹", outline: "2087 年东亚特区的酸雨之夜,丢失了三天记忆的我,手腕终端响起一通匿名警告:「他们来找你了」。" },
+ { title: "学院秘闻", outline: "深夜图书馆地下密室,清冷孤僻的班长跪在圆环阵法前,吟诵着不属于人类的咒词。" },
+ { title: "异界召唤", outline: "再睁眼,没有班主任,只有昏暗的魔法阵与一位哭得梨花带雨的圣女:「勇者大人,请拯救这个世界。」" },
+ ],
+ 女性向: [
+ { title: "摄政王独宠", outline: "穿越成将军府的废物嫡女,冷面摄政王却把整个京城最名贵的红玉镯,亲手戴在了我的腕上……" },
+ { title: "重生前夕", outline: "重生回到分手前夜,他还没说出那句「对不起」。这一次,让我先转身。" },
+ { title: "恶役千金", outline: "一觉醒来,竟成了乙游里被命运钦点的恶役千金,要躲开所有 BAD END……" },
+ { title: "天台之上", outline: "南方多雨的六月,转学第一天,我把伞悄悄递给了那个在天台读诗的少年。" },
+ { title: "登基之夜", outline: "登基大典上群臣俯首,而我只想看那个一直立在阴影里的人,今夜会不会上前一步。" },
+ { title: "江湖玉颜", outline: "江湖传言,那位执剑女侠从不动情。可那个雨夜,她为他收剑而立。" },
+ { title: "学长的告白", outline: "夕阳染红了天台,那个总在篮球场被全校女生围观的学长,第一次叫住了我。" },
+ ],
+};
+
+const GALLERY: Array<{ h: number; rot: number; title: string; outline: string }> = [
+ { h: 300, rot: -0.8, title: "花火之夜", outline: "夏祭的夜空下,浴衣女孩与你约定,今晚最后一发烟火,要一起看完。" },
+ { h: 200, rot: 0.6, title: "霓虹之外", outline: "漂浮的飞车与古老方块字的全息广告——这是赛博东亚的另一种黎明。" },
+ { h: 260, rot: 0.9, title: "放学后的车站", outline: "夕阳染红的乡间月台,无人列车迟迟未来,你和她沉默并立。" },
+ { h: 330, rot: -0.6, title: "星辰咒语", outline: "古老图书馆深处,星纹长袍下的法师女孩低声念出禁咒。" },
+ { h: 200, rot: 1.1, title: "战姬启动", outline: "紧急警报红光中,少女握紧操纵杆——决战时刻已到。" },
+ { h: 300, rot: -1.0, title: "街灯之下", outline: "午夜独行的女侦探,雨雾中藏着尚未揭晓的真相。" },
+ { h: 240, rot: 0.7, title: "全息伞下", outline: "霓虹雨夜,两人共撑全息伞——这一次,是道别还是开始?" },
+ { h: 200, rot: -0.7, title: "竹林之约", outline: "竹林深处的快意一战,落叶纷飞——谁先收剑?" },
+ { h: 330, rot: 0.8, title: "暗夜王座", outline: "烛光摇曳的古老王座之上,公主等待着她唯一的回信。" },
+ { h: 200, rot: -1.1, title: "放学独白", outline: "阳光斜射的空教室,最后一个学生在笔记本上写着什么?" },
+ { h: 260, rot: 0.5, title: "第七封信", outline: "樱花树下展开的信纸,淡淡的笔迹,字字千钧。" },
+ { h: 300, rot: -0.6, title: "月神降临", outline: "银发倾泻、极光环绕——传说中的月神,今夜降临凡间。" },
+ { h: 200, rot: 0.9, title: "血月武士", outline: "血色满月之下,刀光与樱瓣同时落下。" },
+ { h: 330, rot: -0.9, title: "森林女巫", outline: "烛光摇曳的森林小屋,女巫熬制着能改变命运的魔药。" },
+ { h: 200, rot: 0.6, title: "夏日海岸", outline: "粉橙色的夕阳,两个挚友坐在海岸边,把秘密轻轻放进海风里。" },
+ { h: 260, rot: -0.7, title: "屏幕之间", outline: "霓虹青光映在脸上,全屏代码下藏着被遗忘的真相。" },
+];
+
+/* ---------- shared primitives ---------- */
+
+function ImgGlyph() {
return (
-
-
+
+
+
+
+
+ );
+}
-
-
-
-
- 一场 · 开源 · 实验 · MMXXVI
-
-
- 每一帧
-
- 都于
-
- 此刻
- 诞生。
-
-
- 云梦是一部视觉小说 — 场景、对话、选项,整个 界面皆由 AI
- 一帧一帧绘成。你点击。它落笔。故事铺展。
-
-
+function Frame({ className = "" }: { className?: string }) {
+ return
;
+}
-
-
-
-
- “人不能两次踏入同一条河流。
-
-
- 你也不会两次走进同一个云梦。”
-
-
- — 自述 · v0.1
-
-
-
-
-
-
-
-
+function CardBody({
+ title,
+ outline,
+ image,
+}: {
+ title: string;
+ outline: string;
+ image?: string;
+}) {
+ return (
+
+
+ {image ? (
+
+ ) : (
+
+ )}
+
+
-
-
-
-
- 四 扇 门
-
-
- 择 一 世 界 · 或 自 行 编 织
-
-
-
-
- {PRESETS.map((p, i) => (
-
- ))}
-
-
-
-
- {ORDINALS[3]}
-
-
-
- 无 题
-
-
- 带来你自己的世界。用你自己的话讲述它。
-
-
-
- 编 织
-
-
-
-
-
-
-
-
-
-
- 题 跋 · I
-
-
- 一场关于生成式叙事的小型开源实验。一键 Vercel 自建。
-
-
-
-
- 题 跋 · II
-
-
- 文 · 大语言模型
- 图 · 生成式渲染
- 感知 · 视觉解读
-
-
-
-
- 题 跋 · III
-
-
- 三者各自独立配置 — 任何 OpenAI 兼容端点皆可。
-
-
-
-
-
+
+ );
+}
+
+/* ---------- typewriter ---------- */
+
+function Typewriter({ phrases }: { phrases: string[] }) {
+ const [txt, setTxt] = useState("");
+
+ useEffect(() => {
+ let p = 0;
+ let i = 0;
+ let del = false;
+ let timer: ReturnType
;
+ setTxt("");
+ const tick = () => {
+ const full = phrases[p] ?? "";
+ if (!del) {
+ i++;
+ setTxt(full.slice(0, i));
+ if (i >= full.length) {
+ del = true;
+ timer = setTimeout(tick, 1700);
+ return;
+ }
+ timer = setTimeout(tick, 70);
+ } else {
+ i--;
+ setTxt(full.slice(0, i));
+ if (i <= 0) {
+ del = false;
+ p = (p + 1) % phrases.length;
+ timer = setTimeout(tick, 450);
+ return;
+ }
+ timer = setTimeout(tick, 28);
+ }
+ };
+ timer = setTimeout(tick, 500);
+ return () => clearTimeout(timer);
+ }, [phrases]);
+
+ return (
+ <>
+ {txt}
+
+ >
+ );
+}
+
+/* ---------- collapsible category selector ---------- */
+
+function CategorySelect({
+ label,
+ items,
+ value,
+ open,
+ onToggle,
+ onPick,
+}: {
+ label: string;
+ items: string[];
+ value: number;
+ open: boolean;
+ onToggle: () => void;
+ onPick: (i: number) => void;
+}) {
+ return (
+
+
+ {label}
+ {items[value]}
+ ▾
+
+
+ {open && (
+
+ {items.map((it, i) => (
+ onPick(i)}
+ >
+ {it}
+
+ ))}
+
+
+ )}
+
+ );
+}
+
+/* ---------- style picker modal ---------- */
+
+function StyleModal({
+ items,
+ value,
+ onPick,
+ onClose,
+}: {
+ items: string[];
+ value: number;
+ onPick: (i: number) => void;
+ onClose: () => void;
+}) {
+ const [q, setQ] = useState("");
+ const [shown, setShown] = useState(false);
+ useEffect(() => {
+ const id = requestAnimationFrame(() => setShown(true));
+ return () => cancelAnimationFrame(id);
+ }, []);
+ const close = () => {
+ setShown(false);
+ setTimeout(onClose, 300);
+ };
+ const list = items.map((name, i) => ({ name, i })).filter((x) => x.name.includes(q.trim()));
+ return (
+
+
e.stopPropagation()}>
+
+
+
+ 选择绘画风格
+ 默认「自动」· 由模型根据 prompt 判断风格
+
+
+ setQ(e.target.value)}
+ placeholder="搜索风格…"
+ autoFocus
+ />
+ ⌕
+
+
+ ×
+
+
+
+ {list.map(({ name, i }) => (
+
{
+ onPick(i);
+ close();
+ }}
+ >
+
+
{name}
+
+ ))}
+ {list.length === 0 &&
没有匹配的风格
}
+
+
+
+ );
+}
+
+/* ---------- scale-to-fit hero canvas ---------- */
+
+function HeroCanvas({ children }: { children: React.ReactNode }) {
+ const stageRef = useRef(null);
+ const canvasRef = useRef(null);
+
+ useLayoutEffect(() => {
+ const fit = () => {
+ const stage = stageRef.current;
+ const canvas = canvasRef.current;
+ if (!stage || !canvas) return;
+ // scale to fit width; clamp so very wide screens don't pixel-up the design
+ const s = Math.min(1, stage.clientWidth / HERO_CANVAS_W);
+ canvas.style.transform = `scale(${s})`;
+ stage.style.height = HERO_CANVAS_H * s + "px";
+ };
+ fit();
+ window.addEventListener("resize", fit);
+ const ro = new ResizeObserver(fit);
+ if (stageRef.current) ro.observe(stageRef.current);
+ return () => {
+ window.removeEventListener("resize", fit);
+ ro.disconnect();
+ };
+ }, []);
+
+ return (
+
+ );
+}
+
+/* ---------- page ---------- */
+
+export default function HomePage() {
+ const router = useRouter();
+
+ const [sel, setSel] = useState(OPTS.map((o) => o.defaultIndex ?? 0));
+ const [open, setOpen] = useState(-1);
+ const [styleOpen, setStyleOpen] = useState(false);
+ const [prompt, setPrompt] = useState("");
+ const inputRef = useRef(null);
+
+ const styleRow = OPTS.findIndex((o) => o.modal);
+ const genderIndex = sel[0] ?? 0;
+ const gender = (OPTS[0]!.items[genderIndex] as "男性向" | "女性向") ?? "男性向";
+ const phrases = EXAMPLE_PHRASES[gender];
+
+ /* close any open dropdown on outside click */
+ useEffect(() => {
+ const h = (e: MouseEvent) => {
+ const target = e.target as HTMLElement | null;
+ if (!target?.closest?.(".ip-cat")) setOpen(-1);
+ };
+ document.addEventListener("mousedown", h);
+ return () => document.removeEventListener("mousedown", h);
+ }, []);
+
+ const start = () => {
+ const userPrompt = prompt.trim();
+ const artStyle = OPTS[1]!.items[sel[1] ?? 0]!;
+ const plotStyle = OPTS[2]!.items[sel[2] ?? 1]!;
+ const voice = OPTS[3]!.items[sel[3] ?? 1]!;
+ const pace = OPTS[4]!.items[sel[4] ?? 1]!;
+
+ const worldSetting = [
+ `这是一款面向【${gender}】观众的 AI 交互剧情游戏。`,
+ `剧情风格:${plotStyle}。内容节奏:${pace}。`,
+ userPrompt ? `玩家给出的故事种子:「${userPrompt}」。` : "",
+ `请依据上述设定,以极致的戏剧张力与细腻的情感起伏,为玩家编织精彩的故事分支与对话。`,
+ ]
+ .filter(Boolean)
+ .join("\n");
+
+ const styleMap: Record = {
+ 二次元: "唯美二次元动漫插画,日系 galgame 精致质感,柔和温暖的自然光照。",
+ 吉卜力: "吉卜力工作室风格,手绘动画质感,柔和水彩底色,温暖治愈的氛围。",
+ 真实系: "真实电影感,柔和自然光照,胶片颗粒。",
+ 超写实: "超写实人像与场景,电影级布光,皮肤与材质细节精致。",
+ 水彩: "水彩插画,湿润晕染笔触,纸纹底色。",
+ 像素风: "像素风格,复古游戏 16-bit 调色,方块化几何造型。",
+ 日系动画: "现代日系动画 cel-shading,硬光阴影分层,赛璐璐风。",
+ "3D 渲染": "3D 渲染卡通风格,柔和次表面散射,干净的电影级布光。",
+ 蒸汽朋克: "蒸汽朋克美学,铜色齿轮与蒸汽,工业革命氛围。",
+ 玄幻: "国风玄幻插画,仙气缭绕,群山烟雨与神兽萦绕。",
+ 国风水墨: "国潮唯美古风插画,水墨微晕渲染,仙侠浪漫色彩,极具东方神韵。",
+ 赛博朋克: "赛博朋克都市,霓虹反射湿润街道,电子义体高光。",
+ };
+ // 「自动」→ fall back to 二次元 (project default). Plain prompts like
+ // "由模型自动判断画风" are not understood by FLUX — it just paints them
+ // literally, so we'd rather lock in a sensible default.
+ const effectiveStyle = artStyle === "自动" ? "二次元" : artStyle;
+ const styleGuide = styleMap[effectiveStyle] ?? styleMap["二次元"]!;
+ const audioEnabled = voice === "开启";
+
+ sessionStorage.setItem(
+ "yume:custom",
+ JSON.stringify({ worldSetting, styleGuide, audioEnabled }),
+ );
+ router.push("/play?custom=1");
+ };
+
+ const onCardClick = (seed?: string) => {
+ if (seed) setPrompt(seed);
+ inputRef.current?.focus();
+ };
+
+ return (
+
+ {/* ================== HERO (scale-to-fit 1900×980 canvas) ================== */}
+
+ {/* tagline */}
+
+
+ {/* prompt bar */}
+
+
+ {/* category selectors */}
+
+ {OPTS.map((o, r) => (
+ {
+ if (o.modal) {
+ setStyleOpen(true);
+ } else {
+ setOpen(open === r ? -1 : r);
+ }
+ }}
+ onPick={(i) => {
+ setSel((s) => s.map((v, j) => (j === r ? i : v)));
+ setOpen(-1);
+ }}
+ />
+ ))}
+
+
+ {/* hero scattered cards — content switches by 性向 */}
+ {HERO_SLOTS.map((slot, i) => {
+ const content = HERO_CONTENT[gender][i]!;
+ const suffix = gender === "女性向" ? "_f" : "";
+ return (
+ onCardClick(content.outline)}
+ >
+
+
+
+ );
+ })}
+
+
+
+ {/* ================== SCROLL HINT + GALLERY ================== */}
+
+ ↓
+ 继续下滑 · 加载更多示例卡片
+
+
+
+ {GALLERY.map((g, i) => (
+
onCardClick(g.outline)}
+ >
+
+
+
+ ))}
+
+
+ {/* ================== PROJECT INTRO ================== */}
+
+
INFIPLOT · AI 实时交互剧情游戏 · Demo
+
+
+ InfiPlot 是一款用 AI 实时生成内容的交互式剧情游戏 —— 图片、语音与剧情分支都在游玩过程中即时生成。我们希望探索多模态模型在「直接生成图片、视频」这类
+ one-shot 能力之外,更多的可能性。
+
+
+ 我们希望通过这个页面,与赞助商 、未来的团队成员 以及内测用户 建立联系。
+
+
+
团 队
+
+ 我们是一群来自清华大学 等海内外高校、充满激情的年轻人,目前仍处于早期阶段。产品还在打磨,团队也在招募成员 。
+
+
+
加 入 / 合 作
+
+ 如有意加入团队,请将简历发送至 hi@infiplot.com
+
+
+
联 系 方 式
+
+ 邮箱 hi@infiplot.com · Founder X / Twitter @yzh_im
+
+
+
内 测 用 户 群
+
+ 群二维码 / 邀请链接 (待补充)
+
+
+
+ 内测期间本产品可免费使用,但稳定性可能会随并发用户数量而有波动。欢迎赞助商联系我们,提供更多算力资源和商讨长期合作事宜。
+
+ 内测期间生成的内容不会被保存,如有需要,请通过录屏或截图等方式保存游玩体验,并记录下生成故事时的提示词与风格选项等。
+
+ AI 生成的内容不代表本团队立场。
+
+
+
+
+ {styleOpen && styleRow >= 0 && (
+
setSel((s) => s.map((v, j) => (j === styleRow ? i : v)))}
+ onClose={() => setStyleOpen(false)}
+ />
+ )}
);
}
diff --git a/apps/web/app/play/page.tsx b/apps/web/app/play/page.tsx
index 88b0308..5ecd243 100644
--- a/apps/web/app/play/page.tsx
+++ b/apps/web/app/play/page.tsx
@@ -262,6 +262,10 @@ function PlayInner() {
// changes so stale in-flight requests can't poison the new scene's map
// (beat ids like "b1" are scene-local and would collide across scenes).
const beatAudioAbortRef = useRef>(new Map());
+ // User-toggled "语音配音" from the homepage. Defaults to true for back-compat
+ // when older sessionStorage payloads omit the field. Mutated once in
+ // bootstrap and read by fetchBeatAudio to early-return without any /api call.
+ const audioEnabledRef = useRef(true);
// Mirrors for use inside async handlers (closure-stable)
const sessionRef = useRef(null);
@@ -317,6 +321,7 @@ function PlayInner() {
sess: Session,
beat: { id: string; speaker?: string; line?: string; lineDelivery?: string },
): Promise => {
+ if (!audioEnabledRef.current) return; // user toggled 语音配音 → 关闭
if (!beat.speaker || !beat.line) return;
const speaker = sess.characters.find((c) => c.name === beat.speaker);
if (!speaker?.voice) return; // not yet provisioned — server can't synth anyway
@@ -450,7 +455,14 @@ function PlayInner() {
const stored = sessionStorage.getItem("yume:custom");
if (stored) {
try {
- payload = JSON.parse(stored);
+ const parsed = JSON.parse(stored) as {
+ worldSetting: string;
+ styleGuide: string;
+ audioEnabled?: boolean;
+ };
+ payload = { worldSetting: parsed.worldSetting, styleGuide: parsed.styleGuide };
+ // default true for older payloads that omit the flag
+ audioEnabledRef.current = parsed.audioEnabled !== false;
} catch {
payload = null;
}
diff --git a/apps/web/public/home/gallery0.webp b/apps/web/public/home/gallery0.webp
new file mode 100644
index 0000000..3db39da
Binary files /dev/null and b/apps/web/public/home/gallery0.webp differ
diff --git a/apps/web/public/home/gallery1.webp b/apps/web/public/home/gallery1.webp
new file mode 100644
index 0000000..3c83e90
Binary files /dev/null and b/apps/web/public/home/gallery1.webp differ
diff --git a/apps/web/public/home/gallery10.webp b/apps/web/public/home/gallery10.webp
new file mode 100644
index 0000000..7cb9637
Binary files /dev/null and b/apps/web/public/home/gallery10.webp differ
diff --git a/apps/web/public/home/gallery11.webp b/apps/web/public/home/gallery11.webp
new file mode 100644
index 0000000..7594c0e
Binary files /dev/null and b/apps/web/public/home/gallery11.webp differ
diff --git a/apps/web/public/home/gallery12.webp b/apps/web/public/home/gallery12.webp
new file mode 100644
index 0000000..e6fb238
Binary files /dev/null and b/apps/web/public/home/gallery12.webp differ
diff --git a/apps/web/public/home/gallery13.webp b/apps/web/public/home/gallery13.webp
new file mode 100644
index 0000000..235f82c
Binary files /dev/null and b/apps/web/public/home/gallery13.webp differ
diff --git a/apps/web/public/home/gallery14.webp b/apps/web/public/home/gallery14.webp
new file mode 100644
index 0000000..6f60940
Binary files /dev/null and b/apps/web/public/home/gallery14.webp differ
diff --git a/apps/web/public/home/gallery15.webp b/apps/web/public/home/gallery15.webp
new file mode 100644
index 0000000..1ecde7d
Binary files /dev/null and b/apps/web/public/home/gallery15.webp differ
diff --git a/apps/web/public/home/gallery2.webp b/apps/web/public/home/gallery2.webp
new file mode 100644
index 0000000..d696835
Binary files /dev/null and b/apps/web/public/home/gallery2.webp differ
diff --git a/apps/web/public/home/gallery3.webp b/apps/web/public/home/gallery3.webp
new file mode 100644
index 0000000..47cb349
Binary files /dev/null and b/apps/web/public/home/gallery3.webp differ
diff --git a/apps/web/public/home/gallery4.webp b/apps/web/public/home/gallery4.webp
new file mode 100644
index 0000000..0bac913
Binary files /dev/null and b/apps/web/public/home/gallery4.webp differ
diff --git a/apps/web/public/home/gallery5.webp b/apps/web/public/home/gallery5.webp
new file mode 100644
index 0000000..4548659
Binary files /dev/null and b/apps/web/public/home/gallery5.webp differ
diff --git a/apps/web/public/home/gallery6.webp b/apps/web/public/home/gallery6.webp
new file mode 100644
index 0000000..ae925e1
Binary files /dev/null and b/apps/web/public/home/gallery6.webp differ
diff --git a/apps/web/public/home/gallery7.webp b/apps/web/public/home/gallery7.webp
new file mode 100644
index 0000000..49ad884
Binary files /dev/null and b/apps/web/public/home/gallery7.webp differ
diff --git a/apps/web/public/home/gallery8.webp b/apps/web/public/home/gallery8.webp
new file mode 100644
index 0000000..a39e02a
Binary files /dev/null and b/apps/web/public/home/gallery8.webp differ
diff --git a/apps/web/public/home/gallery9.webp b/apps/web/public/home/gallery9.webp
new file mode 100644
index 0000000..f2c3913
Binary files /dev/null and b/apps/web/public/home/gallery9.webp differ
diff --git a/apps/web/public/home/hero0.webp b/apps/web/public/home/hero0.webp
new file mode 100644
index 0000000..ca01a86
Binary files /dev/null and b/apps/web/public/home/hero0.webp differ
diff --git a/apps/web/public/home/hero0_f.webp b/apps/web/public/home/hero0_f.webp
new file mode 100644
index 0000000..2f134ab
Binary files /dev/null and b/apps/web/public/home/hero0_f.webp differ
diff --git a/apps/web/public/home/hero1.webp b/apps/web/public/home/hero1.webp
new file mode 100644
index 0000000..a961d8b
Binary files /dev/null and b/apps/web/public/home/hero1.webp differ
diff --git a/apps/web/public/home/hero1_f.webp b/apps/web/public/home/hero1_f.webp
new file mode 100644
index 0000000..109d21a
Binary files /dev/null and b/apps/web/public/home/hero1_f.webp differ
diff --git a/apps/web/public/home/hero2.webp b/apps/web/public/home/hero2.webp
new file mode 100644
index 0000000..cf96cb2
Binary files /dev/null and b/apps/web/public/home/hero2.webp differ
diff --git a/apps/web/public/home/hero2_f.webp b/apps/web/public/home/hero2_f.webp
new file mode 100644
index 0000000..11b8883
Binary files /dev/null and b/apps/web/public/home/hero2_f.webp differ
diff --git a/apps/web/public/home/hero3.webp b/apps/web/public/home/hero3.webp
new file mode 100644
index 0000000..9628702
Binary files /dev/null and b/apps/web/public/home/hero3.webp differ
diff --git a/apps/web/public/home/hero3_f.webp b/apps/web/public/home/hero3_f.webp
new file mode 100644
index 0000000..df46e9a
Binary files /dev/null and b/apps/web/public/home/hero3_f.webp differ
diff --git a/apps/web/public/home/hero4.webp b/apps/web/public/home/hero4.webp
new file mode 100644
index 0000000..cdf0195
Binary files /dev/null and b/apps/web/public/home/hero4.webp differ
diff --git a/apps/web/public/home/hero4_f.webp b/apps/web/public/home/hero4_f.webp
new file mode 100644
index 0000000..ff104ca
Binary files /dev/null and b/apps/web/public/home/hero4_f.webp differ
diff --git a/apps/web/public/home/hero5.webp b/apps/web/public/home/hero5.webp
new file mode 100644
index 0000000..fa7ed8e
Binary files /dev/null and b/apps/web/public/home/hero5.webp differ
diff --git a/apps/web/public/home/hero5_f.webp b/apps/web/public/home/hero5_f.webp
new file mode 100644
index 0000000..1cb6336
Binary files /dev/null and b/apps/web/public/home/hero5_f.webp differ
diff --git a/apps/web/public/home/hero6.webp b/apps/web/public/home/hero6.webp
new file mode 100644
index 0000000..82ac365
Binary files /dev/null and b/apps/web/public/home/hero6.webp differ
diff --git a/apps/web/public/home/hero6_f.webp b/apps/web/public/home/hero6_f.webp
new file mode 100644
index 0000000..54f3d77
Binary files /dev/null and b/apps/web/public/home/hero6_f.webp differ
diff --git a/apps/web/scripts/generate-home-images.mjs b/apps/web/scripts/generate-home-images.mjs
new file mode 100644
index 0000000..1c7d067
--- /dev/null
+++ b/apps/web/scripts/generate-home-images.mjs
@@ -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);
diff --git a/apps/web/scripts/optimize-home-images.mjs b/apps/web/scripts/optimize-home-images.mjs
new file mode 100644
index 0000000..9b76d1e
--- /dev/null
+++ b/apps/web/scripts/optimize-home-images.mjs
@@ -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`,
+);