Merge pull request #38 from zonghaoyuan/worktree-style-modal-revamp
feat(web): revamp style modal with grid cards, optimized prompts, and polished custom view
@@ -53,22 +53,7 @@ const OPTS: Opt[] = [
|
||||
|
||||
type StoryContent = { title: string; outline: string; style: string; tags: string[] };
|
||||
|
||||
const STYLE_MAP: Record<string, string> = {
|
||||
"古典厚涂油画": "Dark fantasy oil painting style, grand clockwork steampunk city built into a mountain range at twilight, immense gothic spires with glowing green lamps, complex gears and platforms. Richly detailed, impasto texture, dramatic academic lighting. Horizontal cinematic composition.",
|
||||
"极简中国水墨": "Minimalist Chinese ink wash style, vertical sea of clouds and distant jagged peaks. Ethereal, sparse composition with poetic brushstrokes, monochrome palette with subtle blue hints. Large blank mist area for copy space.",
|
||||
"浮世绘木刻": "Ukiyo-e woodblock print style, majestic waves and Mount Fuji visible through cherry branches. Bold outlines, flat colors with paper texture, ancient and mystical atmosphere.",
|
||||
"莫高窟壁画": "Dunhuang fresco style, celestial patterns, stylized lotus flowers and floating geometric patterns on an aged stucco wall. Muted, oxidized mineral colors, delicate line art, historical and divine ambiance.",
|
||||
"波斯细密画": "Persian miniature style, ornate vertical tiled garden pavilion surrounded by tall cypress trees and complex geometric mosaics. High detail, jewel-like colors, flattened perspective, decorative borders.",
|
||||
"吉卜力治愈手绘": "Ghibli hand-painted watercolor style, a vast wildflower meadow hill under a bright blue sky with fluffy clouds, a fantastical airship flying in the distance. Natural daylight, soft washes, nostalgic feel.",
|
||||
"京阿尼细腻日常": "KyoAni anime style, fine line art, warm indoor lighting contrasting the cool moonlight outside, rain streaks on a tall window. Deep emotional atmosphere, delicate light and shadow reflections.",
|
||||
"新海诚唯美光影": "Makoto Shinkai anime style, hyper-detailed, towering dramatic night starry sky with a descending comet trail, glowing cherry tree branches in the foreground. Brilliant lighting effects, vivid colors.",
|
||||
"Galgame CG": "High-quality Galgame CG illustration, dreamlike beach scene at sunset with sparkling waves rolling in. Pastel colors, bloom lighting, clean composition, soft focus.",
|
||||
"3D 动漫电影": "Cinematic 3D animated film style, a rustic wooden hangar at sunrise with volumetric lighting, warm golden hour colors, deep textures, cinematic composition.",
|
||||
"赛博朋克": "Cyberpunk anime style, cel-shaded animation, rainy night streets of a dense neon-drenched futuristic megacity with towering skyscrapers. Hard edges, high saturation, sharp contrast.",
|
||||
"蒸汽波": "Vaporwave aesthetic, anime style, a geometric pink grid floor leading to a palm tree silhouette, neon pink sunset over a purple ocean in the background. Glitch effects, retro pastel colors.",
|
||||
"哥特庄园": "Gothic romance illustration, desolate moonlit ruins of a grand gothic manor on a foggy cliff, misty atmosphere, melancholic blue and grey tones.",
|
||||
"废土科幻": "Post-apocalyptic landscape, vast desert wasteland with the rusted remains of an overgrown highway and ruined skyscrapers under a dusty orange sunset sky.",
|
||||
};
|
||||
import { STYLE_MAP } from "@/lib/options";
|
||||
|
||||
/* 每个性向 24 篇预设剧情(与封面 /home/{m|f}{i}.webp 按索引一一对应)。
|
||||
男/女同索引共享画面尺寸,切性向 crossfade 时卡片高度不跳变。 */
|
||||
@@ -157,7 +142,7 @@ const STORIES: Record<Gender, StoryContent[]> = {
|
||||
{
|
||||
"title": "社团存亡日",
|
||||
"outline": "濒临废部的动画社,唯一社员是总在睡觉的怪人。新来的转校生社长发现,只要完成怪人的“日常委托”,社员就会增加一人,而这些人,都来自被遗忘的动画世界。",
|
||||
"style": "京阿尼细腻日常 (Image 5参考)",
|
||||
"style": "京阿尼 (Image 5参考)",
|
||||
"tags": [
|
||||
"日常",
|
||||
"奇幻",
|
||||
@@ -167,7 +152,7 @@ const STORIES: Record<Gender, StoryContent[]> = {
|
||||
{
|
||||
"title": "黄昏归途",
|
||||
"outline": "他总在黄昏时分,于空无一人的车站遇见少女。她带他穿越时间的缝隙,回到故乡被毁灭前的最后一天。每一次循环,他都必须在拯救她与拯救世界之间做出选择。",
|
||||
"style": "新海诚唯美光影 (Image 2参考)",
|
||||
"style": "新海诚 (Image 2参考)",
|
||||
"tags": [
|
||||
"时间循环",
|
||||
"恋爱",
|
||||
@@ -459,7 +444,7 @@ const STORIES: Record<Gender, StoryContent[]> = {
|
||||
{
|
||||
"title": "夏日未完待续",
|
||||
"outline": "她在文化祭前夜,与青梅竹马的学长在空教室许下约定。第二天醒来,时间永远停在了文化祭前一周。只有她保留记忆,为守护他的笑容,她一遍遍重演青春,试图改写那个令他心碎的结局。",
|
||||
"style": "京阿尼细腻日常 (Image 5参考)",
|
||||
"style": "京阿尼 (Image 5参考)",
|
||||
"tags": [
|
||||
"时间循环",
|
||||
"青春",
|
||||
@@ -469,7 +454,7 @@ const STORIES: Record<Gender, StoryContent[]> = {
|
||||
{
|
||||
"title": "星之轨迹",
|
||||
"outline": "她总在雨天,于旧书店遇见来自未来的他。他说她是拯救未来的关键,赠予她能看到“命运线”的能力。当她终于能看清两人的轨迹,却发现他来自的时间线,正因她的存在而崩塌。",
|
||||
"style": "新海诚唯美光影 (Image 2参考)",
|
||||
"style": "新海诚 (Image 2参考)",
|
||||
"tags": [
|
||||
"穿越",
|
||||
"科幻",
|
||||
@@ -865,8 +850,6 @@ function StyleModal({
|
||||
onClose,
|
||||
customStyleGuide,
|
||||
setCustomStyleGuide,
|
||||
styleOverrides,
|
||||
setStyleOverrides,
|
||||
customStyleRefImage,
|
||||
setCustomStyleRefImage,
|
||||
}: {
|
||||
@@ -876,62 +859,69 @@ function StyleModal({
|
||||
onClose: () => void;
|
||||
customStyleGuide: string;
|
||||
setCustomStyleGuide: (s: string) => void;
|
||||
styleOverrides: Record<string, string>;
|
||||
setStyleOverrides: (o: Record<string, string>) => void;
|
||||
customStyleRefImage: string;
|
||||
setCustomStyleRefImage: (s: string) => void;
|
||||
}) {
|
||||
const [q, setQ] = useState("");
|
||||
const [shown, setShown] = useState(false);
|
||||
// Inline editing:editingIdx === i 时该卡片的 prompt 框变成可编辑 textarea。
|
||||
// 列表保持原位(不跳新页面),其他卡片继续可见——用户随时可以取消并切到别处。
|
||||
const [editingIdx, setEditingIdx] = useState<number | null>(null);
|
||||
const [view, setView] = useState<"grid" | "custom">("grid");
|
||||
const [draft, setDraft] = useState("");
|
||||
// 上传 / 解析参考图的瞬时状态——失败/进行中提示只在此次弹窗内可见。
|
||||
const [parsing, setParsing] = useState(false);
|
||||
const [parseError, setParseError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const thumbV = "v4";
|
||||
const STYLE_THUMB: Record<string, string> = {
|
||||
"自动": `/home/styles/auto.webp?${thumbV}`,
|
||||
"自定义风格": `/home/styles/custom.webp?${thumbV}`,
|
||||
"京阿尼": `/home/styles/kyoani.webp?${thumbV}`,
|
||||
"新海诚": `/home/styles/shinkai.webp?${thumbV}`,
|
||||
"吉卜力": `/home/styles/ghibli.webp?${thumbV}`,
|
||||
"3D 动画": `/home/styles/3d.webp?${thumbV}`,
|
||||
"赛博朋克": `/home/styles/cyberpunk.webp?${thumbV}`,
|
||||
"哥特": `/home/styles/gothic.webp?${thumbV}`,
|
||||
"废土": `/home/styles/wasteland.webp?${thumbV}`,
|
||||
"像素风": `/home/styles/pixel.webp?${thumbV}`,
|
||||
"真实": `/home/styles/real.webp?${thumbV}`,
|
||||
"古典油画": `/home/styles/oil.webp?${thumbV}`,
|
||||
"莫奈": `/home/styles/monet.webp?${thumbV}`,
|
||||
"水彩": `/home/styles/watercolor.webp?${thumbV}`,
|
||||
"水墨": `/home/styles/ink.webp?${thumbV}`,
|
||||
"浮世绘": `/home/styles/ukiyoe.webp?${thumbV}`,
|
||||
"彩铅": `/home/styles/pencil.webp?${thumbV}`,
|
||||
"手绘素描": `/home/styles/sketch.webp?${thumbV}`,
|
||||
"黑白漫画": `/home/styles/manga.webp?${thumbV}`,
|
||||
"儿童绘本": `/home/styles/children.webp?${thumbV}`,
|
||||
"儿童涂鸦": `/home/styles/crayon.webp?${thumbV}`,
|
||||
"黏土手工": `/home/styles/clay.webp?${thumbV}`,
|
||||
};
|
||||
useEffect(() => {
|
||||
const id = requestAnimationFrame(() => setShown(true));
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, []);
|
||||
const closeRef = useRef<() => void>(null);
|
||||
const close = () => {
|
||||
setShown(false);
|
||||
setTimeout(onClose, 280);
|
||||
};
|
||||
const startEditing = (i: number, currentPrompt: string) => {
|
||||
setEditingIdx(i);
|
||||
setDraft(currentPrompt);
|
||||
closeRef.current = close;
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => { if (e.key === "Escape") closeRef.current?.(); };
|
||||
document.addEventListener("keydown", h);
|
||||
return () => document.removeEventListener("keydown", h);
|
||||
}, []);
|
||||
const customIdx = items.indexOf("自定义风格");
|
||||
const openCustomView = (prefill: string) => {
|
||||
setDraft(prefill);
|
||||
setView("custom");
|
||||
};
|
||||
const cancelEditing = () => {
|
||||
setEditingIdx(null);
|
||||
setDraft("");
|
||||
};
|
||||
const saveEditing = () => {
|
||||
if (editingIdx === null) return;
|
||||
const targetName = items[editingIdx];
|
||||
const saveCustom = () => {
|
||||
const t = draft.trim();
|
||||
if (!targetName || !t) return;
|
||||
if (targetName === "自定义") {
|
||||
setCustomStyleGuide(t);
|
||||
} else {
|
||||
// STYLE_MAP 这个 source-of-truth 不动;只往 in-memory overrides 写一条。
|
||||
setStyleOverrides({ ...styleOverrides, [targetName]: t });
|
||||
}
|
||||
onPick(editingIdx);
|
||||
setEditingIdx(null);
|
||||
if (!t) return;
|
||||
setCustomStyleGuide(t);
|
||||
if (customIdx >= 0) onPick(customIdx);
|
||||
close();
|
||||
};
|
||||
const resetOverride = (name: string) => {
|
||||
const next = { ...styleOverrides };
|
||||
delete next[name];
|
||||
setStyleOverrides(next);
|
||||
setDraft(STYLE_MAP[name] ?? "");
|
||||
};
|
||||
|
||||
// 客户端把上传的图片缩到 512px 长边 + webp(0.85),base64 通常落在 30-80KB。
|
||||
// 必须客户端做:(1) 上传 / 后续 /api/scene 都会带这串,包不能太大;
|
||||
// (2) Runware referenceImages 支持 base64,无需另外加 upload 端点。
|
||||
const resizeImageToDataUrl = async (file: File): Promise<string> => {
|
||||
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||||
const r = new FileReader();
|
||||
@@ -955,7 +945,6 @@ function StyleModal({
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("Canvas 2D context unavailable");
|
||||
ctx.drawImage(img, 0, 0, w, h);
|
||||
// webp 比 jpeg 体积更小一些;浏览器全支持。降级到 jpeg 作为兜底。
|
||||
let out = canvas.toDataURL("image/webp", 0.85);
|
||||
if (!out.startsWith("data:image/webp")) {
|
||||
out = canvas.toDataURL("image/jpeg", 0.85);
|
||||
@@ -982,8 +971,6 @@ function StyleModal({
|
||||
throw new Error(j.error ?? `${res.status}`);
|
||||
}
|
||||
const data = (await res.json()) as { stylePrompt: string };
|
||||
// 收到 AI 解析后的 prompt → 覆盖正在编辑的 draft + 持久化参考图。
|
||||
// 用户事后还可以手动改 draft(仍是 textarea)。
|
||||
setDraft(data.stylePrompt);
|
||||
setCustomStyleRefImage(resized);
|
||||
track("style_image_upload", { ok: true });
|
||||
@@ -1000,29 +987,12 @@ function StyleModal({
|
||||
setCustomStyleRefImage("");
|
||||
setParseError(null);
|
||||
};
|
||||
// 标题取去掉括号后缀的"主名"——括号里的英文 / 「Image N参考」之类的脚注
|
||||
// 在标题位上显示噪声太大,挪到下方 prompt 行也已经覆盖到了。两种括号都
|
||||
// 兼容(中文「()」和英文「()」)。
|
||||
const stripSuffix = (s: string) => s.replace(/\s*[((].*?[))]\s*$/, "");
|
||||
|
||||
const q2 = q.trim();
|
||||
const list = items
|
||||
.map((name, i) => {
|
||||
const base = STYLE_MAP[name] ?? "";
|
||||
const override = styleOverrides[name];
|
||||
return {
|
||||
name,
|
||||
title: stripSuffix(name),
|
||||
// 列表里展示的是「有效 prompt」——优先 override,让用户看到自己改过的版本
|
||||
prompt: override ?? base,
|
||||
hasOverride: typeof override === "string" && override.length > 0,
|
||||
i,
|
||||
};
|
||||
})
|
||||
.filter((x) => {
|
||||
if (!q2) return true;
|
||||
const hay = (x.title + " " + x.name + " " + x.prompt).toLowerCase();
|
||||
return hay.includes(q2.toLowerCase());
|
||||
});
|
||||
const list = items.map((name, i) => ({ name, i })).filter((x) => {
|
||||
if (!q2) return true;
|
||||
return x.name.toLowerCase().includes(q2.toLowerCase());
|
||||
});
|
||||
return (
|
||||
<div
|
||||
onMouseDown={close}
|
||||
@@ -1034,27 +1004,43 @@ function StyleModal({
|
||||
<div
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className={
|
||||
"flex w-[860px] 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 " +
|
||||
"flex w-[1400px] max-w-[94vw] h-[86vh] flex-col overflow-hidden rounded-sm border border-clay-900/15 bg-cream-50 shadow-2xl shadow-clay-900/25 transition-all duration-300 " +
|
||||
(shown ? "opacity-100 scale-100" : "opacity-0 scale-95")
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-5 px-6 md:px-8 py-5 border-b border-clay-900/10">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-serif text-xl md:text-2xl text-clay-900">选择绘画风格</span>
|
||||
<span className="text-[11px] text-clay-500 mt-1 tracking-wide">
|
||||
默认「自动」· 点 prompt 框旁的 ✎ 可在该风格基础上修改(默认 prompt 不会被覆盖)
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative ml-auto w-[280px] max-w-[46vw]">
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="搜索风格 / prompt…"
|
||||
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>
|
||||
{view === "custom" ? (
|
||||
<div className="flex flex-1 items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView("grid")}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-sm text-clay-500 hover:bg-cream-100 hover:text-clay-900 transition-colors"
|
||||
aria-label="返回"
|
||||
>
|
||||
<i className="fa-solid fa-arrow-left text-sm" />
|
||||
</button>
|
||||
<span className="font-serif text-xl md:text-2xl text-clay-900">自定义风格</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<span className="font-serif text-xl md:text-2xl text-clay-900">选择绘画风格</span>
|
||||
<span className="text-[11px] text-clay-500 mt-1 tracking-wide">
|
||||
默认「自动」· 由 AI 根据故事自动匹配画风;选择「自定义风格」可输入描述或上传参考图
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative 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}
|
||||
@@ -1064,300 +1050,174 @@ function StyleModal({
|
||||
<i className="fa-solid fa-xmark" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 overflow-y-auto px-4 py-5 md:px-6 md:py-6">
|
||||
{list.map(({ name, title, prompt, hasOverride, i }) => {
|
||||
const isCustom = name === "自定义";
|
||||
const selected = i === value;
|
||||
const editable = isCustom || Boolean(STYLE_MAP[name]);
|
||||
const isEditing = editingIdx === i;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
onClick={(e) => {
|
||||
// 编辑态下:让点击事件落在 textarea/按钮上即可,不要冒泡触发"选中关闭"。
|
||||
// 非编辑态下:点卡片选中此风格(自定义项点卡片直接进编辑)。
|
||||
if (isEditing) return;
|
||||
const tag = (e.target as HTMLElement).tagName;
|
||||
if (tag === "BUTTON" || tag === "TEXTAREA" || tag === "I") return;
|
||||
if (isCustom) {
|
||||
startEditing(i, customStyleGuide);
|
||||
} else {
|
||||
onPick(i);
|
||||
close();
|
||||
}
|
||||
}}
|
||||
className={
|
||||
"flex items-start gap-4 rounded-sm border px-3 py-3 md:px-4 md:py-3.5 text-left transition-all " +
|
||||
(isEditing
|
||||
? "border-ember-500 bg-cream-50 cursor-default"
|
||||
: selected
|
||||
? "border-ember-500 bg-ember-500/5 cursor-pointer"
|
||||
: "border-clay-900/12 hover:border-clay-900/35 hover:bg-cream-100 cursor-pointer")
|
||||
}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
|
||||
{view === "custom" ? (
|
||||
<div className="flex flex-1 flex-col gap-4 overflow-y-auto px-6 py-6 md:px-8">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) handleUploadStyleImage(f);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}}
|
||||
/>
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
autoFocus
|
||||
rows={6}
|
||||
placeholder={"描述你想要的画面风格,例如:\n梦幻水彩风格,柔和的色调,怀旧的氛围\n\n💡 提示:部分绘图模型对英文提示词效果更佳,建议先借助 AI 对话工具生成专业的英文风格描述,再粘贴到这里"}
|
||||
className="w-full flex-1 resize-y rounded-sm border border-clay-900/15 bg-cream-50 px-3 py-2.5 font-sans text-[13px] leading-relaxed text-clay-900 outline-none transition-colors focus:border-ember-500 placeholder:text-clay-400"
|
||||
/>
|
||||
{parseError && (
|
||||
<span className="font-sans text-[11px] text-rose-500">
|
||||
<i className="fa-solid fa-circle-exclamation mr-1" />
|
||||
{parseError}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{customStyleRefImage ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={customStyleRefImage}
|
||||
alt="画风参考图"
|
||||
className="h-8 w-8 shrink-0 rounded-sm border border-clay-900/10 object-cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={parsing}
|
||||
className="font-sans text-[11px] text-clay-500 hover:text-ember-500 transition-colors disabled:opacity-50"
|
||||
>
|
||||
换一张
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeStyleRefImage()}
|
||||
className="font-sans text-[11px] text-clay-400 hover:text-clay-900 transition-colors"
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={parsing}
|
||||
className={
|
||||
"flex h-12 w-12 shrink-0 items-center justify-center rounded-sm border text-base " +
|
||||
(isCustom
|
||||
? "border-ember-500/40 bg-ember-500/10 text-ember-500"
|
||||
: "border-clay-900/10 bg-cream-100 text-clay-400")
|
||||
"flex items-center gap-1.5 rounded-sm border px-3 py-1.5 font-sans text-[12px] transition-colors " +
|
||||
(parsing
|
||||
? "border-clay-900/15 text-clay-400 cursor-wait"
|
||||
: "border-clay-900/15 text-clay-700 hover:border-ember-500 hover:text-ember-500")
|
||||
}
|
||||
>
|
||||
<i
|
||||
className={
|
||||
isCustom ? "fa-solid fa-pen-to-square" : "fa-regular fa-image"
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
{/* 标题(标题永远不可编辑) */}
|
||||
<span
|
||||
className={
|
||||
"font-serif text-base md:text-lg leading-snug flex items-center gap-2 " +
|
||||
(selected || isEditing ? "text-ember-500" : "text-clay-900")
|
||||
}
|
||||
>
|
||||
{isCustom ? "自定义 prompt" : title}
|
||||
{hasOverride && !isEditing && (
|
||||
<span
|
||||
className="rounded-sm border border-ember-500/40 bg-ember-500/10 px-1.5 py-0.5 font-sans text-[10px] tracking-wide text-ember-500"
|
||||
title="你修改过这条 prompt(仅本次会话生效,默认 prompt 不变)"
|
||||
>
|
||||
已改
|
||||
</span>
|
||||
)}
|
||||
{isCustom && customStyleRefImage && !isEditing && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-sm border border-ember-500/40 bg-ember-500/10 px-1.5 py-0.5 font-sans text-[10px] tracking-wide text-ember-500"
|
||||
title="参考图已附带——每一幕画师都会参考这张图"
|
||||
>
|
||||
<i className="fa-regular fa-image text-[9px]" />
|
||||
附参考图
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* 「自动」语义就是「让 AI 自己判断画风」,没有 prompt 可显示也无从编辑;
|
||||
标题下方直接放一句解释,不渲染空文本框 / 铅笔。 */}
|
||||
{name === "自动" ? (
|
||||
<span className="font-sans text-[12px] md:text-[13px] leading-relaxed text-clay-500 mt-1">
|
||||
由 AI 依据世界观自动选择合适画风(无需手动指定 prompt)
|
||||
</span>
|
||||
) : /* prompt 区域:非编辑态是看起来像文本框的只读容器;编辑态是真的 textarea */
|
||||
isEditing ? (
|
||||
<div className="mt-1.5 flex flex-col gap-2">
|
||||
{/* 自定义卡专属:上传画风参考图。上传后会:(1) 用 vision LLM
|
||||
解析成 prompt 覆盖到下方 textarea;(2) 图片本身随会话送到
|
||||
画师,每幕都作为 reference 锚定画风。 */}
|
||||
{isCustom && (
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) handleUploadStyleImage(f);
|
||||
// reset 让同一文件重选能再次触发 onChange
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}}
|
||||
/>
|
||||
{customStyleRefImage ? (
|
||||
<div className="flex items-center gap-3 rounded-sm border border-clay-900/12 bg-cream-100 px-3 py-2.5">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={customStyleRefImage}
|
||||
alt="画风参考图"
|
||||
className="h-14 w-14 shrink-0 rounded-sm border border-clay-900/10 object-cover"
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="font-sans text-[12px] text-clay-900">
|
||||
<i className="fa-solid fa-check mr-1.5 text-ember-500" />
|
||||
参考图已上传
|
||||
</span>
|
||||
<span className="font-sans text-[11px] leading-snug text-clay-500">
|
||||
AI 已解析为下方 prompt;每一幕画师都会参考这张图
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
fileInputRef.current?.click();
|
||||
}}
|
||||
disabled={parsing}
|
||||
className="font-sans text-[11px] text-clay-500 hover:text-ember-500 transition-colors disabled:opacity-50"
|
||||
>
|
||||
换一张
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeStyleRefImage();
|
||||
}}
|
||||
className="font-sans text-[11px] text-clay-400 hover:text-clay-900 transition-colors"
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
fileInputRef.current?.click();
|
||||
}}
|
||||
disabled={parsing}
|
||||
className={
|
||||
"flex items-center justify-center gap-2 rounded-sm border border-dashed px-3 py-2.5 font-sans text-[12px] transition-colors " +
|
||||
(parsing
|
||||
? "border-clay-900/15 bg-cream-100 text-clay-400 cursor-wait"
|
||||
: "border-clay-900/25 text-clay-700 hover:border-ember-500 hover:bg-ember-500/5 hover:text-ember-500")
|
||||
}
|
||||
>
|
||||
{parsing ? (
|
||||
<>
|
||||
<i className="fa-solid fa-circle-notch fa-spin text-[11px]" />
|
||||
AI 正在解析参考图…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<i className="fa-regular fa-image text-[13px]" />
|
||||
上传画风参考图(可选)· AI 自动解析为 prompt
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{parseError && (
|
||||
<span className="font-sans text-[11px] text-rose-500">
|
||||
<i className="fa-solid fa-circle-exclamation mr-1" />
|
||||
{parseError}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
autoFocus
|
||||
rows={6}
|
||||
placeholder={
|
||||
isCustom
|
||||
? "示例:\nA dreamy watercolor illustration, soft pastel washes, gentle line art, nostalgic atmosphere."
|
||||
: ""
|
||||
}
|
||||
className="w-full resize-y rounded-sm border border-ember-500 bg-cream-50 px-3 py-2.5 font-sans text-[13px] leading-relaxed text-clay-900 outline-none placeholder:text-clay-400"
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{!isCustom && styleOverrides[name] ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
resetOverride(name);
|
||||
}}
|
||||
className="font-sans text-xs text-clay-500 hover:text-ember-500 transition-colors"
|
||||
>
|
||||
<i className="fa-solid fa-rotate-left mr-1.5" />
|
||||
还原默认 prompt
|
||||
</button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
cancelEditing();
|
||||
}}
|
||||
className="px-3 py-1.5 font-sans text-xs text-clay-500 hover:text-clay-900 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!draft.trim()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
saveEditing();
|
||||
}}
|
||||
className={
|
||||
"rounded-sm px-4 py-1.5 font-sans text-xs text-cream-50 transition-colors " +
|
||||
(draft.trim()
|
||||
? "bg-clay-900 hover:bg-ember-500"
|
||||
: "bg-clay-300 cursor-not-allowed")
|
||||
}
|
||||
>
|
||||
保存并选用
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{parsing ? (
|
||||
<>
|
||||
<i className="fa-solid fa-circle-notch fa-spin text-[11px]" />
|
||||
解析中…
|
||||
</>
|
||||
) : (
|
||||
/* 只读 prompt 行——无边框、纯文字,铅笔靠 padding-right 留位 */
|
||||
<div className="mt-1 relative">
|
||||
<div
|
||||
className={
|
||||
"pr-8 font-sans text-[12px] md:text-[13px] leading-relaxed line-clamp-2 " +
|
||||
(isCustom && !customStyleGuide
|
||||
? "italic text-clay-400"
|
||||
: "text-clay-500")
|
||||
}
|
||||
>
|
||||
{isCustom
|
||||
? customStyleGuide || "点击此卡片或铅笔编辑你自己的画风 prompt"
|
||||
: prompt || "(这个风格没有默认 prompt——点 ✎ 添加)"}
|
||||
</div>
|
||||
{editable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startEditing(
|
||||
i,
|
||||
isCustom
|
||||
? customStyleGuide
|
||||
: styleOverrides[name] ?? STYLE_MAP[name] ?? "",
|
||||
);
|
||||
}}
|
||||
title={
|
||||
hasOverride
|
||||
? "再次编辑此 prompt"
|
||||
: "在此 prompt 基础上修改(默认 prompt 不会被覆盖)"
|
||||
}
|
||||
aria-label="编辑此风格 prompt"
|
||||
className={
|
||||
"absolute right-0 top-0 flex h-5 w-5 items-center justify-center rounded-sm text-[11px] transition-colors " +
|
||||
(hasOverride
|
||||
? "text-ember-500 hover:bg-ember-500/10"
|
||||
: "text-clay-400 hover:bg-cream-100 hover:text-clay-700")
|
||||
}
|
||||
>
|
||||
<i className="fa-solid fa-pencil" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<>
|
||||
<i className="fa-regular fa-image text-[11px]" />
|
||||
上传参考图
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{list.length === 0 && (
|
||||
<div className="py-12 text-center font-serif text-sm text-clay-400">
|
||||
没有匹配的风格
|
||||
</button>
|
||||
)}
|
||||
<select
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (v && STYLE_MAP[v]) setDraft(STYLE_MAP[v]);
|
||||
}}
|
||||
className="h-8 w-44 rounded-sm border border-clay-900/15 bg-cream-50 px-2 font-sans text-[12px] text-clay-700 outline-none transition-colors focus:border-ember-500"
|
||||
>
|
||||
<option value="">从预设风格导入…</option>
|
||||
{Object.keys(STYLE_MAP).map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView("grid")}
|
||||
className="rounded-sm border border-clay-900/15 px-4 py-1.5 font-sans text-xs text-clay-700 hover:border-clay-900/30 hover:text-clay-900 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!draft.trim()}
|
||||
onClick={saveCustom}
|
||||
className={
|
||||
"rounded-sm px-4 py-1.5 font-sans text-xs transition-colors " +
|
||||
(draft.trim()
|
||||
? "bg-clay-900 text-cream-50 hover:bg-ember-500"
|
||||
: "bg-clay-900/20 text-clay-500 cursor-not-allowed")
|
||||
}
|
||||
>
|
||||
保存并选用
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</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 }) => {
|
||||
const isCustom = name === "自定义风格";
|
||||
const thumb = STYLE_THUMB[name];
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
if (isCustom) {
|
||||
openCustomView(customStyleGuide);
|
||||
return;
|
||||
}
|
||||
onPick(i);
|
||||
close();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
if (isCustom) { openCustomView(customStyleGuide); return; }
|
||||
onPick(i);
|
||||
close();
|
||||
}
|
||||
}}
|
||||
className={
|
||||
"group cursor-pointer rounded-sm border transition-all outline-none focus-visible:ring-2 focus-visible:ring-ember-500 " +
|
||||
(i === value
|
||||
? "border-ember-500 ring-2 ring-ember-500"
|
||||
: "border-clay-900/12 hover:border-ember-500/50 hover:ring-2 hover:ring-ember-500/25")
|
||||
}
|
||||
>
|
||||
<div className="relative w-full overflow-hidden" style={{ paddingBottom: "100%" }}>
|
||||
{thumb ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img src={thumb} alt={name} loading="lazy" className="absolute inset-0 h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-cream-100" />
|
||||
)}
|
||||
</div>
|
||||
<span className={"block px-2 py-2 text-center font-serif text-sm " + (i === value ? "text-ember-500" : "text-clay-700")}>
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{list.length === 0 && (
|
||||
<div className="col-span-full py-12 text-center font-serif text-sm text-clay-400">
|
||||
没有匹配的风格
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1372,16 +1232,7 @@ export default function HomePage() {
|
||||
const [open, setOpen] = useState<number>(-1);
|
||||
const [styleOpen, setStyleOpen] = useState(false);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
// 用户在「自定义」入口里填的 styleGuide 文本(中/英文都行,原样喂给 LLM)。
|
||||
// 仅在内存里持有——刷新即丢,符合「这就是一次性试玩」的语义。
|
||||
const [customStyleGuide, setCustomStyleGuide] = useState("");
|
||||
// 用户对某个预设的 prompt 改写——只覆盖该用户本次会话,绝不污染 STYLE_MAP
|
||||
// 这个 source-of-truth。键是预设名(如 "京阿尼细腻日常"),值是 override prompt。
|
||||
// 选中该预设 + 有 override → 把 override 当 styleGuide 喂给画师。
|
||||
const [styleOverrides, setStyleOverrides] = useState<Record<string, string>>({});
|
||||
// 用户在「自定义」里上传的参考图(已客户端缩到 512px、webp base64)。
|
||||
// 同时随 sessionStorage 透传到 /play → /api/start → session → painter,
|
||||
// 每一幕的 painter 都会把它作为 reference slot 0,锚定整局画风。
|
||||
const [customStyleRefImage, setCustomStyleRefImage] = useState<string>("");
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
@@ -1486,24 +1337,19 @@ export default function HomePage() {
|
||||
]
|
||||
).join("\n");
|
||||
|
||||
// 「自动」→ fall back to Galgame CG (project default). Plain prompts like
|
||||
// "由模型自动判断画风" are not understood by FLUX — it just paints them
|
||||
// literally, so we'd rather lock in a sensible default.
|
||||
// 「自定义」→ 用用户在弹窗里填的原始 styleGuide,原样喂给 LLM;空内容时
|
||||
// 「自动」→ pass "auto" to the server; the engine will run a parallel
|
||||
// LLM call to pick the best style based on the story prompt.
|
||||
// 「自定义风格」→ 用用户在弹窗里填的原始 styleGuide,原样喂给 LLM;空内容时
|
||||
// 退化到默认(避免传入空字符串导致 /api/start 报缺字段)。
|
||||
// TODO(自动路由): 后续实现真正的「自动」——由模型依据世界观 / 玩家 prompt
|
||||
// 选出最合适的画风,再映射到对应风格提示词,而非固定回退到 Galgame。届时
|
||||
// 同步更新风格弹窗副标题(「由模型根据 prompt 判断风格」)使文案与行为一致。
|
||||
const DEFAULT_STYLE = "Galgame CG";
|
||||
const DEFAULT_STYLE = "吉卜力";
|
||||
let styleGuide: string;
|
||||
if (artStyle === "自定义" && customStyleGuide.trim()) {
|
||||
if (artStyle === "自动") {
|
||||
styleGuide = "auto";
|
||||
} else if (artStyle === "自定义风格" && customStyleGuide.trim()) {
|
||||
styleGuide = customStyleGuide.trim();
|
||||
} else if (styleOverrides[artStyle]?.trim()) {
|
||||
// 用户对该预设做过 prompt 修改——优先用 override,不污染 STYLE_MAP。
|
||||
styleGuide = styleOverrides[artStyle]!.trim();
|
||||
} else {
|
||||
const effectiveStyle =
|
||||
artStyle === "自动" || artStyle === "自定义" ? DEFAULT_STYLE : artStyle;
|
||||
artStyle === "自定义风格" ? DEFAULT_STYLE : artStyle;
|
||||
styleGuide = STYLE_MAP[effectiveStyle] ?? STYLE_MAP[DEFAULT_STYLE]!;
|
||||
}
|
||||
const audioEnabled = voice === "开启";
|
||||
@@ -1512,7 +1358,7 @@ export default function HomePage() {
|
||||
// 占用 reference slot(也避免 styleGuide 已经是文本预设、画师收到不相关
|
||||
// 参考图反而产生干扰)。
|
||||
const styleReferenceImage =
|
||||
artStyle === "自定义" && customStyleRefImage ? customStyleRefImage : undefined;
|
||||
artStyle === "自定义风格" && customStyleRefImage ? customStyleRefImage : undefined;
|
||||
|
||||
track("game_start", {
|
||||
source: "prompt",
|
||||
@@ -1857,8 +1703,6 @@ export default function HomePage() {
|
||||
onClose={() => setStyleOpen(false)}
|
||||
customStyleGuide={customStyleGuide}
|
||||
setCustomStyleGuide={setCustomStyleGuide}
|
||||
styleOverrides={styleOverrides}
|
||||
setStyleOverrides={setStyleOverrides}
|
||||
customStyleRefImage={customStyleRefImage}
|
||||
setCustomStyleRefImage={setCustomStyleRefImage}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { chat } from "@infiplot/ai-client";
|
||||
import type { ProviderConfig } from "@infiplot/types";
|
||||
import { STYLE_MAP } from "@/lib/options";
|
||||
|
||||
const STYLE_NAMES = Object.keys(STYLE_MAP);
|
||||
|
||||
const SYSTEM = `You are an art director for a visual novel. Given the story premise, pick the single best-matching art style from the list below. Consider the genre, mood, setting, and target audience.
|
||||
|
||||
Available styles:
|
||||
${STYLE_NAMES.map((s) => `- ${s}`).join("\n")}
|
||||
|
||||
Reply with ONLY the style name, nothing else. If uncertain, default to 吉卜力.`;
|
||||
|
||||
export async function selectStyle(
|
||||
textConfig: ProviderConfig,
|
||||
worldSetting: string,
|
||||
): Promise<string> {
|
||||
const result = await chat(
|
||||
textConfig,
|
||||
[
|
||||
{ role: "system", content: SYSTEM },
|
||||
{ role: "user", content: worldSetting },
|
||||
],
|
||||
{ temperature: 0, tag: "styleSelector" },
|
||||
);
|
||||
|
||||
const picked = result.trim();
|
||||
if (STYLE_MAP[picked]) {
|
||||
return STYLE_MAP[picked];
|
||||
}
|
||||
const fuzzy = STYLE_NAMES.find((s) => picked.includes(s) || s.includes(picked));
|
||||
if (fuzzy) {
|
||||
return STYLE_MAP[fuzzy]!;
|
||||
}
|
||||
console.warn(`[styleSelector] unrecognized style "${picked}", falling back to 吉卜力`);
|
||||
return STYLE_MAP["吉卜力"]!;
|
||||
}
|
||||
@@ -14,7 +14,9 @@ import type {
|
||||
} from "@infiplot/types";
|
||||
import { coerceOrientation } from "@infiplot/types";
|
||||
import { runArchitect } from "./agents/architect";
|
||||
import { selectStyle } from "./agents/styleSelector";
|
||||
import { directInsertBeat, directScene } from "./director";
|
||||
import { STYLE_MAP } from "@/lib/options";
|
||||
import { synthesizeBeat } from "./voice";
|
||||
import { interpret } from "./vision";
|
||||
|
||||
@@ -52,16 +54,31 @@ export async function startSession(
|
||||
orientation: coerceOrientation(req.orientation),
|
||||
};
|
||||
|
||||
// Stage 0 — Architect: expand the terse world/style prompt into a story
|
||||
// bible BEFORE the first scene. Serial by necessity (the opening Writer
|
||||
// reads session.storyState), but it gives the whole story a spine from beat
|
||||
// one — the latency is offset by the director's portrait/voice overlap win.
|
||||
// Stage 0 — Architect (+ optional auto style selection, in parallel).
|
||||
// Both only depend on worldSetting, so they run concurrently.
|
||||
console.log(
|
||||
`[start] worldSetting (${session.worldSetting.length} chars):\n${session.worldSetting}`,
|
||||
);
|
||||
const isAutoStyle = session.styleGuide === "auto";
|
||||
if (isAutoStyle) {
|
||||
session.styleGuide = "由 AI 根据剧情自动匹配最佳画风";
|
||||
}
|
||||
const tArchitect = Date.now();
|
||||
session.storyState = await runArchitect(config.text, session);
|
||||
tlog("[start] Architect", tArchitect);
|
||||
const [architectResult, autoStyleGuide] = await Promise.all([
|
||||
runArchitect(config.text, session),
|
||||
isAutoStyle
|
||||
? selectStyle(config.text, session.worldSetting).catch((err) => {
|
||||
console.warn(`[styleSelector] failed, falling back to 吉卜力:`, err);
|
||||
return null;
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
session.storyState = architectResult;
|
||||
if (isAutoStyle) {
|
||||
session.styleGuide = autoStyleGuide ?? STYLE_MAP["吉卜力"]!;
|
||||
console.log(`[start] auto-selected style: ${session.styleGuide.slice(0, 60)}…`);
|
||||
}
|
||||
tlog("[start] Architect" + (isAutoStyle ? " + StyleSelector" : ""), tArchitect);
|
||||
console.log(
|
||||
`[start] storyBible: logline="${session.storyState.logline}" | genreTags="${session.storyState.genreTags}" | synopsis="${session.storyState.synopsis}"`,
|
||||
);
|
||||
|
||||
@@ -9,22 +9,27 @@ export const GENDERS = ["男性向", "女性向"] as const;
|
||||
|
||||
export const ART_STYLES = [
|
||||
"自动",
|
||||
"自定义",
|
||||
"京阿尼细腻日常",
|
||||
"新海诚唯美光影",
|
||||
"Galgame CG",
|
||||
"3D 动漫电影",
|
||||
"自定义风格",
|
||||
"京阿尼",
|
||||
"新海诚",
|
||||
"吉卜力",
|
||||
"3D 动画",
|
||||
"真实",
|
||||
"赛博朋克",
|
||||
"蒸汽波",
|
||||
"吉卜力治愈手绘",
|
||||
"哥特庄园",
|
||||
"废土科幻",
|
||||
// 以下为小众/区域性画风,留作长尾选项
|
||||
"古典厚涂油画",
|
||||
"极简中国水墨",
|
||||
"浮世绘木刻",
|
||||
"莫高窟壁画",
|
||||
"波斯细密画",
|
||||
"哥特",
|
||||
"废土",
|
||||
"像素风",
|
||||
"古典油画",
|
||||
"莫奈",
|
||||
"水彩",
|
||||
"水墨",
|
||||
"浮世绘",
|
||||
"彩铅",
|
||||
"手绘素描",
|
||||
"黑白漫画",
|
||||
"儿童绘本",
|
||||
"儿童涂鸦",
|
||||
"黏土手工",
|
||||
] as const;
|
||||
|
||||
export const PLOT_STYLES = ["平铺直叙", "多线转折", "悬疑烧脑", "治愈日常"] as const;
|
||||
@@ -35,3 +40,26 @@ export type Gender = (typeof GENDERS)[number];
|
||||
export type ArtStyle = (typeof ART_STYLES)[number];
|
||||
export type PlotStyle = (typeof PLOT_STYLES)[number];
|
||||
export type Pacing = (typeof PACINGS)[number];
|
||||
|
||||
export const STYLE_MAP: Record<string, string> = {
|
||||
"京阿尼": "Kyoto Animation anime style inspired by Beyond the Boundary and Sound Euphonium, precise thin line art with uniform weight, meticulous real-world architectural backgrounds with photographic accuracy, warm golden-hour lighting with soft bokeh and lens diffusion, iridescent color accents and crystalline light effects, delicate translucent gradients on hair and eyes, emotionally nuanced character expressions with subtle micro-expressions, rich ambient occlusion in indoor scenes.",
|
||||
"新海诚": "Makoto Shinkai anime style, ultra-detailed photorealistic backgrounds with simplified anime characters, dramatic crepuscular rays and lens flare, vivid saturated sky gradients from deep blue to golden amber, volumetric cloud rendering, wet surface reflections, anamorphic bokeh highlights, cinematic widescreen composition.",
|
||||
"吉卜力": "Studio Ghibli anime style inspired by Spirited Away and Howl's Moving Castle, hand-painted background art with lush visible brushstrokes, expansive skies with billowing cumulus clouds, warm earthy palette of moss green, ochre, and terracotta, gentle rounded character forms with expressive eyes, richly detailed natural environments with swaying grass and dappled light, a sense of magical wonder woven into everyday life.",
|
||||
"3D 动画": "Cinematic 3D animated film style, Pixar-quality rendering with subsurface scattering on skin, volumetric god rays through atmospheric particles, physically-based material shading, warm filmic color grading, shallow depth of field with soft bokeh, expressive stylized character proportions.",
|
||||
"真实": "Photorealistic cinematic style, natural lighting with soft directional key light, shallow depth of field with anamorphic bokeh, fine film grain texture, lifelike skin with pore-level detail and subsurface scattering, physically-based material rendering, subtle teal-and-orange color grading, 35mm lens perspective.",
|
||||
"赛博朋克": "Cyberpunk anime illustration, neon-soaked urban nightscape, dominant palette of electric cyan, hot magenta, and deep indigo, hard-edged cel shading with sharp specular highlights, holographic signage reflections on wet asphalt, dense atmospheric haze with volumetric neon glow, high contrast between deep shadows and vivid accent lighting.",
|
||||
"哥特": "Gothic romance illustration, dramatic Baroque chiaroscuro with deep shadow pools, cold moonlit rim lighting, muted palette of desaturated indigo, ash grey, and bone white, misty atmospheric perspective, ornate filigree and pointed-arch architectural details, melancholic and hauntingly beautiful mood.",
|
||||
"废土": "Post-apocalyptic landscape illustration, weathered rough textures with rust, corrosion, and cracked concrete, muted dusty palette of burnt sienna, olive drab, and ash grey, hazy amber god-ray lighting through particulate atmosphere, overgrown vegetation reclaiming ruins, desolate yet strangely serene atmosphere.",
|
||||
"像素风": "Pixel art illustration, crisp aliased edges with no anti-aliasing, limited 32-color palette with dithering for gradients, 16-bit era SNES aesthetic, clean tile-based composition, small carefully-placed specular highlights, retro video game atmosphere with warm CRT color warmth.",
|
||||
"古典油画": "Classical oil painting in the academic tradition, rich impasto brushwork with visible palette-knife texture, dramatic Rembrandt lighting with warm chiaroscuro, sfumato blending at subject edges, Renaissance triangular composition, deep glaze layers producing luminous amber and umber tones, museum-quality varnished finish.",
|
||||
"莫奈": "Impressionist painting in the style of Claude Monet, broken-color technique with visible dab brushstrokes, vibrant dappled sunlight filtering through foliage, complementary color shadows of lavender and cobalt, soft atmospheric perspective, plein-air natural palette of cerulean, viridian, and cadmium yellow, shimmering water reflections.",
|
||||
"水彩": "Watercolor illustration on cold-pressed paper, wet-on-wet washes with soft pigment bleeding at edges, visible paper grain texture through translucent layers, granulation in cerulean and burnt sienna passages, intentional white paper reserves as highlights, gentle pastel tones with occasional saturated accents, dreamy luminous atmosphere.",
|
||||
"水墨": "Traditional Chinese ink wash painting, expressive calligraphic brushstrokes with flying-white dry-brush texture (feibai), bold ink splashes contrasted with delicate fine-line detail, monochrome sumi ink with subtle indigo washes, expansive negative space evoking mist and void, sparse poetic composition following the principle of leave-blank (liu bai).",
|
||||
"浮世绘": "Ukiyo-e Japanese woodblock print style, bold sumi-ink outlines with variable line weight, flat color areas with subtle wood-grain texture from printing, limited palette of indigo, vermilion, and ochre with key-block black, bokashi gradient shading technique, washi paper texture, elegant compositional asymmetry.",
|
||||
"彩铅": "Colored pencil illustration on toned paper, fine directional hatching and cross-hatching strokes with visible pencil grain, burnished blending in highlight areas, warm cream paper tone showing through, soft layered color build-up from light to dark, delicate hand-drawn warmth with slight imperfections.",
|
||||
"手绘素描": "Hand-drawn graphite pencil sketch, varied pressure producing light construction lines to deep tonal shading, visible eraser marks and smudge blending, off-white sketchbook paper texture, loose gestural composition with intentionally unfinished edges, raw artistic immediacy.",
|
||||
"黑白漫画": "Black and white Japanese manga illustration, bold variable-weight ink outlines, extreme high-contrast with dense hatching and cross-hatching for tonal shading, screentone dot patterns for mid-tones, dramatic speed lines for motion, cinematic dynamic angles, stark chiaroscuro with no color gradients.",
|
||||
"儿童绘本": "Children's picture book illustration, soft rounded shapes with friendly proportions, bright warm gouache-like palette of primary colors, clean even-weight outline art, simple readable compositions with clear focal points, whimsical cheerful atmosphere with gentle humor, inviting and safe visual tone.",
|
||||
"儿童涂鸦": "Child's crayon and marker drawing style, naive unsteady strokes with wax-crayon texture, bold unmixed primary and secondary colors, cheerfully wrong perspective and scale, figures and objects floating freely on the page, scribbled sky and ground bands, playful uninhibited composition radiating pure joy.",
|
||||
"黏土手工": "Claymation stop-motion animation style, soft rounded sculpted forms with visible fingerprint impressions and slight hand-sculpted imperfections, matte polymer clay texture with subtle surface grain, warm diffused three-point lighting on miniature set, tilt-shift shallow depth of field, charming handmade craft atmosphere.",
|
||||
};
|
||||
|
||||
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 96 KiB |
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Generate style thumbnail images via Runware API.
|
||||
* Uses DeepSeek to pick the best-fit scene for each art style.
|
||||
* Usage: bun run scripts/gen-style-thumbs.ts
|
||||
*/
|
||||
|
||||
const RUNWARE_URL = "https://api.runware.ai/v1";
|
||||
const RUNWARE_KEY = process.env.IMAGE_API_KEY;
|
||||
const LLM_URL = process.env.TEXT_BASE_URL;
|
||||
const LLM_KEY = process.env.TEXT_API_KEY;
|
||||
const LLM_MODEL = process.env.TEXT_MODEL;
|
||||
|
||||
if (!RUNWARE_KEY || !LLM_URL || !LLM_KEY || !LLM_MODEL) {
|
||||
console.error("Missing env vars. Source .env.local first.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const STYLE_MAP: Record<string, string> = {
|
||||
"京阿尼": "Kyoto Animation anime style inspired by Beyond the Boundary and Sound Euphonium, precise thin line art with uniform weight, meticulous real-world architectural backgrounds with photographic accuracy, warm golden-hour lighting with soft bokeh and lens diffusion, iridescent color accents and crystalline light effects, delicate translucent gradients on hair and eyes, emotionally nuanced character expressions with subtle micro-expressions, rich ambient occlusion in indoor scenes.",
|
||||
"新海诚": "Makoto Shinkai anime style, ultra-detailed photorealistic backgrounds with simplified anime characters, dramatic crepuscular rays and lens flare, vivid saturated sky gradients from deep blue to golden amber, volumetric cloud rendering, wet surface reflections, anamorphic bokeh highlights, cinematic widescreen composition.",
|
||||
"吉卜力": "Studio Ghibli anime style inspired by Spirited Away and Howl's Moving Castle, hand-painted background art with lush visible brushstrokes, expansive skies with billowing cumulus clouds, warm earthy palette of moss green, ochre, and terracotta, gentle rounded character forms with expressive eyes, richly detailed natural environments with swaying grass and dappled light, a sense of magical wonder woven into everyday life.",
|
||||
"3D 动画": "Cinematic 3D animated film style, Pixar-quality rendering with subsurface scattering on skin, volumetric god rays through atmospheric particles, physically-based material shading, warm filmic color grading, shallow depth of field with soft bokeh, expressive stylized character proportions.",
|
||||
"真实": "Photorealistic cinematic style, natural lighting with soft directional key light, shallow depth of field with anamorphic bokeh, fine film grain texture, lifelike skin with pore-level detail and subsurface scattering, physically-based material rendering, subtle teal-and-orange color grading, 35mm lens perspective.",
|
||||
"赛博朋克": "Cyberpunk anime illustration, neon-soaked urban nightscape, dominant palette of electric cyan, hot magenta, and deep indigo, hard-edged cel shading with sharp specular highlights, holographic signage reflections on wet asphalt, dense atmospheric haze with volumetric neon glow, high contrast between deep shadows and vivid accent lighting.",
|
||||
"哥特": "Gothic romance illustration, dramatic Baroque chiaroscuro with deep shadow pools, cold moonlit rim lighting, muted palette of desaturated indigo, ash grey, and bone white, misty atmospheric perspective, ornate filigree and pointed-arch architectural details, melancholic and hauntingly beautiful mood.",
|
||||
"废土": "Post-apocalyptic landscape illustration, weathered rough textures with rust, corrosion, and cracked concrete, muted dusty palette of burnt sienna, olive drab, and ash grey, hazy amber god-ray lighting through particulate atmosphere, overgrown vegetation reclaiming ruins, desolate yet strangely serene atmosphere.",
|
||||
"像素风": "Pixel art illustration, crisp aliased edges with no anti-aliasing, limited 32-color palette with dithering for gradients, 16-bit era SNES aesthetic, clean tile-based composition, small carefully-placed specular highlights, retro video game atmosphere with warm CRT color warmth.",
|
||||
"古典油画": "Classical oil painting in the academic tradition, rich impasto brushwork with visible palette-knife texture, dramatic Rembrandt lighting with warm chiaroscuro, sfumato blending at subject edges, Renaissance triangular composition, deep glaze layers producing luminous amber and umber tones, museum-quality varnished finish.",
|
||||
"莫奈": "Impressionist painting in the style of Claude Monet, broken-color technique with visible dab brushstrokes, vibrant dappled sunlight filtering through foliage, complementary color shadows of lavender and cobalt, soft atmospheric perspective, plein-air natural palette of cerulean, viridian, and cadmium yellow, shimmering water reflections.",
|
||||
"水彩": "Watercolor illustration on cold-pressed paper, wet-on-wet washes with soft pigment bleeding at edges, visible paper grain texture through translucent layers, granulation in cerulean and burnt sienna passages, intentional white paper reserves as highlights, gentle pastel tones with occasional saturated accents, dreamy luminous atmosphere.",
|
||||
"水墨": "Traditional Chinese ink wash painting, expressive calligraphic brushstrokes with flying-white dry-brush texture (feibai), bold ink splashes contrasted with delicate fine-line detail, monochrome sumi ink with subtle indigo washes, expansive negative space evoking mist and void, sparse poetic composition following the principle of leave-blank (liu bai).",
|
||||
"浮世绘": "Ukiyo-e Japanese woodblock print style, bold sumi-ink outlines with variable line weight, flat color areas with subtle wood-grain texture from printing, limited palette of indigo, vermilion, and ochre with key-block black, bokashi gradient shading technique, washi paper texture, elegant compositional asymmetry.",
|
||||
"彩铅": "Colored pencil illustration on toned paper, fine directional hatching and cross-hatching strokes with visible pencil grain, burnished blending in highlight areas, warm cream paper tone showing through, soft layered color build-up from light to dark, delicate hand-drawn warmth with slight imperfections.",
|
||||
"手绘素描": "Hand-drawn graphite pencil sketch, varied pressure producing light construction lines to deep tonal shading, visible eraser marks and smudge blending, off-white sketchbook paper texture, loose gestural composition with intentionally unfinished edges, raw artistic immediacy.",
|
||||
"黑白漫画": "Black and white Japanese manga illustration, bold variable-weight ink outlines, extreme high-contrast with dense hatching and cross-hatching for tonal shading, screentone dot patterns for mid-tones, dramatic speed lines for motion, cinematic dynamic angles, stark chiaroscuro with no color gradients.",
|
||||
"儿童绘本": "Children's picture book illustration, soft rounded shapes with friendly proportions, bright warm gouache-like palette of primary colors, clean even-weight outline art, simple readable compositions with clear focal points, whimsical cheerful atmosphere with gentle humor, inviting and safe visual tone.",
|
||||
"儿童涂鸦": "Child's crayon and marker drawing style, naive unsteady strokes with wax-crayon texture, bold unmixed primary and secondary colors, cheerfully wrong perspective and scale, figures and objects floating freely on the page, scribbled sky and ground bands, playful uninhibited composition radiating pure joy.",
|
||||
"黏土手工": "Claymation stop-motion animation style, soft rounded sculpted forms with visible fingerprint impressions and slight hand-sculpted imperfections, matte polymer clay texture with subtle surface grain, warm diffused three-point lighting on miniature set, tilt-shift shallow depth of field, charming handmade craft atmosphere.",
|
||||
};
|
||||
|
||||
const FILE_MAP: Record<string, string> = {
|
||||
"京阿尼": "kyoani", "新海诚": "shinkai", "吉卜力": "ghibli",
|
||||
"3D 动画": "3d", "真实": "real", "赛博朋克": "cyberpunk",
|
||||
"哥特": "gothic", "废土": "wasteland", "像素风": "pixel",
|
||||
"古典油画": "oil", "莫奈": "monet", "水彩": "watercolor",
|
||||
"水墨": "ink", "浮世绘": "ukiyoe", "彩铅": "pencil",
|
||||
"手绘素描": "sketch", "黑白漫画": "manga", "儿童绘本": "children",
|
||||
"儿童涂鸦": "crayon", "黏土手工": "clay",
|
||||
};
|
||||
|
||||
const OUT_DIR = `${import.meta.dir}/../public/home/styles`;
|
||||
|
||||
async function generateScene(styleName: string, stylePrompt: string): Promise<string> {
|
||||
const res = await fetch(`${LLM_URL}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${LLM_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: LLM_MODEL,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `You are an expert art director for InfiPlot, an AI-powered interactive fiction platform where users create and play through visual novel stories. Each story has illustrated scenes with characters in dramatic, emotional, or atmospheric moments.
|
||||
|
||||
Given an art style description, output a single short scene description (1-2 sentences, in English) that would best showcase this style AS A VISUAL NOVEL SCENE. The scene should:
|
||||
- Feature 1-2 characters in a story moment (conversation, contemplation, action, emotional beat)
|
||||
- Feel like a frame from a narrative — not a still life, pure landscape, or portrait
|
||||
- Pick a setting, mood, and lighting that let the style's unique strengths shine
|
||||
- Be visually striking at 512x512 thumbnail size
|
||||
|
||||
Output ONLY the scene description, nothing else.`,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: `Art style name: ${styleName}\nStyle prompt: ${stylePrompt}\n\nWhat scene would best showcase this art style in a 512x512 thumbnail?`,
|
||||
},
|
||||
],
|
||||
max_tokens: 512,
|
||||
temperature: 0.7,
|
||||
}),
|
||||
});
|
||||
|
||||
const json = (await res.json()) as any;
|
||||
const scene = json.choices?.[0]?.message?.content?.trim();
|
||||
if (!scene) throw new Error(`LLM returned no scene for ${styleName}: ${JSON.stringify(json)}`);
|
||||
return scene;
|
||||
}
|
||||
|
||||
async function generateImage(name: string, fullPrompt: string): Promise<void> {
|
||||
const slug = FILE_MAP[name];
|
||||
if (!slug) throw new Error(`No file mapping for "${name}"`);
|
||||
|
||||
const task = {
|
||||
taskType: "imageInference",
|
||||
taskUUID: crypto.randomUUID(),
|
||||
model: "runware:400@6",
|
||||
positivePrompt: fullPrompt,
|
||||
width: 512,
|
||||
height: 512,
|
||||
steps: 4,
|
||||
CFGScale: 3.5,
|
||||
numberResults: 1,
|
||||
outputType: "URL",
|
||||
outputFormat: "WEBP",
|
||||
includeCost: true,
|
||||
};
|
||||
|
||||
const res = await fetch(RUNWARE_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${RUNWARE_KEY}`,
|
||||
},
|
||||
body: JSON.stringify([task]),
|
||||
});
|
||||
|
||||
const json = (await res.json()) as any;
|
||||
if (json.errors?.length) {
|
||||
throw new Error(`Runware error for ${name}: ${JSON.stringify(json.errors)}`);
|
||||
}
|
||||
|
||||
const imageUrl = json.data?.[0]?.imageURL;
|
||||
if (!imageUrl) throw new Error(`No image URL for ${name}: ${JSON.stringify(json)}`);
|
||||
|
||||
const imgRes = await fetch(imageUrl);
|
||||
const buf = await imgRes.arrayBuffer();
|
||||
const outPath = `${OUT_DIR}/${slug}.webp`;
|
||||
await Bun.write(outPath, buf);
|
||||
|
||||
const cost = json.data?.[0]?.cost ?? "?";
|
||||
console.log(` ✓ image saved → ${slug}.webp (${(buf.byteLength / 1024).toFixed(1)}KB, cost: ${cost})`);
|
||||
}
|
||||
|
||||
async function processStyle(name: string, stylePrompt: string): Promise<void> {
|
||||
console.log(`\n[${name}]`);
|
||||
|
||||
// Step 1: LLM picks the best scene
|
||||
const scene = await generateScene(name, stylePrompt);
|
||||
console.log(` scene: ${scene}`);
|
||||
|
||||
// Step 2: Combine style + scene → generate image
|
||||
const fullPrompt = `${stylePrompt} ${scene}`;
|
||||
await generateImage(name, fullPrompt);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const onlyArg = process.argv.find((a) => a.startsWith("--only="));
|
||||
const onlyNames = onlyArg ? onlyArg.replace("--only=", "").split(",") : null;
|
||||
|
||||
const entries = Object.entries(STYLE_MAP).filter(
|
||||
([name]) => !onlyNames || onlyNames.includes(name),
|
||||
);
|
||||
|
||||
if (entries.length === 0) {
|
||||
console.error("No matching styles found.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Generating ${entries.length} style thumbnails (LLM scene selection + Runware)...`);
|
||||
|
||||
const CONCURRENCY = 4;
|
||||
for (let i = 0; i < entries.length; i += CONCURRENCY) {
|
||||
const batch = entries.slice(i, i + CONCURRENCY);
|
||||
await Promise.all(batch.map(([name, prompt]) => processStyle(name, prompt)));
|
||||
}
|
||||
|
||||
console.log(`\n✅ Done! ${entries.length} thumbnails saved to ${OUT_DIR}/`);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -51,6 +51,7 @@
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
"node_modules",
|
||||
"scripts"
|
||||
]
|
||||
}
|
||||
|
||||