feat(web): editorial homepage rework — flat 2×30 stories, autosize input

Revises the InfiPlot homepage from the initial prototype pass.

Stories data model
- Replaces the artificial 7-hero + 16-gallery split with a flat
  per-gender model: 30 preset stories each for 男性向 / 女性向.
- Renames assets hero*/gallery* → m{0..29} / f{0..29}; same index
  shares aspect ratio across genders so the gender crossfade never
  jumps card height.
- Fills in the missing 女性向 set and expands both genders to 30.

Cards
- StoryCard measures aspect ratio at runtime from the loaded image
  (onLoad → naturalWidth/Height), fixing the frosted-caption band
  reflow on lazy image load. Drops ready/fallback props; single
  masonry map over STORIES[gender].

Hero input
- Single-line <input> → auto-growing <textarea> (rows=1, resize-none)
  so long prompts and long card seeds are fully visible. Enter submits,
  Shift+Enter inserts a newline.
- lining-nums on the input so digits sit on the baseline instead of
  Cormorant's default old-style figures.

Typography / styles
- layout.tsx: editorial fonts (Cormorant Garamond + Inter via
  --font-serif / --font-sans) + Font Awesome; drops Patrick Hand /
  Noto Sans SC and the hand-drawn SVG jitter filters.
- globals.css trimmed to the editorial base (paper grain, hairline,
  num, ripple); play/page.tsx font/style follow-up.

Scripts
- generate-home-images.mjs reworked into a flat 2×30 idempotent
  Runware FLUX.2 generator.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
yuanzonghao
2026-06-02 01:14:49 +08:00
parent 136ceff69f
commit 8f94d3aa65
65 changed files with 836 additions and 1074 deletions
+45 -628
View File
@@ -2,650 +2,67 @@
@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:
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;
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;
}
::selection {
background-color: rgb(212 130 74 / 0.28);
color: var(--ink);
background-color: rgb(217 122 46 / 0.28);
color: #2d1810;
}
textarea::placeholder {
color: rgb(168 105 59 / 0.45);
}
}
@layer utilities {
.latin {
font-family: "Patrick Hand", "Caveat", "Cormorant Garamond", cursive;
.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;
}
}
/* ==================== 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);
@keyframes yume-ripple {
0% {
width: 14px;
height: 14px;
opacity: 0.95;
}
100% {
width: 110px;
height: 110px;
opacity: 0;
}
}
+26 -20
View File
@@ -1,6 +1,24 @@
import type { Metadata } from "next";
import { Cormorant_Garamond, Inter } from "next/font/google";
import "./globals.css";
// Editorial 云梦 fonts: drive tailwind `font-serif`/`font-sans` via
// --font-serif / --font-sans across every page (home, /play, /new, CustomForm).
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: "InfiPlot — AI 实时交互剧情游戏",
description: "InfiPlot 是一款用 AI 实时生成图片、语音与剧情分支的交互式剧情游戏 Demo。",
@@ -12,31 +30,19 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<html lang="zh-CN" suppressHydrationWarning>
<html
lang="zh-CN"
className={`${cormorant.variable} ${inter.variable}`}
suppressHydrationWarning
>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
{/* Font Awesome — fa-solid icons used by home, /play, /new, CustomForm. */}
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Patrick+Hand&family=Noto+Sans+SC:wght@300;400;500;600;700&display=swap"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
/>
</head>
<body className="min-h-screen overflow-x-hidden">
{/* Hand-drawn jitter filters used by every .frame element */}
<svg width="0" height="0" style={{ position: "absolute" }} aria-hidden>
<filter id="s1">
<feTurbulence type="fractalNoise" baseFrequency="0.012" numOctaves="2" seed="7" result="n" />
<feDisplacementMap in="SourceGraphic" in2="n" scale="1.2" />
</filter>
<filter id="s2">
<feTurbulence type="fractalNoise" baseFrequency="0.016" numOctaves="2" seed="4" result="n" />
<feDisplacementMap in="SourceGraphic" in2="n" scale="2.6" />
</filter>
<filter id="s3">
<feTurbulence type="fractalNoise" baseFrequency="0.022" numOctaves="3" seed="11" result="n" />
<feDisplacementMap in="SourceGraphic" in2="n" scale="4.2" />
</filter>
</svg>
<body className="bg-cream-50 text-clay-900 font-sans antialiased min-h-screen overflow-x-hidden">
{children}
</body>
</html>
+429 -326
View File
@@ -1,18 +1,19 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
/* ============================================================================
InfiPlot · 低保真原型首页
- 1900px 设计画布 + 等比缩放至视口宽度,最大程度还原原型版式
- 顶部 Hero 浮动散落卡片;下方瀑布流;尾部项目介绍
InfiPlot · 首页(云梦编辑式视觉风格 · 居中构图,呼应低保真原型
- 顶部 Header:左上角衬线 wordmark logo
- Hero 控制区(居中):标题 / prompt 输入框 + 开始 / 5 个类别选择器
- 统一瀑布流(居中定宽):7 张主推 + 16 张画廊,按性向整体 crossfade 切换
- 项目介绍(题跋式排版)
========================================================================== */
const HERO_CANVAS_W = 1900;
const HERO_CANVAS_H = 980;
type Gender = "男性向" | "女性向";
const EXAMPLE_PHRASES: Record<"男性向" | "女性向", string[]> = {
const EXAMPLE_PHRASES: Record<Gender, string[]> = {
: [
"从小一起长大的青梅竹马,突然红着脸向我告白",
"一觉醒来,班上的女生好像都偷偷喜欢上了我",
@@ -59,20 +60,11 @@ const OPTS: Opt[] = [
{ 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 StoryContent = { title: string; outline: string };
type HeroContent = { title: string; outline: string };
const HERO_CONTENT: Record<"男性向" | "女性向", HeroContent[]> = {
/* 每个性向 30 篇预设剧情,与图片 /home/{m|f}{i}.webp 按索引一一对应。
男/女同索引共享画面尺寸,切性向 crossfade 时卡片高度不跳变。 */
const STORIES: Record<Gender, StoryContent[]> = {
: [
{ title: "樱の约定", outline: "樱花纷飞的黄昏,他终于鼓起勇气,向并肩走过六年的青梅竹马说出那句话……" },
{ title: "锈色边境", outline: "漫天黄沙的废土,机械心脏在胸腔中沉重轰鸣。我从钢铁山中挖出一个完好的休眠舱……" },
@@ -81,79 +73,64 @@ const HERO_CONTENT: Record<"男性向" | "女性向", HeroContent[]> = {
{ title: "雨夜霓虹", outline: "2087 年东亚特区的酸雨之夜,丢失了三天记忆的我,手腕终端响起一通匿名警告:「他们来找你了」。" },
{ title: "学院秘闻", outline: "深夜图书馆地下密室,清冷孤僻的班长跪在圆环阵法前,吟诵着不属于人类的咒词。" },
{ title: "异界召唤", outline: "再睁眼,没有班主任,只有昏暗的魔法阵与一位哭得梨花带雨的圣女:「勇者大人,请拯救这个世界。」" },
{ title: "花火之夜", outline: "夏祭的夜空下,浴衣女孩与你约定,今晚最后一发烟火,要一起看完。" },
{ title: "霓虹之外", outline: "漂浮的飞车与古老方块字的全息广告——这是赛博东亚的另一种黎明。" },
{ title: "放学后的车站", outline: "夕阳染红的乡间月台,无人列车迟迟未来,你和她沉默并立。" },
{ title: "星辰咒语", outline: "古老图书馆深处,星纹长袍下的法师女孩低声念出禁咒。" },
{ title: "战姬启动", outline: "紧急警报红光中,少女握紧操纵杆——决战时刻已到。" },
{ title: "街灯之下", outline: "午夜独行的女侦探,雨雾中藏着尚未揭晓的真相。" },
{ title: "全息伞下", outline: "霓虹雨夜,两人共撑全息伞——这一次,是道别还是开始?" },
{ title: "竹林之约", outline: "竹林深处的快意一战,落叶纷飞——谁先收剑?" },
{ title: "暗夜王座", outline: "烛光摇曳的古老王座之上,公主等待着她唯一的回信。" },
{ title: "放学独白", outline: "阳光斜射的空教室,最后一个学生在笔记本上写着什么?" },
{ title: "第七封信", outline: "樱花树下展开的信纸,淡淡的笔迹,字字千钧。" },
{ title: "月神降临", outline: "银发倾泻、极光环绕——传说中的月神,今夜降临凡间。" },
{ title: "血月武士", outline: "血色满月之下,刀光与樱瓣同时落下。" },
{ title: "森林女巫", outline: "烛光摇曳的森林小屋,女巫熬制着能改变命运的魔药。" },
{ title: "夏日海岸", outline: "粉橙色的夕阳,两个挚友坐在海岸边,把秘密轻轻放进海风里。" },
{ title: "屏幕之间", outline: "霓虹青光映在脸上,全屏代码下藏着被遗忘的真相。" },
{ title: "雨夜客栈", outline: "雨夜投宿的破败客栈,邻桌蒙面女子的剑匣里,似乎封着一段江湖恩怨。" },
{ title: "深空警报", outline: "殖民舰舰桥警报骤响,舷窗外那颗未知行星正泛起诡异的红光。" },
{ title: "上海滩暗号", outline: "1936 年的上海滩,留声机旋律里,舞女递来一张写着暗号的牌。" },
{ title: "三长两短", outline: "末世第 173 天,卷帘门外的抓挠声停了,取而代之的是规律的敲门——三长两短。" },
{ title: "正午对决", outline: "正午烈日下的无人小镇,唯一的酒馆门口,一个陌生枪手正等着与我决斗。" },
{ title: "万米之城", outline: "潜水钟沉入万米海沟,探照灯扫过的不是岩壁,而是一座沉睡的远古之城。" },
{ title: "云上海盗", outline: "齿轮轰鸣的飞空艇甲板,云海之上,海盗的黑色气球正逼近舷侧。" },
],
: [
{ title: "摄政王独宠", outline: "穿越成将军府的废物嫡女,冷面摄政王却把整个京城最名贵的红玉镯,亲手戴在了我的腕上……" },
{ title: "重生前夕", outline: "重生回到分手前夜,他还没说出那句「对不起」。这一次,让我先转身。" },
{ title: "恶役千金", outline: "一觉醒来,竟成了乙游里被命运钦点的恶役千金,要躲开所有 BAD END……" },
{ title: "天台之上", outline: "南方多雨的六月,转学第一天,我把伞悄悄递给了那个在天台读诗的少年。" },
{ title: "登基之夜", outline: "登基大典上群臣俯首,而我只想看那个一直立在阴影里的人,今夜会不会上前一步。" },
{ title: "江湖玉颜", outline: "江湖传言,那位执剑女侠从不动情。可那个雨夜,她为他收剑而立。" },
{ title: "重生前夕", outline: "重生回到分手前夜,他还没说出那句「对不起」。这一次,让我先转身。" },
{ title: "恶役千金", outline: "一觉醒来,竟成了乙游里被命运钦点的恶役千金,要躲开所有 BAD END……" },
{ title: "天台之上", outline: "南方多雨的六月,转学第一天,我把伞悄悄递给了那个在天台读诗的少年。" },
{ title: "登基之夜", outline: "登基大典上群臣俯首,而我只想看那个一直立在阴影里的人,今夜会不会上前一步。" },
{ title: "江湖玉颜", outline: "江湖传言,那位执剑女侠从不动情。可那个雨夜,她为他收剑而立。" },
{ title: "学长的告白", outline: "夕阳染红了天台,那个总在篮球场被全校女生围观的学长,第一次叫住了我。" },
{ title: "夏祭灯影", outline: "夏祭的夜空下,他替你挡开人潮,低声说:最后一发烟火,只想和你一起看完。" },
{ title: "雨夜车站", outline: "末班电车迟迟未至,他脱下外套披在你肩上,霓虹在积水里碎成星河。" },
{ title: "黄昏并肩", outline: "夕阳染红的乡间月台,他终于停下脚步回头看你——那句话堵在喉咙里很久了。" },
{ title: "禁书之约", outline: "图书馆最深处,清冷的学生会长合上禁书,抬眼时眸色温柔得不像他。" },
{ title: "骑士誓约", outline: "红色警报响彻舰桥,他单膝跪在你面前:以剑起誓,此生只为你出鞘。" },
{ title: "雨巷追影", outline: "午夜雨巷,他撑伞追上独行的你:这条路太黑,我送你回去。" },
{ title: "共伞之间", outline: "霓虹雨夜,他把全息伞偏向你这侧,自己半边肩膀已被雨打湿。" },
{ title: "竹影收剑", outline: "竹林深处刀光骤停,他为你收剑而立,落叶落在你们之间。" },
{ title: "深宫回眸", outline: "烛影摇红的宫宴上,冷面摄政王越过群臣,只朝你伸出了手。" },
{ title: "空教室", outline: "夕照斜斜铺满空教室,他把写满字的笔记本推到你面前,耳尖泛红。" },
{ title: "樱下情书", outline: "樱花树下,他递来第七封信,这一次落款不再是匿名。" },
{ title: "月下倾心", outline: "银发垂落、极光环绕,传说中的月神俯身,指尖轻触你的脸颊。" },
{ title: "血月相护", outline: "血色满月之下,他挡在你身前,刀光与樱瓣同时落下。" },
{ title: "魔药之约", outline: "森林小屋烛火摇曳,他为你熬一剂改写命运的魔药,只求换你一笑。" },
{ title: "海岸絮语", outline: "粉橙色夕阳里,他和你并肩坐在堤岸,把没说出口的心事交给海风。" },
{ title: "屏光之后", outline: "幽蓝屏光映在他脸上,敲下最后一行代码,他转头:我找到你了。" },
{ title: "龙王契约", outline: "古龙巢穴深处,化为人形的银发龙王单膝跪地,将一枚龙鳞戒指推到我面前。" },
{ title: "洋场先生", outline: "1936 年的上海公馆,那位留洋先生替我挡下流弹,西装袖口洇开一片猩红。" },
{ title: "最后一颗子弹", outline: "末世第 173 天,他用最后一颗子弹打穿破门的丧尸,转身把我护在身后。" },
{ 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 (
<svg viewBox="0 0 120 90" fill="none" stroke="#6f6e69" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round">
<rect x={6} y={6} width={108} height={78} rx={6} />
<circle cx={38} cy={32} r={9} />
<path d="M14 76 L46 46 L66 64 L84 44 L106 76" />
</svg>
);
}
function Frame({ className = "" }: { className?: string }) {
return <div className={"frame " + className} />;
}
function CardBody({
title,
outline,
image,
}: {
title: string;
outline: string;
image?: string;
}) {
return (
<div className="inner">
<div className="img">
{image ? (
<img className="card-photo" src={image} alt={title} loading="lazy" />
) : (
<ImgGlyph />
)}
</div>
<div className="ip-hover">
<h4 className="ip-hover-title">{title}</h4>
<p className="ip-hover-outline">{outline}</p>
</div>
</div>
);
}
/* ---------- typewriter ---------- */
function Typewriter({ phrases }: { phrases: string[] }) {
@@ -195,11 +172,70 @@ function Typewriter({ phrases }: { phrases: string[] }) {
return (
<>
<span>{txt}</span>
<span className="ip-cursor" />
<span className="inline-block w-px h-[1.05em] bg-clay-400 ml-0.5 align-middle animate-pulse" />
</>
);
}
/* ---------- masonry story card ---------- */
function StoryCard({
title,
outline,
image,
placeholderRatio = 4 / 5,
onClick,
}: {
title: string;
outline: string;
image: string;
placeholderRatio?: number;
onClick: () => void;
}) {
// 卡片高度 = 图片真实宽高比。加载前先用 placeholderRatio 占好位(按该类卡片
// 的典型比例),加载后用 naturalWidth/Height 锁死真实比例——绝不塌成 0、也绝不
// 在 lazy 图加载或性向换图时跳变高度。运行时读取,故换任意图都自动适配。
const [ratio, setRatio] = useState<number>();
return (
<button
type="button"
onClick={onClick}
style={{ aspectRatio: ratio ?? placeholderRatio }}
className="group relative block w-full mb-4 md:mb-5 break-inside-avoid overflow-hidden rounded-sm border border-clay-900/10 bg-cream-100 text-left transition-transform duration-300 ease-out hover:-translate-y-1"
>
<img
src={image}
alt={title}
loading="lazy"
onLoad={(e) => {
const el = e.currentTarget;
if (el.naturalWidth && el.naturalHeight) {
setRatio(el.naturalWidth / el.naturalHeight);
}
}}
className="absolute inset-0 h-full w-full object-cover"
/>
{/* hover 浮层:卡片高度已由图片比例锁定,磨砂带占比恒定,hover 前后零回流。 */}
<div className="absolute inset-x-0 bottom-0">
<div className="relative px-4 pt-10 pb-4">
{/* 毛玻璃底:backdrop-blur 0→md(不走 opacity,避免比文字慢半拍);上沿 mask 羽化,避免生硬分界 */}
<div className="absolute inset-0 backdrop-blur-0 transition-[backdrop-filter] duration-300 ease-out group-hover:backdrop-blur-md [mask-image:linear-gradient(to_top,black_62%,transparent)] [-webkit-mask-image:linear-gradient(to_top,black_62%,transparent)]" />
{/* 暗色渐变:opacity 淡入(自带 to-transparent 上沿,无需额外 mask */}
<div className="absolute inset-0 opacity-0 transition-opacity duration-300 ease-out group-hover:opacity-100 bg-gradient-to-t from-clay-900/92 via-clay-900/60 to-transparent" />
<div className="relative opacity-0 transition-opacity duration-300 ease-out group-hover:opacity-100">
<h4 className="font-serif text-cream-50 text-base md:text-lg leading-snug mb-1 [text-shadow:0_1px_8px_rgba(20,10,4,0.6)]">
{title}
</h4>
<p className="font-serif italic text-cream-50/95 text-xs md:text-[13px] leading-relaxed line-clamp-4 [text-shadow:0_1px_6px_rgba(20,10,4,0.55)]">
{outline}
</p>
</div>
</div>
</div>
</button>
);
}
/* ---------- collapsible category selector ---------- */
function CategorySelect({
@@ -218,26 +254,39 @@ function CategorySelect({
onPick: (i: number) => void;
}) {
return (
<div className={"ip-cat" + (open ? " open" : "")}>
<button type="button" className="ip-catbtn" onClick={onToggle}>
<span className="ip-catname">{label}</span>
<span className="ip-catval">{items[value]}</span>
<span className="ip-caret"></span>
<Frame />
<div className="relative">
<button
type="button"
onClick={onToggle}
className="group flex items-center gap-2.5 pb-1.5 border-b border-clay-900/20 hover:border-clay-900/45 transition-colors"
>
<span className="text-[10px] smallcaps text-clay-500">{label}</span>
<span className={"font-serif text-base md:text-lg " + (open ? "text-ember-500" : "text-clay-900")}>
{items[value]}
</span>
<i
className={
"fa-solid fa-chevron-down text-[9px] text-clay-400 transition-transform duration-200 " +
(open ? "rotate-180" : "")
}
/>
</button>
{open && (
<div className="ip-catmenu">
<div className="absolute left-0 top-full mt-2 z-30 min-w-[150px] py-1.5 bg-cream-50 border border-clay-900/15 rounded-sm shadow-xl shadow-clay-900/10">
{items.map((it, i) => (
<button
key={i}
type="button"
className={"ip-catopt" + (i === value ? " on" : "")}
onClick={() => onPick(i)}
className={
"flex w-full items-center justify-between gap-3 px-4 py-1.5 text-sm font-serif transition-colors hover:bg-cream-100 " +
(i === value ? "text-ember-500" : "text-clay-700")
}
>
{it}
{i === value && <i className="fa-solid fa-check text-[10px]" />}
</button>
))}
<Frame />
</div>
)}
</div>
@@ -265,102 +314,80 @@ function StyleModal({
}, []);
const close = () => {
setShown(false);
setTimeout(onClose, 300);
setTimeout(onClose, 280);
};
const list = items.map((name, i) => ({ name, i })).filter((x) => x.name.includes(q.trim()));
return (
<div className={"ip-modal-ov" + (shown ? " show" : "")} onMouseDown={close}>
<div className="ip-modal" onMouseDown={(e) => e.stopPropagation()}>
<Frame />
<div className="ip-modal-hd">
<div className="ip-modal-ttl">
<span className="ip-modal-sub">· prompt </span>
<div
onMouseDown={close}
className={
"fixed inset-0 z-[60] flex items-center justify-center p-6 md:p-10 transition-all duration-300 " +
(shown ? "bg-clay-900/30 backdrop-blur-md" : "bg-clay-900/0 backdrop-blur-0")
}
>
<div
onMouseDown={(e) => e.stopPropagation()}
className={
"flex w-[1000px] max-w-[94vw] max-h-[86vh] flex-col overflow-hidden rounded-sm border border-clay-900/15 bg-cream-50 shadow-2xl shadow-clay-900/25 transition-all duration-300 " +
(shown ? "opacity-100 scale-100" : "opacity-0 scale-95")
}
>
<div className="flex items-center gap-5 px-6 md:px-8 py-5 border-b border-clay-900/10">
<div className="flex flex-col">
<span className="font-serif text-xl md:text-2xl text-clay-900"></span>
<span className="text-[11px] text-clay-500 mt-1 tracking-wide">
· prompt
</span>
</div>
<div className="ip-modal-search">
<div className="relative ml-auto w-[280px] max-w-[46vw]">
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="搜索风格…"
autoFocus
className="h-10 w-full rounded-sm border border-clay-900/15 bg-cream-100 pl-4 pr-10 font-sans text-sm text-clay-900 outline-none transition-colors focus:border-ember-500 placeholder:text-clay-400"
/>
<span className="si"></span>
<i className="fa-solid fa-magnifying-glass absolute right-3.5 top-1/2 -translate-y-1/2 text-sm text-clay-400 pointer-events-none" />
</div>
<button type="button" className="ip-modal-x" onClick={close} aria-label="close">
×
<button
type="button"
onClick={close}
aria-label="关闭"
className="text-xl leading-none text-clay-500 hover:text-clay-900 transition-colors"
>
<i className="fa-solid fa-xmark" />
</button>
</div>
<div className="ip-modal-grid">
<div className="grid grid-cols-2 gap-3 overflow-y-auto px-6 py-6 md:grid-cols-4 md:gap-4 md:px-8">
{list.map(({ name, i }) => (
<div
<button
key={i}
className={"ip-scard" + (i === value ? " on" : "")}
type="button"
onClick={() => {
onPick(i);
close();
}}
className={
"flex h-20 items-center justify-center rounded-sm border px-3 text-center transition-all " +
(i === value
? "border-ember-500 bg-ember-500/5 text-ember-500"
: "border-clay-900/12 text-clay-700 hover:border-clay-900/35 hover:bg-cream-100")
}
>
<div className="sthumb">
<ImgGlyph />
</div>
<div className="sname">{name}</div>
</div>
<span className="font-serif text-base md:text-lg">{name}</span>
</button>
))}
{list.length === 0 && <div className="ip-noresult"></div>}
{list.length === 0 && (
<div className="col-span-full py-12 text-center font-serif text-sm text-clay-400">
</div>
)}
</div>
</div>
</div>
);
}
/* ---------- scale-to-fit hero canvas ---------- */
function HeroCanvas({ children }: { children: React.ReactNode }) {
const stageRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLDivElement>(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 (
<div
ref={stageRef}
style={{ position: "relative", width: "100%", overflow: "hidden" }}
>
<div
ref={canvasRef}
style={{
position: "absolute",
top: 0,
left: 0,
width: HERO_CANVAS_W,
height: HERO_CANVAS_H,
transformOrigin: "top left",
}}
>
{children}
</div>
</div>
);
}
/* ---------- page ---------- */
export default function HomePage() {
@@ -370,23 +397,64 @@ export default function HomePage() {
const [open, setOpen] = useState<number>(-1);
const [styleOpen, setStyleOpen] = useState(false);
const [prompt, setPrompt] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
// 顶部使用提示:默认展示,用户可点 × 永久关闭(localStorage:yume:hintClosed)。
const [hintClosed, setHintClosed] = useState(false);
const styleRow = OPTS.findIndex((o) => o.modal);
const genderIndex = sel[0] ?? 0;
const gender = (OPTS[0]!.items[genderIndex] as "男性向" | "女性向") ?? "男性向";
const gender = (OPTS[0]!.items[genderIndex] as Gender) ?? "男性向";
const phrases = EXAMPLE_PHRASES[gender];
// 性向切换时,整片瀑布流做淡出→换图→淡入的过渡(而非瞬切)。
const [galleryGender, setGalleryGender] = useState<Gender>(gender);
const [fading, setFading] = useState(false);
useEffect(() => {
if (gender === galleryGender) return;
setFading(true);
const t = setTimeout(() => {
setGalleryGender(gender);
setFading(false);
}, 280);
return () => clearTimeout(t);
}, [gender, galleryGender]);
/* 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);
if (!target?.closest?.("[data-cat]")) setOpen(-1);
};
document.addEventListener("mousedown", h);
return () => document.removeEventListener("mousedown", h);
}, []);
useEffect(() => {
try {
if (localStorage.getItem("yume:hintClosed") === "1") setHintClosed(true);
} catch {
/* ignore */
}
}, []);
// 输入框随内容自动增高:长文本整段可见(打字与点卡片填入都覆盖)。
useEffect(() => {
const el = inputRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}, [prompt]);
const closeHint = () => {
setHintClosed(true);
try {
localStorage.setItem("yume:hintClosed", "1");
} catch {
/* ignore */
}
};
const start = () => {
const userPrompt = prompt.trim();
const artStyle = OPTS[1]!.items[sel[1] ?? 0]!;
@@ -420,6 +488,9 @@ export default function HomePage() {
// 「自动」→ 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.
// TODO(自动路由): 后续实现真正的「自动」——由模型依据世界观 / 玩家 prompt
// 选出最合适的画风,再映射到对应风格提示词,而非固定回退到二次元。届时
// 同步更新风格弹窗副标题(「由模型根据 prompt 判断风格」)使文案与行为一致。
const effectiveStyle = artStyle === "自动" ? "二次元" : artStyle;
const styleGuide = styleMap[effectiveStyle] ?? styleMap["二次元"]!;
const audioEnabled = voice === "开启";
@@ -436,196 +507,228 @@ export default function HomePage() {
inputRef.current?.focus();
};
return (
<div className="w-full relative">
{/* ================== HERO (scale-to-fit 1900×980 canvas) ================== */}
<HeroCanvas>
{/* tagline */}
<div
style={{
position: "absolute",
left: "50%",
top: 172,
transform: "translateX(-50%)",
whiteSpace: "nowrap",
}}
>
<div className="ip-tagline" style={{ fontSize: 33 }}>
穿
</div>
</div>
const stories = STORIES[galleryGender];
const imgPrefix = galleryGender === "女性向" ? "f" : "m";
{/* prompt bar */}
<div
style={{
position: "absolute",
left: "50%",
top: 250,
transform: "translateX(-50%)",
width: 1100,
height: 68,
}}
>
return (
<div className="min-h-screen flex flex-col">
{/* ================== HEADER ================== */}
<header className="mx-auto w-full max-w-[1640px] px-6 md:px-16 pt-7 md:pt-10 flex items-center justify-between">
<span className="font-serif text-2xl md:text-[34px] leading-none tracking-tight text-clay-900">
Infi<em className="italic font-light text-ember-500">Plot</em>
</span>
<div className="flex items-center gap-5">
<a
href="https://github.com/zonghaoyuan/infiplot"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
className="text-lg text-clay-500 hover:text-ember-500 transition-colors"
>
<i className="fa-brands fa-github" />
</a>
<a
href="https://x.com/yzh_im"
target="_blank"
rel="noopener noreferrer"
aria-label="X / Twitter"
className="text-base text-clay-500 hover:text-ember-500 transition-colors"
>
<i className="fa-brands fa-x-twitter" />
</a>
</div>
</header>
{/* ================== HERO 控制区(居中,呼应原型布局) ================== */}
<section className="px-6 md:px-16 pt-16 md:pt-24 pb-10 md:pb-14">
<div className="mx-auto max-w-[1100px] text-center">
<h1 className="font-serif font-light text-[32px] md:text-[56px] leading-[1.12] tracking-tight text-clay-900">
</h1>
{/* prompt 输入(居中) */}
<form
className="ip-bar"
style={{ width: 1100, height: 68 }}
onSubmit={(e) => {
e.preventDefault();
start();
}}
className="mx-auto mt-9 md:mt-12 max-w-[760px]"
>
<div className="ip-field">
<input
<div className="relative text-left">
<textarea
ref={inputRef}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
start();
}
}}
rows={1}
placeholder=" "
spellCheck={false}
className="block w-full resize-none overflow-hidden border-b border-clay-900/25 bg-transparent py-3 md:py-4 pr-28 font-serif text-lg md:text-2xl lining-nums text-clay-900 outline-none transition-colors focus:border-ember-500"
/>
{!prompt && (
<div className="ph">
<div className="pointer-events-none absolute left-0 right-0 top-0 overflow-hidden whitespace-nowrap py-3 md:py-4 pr-28 font-serif text-lg md:text-2xl text-clay-400">
<Typewriter phrases={phrases} />
</div>
)}
<Frame />
<button
type="submit"
className="absolute right-0 bottom-2 md:bottom-3 inline-flex items-center gap-2 rounded-sm bg-clay-900 px-5 py-2 md:py-2.5 font-sans text-sm md:text-[15px] text-cream-50 transition-colors hover:bg-ember-500"
>
<i className="fa-solid fa-arrow-right text-xs" />
</button>
</div>
<button type="submit" className="ip-start">
<span> </span>
<Frame />
</button>
</form>
</div>
{/* category selectors */}
<div
style={{
position: "absolute",
left: "50%",
top: 352,
transform: "translateX(-50%)",
width: 1180,
display: "flex",
flexWrap: "wrap",
gap: 12,
justifyContent: "center",
}}
>
{OPTS.map((o, r) => (
<CategorySelect
key={r}
label={o.label}
items={o.items}
value={sel[r] ?? 0}
open={open === r}
onToggle={() => {
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);
}}
/>
))}
</div>
{/* hero scattered cards — content switches by 性向 */}
{HERO_SLOTS.map((slot, i) => {
const content = HERO_CONTENT[gender][i]!;
const suffix = gender === "女性向" ? "_f" : "";
return (
<div
key={`${gender}-${i}`}
className="ip-card"
style={{
left: slot.x,
top: slot.y,
width: slot.w,
height: slot.h,
transform: `rotate(calc(${slot.rot}deg * var(--jit)))`,
}}
onClick={() => onCardClick(content.outline)}
>
<CardBody
title={content.title}
outline={content.outline}
image={`/home/hero${i}${suffix}.webp`}
/>
<Frame />
</div>
);
})}
</HeroCanvas>
{/* ================== SCROLL HINT + GALLERY ================== */}
<div className="ip-sectionnote">
<span className="arr"></span>
·
</div>
<div className="ip-gallery">
{GALLERY.map((g, i) => (
<div
key={i}
className="ip-card gcard"
style={{ height: g.h, ["--gr" as string]: g.rot + "deg" } as React.CSSProperties}
onClick={() => onCardClick(g.outline)}
>
<CardBody
title={g.title}
outline={g.outline}
image={`/home/gallery${i}.webp`}
/>
<Frame />
{/* 类别选择器(居中) */}
<div className="mt-9 md:mt-11 flex flex-wrap justify-center gap-x-8 gap-y-5">
{OPTS.map((o, r) => (
<div data-cat key={r} className="text-left">
<CategorySelect
label={o.label}
items={o.items}
value={sel[r] ?? 0}
open={open === r}
onToggle={() => {
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);
}}
/>
</div>
))}
</div>
))}
</div>
{/* ================== PROJECT INTRO ================== */}
<div className="ip-intro">
<div className="kicker">INFIPLOT · AI · Demo</div>
{/* 使用提示:可被用户永久关闭(localStorage:yume:hintClosed */}
{!hintClosed && (
<div className="relative mx-auto mt-10 md:mt-12 max-w-[640px] rounded-sm border border-clay-900/10 bg-cream-100/50 px-8 py-3.5">
<p className="font-serif text-[13px] md:text-sm leading-relaxed text-clay-500">
{" "}
<em className="not-italic text-ember-500">InfiPlot</em>
</p>
<button
type="button"
onClick={closeHint}
aria-label="不再显示此提示"
className="absolute right-2 top-2 inline-flex h-6 w-6 items-center justify-center rounded-full text-clay-400 transition-colors hover:bg-clay-900/5 hover:text-clay-700"
>
<i className="fa-solid fa-xmark text-xs" />
</button>
</div>
)}
</div>
</section>
<p>
<b>InfiPlot</b> AI
one-shot
</p>
<p>
<b></b><b></b><b></b>
</p>
{/* ================== 统一瀑布流(每性向 30 篇预设剧情) ================== */}
<section className="mx-auto w-full max-w-[1640px] px-6 md:px-16 pt-10 md:pt-14 pb-16 md:pb-24">
<div
className={
"transition-[opacity,filter] duration-300 ease-out " +
(fading ? "opacity-0 blur-[3px]" : "opacity-100 blur-0")
}
>
<div className="columns-2 md:columns-3 xl:columns-4 gap-4 md:gap-5">
{stories.map((c, i) => (
<StoryCard
key={`${imgPrefix}-${i}`}
title={c.title}
outline={c.outline}
image={`/home/${imgPrefix}${i}.webp`}
onClick={() => onCardClick(c.outline)}
/>
))}
</div>
</div>
</section>
<div className="label"> </div>
<p>
<b></b><b></b>
</p>
{/* ================== 项目介绍(居中题跋) ================== */}
<section id="about" className="mx-auto w-full max-w-[1640px] px-6 md:px-16 pb-12 md:pb-16">
<div className="hairline-full w-full mb-12 md:mb-16" />
<div className="label"> / </div>
<p>
<span className="mail">hi@infiplot.com</span>
</p>
<div className="mx-auto max-w-3xl text-center mb-14 md:mb-20">
<p className="font-serif text-clay-800 text-xl md:text-2xl leading-[1.7]">
<b className="font-medium text-clay-900">InfiPlot</b>{" "}
AI
</p>
</div>
<div className="label"> </div>
<p>
<span className="mail">hi@infiplot.com</span> · Founder X / Twitter <b>@yzh_im</b>
</p>
<div className="mx-auto grid max-w-4xl grid-cols-1 gap-y-10 text-center md:grid-cols-3 md:gap-x-10">
<div>
<p className="text-[10px] smallcaps text-clay-500 mb-3"> </p>
<p className="font-serif italic text-clay-700 text-base leading-relaxed">
<span className="not-italic">one-shot</span>
</p>
</div>
<div className="label"> </div>
<p>
/ <span style={{ color: "var(--ink-faint)" }}></span>
</p>
<div>
<p className="text-[10px] smallcaps text-clay-500 mb-3"> </p>
<p className="font-serif text-clay-700 text-base leading-relaxed">
<span className="block mb-2">
{" "}
<a
href="mailto:hi@infiplot.com"
className="text-ember-500 hover:text-ember-400 transition-colors"
>
hi@infiplot.com
</a>
</span>
<a
href="https://x.com/yzh_im"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-clay-700 hover:text-ember-500 transition-colors"
>
<i className="fa-brands fa-x-twitter text-[15px]" />
<span className="font-sans text-sm">@yzh_im</span>
</a>
</p>
<p className="text-[10px] smallcaps text-clay-500 mb-3 mt-7"> </p>
<a
href="https://github.com/zonghaoyuan/infiplot"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-clay-700 hover:text-ember-500 transition-colors"
>
<i className="fa-brands fa-github text-[15px]" />
<span className="font-sans text-sm">zonghaoyuan/infiplot</span>
</a>
</div>
<p style={{ fontSize: 13, color: "var(--ink-faint)", lineHeight: 1.75, marginTop: 32 }}>
使
<div>
<p className="text-[10px] smallcaps text-clay-500 mb-3"> </p>
<p className="font-serif italic text-clay-500 text-base leading-relaxed">
/
</p>
</div>
</div>
<div className="hairline-full w-full mt-14 md:mt-20 mb-12 md:mb-16" />
<p className="mx-auto max-w-3xl text-center font-sans text-xs md:text-[13px] leading-[1.85] text-clay-500">
使ing^-^
<br />
<br />
AI
</p>
<Frame />
</div>
</section>
<footer className="mx-auto w-full max-w-[1640px] px-6 md:px-16 pb-10 mt-auto">
<div className="hairline-full w-full mb-5" />
<div className="flex flex-col items-center text-[10px] smallcaps text-clay-500">
<span>© 2026 InfiPlot. All rights reserved.</span>
</div>
</footer>
{styleOpen && styleRow >= 0 && (
<StyleModal
+36 -12
View File
@@ -266,6 +266,10 @@ function PlayInner() {
// 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<boolean>(true);
// Mirrors `muted` so the closure-stable fetchBeatAudio (deps []) can gate on
// it. Muting stops TTS *synthesis*, not just playback — TTS is the only sound
// source, so synthesizing audio the user can't hear just burns quota.
const mutedRef = useRef<boolean>(muted);
// Mirrors for use inside async handlers (closure-stable)
const sessionRef = useRef<Session | null>(null);
@@ -291,6 +295,9 @@ function PlayInner() {
useEffect(() => {
currentBeatRef.current = currentBeat;
}, [currentBeat]);
useEffect(() => {
mutedRef.current = muted;
}, [muted]);
// Whenever currentBeatId changes, append it to visited (skip consecutive dups)
useEffect(() => {
@@ -322,6 +329,7 @@ function PlayInner() {
beat: { id: string; speaker?: string; line?: string; lineDelivery?: string },
): Promise<void> => {
if (!audioEnabledRef.current) return; // user toggled 语音配音 → 关闭
if (mutedRef.current) return; // 静音 → 不合成 TTS(避免无谓的调用与花费)
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
@@ -367,22 +375,26 @@ function PlayInner() {
beatAudioAbortRef.current.clear();
}
// Fire one /api/beat-audio request per speaking beat each time the scene
// changes. Cancel any in-flight requests from the prior scene first —
// beat ids are scene-local ("b1" repeats across scenes) so a late arrival
// would land under the wrong beat in the audio map otherwise.
useEffect(() => {
cancelBeatAudioFetches();
setBeatAudioMap({});
const scene = currentScene;
// Fire one /api/beat-audio request per speaking beat in the current scene.
// Reads refs (not props) so it stays closure-stable and can be re-run on
// un-mute as well as on scene change.
const prefetchSceneAudio = useCallback(() => {
const scene = currentSceneRef.current;
const sess = sessionRef.current;
if (!scene || !sess) return;
for (const b of scene.beats) {
if (b.speaker && b.line) {
void fetchBeatAudio(sess, b);
}
if (b.speaker && b.line) void fetchBeatAudio(sess, b);
}
}, [currentScene?.id, fetchBeatAudio]);
}, [fetchBeatAudio]);
// (Re)synthesize each time the scene changes. Cancel any in-flight requests
// from the prior scene first — beat ids are scene-local ("b1" repeats across
// scenes) so a late arrival would land under the wrong beat otherwise.
useEffect(() => {
cancelBeatAudioFetches();
setBeatAudioMap({});
prefetchSceneAudio();
}, [currentScene?.id, prefetchSceneAudio]);
// ── Mute persistence (read is via the useState lazy initializer above) ─
const toggleMuted = useCallback(() => {
@@ -397,6 +409,18 @@ function PlayInner() {
});
}, []);
// Muting stops synthesis, not just playback: abort in-flight requests when
// muting. When un-muting, re-synthesize the current scene — fetchBeatAudio
// skips synthesis while muted, so a scene entered muted has no audio to play
// back otherwise. (Clearing the map re-synthesizes already-fetched beats on a
// mid-scene un-mute, but that's bounded to one scene and a rare toggle.)
useEffect(() => {
cancelBeatAudioFetches();
if (muted) return;
setBeatAudioMap({});
prefetchSceneAudio();
}, [muted, prefetchSceneAudio]);
// ── Presentation mode toggle ─────────────────────────────────────────
const togglePresentation = useCallback(async () => {
const entering = !presentation;

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 129 KiB

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 119 KiB

+300 -88
View File
@@ -1,15 +1,19 @@
#!/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/.
* One-off generator: produces the InfiPlot homepage story cards via Runware
* FLUX.2 and writes them as PNGs under apps/web/public/home/.
*
* Flat per-gender layout: 30 male-oriented (m0..m29) + 30 female-oriented
* (f0..f29). Same index shares aspect ratio across genders so the 性向
* crossfade never jumps card height.
*
* 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.
* Idempotent: skips any card whose .png or .webp already exists. Pass --force
* to regenerate everything.
*/
import { fileURLToPath } from "node:url";
@@ -60,230 +64,438 @@ if (!BASE_URL.includes("runware.ai")) {
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 = [
// 30 male-oriented cards (m0..m29). m0..m6 flagship moods, m7..m22 broad
// genre sweep, m23..m29 added range (wuxia / space opera / republican-era /
// apocalypse / western / deep sea / steampunk).
const MALE = [
{
name: "hero0",
name: "m0",
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",
name: "m1",
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",
name: "m2",
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",
name: "m3",
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",
name: "m4",
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",
name: "m5",
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",
name: "m6",
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",
name: "m7",
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",
name: "m8",
prompt:
"cyberpunk neon city skyline at rainy night, flying vehicles, holographic billboards in chinese characters, anime widescreen, cinematic",
w: 1024,
h: 640,
},
{
name: "gallery2",
name: "m9",
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",
name: "m10",
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",
name: "m11",
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",
name: "m12",
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",
name: "m13",
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",
name: "m14",
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",
name: "m15",
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",
name: "m16",
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",
name: "m17",
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",
name: "m18",
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",
name: "m19",
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",
name: "m20",
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",
name: "m21",
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",
name: "m22",
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,
},
{
name: "m23",
prompt:
"anime wuxia scene, a lone swordsman in a rundown rainy-night tavern, a mysterious masked woman at the next table with a sword case beside her, warm lantern light, jianghu atmosphere, vertical composition, galgame illustration",
w: 768,
h: 1024,
},
{
name: "m24",
prompt:
"anime space opera scene, the bridge of a deep-space colony ship with red alert lights flashing, an unknown planet glowing ominous crimson through the viewport, sci-fi galgame illustration, cinematic widescreen",
w: 1024,
h: 640,
},
{
name: "m25",
prompt:
"anime 1930s old Shanghai bund scene, art deco ballroom, a dancer handing a coded playing card to the viewer, gramophone and warm amber lighting, republican era China, cinematic galgame illustration",
w: 1024,
h: 832,
},
{
name: "m26",
prompt:
"anime post-apocalyptic survival scene, interior of a barricaded convenience store at night, a lone survivor tense at the rolling shutter door listening to a rhythmic knock, dim emergency light, vertical composition, galgame illustration",
w: 768,
h: 1024,
},
{
name: "m27",
prompt:
"anime wild west scene, a deserted frontier town at high noon, a lone gunslinger standing outside the saloon ready for a duel, dust and harsh sunlight, cinematic widescreen, anime illustration",
w: 1024,
h: 640,
},
{
name: "m28",
prompt:
"anime deep sea exploration scene, a diving bell descending into an abyssal trench, searchlight revealing an ancient sunken city, eerie blue glow, bioluminescence, vertical composition, galgame illustration",
w: 768,
h: 1024,
},
{
name: "m29",
prompt:
"anime steampunk airship deck scene, brass gears and billowing steam above a sea of clouds, a black pirate balloon approaching the starboard side, dramatic adventure mood, cinematic widescreen, anime illustration",
w: 1024,
h: 832,
},
];
const ALL = [...HERO, ...HERO_F, ...GALLERY];
// 30 female-oriented cards (f0..f29). Same index + aspect ratio as MALE so the
// 女性向 masonry mirrors slot heights; otome / josei love-interest framing.
const FEMALE = [
{
name: "f0",
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: "f1",
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: "f2",
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: "f3",
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: "f4",
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: "f5",
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: "f6",
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,
},
{
name: "f7",
prompt:
"anime otome game illustration, handsome boy in summer yukata shielding a girl from the festival crowd, both watching the last firework bloom in the night sky, warm lantern bokeh, vertical composition, soft watercolor, romantic galgame",
w: 768,
h: 1024,
},
{
name: "f8",
prompt:
"anime josei romance, handsome young man draping his coat over a girl's shoulders on a rainy train platform at night, neon signs shattering into reflections in the puddles, cinematic widescreen, warm melancholic tones, otome illustration",
w: 1024,
h: 640,
},
{
name: "f9",
prompt:
"anime otome scene, a boy stopping and turning back to look at the girl on an empty rural train platform at golden hour dusk, unspoken words between them, slice of life galgame illustration, warm tones, cinematic widescreen",
w: 1024,
h: 832,
},
{
name: "f10",
prompt:
"anime otome game, cold aloof student council president closing a forbidden tome in the depths of an old library, lifting his gaze with unexpectedly gentle eyes toward the viewer, dust motes in candlelight, vertical composition, painterly illustration",
w: 768,
h: 1024,
},
{
name: "f11",
prompt:
"anime otome romance, a handsome knight kneeling on one knee swearing an oath with his sword before the viewer, red emergency alert lighting on a starship bridge, dramatic devotion, otome game illustration, widescreen",
w: 1024,
h: 640,
},
{
name: "f12",
prompt:
"anime otome scene, handsome young man catching up under a single umbrella to a girl walking alone in a midnight rainy alley, offering to walk her home, noir streetlamp glow, rain mist, vertical composition, romantic galgame",
w: 768,
h: 1024,
},
{
name: "f13",
prompt:
"anime otome romance, a boy tilting a glowing holographic umbrella toward the girl while his own shoulder gets soaked in the neon rain, electric blue and pink reflections, intimate quiet moment, galgame illustration",
w: 1024,
h: 832,
},
{
name: "f14",
prompt:
"anime wuxia otome, a handsome swordsman sheathing his blade to stand protectively before a girl in a bamboo grove, falling bamboo leaves drifting between them, golden light, dynamic romantic composition, widescreen",
w: 1024,
h: 640,
},
{
name: "f15",
prompt:
"anime otome game, a cold regent prince crossing a candlelit ancient palace banquet hall, reaching out his hand only toward the viewer while courtiers bow, opulent silks and gold, vertical poster composition, fantasy otome illustration",
w: 768,
h: 1024,
},
{
name: "f16",
prompt:
"anime otome scene, a boy with reddened ears shyly pushing his notebook across a desk toward the girl in a sunset-lit empty classroom, warm orange light, tender romantic moment, slice of life galgame, widescreen",
w: 1024,
h: 640,
},
{
name: "f17",
prompt:
"anime otome romance, a handsome boy handing a love letter to the viewer under a cherry blossom tree, petals drifting in the air, tender expression, soft warm watercolor, slice of life galgame illustration",
w: 1024,
h: 832,
},
{
name: "f18",
prompt:
"anime otome fantasy, a silver-haired ethereal moon god leaning down, fingertip gently touching the viewer's cheek, aurora glow and drifting starlight, dreamy painterly illustration, vertical composition",
w: 768,
h: 1024,
},
{
name: "f19",
prompt:
"anime otome wuxia, a handsome swordsman shielding the girl with his body under a blood red full moon, sword light and sakura petals falling together, dramatic backlight, cinematic widescreen",
w: 1024,
h: 640,
},
{
name: "f20",
prompt:
"anime otome fantasy, a handsome young sorcerer brewing a glowing fate-changing potion for the viewer in a candlelit forest hut, hanging dried herbs, magical sparks rising, warm romantic mood, vertical composition",
w: 768,
h: 1024,
},
{
name: "f21",
prompt:
"anime otome scene, a boy sitting beside the girl on a seaside embankment under a pink-orange sunset, sharing unspoken feelings carried off on the sea breeze, gentle waves, slice of life galgame illustration, widescreen",
w: 1024,
h: 640,
},
{
name: "f22",
prompt:
"anime otome cyberpunk, a handsome hacker boy bathed in blue screen glow turning to look at the viewer after typing the last line of code, neon cyan reflections on his face, intense tender gaze, galgame illustration",
w: 1024,
h: 832,
},
{
name: "f23",
prompt:
"anime otome fantasy, a silver-haired dragon king in humanoid form kneeling on one knee deep in an ancient dragon lair, offering a dragon-scale ring toward the viewer, glowing treasure hoard, vertical composition, otome game illustration",
w: 768,
h: 1024,
},
{
name: "f24",
prompt:
"anime otome josei, 1930s old Shanghai mansion, an elegant refined young gentleman in a western suit shielding the viewer from a stray bullet, crimson blooming on his sleeve cuff, warm amber lighting, cinematic widescreen, otome illustration",
w: 1024,
h: 640,
},
{
name: "f25",
prompt:
"anime otome apocalypse, a handsome rugged survivor firing his last bullet at a zombie breaking through a door, then turning to shield the girl behind him, dim ruined interior, dramatic devotion, otome game illustration, widescreen",
w: 1024,
h: 832,
},
{
name: "f26",
prompt:
"anime otome gothic romance, a pale handsome vampire count bowing to kiss the back of the viewer's hand at a candlelit masquerade ball in a fog-shrouded castle, cold elegant beauty, vertical composition, otome illustration",
w: 768,
h: 1024,
},
{
name: "f27",
prompt:
"anime otome wild west, a silent handsome bounty hunter on horseback in a dusty frontier town reaching down to pull the girl up onto his saddle, golden dust and harsh sunlight, cinematic widescreen, otome illustration",
w: 1024,
h: 640,
},
{
name: "f28",
prompt:
"anime otome fantasy, a luminous handsome merman prince wrapping his arm around the girl's waist, guiding her through a sleeping ancient underwater city, glowing bioluminescent ruins, vertical composition, otome game illustration",
w: 768,
h: 1024,
},
{
name: "f29",
prompt:
"anime otome steampunk, a dashing one-eyed airship captain on the deck handing a telescope to the viewer, brass gears and a sea of clouds behind, adventurous romantic mood, cinematic widescreen, otome illustration",
w: 1024,
h: 832,
},
];
const ALL = [...MALE, ...FEMALE];
/* ---------- Runware caller ---------- */