diff --git a/app/page.tsx b/app/page.tsx index c357472..896e44a 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -53,22 +53,7 @@ const OPTS: Opt[] = [ type StoryContent = { title: string; outline: string; style: string; tags: string[] }; -const STYLE_MAP: Record = { - "古典厚涂油画": "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 = { { "title": "社团存亡日", "outline": "濒临废部的动画社,唯一社员是总在睡觉的怪人。新来的转校生社长发现,只要完成怪人的“日常委托”,社员就会增加一人,而这些人,都来自被遗忘的动画世界。", - "style": "京阿尼细腻日常 (Image 5参考)", + "style": "京阿尼 (Image 5参考)", "tags": [ "日常", "奇幻", @@ -167,7 +152,7 @@ const STORIES: Record = { { "title": "黄昏归途", "outline": "他总在黄昏时分,于空无一人的车站遇见少女。她带他穿越时间的缝隙,回到故乡被毁灭前的最后一天。每一次循环,他都必须在拯救她与拯救世界之间做出选择。", - "style": "新海诚唯美光影 (Image 2参考)", + "style": "新海诚 (Image 2参考)", "tags": [ "时间循环", "恋爱", @@ -459,7 +444,7 @@ const STORIES: Record = { { "title": "夏日未完待续", "outline": "她在文化祭前夜,与青梅竹马的学长在空教室许下约定。第二天醒来,时间永远停在了文化祭前一周。只有她保留记忆,为守护他的笑容,她一遍遍重演青春,试图改写那个令他心碎的结局。", - "style": "京阿尼细腻日常 (Image 5参考)", + "style": "京阿尼 (Image 5参考)", "tags": [ "时间循环", "青春", @@ -469,7 +454,7 @@ const STORIES: Record = { { "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; - setStyleOverrides: (o: Record) => 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(null); + const [view, setView] = useState<"grid" | "custom">("grid"); const [draft, setDraft] = useState(""); - // 上传 / 解析参考图的瞬时状态——失败/进行中提示只在此次弹窗内可见。 const [parsing, setParsing] = useState(false); const [parseError, setParseError] = useState(null); const fileInputRef = useRef(null); + const thumbV = "v4"; + const STYLE_THUMB: Record = { + "自动": `/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 => { const dataUrl = await new Promise((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 (
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") } >
-
- 选择绘画风格 - - 默认「自动」· 点 prompt 框旁的 ✎ 可在该风格基础上修改(默认 prompt 不会被覆盖) - -
-
- 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" - /> - -
+ {view === "custom" ? ( +
+ + 自定义风格 +
+ ) : ( + <> +
+ 选择绘画风格 + + 默认「自动」· 由 AI 根据故事自动匹配画风;选择「自定义风格」可输入描述或上传参考图 + +
+
+ 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" + /> + +
+ + )}
-
- {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 ( -
{ - // 编辑态下:让点击事件落在 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") - } - > - + { + const f = e.target.files?.[0]; + if (f) handleUploadStyleImage(f); + if (fileInputRef.current) fileInputRef.current.value = ""; + }} + /> +