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;