Files
ttstd-infinite-canvas/web/src/components/canvas/canvas-style-picker-modal.tsx

716 lines
76 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Clock3, Copy, Eye, Palette, Pencil, Plus, Search, SlidersHorizontal, Star, Trash2, UserRound } from "lucide-react";
import { App, Button, Input } from "antd";
import { AppModal } from "@/components/ui/product/app-modal";
import { nanoid } from "nanoid";
import { StyleProfileEditorModal } from "@/components/canvas/style-profile-editor-modal";
import { canvasThemes } from "@/lib/canvas-theme";
import { createStyleProfileSnapshot, parseStyleProfile, serializeStyleProfile, type StyleProfileSnapshot } from "@/lib/canvas/style-profile";
import {
compileCanvasStylePreset,
customCanvasStylePreset,
recommendedCanvasStylePresets,
type CanvasStylePreset,
type ProjectStyleSelection,
} from "@/lib/canvas/canvas-style-system";
import { createStyleProfile, deleteStyleProfile, listStyleProfiles, setStyleProfileFavorite, touchStyleProfile, updateStyleProfile, type UserStyleProfile } from "@/services/api/style-profiles";
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
export type { CanvasStylePreset } from "@/lib/canvas/canvas-style-system";
// 分类按短剧制作语境组织:先看媒介,再看题材与视觉气质,避免用品牌名称代替画风。
const PROJECT_STYLE_SCOPE = "【使用边界】本规范是全项目美术与影像风格基线,用于统一角色资产、服装材质、建筑世界观、色彩语言和成片质感;它不是某张图片或某个镜头的提示词。具体场景内容、构图、景别、机位、运镜、动作、光源位置、天气和单场情绪由剧情与分镜节点决定,不得从本规范机械复制。";
const legacyCanvasStylePresets: CanvasStylePreset[] = [
{
id: "urban-live-action",
title: "都市真人短剧",
category: "真人实拍",
description: "中性城市色调、真实东亚演员与生活化服化道;统一自然光感、城市材质和克制表演,服务职场与情感题材。",
tags: ["职场", "情感", "现实生活"],
prompt: [
"【项目定位】当代中国都市真人短剧的写实轻电影风格,视觉核心是真实人物、可信生活空间和克制的精致感;全片保持统一的自然肤色、真实材质与现代城市气质,不做影楼写真、广告大片或网红滤镜。",
"【项目色彩系统】全项目色板使用权重约为 60% 中性白、雾灰、浅木和水泥色,30% 低饱和藏蓝、灰绿和驼色,10% 琥珀、酒红或项目识别色。该比例用于控制角色服装、场景美术和资产库的整体频率,不要求每个画面机械满足;肤色始终保持自然暖中性。",
"【角色设计系统】统一采用真实东亚骨相与当代年龄感,五官、发型、体型、妆容和职业气质自然稳定;保留皮肤纹理与微表情,不使用统一网红脸、过度磨皮、夸张欧式深邃五官或跨集年龄漂移。",
"【服饰与材质系统】按职业、收入、季节和性格建立西装、衬衫、针织、风衣及休闲装的角色衣橱,主配色服从项目色板;羊毛、棉、皮革、玻璃、金属和塑料保持真实粗糙度,固定角色的标志服饰与配饰形成可复用资产。",
"【建筑世界观】统一使用可信的中国当代办公室、公寓、咖啡馆、商场、社区和城市公共空间,建筑尺度、家具系统、中文标识与生活设施符合本地语境;不同地点可有身份差异,但不得跳成欧美城市、空洞样板间或未来科幻空间。",
"【影像与动态基线】全片采用自然光感、可信实景光源、适中的对比度和写实表演节奏;动态气质克制、稳定、生活化,允许分镜按剧情选择静态观察或运动摄影,但不使用无叙事动机的炫技运动与滥用慢动作。",
"【资产一致性】为主要角色固定脸型、发型、体型、衣橱编号和常用道具,为主要地点固定空间布局、材质板和标识系统;跨集变化必须由剧情、时间或季节驱动并在角色资产或场景资产版本中留痕。",
"【全局禁用】禁止欧美人物默认脸、塑料皮肤、强烈青橙滤镜、过饱和霓虹、廉价棚拍、错误中文、建筑地域漂移、服饰随机换色和同一资产在不同节点中改变材质。",
PROJECT_STYLE_SCOPE,
].join("\n"),
imageUrl: "/short-drama-styles/urban-live-action.jpg",
},
{
id: "period-live-action",
title: "古装 / 年代真人",
category: "真人实拍",
description: "先锁定单一历史时期,再统一人物妆发、服装制度、器物与建筑;用低饱和东方色和材质细节建立时代可信度。",
tags: ["古装", "民国", "历史"],
prompt: [
"【项目定位】历史题材真人短剧的写实东方美学。项目启动时必须依据小说从古代王朝、民国或其他年代中锁定一个明确时代子类型,并形成唯一的时代考据基线;全项目人物制度、服化道、器物和建筑只遵循该基线。",
"【项目色彩系统】全项目色板使用权重约为 55% 米白、黛灰、木褐和土黄,30% 靛青、竹青、暗红和月白,15% 朱砂、鎏金、翡翠或项目身份色。该比例控制服装阵营、建筑材质和道具资产的整体分布;阶层差异通过色彩纯度、织物等级和金属用量表达。",
"【角色设计系统】统一采用真实东亚骨相、符合时代的妆容与发式,并按年龄、身份、礼制和劳动状态建立差异;角色的冠发、发髻、胡须、伤痕、体态与身份标记固定,不使用现代偶像妆或与时代无关的审美模板。",
"【服饰与材质系统】古代子类型按襟形、袖型、腰带、冠帽、鞋履和纹样建立制度;民国子类型按旗袍、长衫、学生装、军装和西装建立阶层。丝、麻、纱、锦、皮革、木、玉和金属使用统一材质库,兵器、首饰与器物必须归属明确年代。",
"【建筑世界观】古代子类型使用中式木构、斗拱、瓦顶、院落、回廊、园林和城镇体系;民国子类型使用石库门、骑楼、公馆、车站、报馆和旧式街道体系。项目只启用所选时代的建筑库,统一木、砖、石、灰泥的年代质感与使用痕迹。",
"【影像与动态基线】全片保持自然天光、窗纸漫射、烛火与灯笼等时代可信的光感,表演与动作遵循身份礼制和服装重量;动态风格庄重、克制、有真实惯性,具体节奏与调度交由分镜决定。",
"【资产一致性】建立时代手册、角色衣橱、纹样库、器物库和建筑模块库;同一角色的发饰、衣层与身份纹样不可随机改变,场景修缮、服装污损和身份升级只能依据剧情版本更新。",
"【全局禁用】禁止朝代混搭、现代拉链与家具、廉价化纤反光、塑料饰品、日式鸟居、欧洲城堡、现代偶像妆、随机花瓣滤镜和未经过时代归属的武器器物。",
PROJECT_STYLE_SCOPE,
].join("\n"),
imageUrl: "/short-drama-styles/period-live-action.jpg",
},
{
id: "suspense-noir",
title: "悬疑犯罪夜景",
category: "真人实拍",
description: "蓝黑低照度基底配少量危险色,统一城市夜景材质、实景光感与心理压迫气质,服务线索和反转题材。",
tags: ["悬疑", "犯罪", "反转"],
prompt: [
"【项目定位】现代中国悬疑犯罪真人短剧的写实暗调体系,以低照度、真实城市纹理和心理压迫作为全片视觉母题;风格服务于信息隐藏与揭示,但任何场景都必须保持人物、环境和关键叙事信息可读。",
"【项目色彩系统】全项目色板使用权重约为 65% 蓝黑、炭灰和冷水泥色,25% 青绿荧光、脏黄钠灯和冷白屏幕色,10% 暗红、洋红或项目危险色。强调色只承担危险、权力或关键线索的视觉职责,不扩散成全片霓虹效果。",
"【角色设计系统】统一采用真实东亚人物与自然年龄状态,允许疲惫、细纹、胡茬、伤痕和职业压力留下痕迹;角色脸型、发型、伤痕与精神状态有版本记录,反派通过行为和身份系统塑造,不使用夸张脸谱妆。",
"【服饰与材质系统】建立深色哑光外套、制服、便装和职业服饰库,通过剪裁、磨损和材质区分身份;证件、通讯设备、档案、监控设备和关键道具使用统一设计语言,避免高反光材质破坏暗调体系。",
"【建筑世界观】统一使用中国城市中的旧居民楼、楼梯间、地下空间、办公场所、仓储、便利店、街巷和城郊设施,形成潮湿墙面、玻璃、金属、水泥和旧涂层的材质库;不同地点共享同一城市地域与年代信息。",
"【影像与动态基线】全片保持低调但可读的明暗关系、可解释的城市实景光感与克制的心理张力;动态风格偏观察、跟随和压迫感,具体信息揭示方式由分镜设计,不把霓虹、烟雾或镜头晃动当作悬疑本身。",
"【资产一致性】为角色建立伤痕与服饰版本,为主要地点建立平面关系、材质板和照明基线,为关键道具建立唯一外观与状态记录;跨集变化必须对应明确剧情事件。",
"【全局禁用】禁止黑成不可读、赛博朋克灯海、无来源轮廓光、过量烟雾、反派脸谱化、血浆猎奇、错误警务与城市标识、同一地点地域漂移和关键资产随机变形。",
PROJECT_STYLE_SCOPE,
].join("\n"),
imageUrl: "/short-drama-styles/suspense-noir.jpg",
},
{
id: "chinese-2d",
title: "国漫 2D",
category: "二维动画",
description: "半写实国漫人物、矿物东方色与工笔式场景体系;以稳定线稿、清晰剪影和统一二维资产保障跨集一致性。",
tags: ["古风", "仙侠", "二维"],
prompt: [
"【项目定位】东方古风与仙侠题材的半写实国漫 2D 动画体系,统一采用有粗细变化的手绘线稿、赛璐璐角色上色与工笔式环境绘制;全项目保持二维绘画语言,不混入塑料手办、三维渲染或日系萌系模板。",
"【项目色彩系统】全项目色板使用权重约为 55% 月白、黛青、石青和烟灰,30% 朱砂、靛蓝、竹青和赭石,15% 金色、亮青或项目法术色。该比例用于统筹角色阵营、场景色板和特效资产;每个主要角色固定主色、辅色与识别色。",
"【角色设计系统】成年角色统一采用约 7 至 8 头身和东方骨相,眼睛比例、脸型、发际线、发束、眉眼与年龄感遵循同一角色设计规范;不同角色依靠剪影、身高差、发型和服饰结构区分,正侧背参考图必须属于同一二维设计。",
"【服饰与材质系统】汉服、劲装、甲胄、宗门服和法器按身份与阵营形成款式库,固定衣领、袖型、腰封、纹样和配色;丝绸、麻布、金属、玉石通过二维纹理、线条密度和高光形状区分,不使用三维写实材质贴图。",
"【建筑世界观】统一使用中式木构、斗拱、飞檐、瓦当、廊桥、山门、古城、竹林、云海与东方山水系统,建立可复用的建筑模块和场景色板;世界观内不出现欧洲城堡、日式鸟居或现代城市元素。",
"【影像与动态基线】全片保持清晰剪影、两至三阶角色明暗、工笔场景层次和适度水墨空气感;动画采用关键姿势明确、有限但准确的二维运动,口型、发丝、衣摆和特效遵循统一动画节奏,具体动作由分镜决定。",
"【资产一致性】建立角色线稿规范、标准色卡、表情表、服饰拆件、法术色板、建筑模块与特效元素库;任何新资产先匹配线条粗细、上色层级和纹理密度,再进入分镜生产。",
"【全局禁用】禁止 3D 塑料质感、手办摄影、欧美漫画肌肉模板、幼态大眼、角色随机换脸换发型、服饰纹样漂移、背景风格跳变、法术颜色失控和画面文字水印。",
PROJECT_STYLE_SCOPE,
].join("\n"),
imageUrl: "/short-drama-styles/chinese-2d.jpg",
},
{
id: "ink-narrative",
title: "水墨叙事",
category: "风格化动画",
description: "宣纸留白占主导,以焦墨、淡墨和少量朱砂组织叙事;人物身份靠稳定轮廓、笔法与色点区分,而不是抽象成不可识别墨团。",
tags: ["水墨", "东方", "情绪"],
prompt: [
"【项目定位】以中国水墨画语言构建的叙事动画项目,统一使用宣纸纤维、干湿笔触、飞白、积墨、破墨与轻工笔勾线;全片保持诗意留白和角色可识别性之间的平衡,不把水墨理解成随机滤镜。",
"【项目色彩系统】全项目色板使用权重约为 70% 宣纸暖白与留白,20% 焦墨、浓墨、淡墨组成的五级灰,10% 朱砂、石青、赭石或项目点色。点色按角色、阵营和关键器物分配固定职责,不在不同资产间随机更换。",
"【角色设计系统】人物采用稳定头身、脸部勾线、发髻与外轮廓系统,不同角色拥有固定的笔触节奏、轮廓特征和点色;近实远简是统一的绘制层级,但任何简化都不得破坏角色身份。",
"【服饰与材质系统】长衫、袍服、斗笠、披风、兵器和文房器物通过墨线疏密、干湿变化与有限纹样区分;固定角色的领口、腰带、发饰和关键道具保持明确形状,衣物可融入笔势但不能失去结构。",
"【建筑世界观】统一建立山水、村落、亭台、院墙、廊桥、舟船和古道的水墨资产库,以中式结构、近实远虚和墨色层级保持世界一致;雾、水、云与纸白共享同一留白规则。",
"【影像与动态基线】全片通过墨色浓度、纸白、边缘虚实和点色明度表达光感;动态采用轮廓先行、笔势跟随、墨迹自然生长的统一原则,转场语言可来自墨滴、笔锋或留白,具体动作和调度由分镜决定。",
"【资产一致性】建立角色笔刷、标准墨阶、点色色卡、服饰勾线、建筑结构和自然元素的统一样本;新增资产必须先通过纸张纹理、笔触尺度、灰阶与点色职责检查。",
"【全局禁用】禁止西式水彩插画、随机泼墨、全屏脏灰、角色五官消失、每个资产笔触风格不同、建筑结构融化、彩色过多、廉价纸纹滤镜和随机生成文字印章。",
PROJECT_STYLE_SCOPE,
].join("\n"),
imageUrl: "/short-drama-styles/ink-narrative.jpg",
},
{
id: "three-d-cartoon",
title: "3D 卡通短剧",
category: "三维动画",
description: "轮廓鲜明的风格化三维人物、柔和材质与明快配色;用可读表情、夸张节奏和稳定资产支撑喜剧、亲子与治愈内容。",
tags: ["3D", "喜剧", "治愈"],
prompt: [
"【项目定位】面向喜剧、亲子与治愈内容的风格化 3D 卡通短剧体系,统一采用简洁造型、清晰剪影、半哑光材质、柔和次表面散射和可读表情;保持动画片质感,不进入廉价塑料玩具或超写实恐怖谷。",
"【项目色彩系统】全项目色板使用权重约为 50% 暖白、浅木、柔灰和天空色,35% 高识别度角色主色,15% 黄色、珊瑚红、薄荷绿等项目点睛色。颜色按角色、地点和功能分配,始终保证角色资产与环境资产有稳定明度区分。",
"【角色设计系统】成人统一约 5 至 6 头身,儿童约 3.5 至 4.5 头身,头手可适度夸张但关节结构合理;通过眼鼻嘴比例、发型、体型和剪影建立差异,所有角色共用同一造型语言与表情夸张尺度。",
"【服饰与材质系统】服装款式与纹样简化为可复用大色块,棉布、针织、牛仔、皮革和金属通过统一粗糙度范围区分;道具采用适度圆润边缘和清晰功能造型,保持同一世界中的比例、材质与细节密度一致。",
"【建筑世界观】统一使用圆角、简化几何和清晰功能分区构建都市公寓、学校、店铺、办公室、公园或幻想小镇;背景资产细节层级低于角色但功能合理,所有地点遵循同一比例、材质和色彩系统。",
"【影像与动态基线】全片保持柔和光感、受控高光、干净轮廓和明快节奏;角色运动统一遵循预备动作、挤压拉伸、跟随、缓入缓出和清晰停顿,夸张幅度由项目表演手册约束,具体动作由分镜决定。",
"【资产一致性】建立标准角色比例、表情表、材质球、服饰色板、道具比例和建筑模块库;所有新资产必须匹配既有圆角半径、粗糙度、色彩纯度和细节等级。",
"【全局禁用】禁止塑料公仔反光、僵硬 T Pose、所有角色同脸、过大玻璃眼、毛孔级写实皮肤、随机改变头身、背景贴图模糊、穿模、漂浮道具和不属于同一美术体系的写实资产。",
PROJECT_STYLE_SCOPE,
].join("\n"),
imageUrl: "/short-drama-styles/three-d-cartoon.jpg",
},
{
id: "fantasy-3d",
title: "国风 3D 玄幻",
category: "三维动画",
description: "半写实东方人物、可信中式建筑与分层材质构成玄幻世界;法术色受阵营约束,体积光和特效只强化动作与叙事重点。",
tags: ["玄幻", "法术", "宏大场景"],
prompt: [
"【项目定位】东方玄幻题材的半写实 3D 动画短剧体系,统一采用东方角色设计、电影级 PBR 材质、中式幻想建筑和分级法术视觉语言;项目奇观建立在中国美术与建筑逻辑上,不套用西方魔幻世界模板。",
"【项目色彩系统】全项目色板使用权重约为 55% 黛青、墨黑、冷灰、古铜和云雾白,30% 朱红、靛蓝、青玉、暗金等阵营色,15% 单一高亮能量色。该比例用于宗门、角色、建筑和法术资产的整体规划;每个阵营与角色的能量色具有唯一职责。",
"【角色设计系统】成年角色统一采用约 7.5 至 8 头身、真实东方骨相与适度理想化五官,皮肤、发丝和眼神遵循同一半写实渲染标准;发冠、发束、身高、体型和身份符号固定,力量等级通过服饰层级与能量规则表达。",
"【服饰与材质系统】袍服、劲装、甲胄、披风、宗门纹样、发冠与法器构成阵营化资产库;丝绸、锦缎、皮革、金属、玉石和木材使用统一 PBR 标准与粗糙度范围,武器、吊坠和衣层具有稳定结构。",
"【建筑世界观】统一使用中式木构山门、殿宇、楼阁、城池、洞府、栈道、山水云海和东方祭坛,建立斗拱、屋脊、瓦面、岩石与云雾模块库;所有奇观必须从中式结构演化,不能混入欧洲尖塔、哥特教堂和随机异域拼贴。",
"【影像与动态基线】全片保持可信体积感、受控特效亮度、真实材质和有重量的动画表现;法术遵循统一的形状语法、粒子密度、能量色和力量分级,角色与环境的运动规律一致,具体战斗动作与调度由分镜决定。",
"【资产一致性】建立角色三视图、宗门色板、服饰拆件、材质球、武器法器、建筑模块、法术模板和力量等级表;任何升级、受损或换装都作为有因果的资产版本管理。",
"【全局禁用】禁止欧洲城堡、默认精灵脸、西式板甲混搭、塑料材质、彩虹粒子、全屏过曝特效、法术体系随机变化、角色无因换装换武器和建筑资产跨文化漂移。",
PROJECT_STYLE_SCOPE,
].join("\n"),
imageUrl: "/short-drama-styles/fantasy-3d.jpg",
},
{
id: "future-tech",
title: "未来科技",
category: "科幻实拍",
description: "洁净白灰空间、精密工业结构与克制冷光构成可信近未来;科技来自功能与材质,不依赖满屏霓虹和悬浮界面。",
tags: ["科技", "近未来", "硬科幻"],
prompt: [
"【项目定位】近未来中国科技题材的写实硬科幻短剧体系,以可推演的技术、清晰功能结构和克制先进感为核心;世界比当下领先约十至三十年,所有设备、服装和空间都必须能解释用途,不使用空泛的科幻装饰。",
"【项目色彩系统】全项目色板使用权重约为 65% 冷白、浅灰、钛金属和透明材质,25% 石墨黑、深海蓝和低饱和工业色,10% 青绿或电蓝状态指示色。高亮色只用于设备状态、权限和风险提示,不扩散为环境染色。",
"【角色设计系统】角色保持真实东亚骨相、自然年龄与职业可信度,通过精确剪裁、功能装备和身份识别细节区分科研、工程、医疗、安保与管理角色;义体或可穿戴设备必须符合人体结构并具有明确功能。",
"【服饰与材质系统】建立模块化工作服、防护服、通勤装和轻型外骨骼体系,统一使用哑光技术织物、钛合金、陶瓷、透明聚合物和精密玻璃;接缝、扣具、传感器与磨损位置必须符合使用逻辑。",
"【建筑世界观】统一使用可信的中国近未来实验室、数据中心、交通枢纽、智能住宅、先进制造与城市公共设施;空间强调结构、维护通道、导视和设备尺度,科技升级建立在现有城市之上而非凭空替换。",
"【影像与动态基线】全片保持清晰冷静、低噪点和高材质可读性,光源来自顶灯、工作灯、屏幕与自然天光;动态强调精密操作、机械反馈和空间秩序,具体机位与节奏由分镜决定。",
"【资产一致性】建立设备型号、接口协议、状态灯职责、角色权限色、服装模块和空间导视手册;同类设备共享结构语言,升级与损坏必须形成可追踪版本。",
"【全局禁用】禁止无功能全息屏、满屏蓝色 HUD、随机发光线、霓虹城市套壳、塑料太空服、错误机械结构、设备尺寸漂移、英文乱码和把未来科技等同于赛博朋克。",
PROJECT_STYLE_SCOPE,
].join("\n"),
imageUrl: "/short-drama-styles/future-tech.jpg",
},
{
id: "cyberpunk-neon",
title: "赛博霓虹",
category: "科幻实拍",
description: "高密度亚洲夜城、潮湿旧材质与受控霓虹形成技术失衡的近未来;人物身份由义体、阶层服装和企业色区分。",
tags: ["赛博朋克", "霓虹", "都市夜景"],
prompt: [
"【项目定位】亚洲高密度都市中的赛博朋克短剧体系,视觉核心是先进技术与老旧城市并存、企业秩序与街头生活碰撞;霓虹只作为商业、交通与身份系统的一部分,世界必须保留真实生活痕迹。",
"【项目色彩系统】全项目色板使用权重约为 55% 沥青黑、旧水泥、潮湿金属和烟灰,30% 冷青、脏黄与屏幕白,15% 洋红、警戒红或企业识别色。每种高亮色绑定阵营或功能,避免无差别彩虹霓虹。",
"【角色设计系统】角色采用真实东亚骨相和明确阶层差异,义眼、接口、义肢与皮下设备遵循统一技术代际;街头角色、企业雇员和执法人员通过剪影、改装程度、磨损与身份标记区分。",
"【服饰与材质系统】建立机能夹克、防水层、企业制服、旧式工装和改装配件库,统一使用磨损尼龙、橡胶、旧皮革、氧化金属、透明塑料和低亮度电子元件;服装必须可活动、可收纳并符合天气。",
"【建筑世界观】统一使用高密度亚洲街区、旧楼加建、地下交通、企业塔楼、维修铺、夜市与狭窄居住单元,中文标识与在地设施可信;新技术以加装、覆盖和侵入旧城市的方式出现。",
"【影像与动态基线】全片保持夜景可读、潮湿反射受控、实景光源有出处,动态兼具街头拥挤感与企业空间秩序;雨、烟和故障只在剧情需要时出现,不能遮盖人物表演与关键信息。",
"【资产一致性】建立企业标识、街区导视、义体代际、设备接口、角色改装和服装磨损版本;同一地点的招牌、线路与空间关系跨集保持稳定。",
"【全局禁用】禁止纯装饰霓虹、所有角色都装义体、无来源彩色轮廓光、日文城市替代中国语境、英文乱码、全屏雨雾、过曝招牌、随机机械纹身和未来设备无功能逻辑。",
PROJECT_STYLE_SCOPE,
].join("\n"),
imageUrl: "/short-drama-styles/cyberpunk-neon.jpg",
},
{
id: "retro-hong-kong",
title: "复古港风",
category: "年代实拍",
description: "八九十年代华语都市的胶片色、密集街区与生活化服饰;用钨丝暖光、青绿阴影和真实时代器物建立怀旧感。",
tags: ["港风", "胶片", "年代都市"],
prompt: [
"【项目定位】八十至九十年代华语都市题材的复古港风短剧体系,以真实时代生活、紧凑街区和胶片摄影质感为核心;怀旧来自服化道、空间和人物状态,不靠泛黄滤镜或明星仿妆。",
"【项目色彩系统】全项目色板使用权重约为 50% 奶油白、旧墙灰、墨绿和木褐,35% 钨丝暖黄、青绿色阴影、牛仔蓝与暗红,15% 招牌红、出租车色或角色识别色。色彩保持胶片宽容度和自然肤色。",
"【角色设计系统】统一采用真实东亚骨相与明确时代发型、妆容和体态,按职业与阶层建立差异;角色通过眉形、卷发、短发、眼镜、手表与稳定服装剪影识别,不照搬具体明星面孔。",
"【服饰与材质系统】建立宽肩西装、衬衫、针织、牛仔、风衣、连衣裙和工装衣橱,使用真实棉、羊毛、皮革、涤纶和金属配件;版型、花纹、纽扣、鞋履与随身物件锁定明确年代。",
"【建筑世界观】统一使用骑楼、旧式住宅、茶餐厅、录像厅、办公室、街市、码头、巴士与密集招牌街道,保留窗机、瓷砖、铁闸、电话和时代交通工具;不得混入智能手机与当代城市家具。",
"【影像与动态基线】全片使用适度胶片颗粒、柔和高光、钨丝灯与窗外冷光的自然混合,动态保持手持观察和经典剧情片的稳健节奏;具体景别、变焦与运动由分镜决定。",
"【资产一致性】建立年代器物清单、角色衣橱、招牌字形、交通工具、室内材质和胶片色彩基线;时间推进通过服装、发型与城市更新版本体现。",
"【全局禁用】禁止一键泛黄滤镜、现代手机与车辆、错误年代品牌、过量霓虹、日系昭和混用、具体明星换脸、影楼旗袍写真、干净到失真的街景和随机繁体乱码。",
PROJECT_STYLE_SCOPE,
].join("\n"),
imageUrl: "/short-drama-styles/retro-hong-kong.jpg",
},
{
id: "clay-stop-motion",
title: "黏土定格",
category: "定格动画",
description: "保留手塑痕迹的黏土角色、微缩布景与逐帧动作;统一模型比例、材料颗粒和灯光尺度,形成温暖可触的手工世界。",
tags: ["黏土", "定格", "手工质感"],
prompt: [
"【项目定位】以手工黏土角色和微缩布景制作的定格动画短剧体系,视觉核心是可触摸的材料、真实模型尺度与逐帧表演;全项目保留适度手塑痕迹,不伪装成光滑三维动画。",
"【项目色彩系统】全项目色板使用权重约为 55% 陶土、米白、旧木和纸张原色,30% 角色主色与场景功能色,15% 珊瑚红、芥末黄、湖蓝或项目点睛色。颜色带有实体颜料的轻微不均匀感。",
"【角色设计系统】角色采用统一头身、眼球、嘴型替换与骨架关节规则,通过鼻形、发型、体型和服装剪影区分;指纹、接缝与替换口型可适度保留,但正侧背结构必须一致且可制作。",
"【服饰与材质系统】衣物使用薄黏土、布料、毛线、纸张与细金属件组合,木、石、玻璃和食物以微缩材料模拟;每种材质保持可辨的颗粒、厚度与手工边缘,避免统一塑料表面。",
"【建筑世界观】统一使用可搭建的微缩住宅、街道、店铺、自然景观与室内布景,结构受真实模型尺寸、灯具和摄影空间约束;背景与道具遵循同一缩尺和手工制作语言。",
"【影像与动态基线】全片保持棚内微缩摄影的真实景深、柔和体积光与轻微逐帧节奏,动作强调清晰关键姿势、重量和停顿;不得用运动模糊掩盖定格特征,具体表演由分镜决定。",
"【资产一致性】建立角色骨架、替换脸、手型、标准色泥、缩尺、布景模块与道具制作表;损坏、换装和表情组件必须按资产版本维护。",
"【全局禁用】禁止光滑 CGI、塑料玩具反光、真实人类皮肤、模型比例漂移、关节凭空变形、材质尺度错误、过度景深虚化、随机指纹污渍和布景看似无限真实空间。",
PROJECT_STYLE_SCOPE,
].join("\n"),
imageUrl: "/short-drama-styles/clay-stop-motion.jpg",
},
{
id: "black-white-noir",
title: "黑白默片",
category: "风格化实拍",
description: "以黑白灰阶、硬朗光影和克制表演建立无对白也可读的戏剧性;时代质感来自构图、材质与表演,不靠复古滤镜。",
tags: ["黑白", "默片", "电影感"],
prompt: [
"【项目定位】以黑白电影和默片叙事为核心的风格化实拍短剧体系,强调轮廓、构图、肢体表演和视觉隐喻;可有少量时代道具,但不把项目固定为某一历史年代。",
"【项目色彩系统】全项目使用黑、白和五级中性灰作为主色,允许极少量单色强调作为叙事线索;明暗关系优先服务人物层次与信息可读性,禁止把画面压成一团黑或过度漂白。",
"【角色设计系统】角色保持真实东亚骨相与明确剪影,妆发和年龄感自然稳定;人物通过帽檐、领型、姿态、手势和光影落点区分,正侧背设定必须在灰阶中仍然清楚。",
"【服饰与材质系统】服装按角色身份建立黑白灰明度差、纹理密度和轮廓结构,使用羊毛、棉、皮革、金属与玻璃的真实反差;图案不依赖颜色,必须通过材质和明度可辨。",
"【建筑世界观】统一使用几何清晰、层次明确的城市街道、室内、车站、剧院和工业空间;墙面、玻璃、砖石、金属和雾气都以灰阶材质库控制,不添加无叙事的奇观背景。",
"【影像与动态基线】使用硬光、侧光、背光和局部高光塑造戏剧性,允许少量胶片颗粒与光晕;表演通过停顿、视线、走位和夸张但可信的肢体传达,具体节奏由分镜决定。",
"【资产一致性】建立灰阶色卡、角色轮廓、帽饰与道具明度表、光影基线和场景构图规则;同一角色的服装纹理和标志道具在不同曝光下仍需稳定。",
"【全局禁用】禁止随意套用棕色复古滤镜、画面全黑、现代彩色屏幕抢戏、过度烟雾、夸张舞台妆、无意义鱼眼变形、角色剪影混淆和字幕水印。",
PROJECT_STYLE_SCOPE,
].join("\n"),
imageUrl: "/short-drama-styles/black-white-noir.jpg",
},
{
id: "space-opera",
title: "太空歌剧",
category: "科幻动画",
description: "宏大星际尺度与鲜明阵营服饰结合可信航天结构;用色彩、徽记和材质建立政治与冒险叙事,而不是堆砌宇宙奇观。",
tags: ["星际", "阵营", "史诗"],
prompt: [
"【项目定位】东方叙事语境下的太空歌剧项目,融合星际航行、阵营政治、家族关系与冒险成长;世界观允许宏大奇观,但人物动机、技术限制和空间尺度必须清楚可追踪。",
"【项目色彩系统】全项目色板使用权重约为 45% 深空黑、冷灰、银白和舱体白,35% 阵营主色与舰队识别色,20% 星云紫、能源蓝或警戒橙作为叙事强调;每个阵营只拥有稳定主色和辅助色。",
"【角色设计系统】角色采用真实东方骨相或统一动画化比例,通过军衔、阵营徽记、发型、体型和功能装备区分;太空服、驾驶服和礼服必须适配人体活动,角色脸部与身体比例跨视角稳定。",
"【服饰与材质系统】建立舱内工作服、战术航天服、舰队制服、外交礼服和维修装模块,统一使用复合纤维、陶瓷、金属、透明面罩和软质密封件;接口、扣件、徽记与生命保障装置具有功能逻辑。",
"【建筑世界观】统一使用环形空间站、舰桥、推进舱、殖民地、轨道电梯和低重力生活区,结构遵循压力舱、维护通道、重力与照明逻辑;奇观服务于文明尺度,不随机拼贴异星建筑。",
"【影像与动态基线】保持深空黑与舱内工作光的层次,使用克制体积光和可信屏幕反射;动作体现惯性、失重、穿戴装备和空间限制,具体战斗与运镜由分镜决定。",
"【资产一致性】建立舰船型号、阵营徽记、舱体材质、接口标准、武器尺寸、角色制服和能源颜色版本表;任何舰船升级、损坏与换装都要有剧情原因。",
"【全局禁用】禁止现实宇航服直接套用、星球大小失真、所有阵营同一颜色、无功能 HUD、魔法与科技规则混杂、舰船结构穿透、角色装备随机变化和版权品牌标识。",
PROJECT_STYLE_SCOPE,
].join("\n"),
imageUrl: "/short-drama-styles/space-opera.jpg",
},
{
id: "comic-pop",
title: "漫画分镜",
category: "风格化动画",
description: "高对比块面、网点纹理与漫画格律构成可读的动作喜剧视觉;夸张来自构图、拟声和姿态,角色资产仍保持连续性。",
tags: ["漫画", "动作", "高对比"],
prompt: [
"【项目定位】面向动作、喜剧与都市冒险的漫画分镜风格,统一使用清晰外轮廓、平涂块面、网点纹理和有节奏的画格感;画面可以夸张,但角色关系、动作方向与空间连续性必须可靠。",
"【项目色彩系统】全项目以米白、墨黑和中性灰为结构底色,30% 使用高饱和红、黄、蓝或项目主色,10% 使用单一冲击色承担危险、速度或情绪重点;颜色必须形成角色和阵营识别。",
"【角色设计系统】统一采用稳定头身比例、强轮廓、清晰发型和可重复的表情符号;角色通过剪影、服装块面、姿态和道具区分,夸张表情不得改变基本脸型与身份。",
"【服饰与材质系统】服饰细节简化成大色块、粗线纹样和少量高光,棉、皮革、金属、牛仔和玻璃通过线条密度与高光形状区分;战斗或动作服装必须兼顾可动性。",
"【建筑世界观】使用可快速识别的街区、商店、学校、办公室、车站和屋顶等都市空间,背景以几何块面、透视线和有限网点表达;场景不抢角色轮廓,也不使用现实品牌标识。",
"【影像与动态基线】动作使用速度线、冲击形状、停格姿势和清晰重心,切换节奏由分镜控制;镜头可以模拟画格切分,但不得牺牲表情、手势和动作方向的可读性。",
"【资产一致性】建立角色标准角度、表情表、线宽、网点密度、拟声字形和动作特效模板;不同镜头共享同一线条、色块与纹理规则。",
"【全局禁用】禁止写实照片与漫画角色混用、线条粗细随机、所有画面满屏速度线、复杂背景压住人物、拟声文字乱码、角色换脸、四肢结构错误和过度血腥猎奇。",
PROJECT_STYLE_SCOPE,
].join("\n"),
imageUrl: "/short-drama-styles/comic-pop.jpg",
},
{
id: "storybook-fantasy",
title: "绘本童话",
category: "插画动画",
description: "纸张颗粒、柔和色块和手绘边缘构成温暖童话世界;角色表情与轮廓清楚,避免把绘本感做成泛化的柔焦滤镜。",
tags: ["绘本", "童话", "温暖"],
prompt: [
"【项目定位】面向成长、亲子与寓言叙事的绘本童话动画体系,统一使用手绘边缘、纸张颗粒、有限色块和可读角色表情;故事世界可以奇幻,但情绪和人物关系保持温暖、具体且可理解。",
"【项目色彩系统】全项目色板使用权重约为 55% 纸张暖白、雾蓝、鼠尾草绿和浅木色,30% 角色与场景主色,15% 蜜糖黄、珊瑚红或星光金作为叙事点色;避免所有画面都成为粉彩渐变。",
"【角色设计系统】角色采用统一绘本头身、圆润但有差异的轮廓和清晰眼鼻嘴比例,通过帽子、发型、耳朵、体型与服装区分;正侧背视图必须保留同一手绘造型特征。",
"【服饰与材质系统】衣服使用布料、针织、皮革、木扣、纸张和毛线等可触摸材质,纹样简化为少量重复元素;角色标志物件和衣领、袖口、鞋履保持稳定。",
"【建筑世界观】统一使用村庄、森林、阁楼、车站、河岸、市场与小型城堡等可读场景,建筑结构偏手工搭建和绘本透视;奇幻元素从生活物件变形而来,不做无逻辑异域拼贴。",
"【影像与动态基线】保持柔和但有方向的光影、纸张纹理和有限动画节奏,动作强调呼吸、重心、眼神与衣摆跟随;具体动作与镜头调度交由分镜决定。",
"【资产一致性】建立标准色卡、纸张纹理、笔刷边缘、角色比例、表情表、场景模块和魔法点色规则;新资产必须匹配已有线条和颗粒尺度。",
"【全局禁用】禁止塑料 3D、泛滥柔焦、角色同脸、过度糖果色、阴影方向漂移、复杂文字水印、细节密度失控、随机改发型和把儿童角色做成恐怖谷。",
PROJECT_STYLE_SCOPE,
].join("\n"),
imageUrl: "/short-drama-styles/storybook-fantasy.jpg",
},
{
id: "surreal-dream",
title: "超现实梦境",
category: "风格化实拍",
description: "现实材质与不可能空间并置,以镜像、尺度错位和雾化光线表达梦境;所有超现实变化服务角色心理和叙事线索。",
tags: ["梦境", "超现实", "心理"],
prompt: [
"【项目定位】以现实人物进入不可能空间为核心的超现实梦境短剧体系,梦境不是随机奇观,而是由人物记忆、欲望、恐惧和选择构成的可追踪视觉系统;现实段落与梦境段落必须有明确差异和回返规则。",
"【项目色彩系统】现实段落使用自然中性灰与低饱和环境色,梦境段落使用 35% 雾白、浅金、冷青和暗紫,15% 固定象征色承载角色心理线索;同一象征色不能在不同角色间随意换义。",
"【角色设计系统】角色保持真实东亚骨相、年龄与身体比例,梦境只允许在发型、服装、尺度、影子或局部材质上做有规则的变形;正侧背结构必须仍能确认是同一角色。",
"【服饰与材质系统】现实服装遵循生活逻辑,梦境服装可以通过重复、倒置、透明、过大或过小表达心理,但面料、纽扣、鞋履和标志物件需要保持可追踪变体。",
"【建筑世界观】统一使用镜像房间、无尽楼梯、漂浮街道、倒置建筑、空旷大厅和雾中自然等空间语法;每种不可能空间绑定一个叙事规则,不把随机生成的城堡和星空堆在一起。",
"【影像与动态基线】现实使用可信光源和稳定镜头,梦境使用慢速漂浮、镜面反射、尺度变化与非连续转场;特效保持节制,具体梦境动作和节拍由分镜决定。",
"【资产一致性】建立象征色、梦境规则、角色标志物件、空间变形前后对照、镜面材质和转场语法表;每次变形都能对应剧情节点并可恢复。",
"【全局禁用】禁止无因由的随机变形、全屏烟雾、过度镜面、所有空间都漂浮、梦境与现实没有区别、角色五官融化、符号含义漂移和用噪点掩盖生成错误。",
PROJECT_STYLE_SCOPE,
].join("\n"),
imageUrl: "/short-drama-styles/surreal-dream.jpg",
},
{
id: "nature-healing",
title: "自然疗愈",
category: "真人实拍",
description: "真实自然光、植物与手作生活构成松弛的在地质感;以季节、声音和微小行动表达疗愈,不做广告式空镜。",
tags: ["疗愈", "自然", "慢生活"],
prompt: [
"【项目定位】面向家庭关系、返乡、成长与情绪修复的自然疗愈真人短剧体系,核心是人物在具体自然环境中的生活行动和关系变化;自然不是装饰背景,而是参与叙事的真实空间。",
"【项目色彩系统】全项目色板使用权重约为 55% 叶绿、苔藓绿、土色和米白,30% 木材、石头、天空与季节环境色,15% 赭红、芥末黄或人物识别色;保持自然白平衡和季节连续性。",
"【角色设计系统】角色采用真实东亚面孔、自然年龄与生活状态,保留晒痕、发丝、体态和劳动痕迹;通过衣着层次、动作习惯、发型与随身物件建立差异,不使用商业广告式精致人物。",
"【服饰与材质系统】建立棉麻、针织、旧牛仔、雨具、工作服、帆布鞋与草帽衣橱,材质保留褶皱、泥土、汗水和使用痕迹;固定角色的外套、包和手作物件形成识别资产。",
"【建筑世界观】统一使用山村住宅、林间小屋、农场、茶室、河岸、菜园、集市和小城边缘空间,保留木、石、土、旧墙、工具与本地生活设施;建筑应有地域和季节证据。",
"【影像与动态基线】采用真实日光、阴天漫射、篝火与室内暖灯,允许风、雨、昆虫和环境声进入画面;动态观察人物的微小行动与关系停顿,避免只拍漂亮自然空镜。",
"【资产一致性】建立季节色卡、植物与农具库、住宅布局、角色衣橱、手作物件和天气连续性记录;季节变化应带来可见但渐进的资产版本。",
"【全局禁用】禁止商业民宿广告感、过度航拍、饱和滤镜、欧美乡村替代中国在地生活、干净无人的自然空间、人物永远精致、随机换季和把疗愈做成空洞慢镜头。",
PROJECT_STYLE_SCOPE,
].join("\n"),
imageUrl: "/short-drama-styles/nature-healing.jpg",
},
{
id: "real-life-documentary",
title: "现实生活纪实",
category: "真人实拍",
description: "自然灰土色、真实东亚普通人与中国在地生活空间;保留环境痕迹和观察式影像气质,让家庭与社会议题更可信。",
tags: ["家庭", "成长", "纪实"],
prompt: [
"【项目定位】家庭、成长与社会议题的现实生活纪实短剧体系,接近观察式剧情纪录片:真实东亚普通人、真实中国在地空间、自然曝光和适度不完美;全片追求生活证据与人物关系的可信度,不追求广告级精致。",
"【项目色彩系统】全项目色板使用权重约为 70% 自然灰、米白、旧木、土色和水泥色,20% 当地墙面、家具、植物与公共设施的环境色,10% 衣物、生活用品或项目识别色。色板允许随季节与地区变化,但始终保持低饱和、自然白平衡和在地真实感。",
"【角色设计系统】选择具有真实年龄、体型、家庭关系和职业痕迹的东亚人物,保留皱纹、晒痕、眼袋、发丝与生活状态;妆发接近日常,家人之间保持可信相似性,不使用网红脸、统一精致妆和不符合生活条件的造型。",
"【服饰与材质系统】按季节、职业和经济条件建立家居服、工装、校服、普通衬衫、羽绒服与布鞋等衣橱,保留褶皱、磨损和反复使用痕迹;生活用品采用真实品牌中性化设计与合理使用年限,不做崭新陈列品。",
"【建筑世界观】统一使用中国老小区、城中村、普通住宅、学校、医院、工厂、菜市场、公交站和县城街道,保留线缆、晾晒、污渍、拥挤家具与中文公共标识;项目地点共享明确地域、年代和经济环境。",
"【影像与动态基线】全片保持自然光感、现场混合色温、适度颗粒与观察式表演,允许真实环境中的停顿、重复和轻微不完美;动态风格朴素、贴近人物,不使用炫技摄影或消费苦难的煽情手法,具体调度由分镜决定。",
"【资产一致性】建立家庭成员外貌关系、角色衣橱、生活道具使用痕迹、住宅布局、社区设施和地域标识资产库;时间推进造成的磨损、成长和季节变化必须作为连续版本维护。",
"【全局禁用】禁止商业广告布光、过度磨皮、精致样板房、欧美都市替代中国在地空间、强烈电影滤镜、摆拍式苦难、统一网红脸、无生活痕迹的道具和把纪实误做成低清脏画面。",
PROJECT_STYLE_SCOPE,
].join("\n"),
imageUrl: "/short-drama-styles/real-life.jpg",
},
];
const legacyStyleUpgrades: Partial<Record<string, ProjectStyleSelection>> = {
"urban-live-action": { world: "urban", tone: "romantic", medium: "live-action", character: "realistic" },
"period-live-action": { world: "historical", tone: "epic", medium: "live-action", character: "realistic" },
"suspense-noir": { world: "suspense", tone: "dark", medium: "live-action", character: "realistic" },
"chinese-2d": { world: "xianxia", tone: "epic", medium: "2d-guoman", character: "semi-real" },
"ink-narrative": { world: "xianxia", tone: "healing", medium: "ink", character: "semi-real" },
"three-d-cartoon": { world: "urban", tone: "light-comedy", medium: "3d-cartoon", character: "stylized" },
"fantasy-3d": { world: "xianxia", tone: "epic", medium: "3d-anime", character: "semi-real" },
"future-tech": { world: "science-fiction", tone: "epic", medium: "live-action", character: "realistic" },
"cyberpunk-neon": { world: "science-fiction", tone: "dark", medium: "live-action", character: "realistic" },
"space-opera": { world: "science-fiction", tone: "epic", medium: "3d-anime", character: "semi-real" },
"nature-healing": { world: "pastoral", tone: "healing", medium: "live-action", character: "realistic" },
"real-life-documentary": { world: "urban", tone: "healing", medium: "live-action", character: "realistic" },
};
const resolvedLegacyCanvasStylePresets = legacyCanvasStylePresets.map((preset) => {
const upgrade = legacyStyleUpgrades[preset.id];
if (!upgrade) return preset;
const compiled = compileCanvasStylePreset(upgrade);
const upgraded = { ...compiled, id: preset.id, imageUrl: preset.imageUrl };
return { ...upgraded, profile: createStyleProfileSnapshot({ ...compiled.profile!, presetId: preset.id }) };
});
const resolvableCanvasStylePresets: CanvasStylePreset[] = [...recommendedCanvasStylePresets, ...resolvedLegacyCanvasStylePresets];
// 风格中心不靠重复卡片虚增数量;旧 ID 仍留在解析集合中,保证历史项目可以恢复。
export const canvasStylePresets: CanvasStylePreset[] = resolvableCanvasStylePresets.filter((preset, index, presets) => presets.findIndex((candidate) => candidate.prompt === preset.prompt) === index);
export function resolveCanvasStylePreset(id?: string) {
return resolvableCanvasStylePresets.find((preset) => preset.id === id) || customCanvasStylePreset(id);
}
export function resolveProjectCanvasStyle(presetId?: string, profileJson?: string | null): CanvasStylePreset | undefined {
const preset = resolveCanvasStylePreset(presetId);
const profile = parseStyleProfile(profileJson);
if (!profile) return preset;
return {
id: profile.presetId,
title: profile.title,
category: preset?.category || "项目风格包",
description: profile.description,
tags: [...profile.tags],
prompt: profile.prompt,
imageUrl: profile.coverUrl || preset?.imageUrl || "/short-drama-styles/real-life.jpg",
selection: preset?.selection,
profile,
};
}
const STYLE_FAVORITES_KEY = "canvas:style-library:favorites";
const STYLE_RECENT_KEY = "canvas:style-library:recent";
type StyleCenterTab = "system" | "mine" | "favorites" | "recent";
type SystemStyleLibraryItem = { kind: "system"; preset: CanvasStylePreset };
type UserStyleLibraryItem = { kind: "user"; preset: CanvasStylePreset; entity: UserStyleProfile };
type StyleLibraryItem = SystemStyleLibraryItem | UserStyleLibraryItem;
type StyleEditorState = { profile: StyleProfileSnapshot; entityId?: string };
export function CanvasStylePickerModal({ open, value, currentProfile, startInEditor = false, applying = false, onClose, onSelect }: {
open: boolean;
value?: string;
currentProfile?: StyleProfileSnapshot | null;
startInEditor?: boolean;
applying?: boolean;
onClose: () => void;
onSelect: (preset: CanvasStylePreset) => void;
}) {
const { message, modal } = App.useApp();
const queryClient = useQueryClient();
const theme = canvasThemes[useActiveTheme()];
const [detailPreset, setDetailPreset] = useState<CanvasStylePreset | null>(null);
const [tab, setTab] = useState<StyleCenterTab>("system");
const [query, setQuery] = useState("");
const [category, setCategory] = useState("全部");
const [systemFavoriteIds, setSystemFavoriteIds] = useState<string[]>([]);
const [recentIds, setRecentIds] = useState<string[]>([]);
const [editor, setEditor] = useState<StyleEditorState | null>(null);
const profilesQuery = useQuery({ queryKey: ["style-profiles"], queryFn: listStyleProfiles, enabled: open });
useEffect(() => {
if (!open) return;
setTab("system");
setQuery("");
setCategory("全部");
setSystemFavoriteIds(readStyleIds(STYLE_FAVORITES_KEY));
setRecentIds(readStyleIds(STYLE_RECENT_KEY));
setEditor(startInEditor && currentProfile?.source !== "user" && currentProfile ? { profile: editableCopy(currentProfile) } : null);
}, [currentProfile, open, startInEditor]);
const userItems = useMemo<UserStyleLibraryItem[]>(() => (profilesQuery.data?.profiles || []).flatMap((entity) => {
const preset = userStylePreset(entity);
return preset ? [{ kind: "user" as const, preset, entity }] : [];
}), [profilesQuery.data?.profiles]);
const systemItems = useMemo<SystemStyleLibraryItem[]>(() => canvasStylePresets.map((preset) => ({ kind: "system", preset })), []);
const categories = useMemo(() => ["全部", ...Array.from(new Set(canvasStylePresets.map((preset) => preset.category)))], []);
useEffect(() => {
if (!open || !startInEditor || !currentProfile || currentProfile.source !== "user" || editor || profilesQuery.isFetching || profilesQuery.isError) return;
const sourceId = currentProfile.sourceProfileId || currentProfile.presetId;
const sourceEntity = (profilesQuery.data?.profiles || []).find((profile) => profile.id === sourceId);
const sourceProfile = sourceEntity ? parseStyleProfile(sourceEntity.profileJson) : null;
if (sourceEntity && sourceProfile) {
setEditor({ profile: editableCopy(sourceProfile, sourceEntity.id), entityId: sourceEntity.id });
return;
}
if (profilesQuery.isFetched) setEditor({ profile: editableCopy(currentProfile) });
}, [currentProfile, editor, open, profilesQuery.data?.profiles, profilesQuery.isError, profilesQuery.isFetched, profilesQuery.isFetching, startInEditor]);
const visibleItems = useMemo(() => {
const keyword = query.trim().toLocaleLowerCase("zh-CN");
let source: StyleLibraryItem[] = tab === "system" ? systemItems : tab === "mine" ? userItems : tab === "favorites"
? [...systemItems.filter((item) => systemFavoriteIds.includes(item.preset.id)), ...userItems.filter((item) => item.kind === "user" && item.entity.favorite)]
: [...userItems.filter((item) => item.kind === "user" && item.entity.lastUsedAt).sort((a, b) => String(b.entity.lastUsedAt).localeCompare(String(a.entity.lastUsedAt))), ...recentIds.flatMap((id) => systemItems.filter((item) => item.preset.id === id))];
if (tab === "system" && category !== "全部") source = source.filter((item) => item.preset.category === category);
if (keyword) source = source.filter((item) => `${item.preset.title} ${item.preset.category} ${item.preset.description} ${item.preset.tags.join(" ")}`.toLocaleLowerCase("zh-CN").includes(keyword));
return source;
}, [category, query, recentIds, systemFavoriteIds, systemItems, tab, userItems]);
const saveMutation = useMutation({
mutationFn: ({ profile, apply }: { profile: StyleProfileSnapshot; apply: boolean }) => editor?.entityId
? updateStyleProfile(editor.entityId, serializeStyleProfile(profile)).then((result) => ({ ...result, apply }))
: createStyleProfile(serializeStyleProfile(profile)).then((result) => ({ ...result, apply })),
onSuccess: async ({ profile, apply }) => {
await queryClient.invalidateQueries({ queryKey: ["style-profiles"] });
const preset = userStylePreset(profile);
setEditor(null);
setTab("mine");
message.success("风格已保存到“我的风格”");
if (apply && preset) selectItem({ kind: "user", preset, entity: profile });
},
onError: (error) => message.error(error instanceof Error ? error.message : "风格保存失败"),
});
const favoriteMutation = useMutation({
mutationFn: ({ id, favorite }: { id: string; favorite: boolean }) => setStyleProfileFavorite(id, favorite),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["style-profiles"] }),
onError: (error) => message.error(error instanceof Error ? error.message : "收藏状态更新失败"),
});
const deleteMutation = useMutation({
mutationFn: deleteStyleProfile,
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["style-profiles"] }); message.success("风格已删除"); },
onError: (error) => message.error(error instanceof Error ? error.message : "风格删除失败"),
});
function selectItem(item: StyleLibraryItem) {
if (applying) return;
const { preset } = item;
const next = [preset.id, ...recentIds.filter((id) => id !== preset.id)].slice(0, 12);
setRecentIds(next);
localStorage.setItem(STYLE_RECENT_KEY, JSON.stringify(next));
if (item.kind === "user") void touchStyleProfile(item.entity.id).then(() => queryClient.invalidateQueries({ queryKey: ["style-profiles"] })).catch((error) => message.warning(error instanceof Error ? error.message : "最近使用记录更新失败"));
onSelect(preset);
}
function toggleSystemFavorite(presetId: string) {
const next = systemFavoriteIds.includes(presetId) ? systemFavoriteIds.filter((id) => id !== presetId) : [presetId, ...systemFavoriteIds];
setSystemFavoriteIds(next);
localStorage.setItem(STYLE_FAVORITES_KEY, JSON.stringify(next));
}
function createNewStyle() {
setEditor({ profile: blankUserStyle() });
}
function copyPreset(preset: CanvasStylePreset) {
setEditor({ profile: editableCopy(preset.profile || createStyleProfileSnapshot(preset), undefined, `${preset.title} 副本`, preset.imageUrl) });
}
function confirmDelete(entity: UserStyleProfile) {
modal.confirm({ title: `删除“${entity.name}”?`, content: "项目中已经保存的快照不会被删除,但该风格将从“我的风格”中移除。", okText: "删除", cancelText: "取消", okButtonProps: { danger: true }, onOk: () => deleteMutation.mutateAsync(entity.id) });
}
return (
<>
<AppModal rootClassName="canvas-style-picker-modal" open={open} title={null} footer={null} centered width="min(1240px, calc(100vw - 24px))" onCancel={onClose} flush>
<div className="canvas-style-center-shell flex min-h-0 flex-col overflow-hidden" style={{ color: theme.node.text, background: theme.node.panel }}>
<header className="flex min-h-16 flex-col gap-3 border-b px-4 py-3 pr-12 sm:flex-row sm:items-center sm:justify-between sm:px-5 sm:pr-14" style={{ borderColor: theme.node.stroke }}>
<div className="min-w-0">
<h2 className="text-base font-semibold">风格中心</h2>
<p className="mt-0.5 text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>系统规范 · 个人风格 · 项目快照</p>
</div>
<div className="flex min-w-0 gap-2 sm:items-center">
<Input allowClear className="style-center-search min-w-0 flex-1" prefix={<Search className="size-3.5 text-foreground/35" />} value={query} placeholder="搜索风格、题材或媒介" onChange={(event) => setQuery(event.target.value)} />
<Button type="primary" className="shrink-0" icon={<Plus className="size-3.5" />} onClick={createNewStyle}>新建风格</Button>
</div>
</header>
<div className="style-center-workspace grid min-h-0 flex-1">
<aside className="style-center-sidebar thin-scrollbar min-h-0 overflow-auto border-b p-2 sm:p-3 lg:border-b-0 lg:border-r" style={{ borderColor: theme.node.stroke }}>
<nav className="style-center-primary-nav flex gap-1 lg:flex-col" aria-label="风格中心视图">
<StyleCenterNavItem active={tab === "system"} icon={<Palette className="size-3.5" />} label="系统风格" count={systemItems.length} onClick={() => setTab("system")} />
<StyleCenterNavItem active={tab === "mine"} icon={<UserRound className="size-3.5" />} label="我的风格" count={userItems.length} onClick={() => setTab("mine")} />
<StyleCenterNavItem active={tab === "favorites"} icon={<Star className="size-3.5" />} label="收藏" onClick={() => setTab("favorites")} />
<StyleCenterNavItem active={tab === "recent"} icon={<Clock3 className="size-3.5" />} label="最近使用" onClick={() => setTab("recent")} />
</nav>
{tab === "system" ? (
<div className="style-center-category-panel mt-2 border-t pt-2 lg:mt-4 lg:pt-3" style={{ borderColor: theme.node.stroke }}>
<div className="hidden px-2 pb-1.5 text-[var(--fs-tiny)] font-medium lg:block" style={{ color: theme.node.muted }}>分类</div>
<nav className="style-center-category-nav flex gap-1 lg:flex-col" aria-label="系统风格分类">
{categories.map((item) => <button key={item} type="button" aria-current={category === item ? "page" : undefined} className={`style-center-category-item h-9 shrink-0 rounded-md px-2.5 text-left text-xs outline-none transition-colors focus-visible:ring-2 ${category === item ? "is-active font-medium" : ""}`} onClick={() => setCategory(item)}>{item}</button>)}
</nav>
</div>
) : null}
</aside>
<main className="flex min-h-0 min-w-0 flex-col">
<div className="flex h-11 shrink-0 items-center justify-between gap-3 border-b px-3 sm:px-4" style={{ borderColor: theme.node.stroke }}>
<div className="min-w-0 truncate text-xs font-medium">{styleCenterViewTitle(tab, category)}</div>
<div className="shrink-0 text-[var(--fs-tiny)] tabular-nums" style={{ color: theme.node.muted }}>{visibleItems.length} 个风格</div>
</div>
<div className="style-center-grid thin-scrollbar min-h-0 flex-1 content-start overflow-y-auto p-3 sm:p-4">
{visibleItems.map((item) => (
<StyleCenterCard
key={`${item.kind}-${item.preset.id}`}
item={item}
active={item.preset.id === value}
applying={applying}
favorite={item.kind === "user" ? item.entity.favorite : systemFavoriteIds.includes(item.preset.id)}
theme={theme}
onApply={() => selectItem(item)}
onDetail={() => setDetailPreset(item.preset)}
onFavorite={() => item.kind === "user" ? favoriteMutation.mutate({ id: item.entity.id, favorite: !item.entity.favorite }) : toggleSystemFavorite(item.preset.id)}
onCopy={() => copyPreset(item.preset)}
onEdit={item.kind === "user" ? () => setEditor({ profile: editableCopy(item.preset.profile!, item.entity.id), entityId: item.entity.id }) : undefined}
onDelete={item.kind === "user" ? () => confirmDelete(item.entity) : undefined}
/>
))}
{!visibleItems.length ? <EmptyStyleCenter tab={tab} loading={profilesQuery.isLoading} failed={profilesQuery.isError} color={theme.node.muted} onCreate={createNewStyle} onBrowse={() => { setTab("system"); setQuery(""); }} /> : null}
</div>
</main>
</div>
</div>
</AppModal>
<CanvasStyleDetailModal open={Boolean(detailPreset)} preset={detailPreset} selected={detailPreset?.id === value} onClose={() => setDetailPreset(null)} onSelect={(preset) => { setDetailPreset(null); const item = [...systemItems, ...userItems].find((candidate) => candidate.preset.id === preset.id); if (item) selectItem(item); }} />
<StyleProfileEditorModal open={Boolean(editor)} initialProfile={editor?.profile || null} saving={saveMutation.isPending} onClose={() => setEditor(null)} onSave={(profile, apply) => saveMutation.mutate({ profile, apply })} />
</>
);
}
function readStyleIds(key: string) {
try {
const parsed = JSON.parse(localStorage.getItem(key) || "[]");
return Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === "string").slice(0, 100) : [];
} catch {
return [];
}
}
function StyleCenterCard({ item, active, applying, favorite, theme, onApply, onDetail, onFavorite, onCopy, onEdit, onDelete }: { item: StyleLibraryItem; active: boolean; applying: boolean; favorite: boolean; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onApply: () => void; onDetail: () => void; onFavorite: () => void; onCopy: () => void; onEdit?: () => void; onDelete?: () => void }) {
const { preset } = item;
return (
<article className={`style-center-card group flex min-w-0 flex-col overflow-hidden rounded-md border ${active ? "is-active" : ""}`} style={{ background: theme.canvas.background, borderColor: active ? theme.node.activeStroke : theme.node.stroke, boxShadow: active ? `inset 0 0 0 1px ${theme.node.activeStroke}` : undefined }}>
<div className="relative overflow-hidden border-b" style={{ borderColor: theme.node.stroke }}>
<button type="button" className="style-center-card-cover block w-full overflow-hidden outline-none focus-visible:ring-2 focus-visible:ring-inset" style={{ "--tw-ring-color": theme.node.activeStroke } as CSSProperties} onClick={onDetail} aria-label={`查看${preset.title}规范`}>
<img src={preset.imageUrl} width="640" height="360" alt={`${preset.title}画风示意`} className="h-full w-full object-cover transition-transform" />
</button>
<button type="button" className={`style-center-favorite-action absolute right-2 top-2 grid size-8 place-items-center rounded-md outline-none focus-visible:ring-2 ${favorite ? "is-active" : ""}`} onClick={onFavorite} aria-label={favorite ? "取消收藏" : "收藏"} title={favorite ? "取消收藏" : "收藏"}><Star className="size-3.5" fill={favorite ? "currentColor" : "none"} /></button>
</div>
<div className="flex min-h-0 flex-1 flex-col px-3 pt-3">
<div className="flex min-w-0 items-center gap-2 text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}><span className="truncate">{item.kind === "user" ? "我的风格" : preset.category}</span>{active ? <span className="ml-auto flex shrink-0 items-center gap-1 font-medium" style={{ color: theme.node.activeStroke }}><Check className="size-3" />当前</span> : null}</div>
<h3 className="mt-1.5 truncate text-sm font-semibold">{preset.title}</h3>
<p className="mt-1.5 line-clamp-2 text-xs leading-5" style={{ color: theme.node.muted }}>{preset.description}</p>
<div className="mt-2 flex min-h-6 flex-wrap gap-1">{preset.tags.slice(0, 3).map((tag) => <span key={tag} className="rounded bg-foreground/5 px-1.5 py-0.5 text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>{tag}</span>)}</div>
</div>
<footer className="mt-2 flex min-h-11 items-center gap-1 border-t px-2" style={{ borderColor: theme.node.stroke }}>
<IconAction label="查看规范" onClick={onDetail}><Eye className="size-3.5" /></IconAction>
<IconAction label="复制并编辑" onClick={onCopy}><Copy className="size-3.5" /></IconAction>
{onEdit ? <IconAction label="编辑风格" onClick={onEdit}><Pencil className="size-3.5" /></IconAction> : null}
{onDelete ? <IconAction label="删除风格" danger onClick={onDelete}><Trash2 className="size-3.5" /></IconAction> : null}
<Button type="default" size="small" className={`style-center-apply-button ml-auto ${active ? "is-current" : ""}`} disabled={active || applying} icon={active ? <Check className="size-3.5" /> : <Palette className="size-3.5" />} onClick={onApply}>{active ? "当前" : "应用"}</Button>
</footer>
</article>
);
}
function IconAction({ label, danger = false, onClick, children }: { label: string; danger?: boolean; onClick: () => void; children: ReactNode }) {
return <button type="button" className={`style-center-icon-action grid size-8 place-items-center rounded-md outline-none focus-visible:ring-2 ${danger ? "is-danger" : ""}`} onClick={onClick} aria-label={label} title={label}>{children}</button>;
}
function StyleCenterNavItem({ active, icon, label, count, onClick }: { active: boolean; icon: ReactNode; label: string; count?: number; onClick: () => void }) {
return <button type="button" aria-current={active ? "page" : undefined} className={`style-center-nav-item flex h-11 min-w-fit items-center gap-2 rounded-md px-2.5 text-xs outline-none transition-colors focus-visible:ring-2 lg:w-full ${active ? "is-active font-medium" : ""}`} onClick={onClick}>{icon}<span className="whitespace-nowrap">{label}</span>{count !== undefined ? <span className="ml-auto tabular-nums opacity-55">{count}</span> : null}</button>;
}
function styleCenterViewTitle(tab: StyleCenterTab, category: string) {
if (tab === "mine") return "我的风格";
if (tab === "favorites") return "收藏风格";
if (tab === "recent") return "最近使用";
return category === "全部" ? "全部系统风格" : category;
}
function EmptyStyleCenter({ tab, loading, failed, color, onCreate, onBrowse }: { tab: StyleCenterTab; loading: boolean; failed: boolean; color: string; onCreate: () => void; onBrowse: () => void }) {
return <div className="col-span-full grid min-h-64 place-items-center text-center"><div><Search className="mx-auto size-5" style={{ color }} /><p className="mt-2 text-xs font-medium">{loading ? "正在加载风格库" : failed && tab !== "system" ? "我的风格加载失败,请检查登录状态或后端服务" : tab === "mine" ? "还没有自己的风格" : tab === "favorites" ? "还没有收藏风格" : tab === "recent" ? "还没有使用记录" : "没有匹配的系统风格"}</p><div className="mt-3 flex justify-center gap-2">{tab === "mine" && !failed ? <Button size="small" icon={<Plus className="size-3.5" />} onClick={onCreate}>新建风格</Button> : null}<Button size="small" onClick={onBrowse}>浏览系统风格</Button></div></div></div>;
}
export function userStylePreset(entity: UserStyleProfile): CanvasStylePreset | null {
const profile = parseStyleProfile(entity.profileJson);
if (!profile) return null;
return { id: profile.presetId, title: profile.title, category: "我的风格", description: profile.description, tags: [...profile.tags], prompt: profile.prompt, imageUrl: profile.coverUrl || entity.coverUrl || "/short-drama-styles/real-life.jpg", profile };
}
function blankUserStyle(): StyleProfileSnapshot {
return createStyleProfileSnapshot({ presetId: `draft-${nanoid()}`, title: "未命名风格", description: "", tags: [], prompt: "", assets: [], source: "user", revision: 1 });
}
function editableCopy(profile: StyleProfileSnapshot, entityId?: string, title?: string, coverUrl?: string): StyleProfileSnapshot {
return createStyleProfileSnapshot({ ...profile, presetId: entityId || `draft-${nanoid()}`, sourceProfileId: entityId, title: title || profile.title, coverUrl: profile.coverUrl || coverUrl, source: "user", revision: entityId ? profile.revision : 1 });
}
export function CanvasStyleDetailModal({ open, preset, selected = false, onClose, onSelect }: { open: boolean; preset: CanvasStylePreset | null; selected?: boolean; onClose: () => void; onSelect?: (preset: CanvasStylePreset) => void }) {
const theme = canvasThemes[useActiveTheme()];
const sections = preset ? parseStyleSections(preset.prompt) : [];
return (
<AppModal rootClassName="canvas-style-detail-modal" open={open} title={null} footer={null} centered destroyOnHidden width="min(820px, calc(100vw - 24px))" onCancel={onClose} flush>
{preset ? <div className="canvas-style-detail-shell flex flex-col overflow-hidden" style={{ color: theme.node.text, background: theme.node.panel }}>
<div className="flex h-44 shrink-0 items-center justify-center overflow-hidden border-b sm:h-52" style={{ borderColor: theme.node.stroke, background: theme.canvas.background }}>
<img src={preset.imageUrl} width="960" height="540" alt={`${preset.title}画风示意`} className="h-full w-full object-contain" style={preset.id === "black-white-noir" ? { filter: "grayscale(1) contrast(1.08)" } : undefined} />
</div>
<header className="border-b px-4 py-3 pr-12 sm:px-5 sm:pr-12" style={{ borderColor: theme.node.stroke }}>
<div className="flex items-center gap-2 text-[var(--fs-tiny)] font-medium" style={{ color: theme.node.activeStroke }}><span>{preset.category}</span><span className="flex items-center gap-1" style={{ color: theme.node.muted }}><SlidersHorizontal className="size-3" />{preset.profile?.assets.length ? `${preset.profile.assets.length} 个执行资产` : "Prompt 通用"}</span></div>
<h2 className="mt-1 text-base font-semibold">{preset.title}</h2>
<p className="mt-1.5 text-xs leading-5" style={{ color: theme.node.muted }}>{preset.description}</p>
</header>
<div className="grid shrink-0 grid-cols-3 divide-x border-b text-[var(--fs-tiny)]" style={{ borderColor: theme.node.stroke }}>
<StyleDetailMetric label="执行方式" value={preset.profile?.assets.length ? "组合资产" : "Prompt 通用"} muted={theme.node.muted} />
<StyleDetailMetric label="执行策略" value={preset.profile?.executionPolicy === "strict-assets" ? "严格校验" : "兼容降级"} muted={theme.node.muted} />
<StyleDetailMetric label="快照版本" value={`r${preset.profile?.revision || 1}`} muted={theme.node.muted} />
</div>
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 sm:px-5">
{preset.profile?.assets.length ? <section className="border-b py-3" style={{ borderColor: theme.node.stroke }}><h3 className="text-xs font-semibold">执行资产</h3><div className="mt-2 divide-y" style={{ borderColor: theme.node.stroke }}>{preset.profile.assets.map((asset) => <div key={asset.id} className="flex items-center justify-between gap-3 py-2 text-xs"><span className="min-w-0"><span className="block truncate font-medium">{asset.title}</span><span className="mt-0.5 block text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>{kindOptionsLabel(asset.kind)} · {asset.provider}</span></span><span className="shrink-0 text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>{asset.status === "validated" ? "已验证" : asset.status === "unavailable" ? "不可用" : "待验证"}</span></div>)}</div></section> : null}
{sections.map((section) => <section key={section.title} className="border-b py-3 last:border-b-0" style={{ borderColor: theme.node.stroke }}><h3 className="text-xs font-semibold">{section.title}</h3><p className="mt-1.5 text-xs leading-5" style={{ color: theme.node.muted }}>{section.content}</p></section>)}
{preset.profile?.negativePrompt ? <section className="border-b py-3 last:border-b-0" style={{ borderColor: theme.node.stroke }}><h3 className="text-xs font-semibold">全局负面 Prompt</h3><p className="mt-1.5 whitespace-pre-wrap text-xs leading-5" style={{ color: theme.node.muted }}>{preset.profile.negativePrompt}</p></section> : null}
</div>
<footer className="flex shrink-0 flex-wrap justify-end gap-2 border-t px-4 py-3 sm:px-5" style={{ borderColor: theme.node.stroke }}><Button onClick={onClose}>关闭</Button>{onSelect ? <Button type="primary" disabled={selected} icon={selected ? <Check className="size-3.5" /> : <Palette className="size-3.5" />} onClick={() => onSelect(preset)}>{selected ? "当前画风" : "选择该画风"}</Button> : null}</footer>
</div> : null}
</AppModal>
);
}
function StyleDetailMetric({ label, value, muted }: { label: string; value: string; muted: string }) {
return <div className="min-w-0 px-3 py-2.5 text-center"><span className="block" style={{ color: muted }}>{label}</span><span className="mt-0.5 block truncate font-medium">{value}</span></div>;
}
function kindOptionsLabel(kind: string) {
return kind === "lora" ? "LoRA" : kind === "template" ? "图片模板" : kind === "reference" ? "参考图组" : "提示词模块";
}
function parseStyleSections(prompt: string) {
return prompt.split("\n").map((line) => {
const match = line.match(/^【([^】]+)】(.*)$/);
return match ? { title: match[1], content: match[2] } : { title: "补充规范", content: line };
}).filter((section) => section.content);
}