Merge pull request #9 from zonghaoyuan/feature/infiplot-low-fi-homepage
feat(web): InfiPlot low-fi homepage with AI-generated cards + gender-…
@@ -2,6 +2,8 @@ import type { Metadata } from "next";
|
|||||||
import { Cormorant_Garamond, Inter } from "next/font/google";
|
import { Cormorant_Garamond, Inter } from "next/font/google";
|
||||||
import "./globals.css";
|
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({
|
const cormorant = Cormorant_Garamond({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
weight: ["300", "400", "500", "600"],
|
weight: ["300", "400", "500", "600"],
|
||||||
@@ -18,8 +20,8 @@ const inter = Inter({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "云梦 — AI 视觉小说",
|
title: "InfiPlot — AI 实时交互剧情游戏",
|
||||||
description: "一部由 AI 实时绘制每一帧的开源视觉小说。",
|
description: "InfiPlot 是一款用 AI 实时生成图片、语音与剧情分支的交互式剧情游戏 Demo。",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -34,12 +36,13 @@ export default function RootLayout({
|
|||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
<head>
|
<head>
|
||||||
|
{/* Font Awesome — fa-solid icons used by home, /play, /new, CustomForm. */}
|
||||||
<link
|
<link
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
||||||
/>
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body className="bg-cream-50 text-clay-900 font-sans antialiased min-h-screen">
|
<body className="bg-cream-50 text-clay-900 font-sans antialiased min-h-screen overflow-x-hidden">
|
||||||
{children}
|
{children}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,156 +1,743 @@
|
|||||||
import Link from "next/link";
|
"use client";
|
||||||
import { PRESETS } from "@/lib/presets";
|
|
||||||
import { PresetCard } from "@/components/PresetCard";
|
|
||||||
|
|
||||||
const ORDINALS = ["Ⅰ", "Ⅱ", "Ⅲ", "Ⅳ"];
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
InfiPlot · 首页(云梦编辑式视觉风格 · 居中构图,呼应低保真原型)
|
||||||
|
- 顶部 Header:左上角衬线 wordmark logo
|
||||||
|
- Hero 控制区(居中):标题 / prompt 输入框 + 开始 / 5 个类别选择器
|
||||||
|
- 统一瀑布流(居中定宽):7 张主推 + 16 张画廊,按性向整体 crossfade 切换
|
||||||
|
- 项目介绍(题跋式排版)
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
type Gender = "男性向" | "女性向";
|
||||||
|
|
||||||
|
const EXAMPLE_PHRASES: Record<Gender, 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 },
|
||||||
|
];
|
||||||
|
|
||||||
|
type StoryContent = { title: string; outline: string };
|
||||||
|
|
||||||
|
/* 每个性向 30 篇预设剧情,与图片 /home/{m|f}{i}.webp 按索引一一对应。
|
||||||
|
男/女同索引共享画面尺寸,切性向 crossfade 时卡片高度不跳变。 */
|
||||||
|
const STORIES: Record<Gender, StoryContent[]> = {
|
||||||
|
男性向: [
|
||||||
|
{ title: "樱の约定", outline: "樱花纷飞的黄昏,他终于鼓起勇气,向并肩走过六年的青梅竹马说出那句话……" },
|
||||||
|
{ title: "锈色边境", outline: "漫天黄沙的废土,机械心脏在胸腔中沉重轰鸣。我从钢铁山中挖出一个完好的休眠舱……" },
|
||||||
|
{ title: "云海仙踪", outline: "凡骨少年偶得神秘残碑,登顶云海仙山,神魔同修之路自此开启。" },
|
||||||
|
{ title: "六月雨季", outline: "南方县城的多雨六月,转学第一天,注意到那个总在天台读诗的同学。雨水打湿了未送出的伞……" },
|
||||||
|
{ 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: "夏祭的夜空下,他替你挡开人潮,低声说:最后一发烟火,只想和你一起看完。" },
|
||||||
|
{ 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: "飞空艇甲板上,独眼船长把望远镜递到我眼前:「看,那是只属于我们的航线。」" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---------- typewriter ---------- */
|
||||||
|
|
||||||
|
function Typewriter({ phrases }: { phrases: string[] }) {
|
||||||
|
const [txt, setTxt] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let p = 0;
|
||||||
|
let i = 0;
|
||||||
|
let del = false;
|
||||||
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
|
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]);
|
||||||
|
|
||||||
export default function LandingPage() {
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col">
|
<>
|
||||||
<header className="px-6 md:px-16 pt-7 md:pt-10 flex items-center justify-between">
|
<span>{txt}</span>
|
||||||
<div className="flex items-center gap-4">
|
<span className="inline-block w-px h-[1.05em] bg-clay-400 ml-0.5 align-middle animate-pulse" />
|
||||||
<span className="text-[10px] smallcaps text-clay-700 font-medium">
|
</>
|
||||||
云梦
|
);
|
||||||
</span>
|
}
|
||||||
<span className="hairline w-10 hidden md:block" />
|
|
||||||
<span className="text-[10px] smallcaps text-clay-500 hidden md:block">
|
/* ---------- masonry story card ---------- */
|
||||||
逐 · 帧 · 而 · 成
|
|
||||||
</span>
|
function StoryCard({
|
||||||
</div>
|
title,
|
||||||
<nav className="flex items-center gap-5 text-[10px] smallcaps text-clay-600">
|
outline,
|
||||||
<a
|
image,
|
||||||
href="https://github.com"
|
placeholderRatio = 4 / 5,
|
||||||
className="hover:text-clay-900 transition-colors"
|
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"
|
||||||
>
|
>
|
||||||
GitHub
|
<img
|
||||||
</a>
|
src={image}
|
||||||
<span className="text-clay-300">·</span>
|
alt={title}
|
||||||
<a href="#about" className="hover:text-clay-900 transition-colors">
|
loading="lazy"
|
||||||
关于
|
onLoad={(e) => {
|
||||||
</a>
|
const el = e.currentTarget;
|
||||||
</nav>
|
if (el.naturalWidth && el.naturalHeight) {
|
||||||
</header>
|
setRatio(el.naturalWidth / el.naturalHeight);
|
||||||
|
}
|
||||||
<section className="px-6 md:px-16 pt-20 md:pt-36 pb-20 md:pb-28">
|
}}
|
||||||
<div className="grid grid-cols-12 gap-8">
|
className="absolute inset-0 h-full w-full object-cover"
|
||||||
<div className="col-span-12 md:col-span-7 animate-fade-in">
|
/>
|
||||||
<p className="text-[10px] smallcaps text-clay-500 mb-8">
|
{/* hover 浮层:卡片高度已由图片比例锁定,磨砂带占比恒定,hover 前后零回流。 */}
|
||||||
一场 · 开源 · 实验 · MMXXVI
|
<div className="absolute inset-x-0 bottom-0">
|
||||||
</p>
|
<div className="relative px-4 pt-10 pb-4">
|
||||||
<h1 className="font-serif font-light text-[56px] md:text-[104px] leading-[0.94] text-clay-900 tracking-tight">
|
{/* 毛玻璃底:backdrop-blur 0→md(不走 opacity,避免比文字慢半拍);上沿 mask 羽化,避免生硬分界 */}
|
||||||
每<em className="italic font-light text-clay-600">一帧</em>
|
<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)]" />
|
||||||
<br />
|
{/* 暗色渐变: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" />
|
||||||
<br />
|
<div className="relative opacity-0 transition-opacity duration-300 ease-out group-hover:opacity-100">
|
||||||
<span className="text-ember-500 italic font-light">此刻</span>
|
<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}
|
||||||
</h1>
|
</h4>
|
||||||
<p className="mt-10 md:mt-14 max-w-md font-serif text-lg md:text-xl text-clay-700 leading-[1.65]">
|
<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)]">
|
||||||
云梦是一部视觉小说 — 场景、对话、选项,<em>整个</em>界面皆由 AI
|
{outline}
|
||||||
一帧一帧绘成。你点击。它落笔。故事铺展。
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside className="col-span-12 md:col-span-4 md:col-start-9 mt-8 md:mt-0 flex md:items-end">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="hairline w-12" />
|
|
||||||
<p className="font-serif italic text-clay-600 text-base md:text-[17px] leading-relaxed max-w-[280px]">
|
|
||||||
“人不能两次踏入同一条河流。
|
|
||||||
</p>
|
|
||||||
<p className="font-serif italic text-clay-600 text-base md:text-[17px] leading-relaxed max-w-[280px]">
|
|
||||||
你也不会两次走进同一个云梦。”
|
|
||||||
</p>
|
|
||||||
<p className="text-[10px] smallcaps text-clay-500 pt-2">
|
|
||||||
— 自述 · v0.1
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
<div className="px-6 md:px-16">
|
/* ---------- collapsible category selector ---------- */
|
||||||
<div className="hairline-full w-full" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section className="px-6 md:px-16 pt-14 md:pt-20 pb-16 md:pb-24">
|
function CategorySelect({
|
||||||
<div className="flex items-baseline justify-between mb-8 md:mb-10">
|
label,
|
||||||
<h2 className="text-[10px] smallcaps text-clay-700 font-medium">
|
items,
|
||||||
四 扇 门
|
value,
|
||||||
</h2>
|
open,
|
||||||
<p className="text-[10px] smallcaps text-clay-500 hidden md:block">
|
onToggle,
|
||||||
择 一 世 界 · 或 自 行 编 织
|
onPick,
|
||||||
</p>
|
}: {
|
||||||
</div>
|
label: string;
|
||||||
|
items: string[];
|
||||||
<div className="grid grid-cols-1">
|
value: number;
|
||||||
{PRESETS.map((p, i) => (
|
open: boolean;
|
||||||
<PresetCard key={p.id} preset={p} ordinal={ORDINALS[i]!} />
|
onToggle: () => void;
|
||||||
|
onPick: (i: number) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<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="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"
|
||||||
|
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>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<Link
|
|
||||||
href="/new"
|
|
||||||
className="group block w-full py-10 md:py-12 border-t border-b border-clay-900/10 hover:border-clay-900/35 transition-[border-color] duration-500"
|
|
||||||
>
|
|
||||||
<div className="flex items-baseline gap-6 md:gap-10">
|
|
||||||
<span className="font-serif italic text-2xl md:text-3xl text-clay-400 group-hover:text-clay-700 transition-colors duration-500 w-8 shrink-0">
|
|
||||||
{ORDINALS[3]}
|
|
||||||
</span>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className="font-serif text-3xl md:text-4xl text-clay-900 leading-tight mb-2.5">
|
|
||||||
无 题
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-clay-600 leading-relaxed max-w-md">
|
|
||||||
带来你自己的世界。用你自己的话讲述它。
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="hidden md:flex items-center gap-3 text-[10px] tracking-[0.4em] text-clay-400 group-hover:text-ember-500 transition-colors duration-500 shrink-0 self-center">
|
)}
|
||||||
编 织
|
</div>
|
||||||
<span className="w-7 h-px bg-current transition-all duration-500 group-hover:w-12" />
|
);
|
||||||
</span>
|
}
|
||||||
</div>
|
|
||||||
</Link>
|
/* ---------- style picker modal ---------- */
|
||||||
</div>
|
|
||||||
</section>
|
function StyleModal({
|
||||||
|
items,
|
||||||
<section
|
value,
|
||||||
id="about"
|
onPick,
|
||||||
className="px-6 md:px-16 pb-20 md:pb-28 grid grid-cols-12 gap-8"
|
onClose,
|
||||||
>
|
}: {
|
||||||
<div className="col-span-12 md:col-span-3">
|
items: string[];
|
||||||
<p className="text-[10px] smallcaps text-clay-500 mb-3">
|
value: number;
|
||||||
题 跋 · I
|
onPick: (i: number) => void;
|
||||||
</p>
|
onClose: () => void;
|
||||||
<p className="font-serif italic text-clay-700 text-base leading-relaxed">
|
}) {
|
||||||
一场关于生成式叙事的小型开源实验。一键 Vercel 自建。
|
const [q, setQ] = useState("");
|
||||||
</p>
|
const [shown, setShown] = useState(false);
|
||||||
</div>
|
useEffect(() => {
|
||||||
<div className="col-span-12 md:col-span-3 md:col-start-5">
|
const id = requestAnimationFrame(() => setShown(true));
|
||||||
<p className="text-[10px] smallcaps text-clay-500 mb-3">
|
return () => cancelAnimationFrame(id);
|
||||||
题 跋 · II
|
}, []);
|
||||||
</p>
|
const close = () => {
|
||||||
<ul className="font-serif text-clay-700 text-base leading-relaxed space-y-1">
|
setShown(false);
|
||||||
<li>文 · 大语言模型</li>
|
setTimeout(onClose, 280);
|
||||||
<li>图 · 生成式渲染</li>
|
};
|
||||||
<li>感知 · 视觉解读</li>
|
const list = items.map((name, i) => ({ name, i })).filter((x) => x.name.includes(q.trim()));
|
||||||
</ul>
|
return (
|
||||||
</div>
|
<div
|
||||||
<div className="col-span-12 md:col-span-3 md:col-start-9">
|
onMouseDown={close}
|
||||||
<p className="text-[10px] smallcaps text-clay-500 mb-3">
|
className={
|
||||||
题 跋 · III
|
"fixed inset-0 z-[60] flex items-center justify-center p-6 md:p-10 transition-all duration-300 " +
|
||||||
</p>
|
(shown ? "bg-clay-900/30 backdrop-blur-md" : "bg-clay-900/0 backdrop-blur-0")
|
||||||
<p className="font-serif italic text-clay-700 text-base leading-relaxed">
|
}
|
||||||
三者各自独立配置 — 任何 OpenAI 兼容端点皆可。
|
>
|
||||||
</p>
|
<div
|
||||||
</div>
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
</section>
|
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 " +
|
||||||
<footer className="px-6 md:px-16 pb-10 mt-auto">
|
(shown ? "opacity-100 scale-100" : "opacity-0 scale-95")
|
||||||
<div className="hairline-full w-full mb-5" />
|
}
|
||||||
<div className="flex items-center justify-between text-[10px] smallcaps text-clay-500">
|
>
|
||||||
<span>MMXXVI</span>
|
<div className="flex items-center gap-5 px-6 md:px-8 py-5 border-b border-clay-900/10">
|
||||||
<span className="num">Ⅰ · Ⅰ</span>
|
<div className="flex flex-col">
|
||||||
</div>
|
<span className="font-serif text-xl md:text-2xl text-clay-900">选择绘画风格</span>
|
||||||
</footer>
|
<span className="text-[11px] text-clay-500 mt-1 tracking-wide">
|
||||||
|
默认「自动」· 由模型根据 prompt 判断风格
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
<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"
|
||||||
|
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="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 }) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="font-serif text-base md:text-lg">{name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{list.length === 0 && (
|
||||||
|
<div className="col-span-full py-12 text-center font-serif text-sm text-clay-400">
|
||||||
|
没有匹配的风格
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- page ---------- */
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [sel, setSel] = useState<number[]>(OPTS.map((o) => o.defaultIndex ?? 0));
|
||||||
|
const [open, setOpen] = useState<number>(-1);
|
||||||
|
const [styleOpen, setStyleOpen] = useState(false);
|
||||||
|
const [prompt, setPrompt] = useState("");
|
||||||
|
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 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?.("[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]!;
|
||||||
|
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<string, string> = {
|
||||||
|
二次元: "唯美二次元动漫插画,日系 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.
|
||||||
|
// TODO(自动路由): 后续实现真正的「自动」——由模型依据世界观 / 玩家 prompt
|
||||||
|
// 选出最合适的画风,再映射到对应风格提示词,而非固定回退到二次元。届时
|
||||||
|
// 同步更新风格弹窗副标题(「由模型根据 prompt 判断风格」)使文案与行为一致。
|
||||||
|
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();
|
||||||
|
};
|
||||||
|
|
||||||
|
const stories = STORIES[galleryGender];
|
||||||
|
const imgPrefix = galleryGender === "女性向" ? "f" : "m";
|
||||||
|
|
||||||
|
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
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
start();
|
||||||
|
}}
|
||||||
|
className="mx-auto mt-9 md:mt-12 max-w-[760px]"
|
||||||
|
>
|
||||||
|
<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="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>
|
||||||
|
)}
|
||||||
|
<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>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* 类别选择器(居中) */}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* 使用提示:可被用户永久关闭(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>
|
||||||
|
|
||||||
|
{/* ================== 统一瀑布流(每性向 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>
|
||||||
|
|
||||||
|
{/* ================== 项目介绍(居中题跋) ================== */}
|
||||||
|
<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="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="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>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</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
|
||||||
|
items={OPTS[styleRow]!.items}
|
||||||
|
value={sel[styleRow] ?? 0}
|
||||||
|
onPick={(i) => setSel((s) => s.map((v, j) => (j === styleRow ? i : v)))}
|
||||||
|
onClose={() => setStyleOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -262,6 +262,14 @@ function PlayInner() {
|
|||||||
// changes so stale in-flight requests can't poison the new scene's map
|
// 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).
|
// (beat ids like "b1" are scene-local and would collide across scenes).
|
||||||
const beatAudioAbortRef = useRef<Map<string, AbortController>>(new Map());
|
const beatAudioAbortRef = useRef<Map<string, AbortController>>(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<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)
|
// Mirrors for use inside async handlers (closure-stable)
|
||||||
const sessionRef = useRef<Session | null>(null);
|
const sessionRef = useRef<Session | null>(null);
|
||||||
@@ -287,6 +295,9 @@ function PlayInner() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
currentBeatRef.current = currentBeat;
|
currentBeatRef.current = currentBeat;
|
||||||
}, [currentBeat]);
|
}, [currentBeat]);
|
||||||
|
useEffect(() => {
|
||||||
|
mutedRef.current = muted;
|
||||||
|
}, [muted]);
|
||||||
|
|
||||||
// Whenever currentBeatId changes, append it to visited (skip consecutive dups)
|
// Whenever currentBeatId changes, append it to visited (skip consecutive dups)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -317,6 +328,8 @@ function PlayInner() {
|
|||||||
sess: Session,
|
sess: Session,
|
||||||
beat: { id: string; speaker?: string; line?: string; lineDelivery?: string },
|
beat: { id: string; speaker?: string; line?: string; lineDelivery?: string },
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
|
if (!audioEnabledRef.current) return; // user toggled 语音配音 → 关闭
|
||||||
|
if (mutedRef.current) return; // 静音 → 不合成 TTS(避免无谓的调用与花费)
|
||||||
if (!beat.speaker || !beat.line) return;
|
if (!beat.speaker || !beat.line) return;
|
||||||
const speaker = sess.characters.find((c) => c.name === beat.speaker);
|
const speaker = sess.characters.find((c) => c.name === beat.speaker);
|
||||||
if (!speaker?.voice) return; // not yet provisioned — server can't synth anyway
|
if (!speaker?.voice) return; // not yet provisioned — server can't synth anyway
|
||||||
@@ -362,22 +375,26 @@ function PlayInner() {
|
|||||||
beatAudioAbortRef.current.clear();
|
beatAudioAbortRef.current.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fire one /api/beat-audio request per speaking beat each time the scene
|
// Fire one /api/beat-audio request per speaking beat in the current scene.
|
||||||
// changes. Cancel any in-flight requests from the prior scene first —
|
// Reads refs (not props) so it stays closure-stable and can be re-run on
|
||||||
// beat ids are scene-local ("b1" repeats across scenes) so a late arrival
|
// un-mute as well as on scene change.
|
||||||
// would land under the wrong beat in the audio map otherwise.
|
const prefetchSceneAudio = useCallback(() => {
|
||||||
useEffect(() => {
|
const scene = currentSceneRef.current;
|
||||||
cancelBeatAudioFetches();
|
|
||||||
setBeatAudioMap({});
|
|
||||||
const scene = currentScene;
|
|
||||||
const sess = sessionRef.current;
|
const sess = sessionRef.current;
|
||||||
if (!scene || !sess) return;
|
if (!scene || !sess) return;
|
||||||
for (const b of scene.beats) {
|
for (const b of scene.beats) {
|
||||||
if (b.speaker && b.line) {
|
if (b.speaker && b.line) void fetchBeatAudio(sess, b);
|
||||||
void fetchBeatAudio(sess, b);
|
|
||||||
}
|
}
|
||||||
}
|
}, [fetchBeatAudio]);
|
||||||
}, [currentScene?.id, 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) ─
|
// ── Mute persistence (read is via the useState lazy initializer above) ─
|
||||||
const toggleMuted = useCallback(() => {
|
const toggleMuted = useCallback(() => {
|
||||||
@@ -392,6 +409,27 @@ 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.)
|
||||||
|
//
|
||||||
|
// Gate on actual mute *transitions*: on mount this effect would otherwise
|
||||||
|
// fire alongside the scene effect above (both call prefetchSceneAudio),
|
||||||
|
// doubling the initial /api/beat-audio batch — the first set is dispatched
|
||||||
|
// only to be aborted mid-flight, burning TTS quota.
|
||||||
|
const prevMutedRef = useRef(muted);
|
||||||
|
useEffect(() => {
|
||||||
|
const prev = prevMutedRef.current;
|
||||||
|
prevMutedRef.current = muted;
|
||||||
|
if (prev === muted) return;
|
||||||
|
cancelBeatAudioFetches();
|
||||||
|
if (muted) return;
|
||||||
|
setBeatAudioMap({});
|
||||||
|
prefetchSceneAudio();
|
||||||
|
}, [muted, prefetchSceneAudio]);
|
||||||
|
|
||||||
// ── Presentation mode toggle ─────────────────────────────────────────
|
// ── Presentation mode toggle ─────────────────────────────────────────
|
||||||
const togglePresentation = useCallback(async () => {
|
const togglePresentation = useCallback(async () => {
|
||||||
const entering = !presentation;
|
const entering = !presentation;
|
||||||
@@ -450,7 +488,14 @@ function PlayInner() {
|
|||||||
const stored = sessionStorage.getItem("yume:custom");
|
const stored = sessionStorage.getItem("yume:custom");
|
||||||
if (stored) {
|
if (stored) {
|
||||||
try {
|
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 {
|
} catch {
|
||||||
payload = null;
|
payload = null;
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 119 KiB |
@@ -0,0 +1,585 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* One-off generator: produces the InfiPlot homepage story cards via Runware
|
||||||
|
* FLUX.2 and writes them as PNGs under apps/web/public/home/.
|
||||||
|
*
|
||||||
|
* Flat per-gender layout: 30 male-oriented (m0..m29) + 30 female-oriented
|
||||||
|
* (f0..f29). Same index shares aspect ratio across genders so the 性向
|
||||||
|
* crossfade never jumps card height.
|
||||||
|
*
|
||||||
|
* Reads IMAGE_BASE_URL / IMAGE_API_KEY / IMAGE_MODEL from apps/web/.env.local.
|
||||||
|
*
|
||||||
|
* Run once:
|
||||||
|
* node apps/web/scripts/generate-home-images.mjs
|
||||||
|
*
|
||||||
|
* Idempotent: skips any card whose .png or .webp already exists. Pass --force
|
||||||
|
* to regenerate everything.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
// 30 male-oriented cards (m0..m29). m0..m6 flagship moods, m7..m22 broad
|
||||||
|
// genre sweep, m23..m29 added range (wuxia / space opera / republican-era /
|
||||||
|
// apocalypse / western / deep sea / steampunk).
|
||||||
|
const MALE = [
|
||||||
|
{
|
||||||
|
name: "m0",
|
||||||
|
prompt:
|
||||||
|
"anime visual novel cover art, two high school students standing under cherry blossom petals at dusk, warm golden sunset light, soft watercolor texture, japanese galgame illustration, widescreen composition",
|
||||||
|
w: 1024,
|
||||||
|
h: 640,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "m1",
|
||||||
|
prompt:
|
||||||
|
"post-apocalyptic wasteland anime, lone scavenger silhouette against rusted mecha mountain, golden dust storm sweeping across the dunes, cinematic widescreen, anime concept art, dramatic backlight",
|
||||||
|
w: 1024,
|
||||||
|
h: 640,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "m2",
|
||||||
|
prompt:
|
||||||
|
"anime xianxia cultivator boy in flowing white robes standing on a floating mountain peak above a sea of clouds, vermillion banners fluttering, vertical poster composition, chinese mythology, galgame illustration",
|
||||||
|
w: 768,
|
||||||
|
h: 1024,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "m3",
|
||||||
|
prompt:
|
||||||
|
"anime visual novel scene, southern chinese small town in june rain, a transfer student looking back from a rainy classroom window, ceiling fan in background, soft warm afternoon tones, slice of life galgame illustration",
|
||||||
|
w: 1024,
|
||||||
|
h: 832,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "m4",
|
||||||
|
prompt:
|
||||||
|
"cyberpunk anime portrait, amnesiac detective standing in neon-soaked rainy alley of an east-asian metropolis in 2087, holographic signs reflecting on wet pavement, vertical composition, blade runner palette, anime illustration",
|
||||||
|
w: 768,
|
||||||
|
h: 1024,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "m5",
|
||||||
|
prompt:
|
||||||
|
"anime mystery scene, late-night high school library underground chamber, flickering candlelight, a class president kneeling before a glowing rune circle on the stone floor, gothic galgame style, mysterious teal-green glow",
|
||||||
|
w: 1024,
|
||||||
|
h: 640,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "m6",
|
||||||
|
prompt:
|
||||||
|
"anime isekai cathedral scene, silver-haired holy maiden with tearful eyes kneeling before a glowing magic summoning circle, golden cathedral light streaming through stained glass, summoned hero just appearing in modern school uniform, warm galgame illustration",
|
||||||
|
w: 1024,
|
||||||
|
h: 640,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "m7",
|
||||||
|
prompt:
|
||||||
|
"anime girl in summer yukata watching fireworks at a japanese festival night, warm bokeh lanterns, vertical composition, soft watercolor, slice of life galgame",
|
||||||
|
w: 768,
|
||||||
|
h: 1024,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "m8",
|
||||||
|
prompt:
|
||||||
|
"cyberpunk neon city skyline at rainy night, flying vehicles, holographic billboards in chinese characters, anime widescreen, cinematic",
|
||||||
|
w: 1024,
|
||||||
|
h: 640,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "m9",
|
||||||
|
prompt:
|
||||||
|
"anime two students standing on empty rural train platform after school, golden hour, slice of life galgame illustration, cinematic widescreen, warm tones",
|
||||||
|
w: 1024,
|
||||||
|
h: 832,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "m10",
|
||||||
|
prompt:
|
||||||
|
"anime mage girl in star-embroidered robes casting starlight spell, ancient fantasy library, vertical composition, magical particles, painterly illustration",
|
||||||
|
w: 768,
|
||||||
|
h: 1024,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "m11",
|
||||||
|
prompt:
|
||||||
|
"anime mecha pilot girl strapped in cockpit, holographic interfaces around her, dramatic red emergency lighting, intense expression, mecha anime style",
|
||||||
|
w: 1024,
|
||||||
|
h: 640,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "m12",
|
||||||
|
prompt:
|
||||||
|
"anime detective girl in long trench coat under a flickering streetlamp at midnight, noir mood, vertical composition, rain mist, cinematic anime",
|
||||||
|
w: 768,
|
||||||
|
h: 1024,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "m13",
|
||||||
|
prompt:
|
||||||
|
"anime cyberpunk couple sharing a quiet moment in a neon-lit rainy alley, holographic umbrella, electric blue and pink reflections, romantic galgame illustration",
|
||||||
|
w: 1024,
|
||||||
|
h: 832,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "m14",
|
||||||
|
prompt:
|
||||||
|
"anime sword duel between two xianxia cultivators in a bamboo grove, motion blur on swords, falling bamboo leaves, dynamic action composition",
|
||||||
|
w: 1024,
|
||||||
|
h: 640,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "m15",
|
||||||
|
prompt:
|
||||||
|
"anime princess in ornate eastern gown seated on an ancient carved throne, candlelight, intricate background tapestries, vertical poster composition, fantasy galgame",
|
||||||
|
w: 768,
|
||||||
|
h: 1024,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "m16",
|
||||||
|
prompt:
|
||||||
|
"anime classroom afternoon, sun streaming through windows onto empty desks, a single uniformed student writing in a notebook, slice of life watercolor, nostalgic",
|
||||||
|
w: 1024,
|
||||||
|
h: 640,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "m17",
|
||||||
|
prompt:
|
||||||
|
"anime girl reading a folded letter under a cherry blossom tree, melancholic expression, petals drifting, soft warm watercolor, slice of life galgame",
|
||||||
|
w: 1024,
|
||||||
|
h: 832,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "m18",
|
||||||
|
prompt:
|
||||||
|
"anime moon goddess descending from a starlit sky, silver hair flowing, ethereal aurora glow, dreamy painterly illustration, vertical composition",
|
||||||
|
w: 768,
|
||||||
|
h: 1024,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "m19",
|
||||||
|
prompt:
|
||||||
|
"anime samurai standing alone under a blood red full moon, sakura petals carried on the wind, katana drawn, dramatic backlight, cinematic widescreen",
|
||||||
|
w: 1024,
|
||||||
|
h: 640,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "m20",
|
||||||
|
prompt:
|
||||||
|
"anime witch girl brewing a glowing potion in a candlelit forest hut, hanging dried herbs, magical sparks rising from the cauldron, vertical composition",
|
||||||
|
w: 768,
|
||||||
|
h: 1024,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "m21",
|
||||||
|
prompt:
|
||||||
|
"anime beach summer scene, two girlfriends sitting on the sand watching a pink-orange sunset, gentle waves, slice of life galgame illustration",
|
||||||
|
w: 1024,
|
||||||
|
h: 640,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "m22",
|
||||||
|
prompt:
|
||||||
|
"anime hacker girl in a dim apartment surrounded by glowing screens, neon cyan reflections on her face, intense focus, cyberpunk galgame style",
|
||||||
|
w: 1024,
|
||||||
|
h: 832,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "m23",
|
||||||
|
prompt:
|
||||||
|
"anime wuxia scene, a lone swordsman in a rundown rainy-night tavern, a mysterious masked woman at the next table with a sword case beside her, warm lantern light, jianghu atmosphere, vertical composition, galgame illustration",
|
||||||
|
w: 768,
|
||||||
|
h: 1024,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "m24",
|
||||||
|
prompt:
|
||||||
|
"anime space opera scene, the bridge of a deep-space colony ship with red alert lights flashing, an unknown planet glowing ominous crimson through the viewport, sci-fi galgame illustration, cinematic widescreen",
|
||||||
|
w: 1024,
|
||||||
|
h: 640,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "m25",
|
||||||
|
prompt:
|
||||||
|
"anime 1930s old Shanghai bund scene, art deco ballroom, a dancer handing a coded playing card to the viewer, gramophone and warm amber lighting, republican era China, cinematic galgame illustration",
|
||||||
|
w: 1024,
|
||||||
|
h: 832,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "m26",
|
||||||
|
prompt:
|
||||||
|
"anime post-apocalyptic survival scene, interior of a barricaded convenience store at night, a lone survivor tense at the rolling shutter door listening to a rhythmic knock, dim emergency light, vertical composition, galgame illustration",
|
||||||
|
w: 768,
|
||||||
|
h: 1024,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "m27",
|
||||||
|
prompt:
|
||||||
|
"anime wild west scene, a deserted frontier town at high noon, a lone gunslinger standing outside the saloon ready for a duel, dust and harsh sunlight, cinematic widescreen, anime illustration",
|
||||||
|
w: 1024,
|
||||||
|
h: 640,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "m28",
|
||||||
|
prompt:
|
||||||
|
"anime deep sea exploration scene, a diving bell descending into an abyssal trench, searchlight revealing an ancient sunken city, eerie blue glow, bioluminescence, vertical composition, galgame illustration",
|
||||||
|
w: 768,
|
||||||
|
h: 1024,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "m29",
|
||||||
|
prompt:
|
||||||
|
"anime steampunk airship deck scene, brass gears and billowing steam above a sea of clouds, a black pirate balloon approaching the starboard side, dramatic adventure mood, cinematic widescreen, anime illustration",
|
||||||
|
w: 1024,
|
||||||
|
h: 832,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 30 female-oriented cards (f0..f29). Same index + aspect ratio as MALE so the
|
||||||
|
// 女性向 masonry mirrors slot heights; otome / josei love-interest framing.
|
||||||
|
const FEMALE = [
|
||||||
|
{
|
||||||
|
name: "f0",
|
||||||
|
prompt:
|
||||||
|
"anime josei otome game illustration, beautiful female protagonist in ornate eastern hanfu silk robes, behind her a tall stoic regent prince in dark embroidered robes leaning down to clasp a red jade bracelet on her wrist, ancient chinese palace interior, soft candlelight, romantic widescreen composition",
|
||||||
|
w: 1024,
|
||||||
|
h: 640,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "f1",
|
||||||
|
prompt:
|
||||||
|
"anime modern romance scene, young woman in pajamas sitting on a bed at dawn, golden light through curtains, looking at her phone in shock as if she has just been pulled back in time, soft warm tones, melancholic otome illustration, widescreen",
|
||||||
|
w: 1024,
|
||||||
|
h: 640,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "f2",
|
||||||
|
prompt:
|
||||||
|
"anime villainess otome game character, beautiful young noblewoman with elaborate golden ringlet hair and crimson ballgown, standing alone in a baroque royal academy ballroom while other noble girls glare from the background, dramatic chandelier light, vertical poster composition, otome game cover art",
|
||||||
|
w: 768,
|
||||||
|
h: 1024,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "f3",
|
||||||
|
prompt:
|
||||||
|
"anime visual novel scene, female high school transfer student standing on a rainy southern chinese town rooftop, sharing her umbrella with a moody boy reading poetry on the railing, soft warm afternoon palette, slice of life otome illustration",
|
||||||
|
w: 1024,
|
||||||
|
h: 832,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "f4",
|
||||||
|
prompt:
|
||||||
|
"anime josei coronation scene, beautiful young empress in ornate ceremonial robes seated on a high eastern throne, head turned to glance at a handsome attendant standing in the shadowed pillars below, vertical composition, opulent silks and gold, otome game illustration",
|
||||||
|
w: 768,
|
||||||
|
h: 1024,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "f5",
|
||||||
|
prompt:
|
||||||
|
"anime wuxia swordswoman in flowing light hanfu, jade hairpin, white sword raised mid-stance, cherry blossoms swirling around her, mountain pavilion in the background at golden hour, dynamic widescreen otome wuxia illustration",
|
||||||
|
w: 1024,
|
||||||
|
h: 640,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "f6",
|
||||||
|
prompt:
|
||||||
|
"anime visual novel scene, female high school student standing on a sunset rooftop looking up at a tall handsome senior in school uniform, warm orange sky, golden hour, romantic galgame otome cover art, widescreen",
|
||||||
|
w: 1024,
|
||||||
|
h: 640,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "f7",
|
||||||
|
prompt:
|
||||||
|
"anime otome game illustration, handsome boy in summer yukata shielding a girl from the festival crowd, both watching the last firework bloom in the night sky, warm lantern bokeh, vertical composition, soft watercolor, romantic galgame",
|
||||||
|
w: 768,
|
||||||
|
h: 1024,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "f8",
|
||||||
|
prompt:
|
||||||
|
"anime josei romance, handsome young man draping his coat over a girl's shoulders on a rainy train platform at night, neon signs shattering into reflections in the puddles, cinematic widescreen, warm melancholic tones, otome illustration",
|
||||||
|
w: 1024,
|
||||||
|
h: 640,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "f9",
|
||||||
|
prompt:
|
||||||
|
"anime otome scene, a boy stopping and turning back to look at the girl on an empty rural train platform at golden hour dusk, unspoken words between them, slice of life galgame illustration, warm tones, cinematic widescreen",
|
||||||
|
w: 1024,
|
||||||
|
h: 832,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "f10",
|
||||||
|
prompt:
|
||||||
|
"anime otome game, cold aloof student council president closing a forbidden tome in the depths of an old library, lifting his gaze with unexpectedly gentle eyes toward the viewer, dust motes in candlelight, vertical composition, painterly illustration",
|
||||||
|
w: 768,
|
||||||
|
h: 1024,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "f11",
|
||||||
|
prompt:
|
||||||
|
"anime otome romance, a handsome knight kneeling on one knee swearing an oath with his sword before the viewer, red emergency alert lighting on a starship bridge, dramatic devotion, otome game illustration, widescreen",
|
||||||
|
w: 1024,
|
||||||
|
h: 640,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "f12",
|
||||||
|
prompt:
|
||||||
|
"anime otome scene, handsome young man catching up under a single umbrella to a girl walking alone in a midnight rainy alley, offering to walk her home, noir streetlamp glow, rain mist, vertical composition, romantic galgame",
|
||||||
|
w: 768,
|
||||||
|
h: 1024,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "f13",
|
||||||
|
prompt:
|
||||||
|
"anime otome romance, a boy tilting a glowing holographic umbrella toward the girl while his own shoulder gets soaked in the neon rain, electric blue and pink reflections, intimate quiet moment, galgame illustration",
|
||||||
|
w: 1024,
|
||||||
|
h: 832,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "f14",
|
||||||
|
prompt:
|
||||||
|
"anime wuxia otome, a handsome swordsman sheathing his blade to stand protectively before a girl in a bamboo grove, falling bamboo leaves drifting between them, golden light, dynamic romantic composition, widescreen",
|
||||||
|
w: 1024,
|
||||||
|
h: 640,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "f15",
|
||||||
|
prompt:
|
||||||
|
"anime otome game, a cold regent prince crossing a candlelit ancient palace banquet hall, reaching out his hand only toward the viewer while courtiers bow, opulent silks and gold, vertical poster composition, fantasy otome illustration",
|
||||||
|
w: 768,
|
||||||
|
h: 1024,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "f16",
|
||||||
|
prompt:
|
||||||
|
"anime otome scene, a boy with reddened ears shyly pushing his notebook across a desk toward the girl in a sunset-lit empty classroom, warm orange light, tender romantic moment, slice of life galgame, widescreen",
|
||||||
|
w: 1024,
|
||||||
|
h: 640,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "f17",
|
||||||
|
prompt:
|
||||||
|
"anime otome romance, a handsome boy handing a love letter to the viewer under a cherry blossom tree, petals drifting in the air, tender expression, soft warm watercolor, slice of life galgame illustration",
|
||||||
|
w: 1024,
|
||||||
|
h: 832,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "f18",
|
||||||
|
prompt:
|
||||||
|
"anime otome fantasy, a silver-haired ethereal moon god leaning down, fingertip gently touching the viewer's cheek, aurora glow and drifting starlight, dreamy painterly illustration, vertical composition",
|
||||||
|
w: 768,
|
||||||
|
h: 1024,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "f19",
|
||||||
|
prompt:
|
||||||
|
"anime otome wuxia, a handsome swordsman shielding the girl with his body under a blood red full moon, sword light and sakura petals falling together, dramatic backlight, cinematic widescreen",
|
||||||
|
w: 1024,
|
||||||
|
h: 640,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "f20",
|
||||||
|
prompt:
|
||||||
|
"anime otome fantasy, a handsome young sorcerer brewing a glowing fate-changing potion for the viewer in a candlelit forest hut, hanging dried herbs, magical sparks rising, warm romantic mood, vertical composition",
|
||||||
|
w: 768,
|
||||||
|
h: 1024,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "f21",
|
||||||
|
prompt:
|
||||||
|
"anime otome scene, a boy sitting beside the girl on a seaside embankment under a pink-orange sunset, sharing unspoken feelings carried off on the sea breeze, gentle waves, slice of life galgame illustration, widescreen",
|
||||||
|
w: 1024,
|
||||||
|
h: 640,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "f22",
|
||||||
|
prompt:
|
||||||
|
"anime otome cyberpunk, a handsome hacker boy bathed in blue screen glow turning to look at the viewer after typing the last line of code, neon cyan reflections on his face, intense tender gaze, galgame illustration",
|
||||||
|
w: 1024,
|
||||||
|
h: 832,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "f23",
|
||||||
|
prompt:
|
||||||
|
"anime otome fantasy, a silver-haired dragon king in humanoid form kneeling on one knee deep in an ancient dragon lair, offering a dragon-scale ring toward the viewer, glowing treasure hoard, vertical composition, otome game illustration",
|
||||||
|
w: 768,
|
||||||
|
h: 1024,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "f24",
|
||||||
|
prompt:
|
||||||
|
"anime otome josei, 1930s old Shanghai mansion, an elegant refined young gentleman in a western suit shielding the viewer from a stray bullet, crimson blooming on his sleeve cuff, warm amber lighting, cinematic widescreen, otome illustration",
|
||||||
|
w: 1024,
|
||||||
|
h: 640,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "f25",
|
||||||
|
prompt:
|
||||||
|
"anime otome apocalypse, a handsome rugged survivor firing his last bullet at a zombie breaking through a door, then turning to shield the girl behind him, dim ruined interior, dramatic devotion, otome game illustration, widescreen",
|
||||||
|
w: 1024,
|
||||||
|
h: 832,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "f26",
|
||||||
|
prompt:
|
||||||
|
"anime otome gothic romance, a pale handsome vampire count bowing to kiss the back of the viewer's hand at a candlelit masquerade ball in a fog-shrouded castle, cold elegant beauty, vertical composition, otome illustration",
|
||||||
|
w: 768,
|
||||||
|
h: 1024,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "f27",
|
||||||
|
prompt:
|
||||||
|
"anime otome wild west, a silent handsome bounty hunter on horseback in a dusty frontier town reaching down to pull the girl up onto his saddle, golden dust and harsh sunlight, cinematic widescreen, otome illustration",
|
||||||
|
w: 1024,
|
||||||
|
h: 640,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "f28",
|
||||||
|
prompt:
|
||||||
|
"anime otome fantasy, a luminous handsome merman prince wrapping his arm around the girl's waist, guiding her through a sleeping ancient underwater city, glowing bioluminescent ruins, vertical composition, otome game illustration",
|
||||||
|
w: 768,
|
||||||
|
h: 1024,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "f29",
|
||||||
|
prompt:
|
||||||
|
"anime otome steampunk, a dashing one-eyed airship captain on the deck handing a telescope to the viewer, brass gears and a sea of clouds behind, adventurous romantic mood, cinematic widescreen, otome illustration",
|
||||||
|
w: 1024,
|
||||||
|
h: 832,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const ALL = [...MALE, ...FEMALE];
|
||||||
|
|
||||||
|
/* ---------- Runware caller ---------- */
|
||||||
|
|
||||||
|
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);
|
||||||
@@ -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`,
|
||||||
|
);
|
||||||