diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index b15dc09..43faabb 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -2,67 +2,650 @@ @tailwind components; @tailwind utilities; +/* ==================== InfiPlot — low-fi prototype tokens ==================== */ +:root { + --ink: #3a3a38; + --ink-soft: #6f6e69; + --ink-faint: #a9a7a0; + --line: #cfccc4; + --paper: #f3f1ec; + --paper-2: #e9e6df; + --fill: #ddd9d0; + --accent: #d4824a; + --jit: 1; + --sketch-filter: url(#s2); +} + @layer base { html { font-feature-settings: "ss01", "kern", "liga"; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; + background: var(--paper); } body { - background-image: - radial-gradient(rgba(133, 79, 37, 0.025) 1px, transparent 1px), - radial-gradient(rgba(133, 79, 37, 0.018) 1px, transparent 1px); - background-size: 28px 28px, 38px 38px; - background-position: 0 0, 14px 19px; + background: + repeating-linear-gradient(0deg, transparent 0 38px, rgba(0, 0, 0, 0.018) 38px 39px), + var(--paper); + color: var(--ink); + font-family: "Noto Sans SC", "PingFang SC", system-ui, sans-serif; } ::selection { - background-color: rgb(217 122 46 / 0.28); - color: #2d1810; - } - - textarea::placeholder { - color: rgb(168 105 59 / 0.45); + background-color: rgb(212 130 74 / 0.28); + color: var(--ink); } } @layer utilities { - .hairline { - background-image: linear-gradient( - to right, - transparent, - rgba(45, 24, 16, 0.18) 18%, - rgba(45, 24, 16, 0.18) 82%, - transparent - ); - height: 1px; - } - - .hairline-full { - height: 1px; - background: rgba(45, 24, 16, 0.14); - } - - .num { - font-variant-numeric: tabular-nums lining-nums; - } - - .smallcaps { - text-transform: uppercase; - letter-spacing: 0.32em; + .latin { + font-family: "Patrick Hand", "Caveat", "Cormorant Garamond", cursive; } } -@keyframes yume-ripple { - 0% { - width: 14px; - height: 14px; - opacity: 0.95; - } - 100% { - width: 110px; - height: 110px; - opacity: 0; - } +/* ==================== hand-drawn frame ==================== */ +.frame { + position: absolute; + inset: 0; + border: 2px solid var(--ink); + border-radius: 12px; + filter: var(--sketch-filter); + background: transparent; + pointer-events: none; +} +.frame.soft { + border-color: var(--ink-soft); +} + +/* ==================== logo ==================== */ +.ip-logo { + display: inline-flex; + align-items: center; + gap: 12px; +} +.ip-logo .mark { + position: relative; + width: 30px; + height: 30px; +} +.ip-logo .mark .frame { + border-radius: 50%; +} +.ip-logo .mark span { + position: absolute; + inset: 0; + display: grid; + place-items: center; + font-size: 15px; + color: var(--ink-soft); +} +.ip-logo .word { + font-family: "Patrick Hand", "Caveat", cursive; + font-size: 24px; + letter-spacing: 2px; + color: var(--ink); + font-weight: 400; +} + +/* ==================== input bar (prompt + start) ==================== */ +.ip-tagline { + font-size: clamp(22px, 2.6vw, 33px); + color: var(--ink); + font-weight: 500; + letter-spacing: 1px; + text-align: center; +} +.ip-bar { + display: flex; + gap: 16px; + align-items: stretch; + width: min(1100px, 92vw); + height: 68px; +} +.ip-field { + position: relative; + flex: 1; +} +.ip-field .frame { + border-radius: 16px; +} +.ip-field input { + position: relative; + width: 100%; + height: 100%; + padding: 0 28px; + background: transparent; + border: none; + outline: none; + font: inherit; + font-size: 20px; + color: var(--ink); + z-index: 1; +} +.ip-field input::placeholder { + color: transparent; +} +.ip-field .ph { + position: absolute; + inset: 0; + display: flex; + align-items: center; + padding: 0 28px; + font-size: 20px; + color: var(--ink-faint); + font-weight: 300; + white-space: nowrap; + overflow: hidden; + pointer-events: none; + z-index: 0; +} +.ip-cursor { + display: inline-block; + width: 2px; + height: 23px; + background: var(--ink-faint); + margin: 0 1px 0 3px; + vertical-align: -4px; + animation: ip-blink 1.1s steps(1) infinite; +} +@keyframes ip-blink { + 50% { opacity: 0; } +} + +.ip-start { + position: relative; + width: 176px; + flex: none; + display: grid; + place-items: center; + cursor: pointer; + border: none; + background: transparent; + padding: 0; +} +.ip-start .frame { + border-radius: 16px; + background: var(--accent); + border-color: var(--accent); + z-index: 0; +} +.ip-start span { + position: relative; + z-index: 1; + color: #fff; + font-size: 22px; + letter-spacing: 6px; + font-weight: 500; + padding-left: 6px; +} +.ip-start:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +/* ==================== collapsible category pills ==================== */ +.ip-cat { + position: relative; +} +.ip-catbtn { + position: relative; + height: 42px; + padding: 0 16px; + display: flex; + align-items: center; + gap: 9px; + cursor: pointer; + white-space: nowrap; + border: none; + background: transparent; +} +.ip-catbtn .frame { + border-radius: 21px; + border-color: var(--line); +} +.ip-catname { + position: relative; + z-index: 2; + font-size: 12.5px; + color: var(--ink-faint); +} +.ip-catval { + position: relative; + z-index: 2; + font-size: 15px; + color: var(--ink); + font-weight: 600; +} +.ip-caret { + position: relative; + z-index: 2; + font-size: 11px; + color: var(--ink-soft); + transition: transform 0.15s; +} +.ip-cat.open .ip-caret { + transform: rotate(180deg); +} +.ip-cat.open .ip-catbtn .frame { + border-color: var(--accent); +} +.ip-cat.open .ip-catval { + color: var(--accent); +} +.ip-catmenu { + position: absolute; + top: 50px; + left: 0; + min-width: calc(100% + 8px); + padding: 7px; + z-index: 20; + display: flex; + flex-direction: column; + gap: 2px; + background: var(--paper); +} +.ip-catmenu .frame { + border-radius: 12px; + border-color: var(--ink-soft); +} +.ip-catopt { + position: relative; + z-index: 1; + padding: 8px 16px; + border-radius: 8px; + font-size: 14px; + color: var(--ink-soft); + cursor: pointer; + white-space: nowrap; + background: transparent; + border: none; + text-align: left; + font: inherit; +} +.ip-catopt:hover { + background: var(--paper-2); +} +.ip-catopt.on { + color: var(--accent); + font-weight: 600; +} +.ip-catopt.on::after { + content: "\2713"; + margin-left: 8px; + font-size: 12px; +} + +/* ==================== scattered story cards ==================== */ +.ip-card { + position: absolute; + cursor: pointer; + transition: transform 0.25s ease; +} +.ip-card:hover { + transform: rotate(0deg) translateY(-4px) !important; +} +.ip-card .inner { + position: absolute; + inset: 0; + border-radius: 12px; + overflow: hidden; +} +.ip-card .img { + position: absolute; + inset: 0; + background: var(--fill); + display: grid; + place-items: center; +} +.ip-card .img svg { + width: 40%; + max-width: 120px; + opacity: 0.55; +} +.ip-card .img img.card-photo { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.ip-card .frame { + z-index: 3; +} +/* hover overlay: bottom-up dark gradient with title + outline */ +.ip-hover { + position: absolute; + inset: 0; + z-index: 2; + display: flex; + flex-direction: column; + justify-content: flex-end; + padding: 16px 18px 16px; + color: #fff; + background: linear-gradient(to top, rgba(0, 0, 0, 0.9), rgba(0, 0, 0, 0.45) 45%, rgba(0, 0, 0, 0) 100%); + opacity: 0; + transition: opacity 0.3s ease; + user-select: none; + border-radius: 12px; +} +.ip-card:hover .ip-hover { + opacity: 1; +} +.ip-hover-title { + margin: 0 0 6px; + font-size: 16px; + font-weight: 700; + letter-spacing: 0.08em; + color: #fff; +} +.ip-hover-outline { + margin: 0; + font-size: 12px; + line-height: 1.55; + color: rgba(231, 226, 215, 0.92); + font-style: italic; + display: -webkit-box; + -webkit-line-clamp: 4; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* ==================== gallery masonry ==================== */ +.ip-gallery { + width: min(1640px, 96vw); + margin: 14px auto 0; + column-count: 4; + column-gap: 24px; +} +@media (max-width: 1100px) { + .ip-gallery { column-count: 3; } +} +@media (max-width: 780px) { + .ip-gallery { column-count: 2; } +} +@media (max-width: 480px) { + .ip-gallery { column-count: 1; } +} +.ip-card.gcard { + position: relative; + width: 100%; + display: inline-block; + margin: 0 0 24px; + break-inside: avoid; + transform: rotate(calc(var(--gr, 0deg) * var(--jit))); +} + +.ip-sectionnote { + width: 100%; + margin: 34px auto 6px; + text-align: center; + font-family: "Patrick Hand", "Caveat", cursive; + font-size: 19px; + color: var(--accent); +} +.ip-sectionnote .arr { + display: block; + font-size: 22px; + line-height: 1; + margin-bottom: 2px; +} + +/* ==================== project intro ==================== */ +.ip-intro { + position: relative; + width: min(1500px, 94vw); + margin: 70px auto 110px; + padding: 58px clamp(28px, 5vw, 80px) 64px; +} +.ip-intro .frame { + border-radius: 16px; + border-color: var(--ink-soft); +} +.ip-intro .imark { + position: relative; + z-index: 1; + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 6px; +} +.ip-intro .imark .gl { + width: 34px; + height: 34px; + display: grid; + place-items: center; + font-size: 18px; + color: var(--ink-soft); + border: 2px solid var(--ink-soft); + border-radius: 50%; +} +.ip-intro h2 { + position: relative; + z-index: 1; + margin: 0; + font-family: "Patrick Hand", "Caveat", cursive; + font-size: 38px; + letter-spacing: 1px; + color: var(--ink); + font-weight: 400; +} +.ip-intro .kicker { + position: relative; + z-index: 1; + font-size: 15px; + color: var(--ink-soft); + letter-spacing: 3px; + margin: 2px 0 26px; + font-family: "Patrick Hand", "Caveat", cursive; +} +.ip-intro p { + position: relative; + z-index: 1; + max-width: 1180px; + font-size: 18px; + line-height: 1.9; + color: var(--ink-soft); + margin: 0 0 18px; + font-weight: 300; + text-wrap: pretty; +} +.ip-intro .label { + position: relative; + z-index: 1; + font-size: 13px; + color: var(--ink-faint); + letter-spacing: 2px; + margin: 26px 0 8px; + font-weight: 500; +} +.ip-intro b { + color: var(--ink); + font-weight: 600; +} +.ip-intro .mail { + color: var(--accent); + font-weight: 500; +} + +/* ==================== style picker modal ==================== */ +.ip-modal-ov { + position: fixed; + inset: 0; + z-index: 60; + display: flex; + align-items: center; + justify-content: center; + padding: 40px; + background: rgba(40, 38, 34, 0); + backdrop-filter: blur(0px); + -webkit-backdrop-filter: blur(0px); + transition: background 0.28s ease, backdrop-filter 0.28s ease, -webkit-backdrop-filter 0.28s ease; +} +.ip-modal-ov.show { + background: rgba(40, 38, 34, 0.34); + backdrop-filter: blur(7px); + -webkit-backdrop-filter: blur(7px); +} +.ip-modal { + position: relative; + width: 1120px; + max-width: 94vw; + max-height: 88vh; + display: flex; + flex-direction: column; + background: var(--paper); + border-radius: 16px; + overflow: hidden; + box-shadow: 0 30px 80px rgba(0, 0, 0, 0.32); + transform: scale(0.92); + opacity: 0; + transition: transform 0.3s cubic-bezier(0.2, 0.82, 0.25, 1), opacity 0.24s ease; +} +.ip-modal-ov.show .ip-modal { + transform: scale(1); + opacity: 1; +} +.ip-modal .frame { + border-radius: 16px; + border-color: var(--ink-soft); + z-index: 0; +} +.ip-modal-hd { + position: relative; + z-index: 1; + display: flex; + align-items: center; + gap: 20px; + padding: 22px 26px; + border-bottom: 1.5px dashed var(--line); +} +.ip-modal-ttl { + display: flex; + flex-direction: column; + font-size: 22px; + font-weight: 600; + color: var(--ink); + white-space: nowrap; +} +.ip-modal-sub { + font-size: 13px; + font-weight: 400; + color: var(--ink-faint); + margin-top: 3px; +} +.ip-modal-search { + position: relative; + margin-left: auto; + width: 320px; + max-width: 46vw; +} +.ip-modal-search input { + width: 100%; + height: 42px; + padding: 0 40px 0 16px; + border-radius: 21px; + border: 1.5px solid var(--line); + background: var(--paper-2); + font: inherit; + font-size: 15px; + color: var(--ink); + outline: none; +} +.ip-modal-search input::placeholder { + color: var(--ink-faint); +} +.ip-modal-search .si { + position: absolute; + right: 15px; + top: 50%; + transform: translateY(-50%); + color: var(--ink-faint); + font-size: 18px; + pointer-events: none; +} +.ip-modal-x { + font-size: 28px; + color: var(--ink-soft); + cursor: pointer; + line-height: 1; + padding: 0 2px; + background: transparent; + border: none; +} +.ip-modal-grid { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 18px; + padding: 24px 26px 28px; + overflow-y: auto; +} +@media (max-width: 780px) { + .ip-modal-grid { grid-template-columns: repeat(2, 1fr); } +} +.ip-scard { + position: relative; + cursor: pointer; +} +.ip-scard .sthumb { + position: relative; + height: 160px; + background: var(--fill); + display: grid; + place-items: center; + border-radius: 11px; + overflow: hidden; +} +.ip-scard .sthumb svg { + width: 32%; + max-width: 84px; + opacity: 0.5; +} +.ip-scard .sthumb::after { + content: ""; + position: absolute; + inset: 0; + border-radius: 11px; + border: 2px solid transparent; +} +.ip-scard.on .sthumb::after { + border-color: var(--accent); +} +.ip-scard .sname { + text-align: center; + padding: 10px 4px 2px; + font-size: 15px; + color: var(--ink); +} +.ip-scard.on .sname { + color: var(--accent); + font-weight: 600; +} +.ip-noresult { + grid-column: 1 / -1; + text-align: center; + color: var(--ink-faint); + padding: 48px 0; + font-size: 15px; +} + +/* ==================== avatar (bottom-left of hero) ==================== */ +.ip-avatar { + position: relative; + width: 46px; + height: 46px; +} +.ip-avatar .frame { + border-radius: 50%; + border-color: var(--ink-soft); +} +.ip-avatar span { + position: absolute; + inset: 0; + display: grid; + place-items: center; + font-family: "Patrick Hand", "Caveat", cursive; + font-size: 22px; + color: var(--ink-soft); } diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 9bb1cdc..e7ae148 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,25 +1,9 @@ import type { Metadata } from "next"; -import { Cormorant_Garamond, Inter } from "next/font/google"; import "./globals.css"; -const cormorant = Cormorant_Garamond({ - subsets: ["latin"], - weight: ["300", "400", "500", "600"], - style: ["normal", "italic"], - variable: "--font-serif", - display: "swap", -}); - -const inter = Inter({ - subsets: ["latin"], - weight: ["300", "400", "500"], - variable: "--font-sans", - display: "swap", -}); - export const metadata: Metadata = { - title: "云梦 — AI 视觉小说", - description: "一部由 AI 实时绘制每一帧的开源视觉小说。", + title: "InfiPlot — AI 实时交互剧情游戏", + description: "InfiPlot 是一款用 AI 实时生成图片、语音与剧情分支的交互式剧情游戏 Demo。", }; export default function RootLayout({ @@ -28,18 +12,31 @@ export default function RootLayout({ children: React.ReactNode; }) { return ( - + + + - + + {/* Hand-drawn jitter filters used by every .frame element */} + + + + + + + + + + + + + + {children} diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 46fa03e..3b96ae4 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,156 +1,640 @@ -import Link from "next/link"; -import { PRESETS } from "@/lib/presets"; -import { PresetCard } from "@/components/PresetCard"; +"use client"; -const ORDINALS = ["Ⅰ", "Ⅱ", "Ⅲ", "Ⅳ"]; +import { useRouter } from "next/navigation"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; -export default function LandingPage() { +/* ============================================================================ + InfiPlot · 低保真原型首页 + - 1900px 设计画布 + 等比缩放至视口宽度,最大程度还原原型版式 + - 顶部 Hero 浮动散落卡片;下方瀑布流;尾部项目介绍 + ========================================================================== */ + +const HERO_CANVAS_W = 1900; +const HERO_CANVAS_H = 980; + +const EXAMPLE_PHRASES: Record<"男性向" | "女性向", string[]> = { + 男性向: [ + "从小一起长大的青梅竹马,突然红着脸向我告白", + "一觉醒来,班上的女生好像都偷偷喜欢上了我", + "三年之期已到,原来我是富家公子,报仇时机已到", + "我带着无限 Token 穿越回了互联网诞生前夕……", + ], + 女性向: [ + "穿越成将军府的废物嫡女,冷面摄政王却独宠我一人", + "重生回到分手前夜,这一次换我先放手", + "一觉醒来成了乙游里的恶役千金,要躲开所有死亡结局", + ], +}; + +type Opt = { + label: string; + items: string[]; + defaultIndex?: number; + modal?: boolean; +}; + +const OPTS: Opt[] = [ + { label: "性向", items: ["男性向", "女性向"] }, + { + label: "绘画风格", + modal: true, + items: [ + "自动", + "二次元", + "吉卜力", + "真实系", + "超写实", + "水彩", + "像素风", + "日系动画", + "3D 渲染", + "蒸汽朋克", + "玄幻", + "国风水墨", + "赛博朋克", + ], + }, + { label: "剧情风格", items: ["平铺直叙", "多线转折", "悬疑烧脑", "治愈日常"], defaultIndex: 1 }, + { label: "语音配音", items: ["关闭", "开启"], defaultIndex: 1 }, + { label: "内容节奏", items: ["慢热细腻", "紧凑爽快"], defaultIndex: 1 }, +]; + +/* Hero slot geometry — 7 fixed positions, contents switch by 性向 */ +const HERO_SLOTS = [ + { x: 55, y: 470, w: 330, h: 196, rot: -1.6 }, + { x: 55, y: 690, w: 330, h: 196, rot: 1.3 }, + { x: 418, y: 566, w: 286, h: 352, rot: -1.1 }, + { x: 765, y: 642, w: 326, h: 258, rot: 1.1 }, + { x: 1130, y: 570, w: 326, h: 352, rot: 1.4 }, + { x: 1492, y: 478, w: 358, h: 200, rot: 1.6 }, + { x: 1492, y: 688, w: 358, h: 200, rot: -1.3 }, +] as const; + +type HeroContent = { title: string; outline: string }; + +const HERO_CONTENT: Record<"男性向" | "女性向", HeroContent[]> = { + 男性向: [ + { title: "樱の约定", outline: "樱花纷飞的黄昏,他终于鼓起勇气,向并肩走过六年的青梅竹马说出那句话……" }, + { title: "锈色边境", outline: "漫天黄沙的废土,机械心脏在胸腔中沉重轰鸣。我从钢铁山中挖出一个完好的休眠舱……" }, + { title: "云海仙踪", outline: "凡骨少年偶得神秘残碑,登顶云海仙山,神魔同修之路自此开启。" }, + { title: "六月雨季", outline: "南方县城的多雨六月,转学第一天,注意到那个总在天台读诗的同学。雨水打湿了未送出的伞……" }, + { title: "雨夜霓虹", outline: "2087 年东亚特区的酸雨之夜,丢失了三天记忆的我,手腕终端响起一通匿名警告:「他们来找你了」。" }, + { title: "学院秘闻", outline: "深夜图书馆地下密室,清冷孤僻的班长跪在圆环阵法前,吟诵着不属于人类的咒词。" }, + { title: "异界召唤", outline: "再睁眼,没有班主任,只有昏暗的魔法阵与一位哭得梨花带雨的圣女:「勇者大人,请拯救这个世界。」" }, + ], + 女性向: [ + { title: "摄政王独宠", outline: "穿越成将军府的废物嫡女,冷面摄政王却把整个京城最名贵的红玉镯,亲手戴在了我的腕上……" }, + { title: "重生前夕", outline: "重生回到分手前夜,他还没说出那句「对不起」。这一次,让我先转身。" }, + { title: "恶役千金", outline: "一觉醒来,竟成了乙游里被命运钦点的恶役千金,要躲开所有 BAD END……" }, + { title: "天台之上", outline: "南方多雨的六月,转学第一天,我把伞悄悄递给了那个在天台读诗的少年。" }, + { title: "登基之夜", outline: "登基大典上群臣俯首,而我只想看那个一直立在阴影里的人,今夜会不会上前一步。" }, + { title: "江湖玉颜", outline: "江湖传言,那位执剑女侠从不动情。可那个雨夜,她为他收剑而立。" }, + { title: "学长的告白", outline: "夕阳染红了天台,那个总在篮球场被全校女生围观的学长,第一次叫住了我。" }, + ], +}; + +const GALLERY: Array<{ h: number; rot: number; title: string; outline: string }> = [ + { h: 300, rot: -0.8, title: "花火之夜", outline: "夏祭的夜空下,浴衣女孩与你约定,今晚最后一发烟火,要一起看完。" }, + { h: 200, rot: 0.6, title: "霓虹之外", outline: "漂浮的飞车与古老方块字的全息广告——这是赛博东亚的另一种黎明。" }, + { h: 260, rot: 0.9, title: "放学后的车站", outline: "夕阳染红的乡间月台,无人列车迟迟未来,你和她沉默并立。" }, + { h: 330, rot: -0.6, title: "星辰咒语", outline: "古老图书馆深处,星纹长袍下的法师女孩低声念出禁咒。" }, + { h: 200, rot: 1.1, title: "战姬启动", outline: "紧急警报红光中,少女握紧操纵杆——决战时刻已到。" }, + { h: 300, rot: -1.0, title: "街灯之下", outline: "午夜独行的女侦探,雨雾中藏着尚未揭晓的真相。" }, + { h: 240, rot: 0.7, title: "全息伞下", outline: "霓虹雨夜,两人共撑全息伞——这一次,是道别还是开始?" }, + { h: 200, rot: -0.7, title: "竹林之约", outline: "竹林深处的快意一战,落叶纷飞——谁先收剑?" }, + { h: 330, rot: 0.8, title: "暗夜王座", outline: "烛光摇曳的古老王座之上,公主等待着她唯一的回信。" }, + { h: 200, rot: -1.1, title: "放学独白", outline: "阳光斜射的空教室,最后一个学生在笔记本上写着什么?" }, + { h: 260, rot: 0.5, title: "第七封信", outline: "樱花树下展开的信纸,淡淡的笔迹,字字千钧。" }, + { h: 300, rot: -0.6, title: "月神降临", outline: "银发倾泻、极光环绕——传说中的月神,今夜降临凡间。" }, + { h: 200, rot: 0.9, title: "血月武士", outline: "血色满月之下,刀光与樱瓣同时落下。" }, + { h: 330, rot: -0.9, title: "森林女巫", outline: "烛光摇曳的森林小屋,女巫熬制着能改变命运的魔药。" }, + { h: 200, rot: 0.6, title: "夏日海岸", outline: "粉橙色的夕阳,两个挚友坐在海岸边,把秘密轻轻放进海风里。" }, + { h: 260, rot: -0.7, title: "屏幕之间", outline: "霓虹青光映在脸上,全屏代码下藏着被遗忘的真相。" }, +]; + +/* ---------- shared primitives ---------- */ + +function ImgGlyph() { return ( -
-
-
- - 云梦 - - - - 逐 · 帧 · 而 · 成 - -
- -
+ + + + + + ); +} -
-
-
-

- 一场 · 开源 · 实验 · MMXXVI -

-

- 每一帧 -
- 都于 -
- 此刻 - 诞生。 -

-

- 云梦是一部视觉小说 — 场景、对话、选项,整个界面皆由 AI - 一帧一帧绘成。你点击。它落笔。故事铺展。 -

-
+function Frame({ className = "" }: { className?: string }) { + return
; +} - -
-
- -
-
+function CardBody({ + title, + outline, + image, +}: { + title: string; + outline: string; + image?: string; +}) { + return ( +
+
+ {image ? ( + {title} + ) : ( + + )} +
+
+

{title}

+

{outline}

- -
-
-

- 四 扇 门 -

-

- 择 一 世 界 · 或 自 行 编 织 -

-
- -
- {PRESETS.map((p, i) => ( - - ))} - - -
- - {ORDINALS[3]} - -
-

- 无 题 -

-

- 带来你自己的世界。用你自己的话讲述它。 -

-
- - 编 织 - - -
- -
-
- -
-
-

- 题 跋 · I -

-

- 一场关于生成式叙事的小型开源实验。一键 Vercel 自建。 -

-
-
-

- 题 跋 · II -

-
    -
  • 文 · 大语言模型
  • -
  • 图 · 生成式渲染
  • -
  • 感知 · 视觉解读
  • -
-
-
-

- 题 跋 · III -

-

- 三者各自独立配置 — 任何 OpenAI 兼容端点皆可。 -

-
-
- -
-
-
- MMXXVI - Ⅰ · Ⅰ -
-
+
+ ); +} + +/* ---------- typewriter ---------- */ + +function Typewriter({ phrases }: { phrases: string[] }) { + const [txt, setTxt] = useState(""); + + useEffect(() => { + let p = 0; + let i = 0; + let del = false; + let timer: ReturnType; + setTxt(""); + const tick = () => { + const full = phrases[p] ?? ""; + if (!del) { + i++; + setTxt(full.slice(0, i)); + if (i >= full.length) { + del = true; + timer = setTimeout(tick, 1700); + return; + } + timer = setTimeout(tick, 70); + } else { + i--; + setTxt(full.slice(0, i)); + if (i <= 0) { + del = false; + p = (p + 1) % phrases.length; + timer = setTimeout(tick, 450); + return; + } + timer = setTimeout(tick, 28); + } + }; + timer = setTimeout(tick, 500); + return () => clearTimeout(timer); + }, [phrases]); + + return ( + <> + {txt} + + + ); +} + +/* ---------- collapsible category selector ---------- */ + +function CategorySelect({ + label, + items, + value, + open, + onToggle, + onPick, +}: { + label: string; + items: string[]; + value: number; + open: boolean; + onToggle: () => void; + onPick: (i: number) => void; +}) { + return ( +
+ + {open && ( +
+ {items.map((it, i) => ( + + ))} + +
+ )} +
+ ); +} + +/* ---------- style picker modal ---------- */ + +function StyleModal({ + items, + value, + onPick, + onClose, +}: { + items: string[]; + value: number; + onPick: (i: number) => void; + onClose: () => void; +}) { + const [q, setQ] = useState(""); + const [shown, setShown] = useState(false); + useEffect(() => { + const id = requestAnimationFrame(() => setShown(true)); + return () => cancelAnimationFrame(id); + }, []); + const close = () => { + setShown(false); + setTimeout(onClose, 300); + }; + const list = items.map((name, i) => ({ name, i })).filter((x) => x.name.includes(q.trim())); + return ( +
+
e.stopPropagation()}> + +
+
+ 选择绘画风格 + 默认「自动」· 由模型根据 prompt 判断风格 +
+
+ setQ(e.target.value)} + placeholder="搜索风格…" + autoFocus + /> + +
+ +
+
+ {list.map(({ name, i }) => ( +
{ + onPick(i); + close(); + }} + > +
+ +
+
{name}
+
+ ))} + {list.length === 0 &&
没有匹配的风格
} +
+
+
+ ); +} + +/* ---------- scale-to-fit hero canvas ---------- */ + +function HeroCanvas({ children }: { children: React.ReactNode }) { + const stageRef = useRef(null); + const canvasRef = useRef(null); + + useLayoutEffect(() => { + const fit = () => { + const stage = stageRef.current; + const canvas = canvasRef.current; + if (!stage || !canvas) return; + // scale to fit width; clamp so very wide screens don't pixel-up the design + const s = Math.min(1, stage.clientWidth / HERO_CANVAS_W); + canvas.style.transform = `scale(${s})`; + stage.style.height = HERO_CANVAS_H * s + "px"; + }; + fit(); + window.addEventListener("resize", fit); + const ro = new ResizeObserver(fit); + if (stageRef.current) ro.observe(stageRef.current); + return () => { + window.removeEventListener("resize", fit); + ro.disconnect(); + }; + }, []); + + return ( +
+
+ {children} +
+
+ ); +} + +/* ---------- page ---------- */ + +export default function HomePage() { + const router = useRouter(); + + const [sel, setSel] = useState(OPTS.map((o) => o.defaultIndex ?? 0)); + const [open, setOpen] = useState(-1); + const [styleOpen, setStyleOpen] = useState(false); + const [prompt, setPrompt] = useState(""); + const inputRef = useRef(null); + + const styleRow = OPTS.findIndex((o) => o.modal); + const genderIndex = sel[0] ?? 0; + const gender = (OPTS[0]!.items[genderIndex] as "男性向" | "女性向") ?? "男性向"; + const phrases = EXAMPLE_PHRASES[gender]; + + /* close any open dropdown on outside click */ + useEffect(() => { + const h = (e: MouseEvent) => { + const target = e.target as HTMLElement | null; + if (!target?.closest?.(".ip-cat")) setOpen(-1); + }; + document.addEventListener("mousedown", h); + return () => document.removeEventListener("mousedown", h); + }, []); + + const start = () => { + const userPrompt = prompt.trim(); + const artStyle = OPTS[1]!.items[sel[1] ?? 0]!; + const plotStyle = OPTS[2]!.items[sel[2] ?? 1]!; + const voice = OPTS[3]!.items[sel[3] ?? 1]!; + const pace = OPTS[4]!.items[sel[4] ?? 1]!; + + const worldSetting = [ + `这是一款面向【${gender}】观众的 AI 交互剧情游戏。`, + `剧情风格:${plotStyle}。内容节奏:${pace}。`, + userPrompt ? `玩家给出的故事种子:「${userPrompt}」。` : "", + `请依据上述设定,以极致的戏剧张力与细腻的情感起伏,为玩家编织精彩的故事分支与对话。`, + ] + .filter(Boolean) + .join("\n"); + + const styleMap: Record = { + 二次元: "唯美二次元动漫插画,日系 galgame 精致质感,柔和温暖的自然光照。", + 吉卜力: "吉卜力工作室风格,手绘动画质感,柔和水彩底色,温暖治愈的氛围。", + 真实系: "真实电影感,柔和自然光照,胶片颗粒。", + 超写实: "超写实人像与场景,电影级布光,皮肤与材质细节精致。", + 水彩: "水彩插画,湿润晕染笔触,纸纹底色。", + 像素风: "像素风格,复古游戏 16-bit 调色,方块化几何造型。", + 日系动画: "现代日系动画 cel-shading,硬光阴影分层,赛璐璐风。", + "3D 渲染": "3D 渲染卡通风格,柔和次表面散射,干净的电影级布光。", + 蒸汽朋克: "蒸汽朋克美学,铜色齿轮与蒸汽,工业革命氛围。", + 玄幻: "国风玄幻插画,仙气缭绕,群山烟雨与神兽萦绕。", + 国风水墨: "国潮唯美古风插画,水墨微晕渲染,仙侠浪漫色彩,极具东方神韵。", + 赛博朋克: "赛博朋克都市,霓虹反射湿润街道,电子义体高光。", + }; + // 「自动」→ fall back to 二次元 (project default). Plain prompts like + // "由模型自动判断画风" are not understood by FLUX — it just paints them + // literally, so we'd rather lock in a sensible default. + const effectiveStyle = artStyle === "自动" ? "二次元" : artStyle; + const styleGuide = styleMap[effectiveStyle] ?? styleMap["二次元"]!; + const audioEnabled = voice === "开启"; + + sessionStorage.setItem( + "yume:custom", + JSON.stringify({ worldSetting, styleGuide, audioEnabled }), + ); + router.push("/play?custom=1"); + }; + + const onCardClick = (seed?: string) => { + if (seed) setPrompt(seed); + inputRef.current?.focus(); + }; + + return ( +
+ {/* ================== HERO (scale-to-fit 1900×980 canvas) ================== */} + + {/* tagline */} +
+
+ 今天想穿越到什么故事? +
+
+ + {/* prompt bar */} +
+
{ + e.preventDefault(); + start(); + }} + > +
+ setPrompt(e.target.value)} + placeholder=" " + spellCheck={false} + /> + {!prompt && ( +
+ +
+ )} + +
+ +
+
+ + {/* category selectors */} +
+ {OPTS.map((o, r) => ( + { + if (o.modal) { + setStyleOpen(true); + } else { + setOpen(open === r ? -1 : r); + } + }} + onPick={(i) => { + setSel((s) => s.map((v, j) => (j === r ? i : v))); + setOpen(-1); + }} + /> + ))} +
+ + {/* hero scattered cards — content switches by 性向 */} + {HERO_SLOTS.map((slot, i) => { + const content = HERO_CONTENT[gender][i]!; + const suffix = gender === "女性向" ? "_f" : ""; + return ( +
onCardClick(content.outline)} + > + + +
+ ); + })} + +
+ + {/* ================== SCROLL HINT + GALLERY ================== */} +
+ + 继续下滑 · 加载更多示例卡片 +
+ +
+ {GALLERY.map((g, i) => ( +
onCardClick(g.outline)} + > + + +
+ ))} +
+ + {/* ================== PROJECT INTRO ================== */} +
+
INFIPLOT · AI 实时交互剧情游戏 · Demo
+ +

+ InfiPlot 是一款用 AI 实时生成内容的交互式剧情游戏 —— 图片、语音与剧情分支都在游玩过程中即时生成。我们希望探索多模态模型在「直接生成图片、视频」这类 + one-shot 能力之外,更多的可能性。 +

+

+ 我们希望通过这个页面,与赞助商、未来的团队成员以及内测用户建立联系。 +

+ +
团 队
+

+ 我们是一群来自清华大学等海内外高校、充满激情的年轻人,目前仍处于早期阶段。产品还在打磨,团队也在招募成员。 +

+ +
加 入 / 合 作
+

+ 如有意加入团队,请将简历发送至 hi@infiplot.com +

+ +
联 系 方 式
+

+ 邮箱 hi@infiplot.com · Founder X / Twitter @yzh_im +

+ +
内 测 用 户 群
+

+ 群二维码 / 邀请链接 (待补充) +

+ +

+ 内测期间本产品可免费使用,但稳定性可能会随并发用户数量而有波动。欢迎赞助商联系我们,提供更多算力资源和商讨长期合作事宜。 +
+ 内测期间生成的内容不会被保存,如有需要,请通过录屏或截图等方式保存游玩体验,并记录下生成故事时的提示词与风格选项等。 +
+ AI 生成的内容不代表本团队立场。 +

+ +
+ + {styleOpen && styleRow >= 0 && ( + setSel((s) => s.map((v, j) => (j === styleRow ? i : v)))} + onClose={() => setStyleOpen(false)} + /> + )}
); } diff --git a/apps/web/app/play/page.tsx b/apps/web/app/play/page.tsx index 88b0308..5ecd243 100644 --- a/apps/web/app/play/page.tsx +++ b/apps/web/app/play/page.tsx @@ -262,6 +262,10 @@ function PlayInner() { // changes so stale in-flight requests can't poison the new scene's map // (beat ids like "b1" are scene-local and would collide across scenes). const beatAudioAbortRef = useRef>(new Map()); + // User-toggled "语音配音" from the homepage. Defaults to true for back-compat + // when older sessionStorage payloads omit the field. Mutated once in + // bootstrap and read by fetchBeatAudio to early-return without any /api call. + const audioEnabledRef = useRef(true); // Mirrors for use inside async handlers (closure-stable) const sessionRef = useRef(null); @@ -317,6 +321,7 @@ function PlayInner() { sess: Session, beat: { id: string; speaker?: string; line?: string; lineDelivery?: string }, ): Promise => { + if (!audioEnabledRef.current) return; // user toggled 语音配音 → 关闭 if (!beat.speaker || !beat.line) return; const speaker = sess.characters.find((c) => c.name === beat.speaker); if (!speaker?.voice) return; // not yet provisioned — server can't synth anyway @@ -450,7 +455,14 @@ function PlayInner() { const stored = sessionStorage.getItem("yume:custom"); if (stored) { try { - payload = JSON.parse(stored); + const parsed = JSON.parse(stored) as { + worldSetting: string; + styleGuide: string; + audioEnabled?: boolean; + }; + payload = { worldSetting: parsed.worldSetting, styleGuide: parsed.styleGuide }; + // default true for older payloads that omit the flag + audioEnabledRef.current = parsed.audioEnabled !== false; } catch { payload = null; } diff --git a/apps/web/public/home/gallery0.webp b/apps/web/public/home/gallery0.webp new file mode 100644 index 0000000..3db39da Binary files /dev/null and b/apps/web/public/home/gallery0.webp differ diff --git a/apps/web/public/home/gallery1.webp b/apps/web/public/home/gallery1.webp new file mode 100644 index 0000000..3c83e90 Binary files /dev/null and b/apps/web/public/home/gallery1.webp differ diff --git a/apps/web/public/home/gallery10.webp b/apps/web/public/home/gallery10.webp new file mode 100644 index 0000000..7cb9637 Binary files /dev/null and b/apps/web/public/home/gallery10.webp differ diff --git a/apps/web/public/home/gallery11.webp b/apps/web/public/home/gallery11.webp new file mode 100644 index 0000000..7594c0e Binary files /dev/null and b/apps/web/public/home/gallery11.webp differ diff --git a/apps/web/public/home/gallery12.webp b/apps/web/public/home/gallery12.webp new file mode 100644 index 0000000..e6fb238 Binary files /dev/null and b/apps/web/public/home/gallery12.webp differ diff --git a/apps/web/public/home/gallery13.webp b/apps/web/public/home/gallery13.webp new file mode 100644 index 0000000..235f82c Binary files /dev/null and b/apps/web/public/home/gallery13.webp differ diff --git a/apps/web/public/home/gallery14.webp b/apps/web/public/home/gallery14.webp new file mode 100644 index 0000000..6f60940 Binary files /dev/null and b/apps/web/public/home/gallery14.webp differ diff --git a/apps/web/public/home/gallery15.webp b/apps/web/public/home/gallery15.webp new file mode 100644 index 0000000..1ecde7d Binary files /dev/null and b/apps/web/public/home/gallery15.webp differ diff --git a/apps/web/public/home/gallery2.webp b/apps/web/public/home/gallery2.webp new file mode 100644 index 0000000..d696835 Binary files /dev/null and b/apps/web/public/home/gallery2.webp differ diff --git a/apps/web/public/home/gallery3.webp b/apps/web/public/home/gallery3.webp new file mode 100644 index 0000000..47cb349 Binary files /dev/null and b/apps/web/public/home/gallery3.webp differ diff --git a/apps/web/public/home/gallery4.webp b/apps/web/public/home/gallery4.webp new file mode 100644 index 0000000..0bac913 Binary files /dev/null and b/apps/web/public/home/gallery4.webp differ diff --git a/apps/web/public/home/gallery5.webp b/apps/web/public/home/gallery5.webp new file mode 100644 index 0000000..4548659 Binary files /dev/null and b/apps/web/public/home/gallery5.webp differ diff --git a/apps/web/public/home/gallery6.webp b/apps/web/public/home/gallery6.webp new file mode 100644 index 0000000..ae925e1 Binary files /dev/null and b/apps/web/public/home/gallery6.webp differ diff --git a/apps/web/public/home/gallery7.webp b/apps/web/public/home/gallery7.webp new file mode 100644 index 0000000..49ad884 Binary files /dev/null and b/apps/web/public/home/gallery7.webp differ diff --git a/apps/web/public/home/gallery8.webp b/apps/web/public/home/gallery8.webp new file mode 100644 index 0000000..a39e02a Binary files /dev/null and b/apps/web/public/home/gallery8.webp differ diff --git a/apps/web/public/home/gallery9.webp b/apps/web/public/home/gallery9.webp new file mode 100644 index 0000000..f2c3913 Binary files /dev/null and b/apps/web/public/home/gallery9.webp differ diff --git a/apps/web/public/home/hero0.webp b/apps/web/public/home/hero0.webp new file mode 100644 index 0000000..ca01a86 Binary files /dev/null and b/apps/web/public/home/hero0.webp differ diff --git a/apps/web/public/home/hero0_f.webp b/apps/web/public/home/hero0_f.webp new file mode 100644 index 0000000..2f134ab Binary files /dev/null and b/apps/web/public/home/hero0_f.webp differ diff --git a/apps/web/public/home/hero1.webp b/apps/web/public/home/hero1.webp new file mode 100644 index 0000000..a961d8b Binary files /dev/null and b/apps/web/public/home/hero1.webp differ diff --git a/apps/web/public/home/hero1_f.webp b/apps/web/public/home/hero1_f.webp new file mode 100644 index 0000000..109d21a Binary files /dev/null and b/apps/web/public/home/hero1_f.webp differ diff --git a/apps/web/public/home/hero2.webp b/apps/web/public/home/hero2.webp new file mode 100644 index 0000000..cf96cb2 Binary files /dev/null and b/apps/web/public/home/hero2.webp differ diff --git a/apps/web/public/home/hero2_f.webp b/apps/web/public/home/hero2_f.webp new file mode 100644 index 0000000..11b8883 Binary files /dev/null and b/apps/web/public/home/hero2_f.webp differ diff --git a/apps/web/public/home/hero3.webp b/apps/web/public/home/hero3.webp new file mode 100644 index 0000000..9628702 Binary files /dev/null and b/apps/web/public/home/hero3.webp differ diff --git a/apps/web/public/home/hero3_f.webp b/apps/web/public/home/hero3_f.webp new file mode 100644 index 0000000..df46e9a Binary files /dev/null and b/apps/web/public/home/hero3_f.webp differ diff --git a/apps/web/public/home/hero4.webp b/apps/web/public/home/hero4.webp new file mode 100644 index 0000000..cdf0195 Binary files /dev/null and b/apps/web/public/home/hero4.webp differ diff --git a/apps/web/public/home/hero4_f.webp b/apps/web/public/home/hero4_f.webp new file mode 100644 index 0000000..ff104ca Binary files /dev/null and b/apps/web/public/home/hero4_f.webp differ diff --git a/apps/web/public/home/hero5.webp b/apps/web/public/home/hero5.webp new file mode 100644 index 0000000..fa7ed8e Binary files /dev/null and b/apps/web/public/home/hero5.webp differ diff --git a/apps/web/public/home/hero5_f.webp b/apps/web/public/home/hero5_f.webp new file mode 100644 index 0000000..1cb6336 Binary files /dev/null and b/apps/web/public/home/hero5_f.webp differ diff --git a/apps/web/public/home/hero6.webp b/apps/web/public/home/hero6.webp new file mode 100644 index 0000000..82ac365 Binary files /dev/null and b/apps/web/public/home/hero6.webp differ diff --git a/apps/web/public/home/hero6_f.webp b/apps/web/public/home/hero6_f.webp new file mode 100644 index 0000000..54f3d77 Binary files /dev/null and b/apps/web/public/home/hero6_f.webp differ diff --git a/apps/web/scripts/generate-home-images.mjs b/apps/web/scripts/generate-home-images.mjs new file mode 100644 index 0000000..1c7d067 --- /dev/null +++ b/apps/web/scripts/generate-home-images.mjs @@ -0,0 +1,373 @@ +#!/usr/bin/env node +/** + * One-off generator: produces 23 AI cards (7 hero + 16 gallery) for the + * InfiPlot homepage via Runware FLUX.2 and writes them as PNGs under + * apps/web/public/home/. + * + * Reads IMAGE_BASE_URL / IMAGE_API_KEY / IMAGE_MODEL from apps/web/.env.local. + * + * Run once: + * node apps/web/scripts/generate-home-images.mjs + * + * Idempotent: skips any PNG that already exists. Pass --force to regenerate. + */ + +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +import { readFileSync, existsSync, mkdirSync, writeFileSync, statSync } from "node:fs"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const WEB_ROOT = resolve(__dirname, ".."); +const ENV_FILE = resolve(WEB_ROOT, ".env.local"); +const OUT_DIR = resolve(WEB_ROOT, "public", "home"); + +const FORCE = process.argv.includes("--force"); + +/* ---------- env loading (tiny .env parser) ---------- */ +function loadEnv(path) { + const txt = readFileSync(path, "utf8"); + const env = {}; + for (const raw of txt.split(/\r?\n/)) { + const line = raw.trim(); + if (!line || line.startsWith("#")) continue; + const eq = line.indexOf("="); + if (eq < 0) continue; + const k = line.slice(0, eq).trim(); + let v = line.slice(eq + 1).trim(); + if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) { + v = v.slice(1, -1); + } + env[k] = v; + } + return env; +} + +const env = loadEnv(ENV_FILE); +const BASE_URL = env.IMAGE_BASE_URL; +const API_KEY = env.IMAGE_API_KEY; +const MODEL = env.IMAGE_MODEL; +if (!BASE_URL || !API_KEY || !MODEL) { + console.error("Missing IMAGE_BASE_URL / IMAGE_API_KEY / IMAGE_MODEL in", ENV_FILE); + process.exit(2); +} +if (!BASE_URL.includes("runware.ai")) { + console.error("This script assumes Runware. Got:", BASE_URL); + process.exit(2); +} + +/* ---------- prompts ---------- */ + +const BASE_QUALITY = + "masterpiece, best quality, highly detailed, cinematic lighting, soft warm color grading, intricate background, no text, no watermark"; + +// 7 hero cards — varied flagship moods that showcase the platform's range +const HERO = [ + { + name: "hero0", + prompt: + "anime visual novel cover art, two high school students standing under cherry blossom petals at dusk, warm golden sunset light, soft watercolor texture, japanese galgame illustration, widescreen composition", + w: 1024, + h: 640, + }, + { + name: "hero1", + prompt: + "post-apocalyptic wasteland anime, lone scavenger silhouette against rusted mecha mountain, golden dust storm sweeping across the dunes, cinematic widescreen, anime concept art, dramatic backlight", + w: 1024, + h: 640, + }, + { + name: "hero2", + prompt: + "anime xianxia cultivator boy in flowing white robes standing on a floating mountain peak above a sea of clouds, vermillion banners fluttering, vertical poster composition, chinese mythology, galgame illustration", + w: 768, + h: 1024, + }, + { + name: "hero3", + prompt: + "anime visual novel scene, southern chinese small town in june rain, a transfer student looking back from a rainy classroom window, ceiling fan in background, soft warm afternoon tones, slice of life galgame illustration", + w: 1024, + h: 832, + }, + { + name: "hero4", + prompt: + "cyberpunk anime portrait, amnesiac detective standing in neon-soaked rainy alley of an east-asian metropolis in 2087, holographic signs reflecting on wet pavement, vertical composition, blade runner palette, anime illustration", + w: 768, + h: 1024, + }, + { + name: "hero5", + prompt: + "anime mystery scene, late-night high school library underground chamber, flickering candlelight, a class president kneeling before a glowing rune circle on the stone floor, gothic galgame style, mysterious teal-green glow", + w: 1024, + h: 640, + }, + { + name: "hero6", + prompt: + "anime isekai cathedral scene, silver-haired holy maiden with tearful eyes kneeling before a glowing magic summoning circle, golden cathedral light streaming through stained glass, summoned hero just appearing in modern school uniform, warm galgame illustration", + w: 1024, + h: 640, + }, +]; + +// 7 female-oriented hero cards — same slot aspect ratios as HERO above, +// otome / josei / xianxia / cyberpunk romance angles +const HERO_F = [ + { + name: "hero0_f", + prompt: + "anime josei otome game illustration, beautiful female protagonist in ornate eastern hanfu silk robes, behind her a tall stoic regent prince in dark embroidered robes leaning down to clasp a red jade bracelet on her wrist, ancient chinese palace interior, soft candlelight, romantic widescreen composition", + w: 1024, + h: 640, + }, + { + name: "hero1_f", + prompt: + "anime modern romance scene, young woman in pajamas sitting on a bed at dawn, golden light through curtains, looking at her phone in shock as if she has just been pulled back in time, soft warm tones, melancholic otome illustration, widescreen", + w: 1024, + h: 640, + }, + { + name: "hero2_f", + prompt: + "anime villainess otome game character, beautiful young noblewoman with elaborate golden ringlet hair and crimson ballgown, standing alone in a baroque royal academy ballroom while other noble girls glare from the background, dramatic chandelier light, vertical poster composition, otome game cover art", + w: 768, + h: 1024, + }, + { + name: "hero3_f", + prompt: + "anime visual novel scene, female high school transfer student standing on a rainy southern chinese town rooftop, sharing her umbrella with a moody boy reading poetry on the railing, soft warm afternoon palette, slice of life otome illustration", + w: 1024, + h: 832, + }, + { + name: "hero4_f", + prompt: + "anime josei coronation scene, beautiful young empress in ornate ceremonial robes seated on a high eastern throne, head turned to glance at a handsome attendant standing in the shadowed pillars below, vertical composition, opulent silks and gold, otome game illustration", + w: 768, + h: 1024, + }, + { + name: "hero5_f", + prompt: + "anime wuxia swordswoman in flowing light hanfu, jade hairpin, white sword raised mid-stance, cherry blossoms swirling around her, mountain pavilion in the background at golden hour, dynamic widescreen otome wuxia illustration", + w: 1024, + h: 640, + }, + { + name: "hero6_f", + prompt: + "anime visual novel scene, female high school student standing on a sunset rooftop looking up at a tall handsome senior in school uniform, warm orange sky, golden hour, romantic galgame otome cover art, widescreen", + w: 1024, + h: 640, + }, +]; + +// 16 gallery cards — broader sweep of genres / moods showcased by the platform +const GALLERY = [ + { + name: "gallery0", + prompt: + "anime girl in summer yukata watching fireworks at a japanese festival night, warm bokeh lanterns, vertical composition, soft watercolor, slice of life galgame", + w: 768, + h: 1024, + }, + { + name: "gallery1", + prompt: + "cyberpunk neon city skyline at rainy night, flying vehicles, holographic billboards in chinese characters, anime widescreen, cinematic", + w: 1024, + h: 640, + }, + { + name: "gallery2", + prompt: + "anime two students standing on empty rural train platform after school, golden hour, slice of life galgame illustration, cinematic widescreen, warm tones", + w: 1024, + h: 832, + }, + { + name: "gallery3", + prompt: + "anime mage girl in star-embroidered robes casting starlight spell, ancient fantasy library, vertical composition, magical particles, painterly illustration", + w: 768, + h: 1024, + }, + { + name: "gallery4", + prompt: + "anime mecha pilot girl strapped in cockpit, holographic interfaces around her, dramatic red emergency lighting, intense expression, mecha anime style", + w: 1024, + h: 640, + }, + { + name: "gallery5", + prompt: + "anime detective girl in long trench coat under a flickering streetlamp at midnight, noir mood, vertical composition, rain mist, cinematic anime", + w: 768, + h: 1024, + }, + { + name: "gallery6", + prompt: + "anime cyberpunk couple sharing a quiet moment in a neon-lit rainy alley, holographic umbrella, electric blue and pink reflections, romantic galgame illustration", + w: 1024, + h: 832, + }, + { + name: "gallery7", + prompt: + "anime sword duel between two xianxia cultivators in a bamboo grove, motion blur on swords, falling bamboo leaves, dynamic action composition", + w: 1024, + h: 640, + }, + { + name: "gallery8", + prompt: + "anime princess in ornate eastern gown seated on an ancient carved throne, candlelight, intricate background tapestries, vertical poster composition, fantasy galgame", + w: 768, + h: 1024, + }, + { + name: "gallery9", + prompt: + "anime classroom afternoon, sun streaming through windows onto empty desks, a single uniformed student writing in a notebook, slice of life watercolor, nostalgic", + w: 1024, + h: 640, + }, + { + name: "gallery10", + prompt: + "anime girl reading a folded letter under a cherry blossom tree, melancholic expression, petals drifting, soft warm watercolor, slice of life galgame", + w: 1024, + h: 832, + }, + { + name: "gallery11", + prompt: + "anime moon goddess descending from a starlit sky, silver hair flowing, ethereal aurora glow, dreamy painterly illustration, vertical composition", + w: 768, + h: 1024, + }, + { + name: "gallery12", + prompt: + "anime samurai standing alone under a blood red full moon, sakura petals carried on the wind, katana drawn, dramatic backlight, cinematic widescreen", + w: 1024, + h: 640, + }, + { + name: "gallery13", + prompt: + "anime witch girl brewing a glowing potion in a candlelit forest hut, hanging dried herbs, magical sparks rising from the cauldron, vertical composition", + w: 768, + h: 1024, + }, + { + name: "gallery14", + prompt: + "anime beach summer scene, two girlfriends sitting on the sand watching a pink-orange sunset, gentle waves, slice of life galgame illustration", + w: 1024, + h: 640, + }, + { + name: "gallery15", + prompt: + "anime hacker girl in a dim apartment surrounded by glowing screens, neon cyan reflections on her face, intense focus, cyberpunk galgame style", + w: 1024, + h: 832, + }, +]; + +const ALL = [...HERO, ...HERO_F, ...GALLERY]; + +/* ---------- Runware caller ---------- */ + +async function generate({ prompt, w, h }) { + const body = [ + { + taskType: "imageInference", + taskUUID: crypto.randomUUID(), + model: MODEL, + positivePrompt: `${prompt}, ${BASE_QUALITY}`, + width: w, + height: h, + steps: 4, + CFGScale: 3.5, + numberResults: 1, + outputType: "base64Data", + outputFormat: "PNG", + }, + ]; + const res = await fetch(BASE_URL.replace(/\/$/, ""), { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${API_KEY}`, + }, + body: JSON.stringify(body), + }); + const text = await res.text(); + let json; + try { + json = JSON.parse(text); + } catch { + throw new Error(`HTTP ${res.status}: ${text.slice(0, 300)}`); + } + if (json.errors?.length) { + const e = json.errors[0]; + throw new Error(`Runware [${e.code ?? "?"}]: ${e.message ?? "no msg"}`); + } + const b64 = json.data?.[0]?.imageBase64Data; + if (!b64) throw new Error(`No image data: ${text.slice(0, 200)}`); + return Buffer.from(b64, "base64"); +} + +/* ---------- main loop ---------- */ + +if (!existsSync(OUT_DIR)) mkdirSync(OUT_DIR, { recursive: true }); + +const total = ALL.length; +let done = 0; +let skipped = 0; +let failed = 0; +const t0 = Date.now(); + +console.log(`[gen] ${total} cards → ${OUT_DIR}`); + +for (const card of ALL) { + const out = resolve(OUT_DIR, `${card.name}.png`); + const webpOut = resolve(OUT_DIR, `${card.name}.webp`); + if (!FORCE && (existsSync(out) || existsSync(webpOut))) { + const path = existsSync(out) ? out : webpOut; + const size = statSync(path).size; + if (size > 1024) { + skipped++; + done++; + console.log(`[${done}/${total}] skip ${card.name} (${size} B)`); + continue; + } + } + const label = `[${++done}/${total}] ${card.name}`; + process.stdout.write(`${label} … `); + const t = Date.now(); + try { + const buf = await generate(card); + writeFileSync(out, buf); + process.stdout.write(`ok ${buf.length} B in ${Math.round((Date.now() - t) / 100) / 10}s\n`); + } catch (e) { + failed++; + process.stdout.write(`FAIL: ${e.message}\n`); + } +} + +console.log( + `\n[gen] done in ${Math.round((Date.now() - t0) / 1000)}s — generated ${ + done - skipped - failed + } / skipped ${skipped} / failed ${failed}`, +); +process.exit(failed ? 1 : 0); diff --git a/apps/web/scripts/optimize-home-images.mjs b/apps/web/scripts/optimize-home-images.mjs new file mode 100644 index 0000000..9b76d1e --- /dev/null +++ b/apps/web/scripts/optimize-home-images.mjs @@ -0,0 +1,42 @@ +#!/usr/bin/env node +/** + * Compresses the freshly generated apps/web/public/home/*.png into much + * smaller .webp files alongside them, then deletes the originals. + * Output webps target ~1200px on the long edge and quality 78. + */ + +import { readdirSync, statSync, unlinkSync } from "node:fs"; +import { resolve, dirname, extname, basename } from "node:path"; +import { fileURLToPath } from "node:url"; +import sharp from "sharp"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const DIR = resolve(__dirname, "..", "public", "home"); + +const MAX_EDGE = 1200; +const QUALITY = 78; + +const files = readdirSync(DIR).filter((f) => f.toLowerCase().endsWith(".png")); +let totalIn = 0; +let totalOut = 0; + +for (const f of files) { + const inPath = resolve(DIR, f); + const outPath = resolve(DIR, basename(f, extname(f)) + ".webp"); + const inSize = statSync(inPath).size; + totalIn += inSize; + + const img = sharp(inPath); + const meta = await img.metadata(); + const longEdge = Math.max(meta.width ?? 0, meta.height ?? 0); + const resized = longEdge > MAX_EDGE ? img.resize({ width: meta.width >= meta.height ? MAX_EDGE : undefined, height: meta.height > meta.width ? MAX_EDGE : undefined }) : img; + await resized.webp({ quality: QUALITY, effort: 5 }).toFile(outPath); + const outSize = statSync(outPath).size; + totalOut += outSize; + console.log(`${f.padEnd(16)} ${(inSize / 1024).toFixed(0).padStart(5)} KB → ${(outSize / 1024).toFixed(0).padStart(4)} KB`); + unlinkSync(inPath); +} + +console.log( + `\nTotal: ${(totalIn / 1024 / 1024).toFixed(1)} MB → ${(totalOut / 1024 / 1024).toFixed(2)} MB`, +);