export const STYLE_PRO_CONFIG_KEY = "style_pro_config"; export type StyleProOption = { id: string; label: string; description?: string; prompt: string; jsonKey?: string; enabled: boolean; order: number; }; export type StyleProCustomFieldType = "text" | "textarea" | "color"; export type StyleProCustomField = { id: string; label: string; description?: string; type: StyleProCustomFieldType; placeholder?: string; defaultValue?: string; promptTemplate?: string; required: boolean; enabled: boolean; order: number; maxLength?: number; }; export type StyleProCategory = { id: string; title: string; description?: string; required: boolean; minSelections: number; maxSelections: number; enabled: boolean; order: number; customFields: StyleProCustomField[]; options: StyleProOption[]; }; export type StyleProPromptDisplayMode = "stacked" | "carousel"; export type StyleProGenerationMode = "style" | "realistic"; export type StyleProConfig = { enabled: boolean; modelId: string; realisticFirstModelId: string; realisticSecondModelId: string; realisticFirstPrompt: string; realisticSecondPrompt: string; titleTextColor: string; buttonBackgroundColor: string; buttonTextColor: string; selectedButtonBackgroundColor: string; selectedButtonTextColor: string; defaultPrompt: string; negativePrompt: string; aspectRatio: string; imageSize: string; promptDisplayMode: StyleProPromptDisplayMode; customFields: StyleProCustomField[]; categories: StyleProCategory[]; }; export type PublicStyleProOption = Omit; export type PublicStyleProCustomField = Omit< StyleProCustomField, "promptTemplate" >; export type PublicStyleProCategory = Omit< StyleProCategory, "customFields" | "options" > & { customFields: PublicStyleProCustomField[]; options: PublicStyleProOption[]; }; export type PublicStyleProConfig = Omit< StyleProConfig, | "modelId" | "realisticFirstModelId" | "realisticSecondModelId" | "realisticFirstPrompt" | "realisticSecondPrompt" | "defaultPrompt" | "negativePrompt" | "customFields" | "categories" > & { realisticModeAvailable: boolean; customFields: PublicStyleProCustomField[]; categories: PublicStyleProCategory[]; }; export type StyleProSelectionMap = Record; export type StyleProCustomInputMap = { global?: Record; categories?: Record>; }; export type StyleProHistoryItem = { id: string; sourceImageUrl: string; resultImageUrl: string; selections: StyleProSelectionMap; optionLabels: string[]; customInputs?: StyleProCustomInputMap; createdAt: string; generationMode?: StyleProGenerationMode; model?: string; modelRef?: string; width?: number; height?: number; }; export type StyleProPersistedState = { sourceImageUrl?: string; history: StyleProHistoryItem[]; }; const DEFAULT_PROMPT = [ "你是专业商业视觉风格化摄影师。", "请以用户上传的图片作为主体参考,保持主体结构、数量、相对位置和核心识别特征一致。", "可以根据用户选择调整光线、材质、氛围、机位与画面质感。", "不要新增无关主体,不要改变产品类别,不要破坏原始构图逻辑。", ].join("\n"); const DEFAULT_REALISTIC_FIRST_PROMPT = [ "你是专业商业摄影实景化重塑摄影师。", "请先基于用户上传图片生成一张真实摄影感的中间图。", "保持主体结构、比例、数量、位置和核心识别特征一致。", "只优化真实光影、材质、环境细节和空间可信度,不新增无关主体。", ].join("\n"); const DEFAULT_REALISTIC_SECOND_PROMPT = [ "你是专业商业视觉最终成片摄影师。", "请基于上一阶段中间图进行最终实景化成片。", "严格保持主体结构和画面逻辑,融合用户选择的隐藏 JSON 提示词。", "输出真实、自然、可商用的最终图片。", ].join("\n"); function presetOption( id: string, label: string, prompt: string, jsonKey: string, order: number ): StyleProOption { return { id, label, description: prompt.length > 52 ? `${prompt.slice(0, 52)}...` : prompt, prompt, jsonKey, enabled: true, order, }; } function presetCategory(params: { id: string; title: string; description: string; jsonKey: string; required?: boolean; minSelections?: number; maxSelections?: number; order: number; options: Array<{ id: string; label: string; prompt: string; }>; }): StyleProCategory { return { id: params.id, title: params.title, description: params.description, required: params.required ?? false, minSelections: params.minSelections ?? 0, maxSelections: params.maxSelections ?? 1, enabled: true, order: params.order, customFields: [], options: params.options.map((option, index) => presetOption(option.id, option.label, option.prompt, params.jsonKey, (index + 1) * 10) ), }; } const INDEX_25_STYLE_PRO_CATEGORIES: StyleProCategory[] = [ presetCategory({ id: "scene-time", title: "时点", description: "来自 index (25).html 的时点与天气预设,用于控制时间边界、色温和环境光感。", jsonKey: "场景类型", required: true, minSelections: 1, maxSelections: 1, order: 10, options: [ { id: "dawn_twilight", label: "黎明", prompt: "时间边界:日出前 30 分钟至日出瞬间,太阳高度角处于 -6°~0°。地平线有暖橙红渐变,无直射太阳光,以大气层散射漫射光为主,暗部细节丰富,天空从暖红橙过渡到淡蓝青蓝。", }, { id: "sunrise", label: "日出", prompt: "日出:时间边界为太阳上边缘接触地平线至完全跃出地平线的 1-3 分钟。太阳直射光强但不刺眼,红橙光占比高,色温 1800K-2500K,光线有明显体积感,长阴影、星芒和地平线光晕明显。", }, { id: "golden", label: "黄金时刻", prompt: "黄金时刻:日出后或日落前约 1 小时,太阳低角度斜射,色温 2800K-4000K,方向明确但柔和,阴影绵长,高光不过曝,暖调质感、通透空气和高饱和度明显。", }, { id: "early_morning", label: "清晨", prompt: "清晨:日出后 1 小时至正午前 3 小时,太阳高度逐步升高,直射光适中,阴影清晰但不生硬,色温 4000K-5200K,画面明暗对比适中、色彩还原准确、空间纵深感强。", }, { id: "day", label: "日景", prompt: "日景:白天常态时段,稳定太阳直射光,色温 5200K-5800K,曝光充足,细节完整,色彩还原真实,空间感和立体感强,是最通用的日常场景。", }, { id: "sunset", label: "日落", prompt: "日落:日落前约 1 小时,红橙色光更强,色温 2700K-3800K,空气湿度和体积光更明显,天空偏橙红或粉紫渐变,长阴影柔和,氛围浓烈。", }, { id: "blue", label: "蓝调", prompt: "蓝调时刻:日出前或日落后 20-30 分钟,太阳完全低于地平线,仅有均匀冷蓝散射天光,色温 9000K-11000K,人工暖光形成强烈冷暖对比,画面干净通透。", }, { id: "night", label: "夜景", prompt: "夜景:无自然直射光,以月光、星光、城市人工光为主,明暗反差强,暗部占比高,亮部集中在光源区域,暖黄、冷蓝、霓虹和月光形成丰富色彩层次。", }, { id: "sunny", label: "晴天", prompt: "晴天:云量低、太阳完整可见,蓝天纯净,直射硬光强,阴影锐利,色温约 5500K,空气通透度高,高对比、高饱和、细节清晰。", }, { id: "overcast", label: "全阴", prompt: "全阴:厚云完全覆盖,太阳轮廓不可见,光线完全漫射且无方向性,色温 6000K-7000K,整体微冷,低对比、低饱和、阴影细节丰富但层次较弱。", }, { id: "after_rain", label: "雨霁", prompt: "雨霁:雨后云层散开,空气尘埃被清洗,通透度极高,地面积水形成镜面反射,色彩饱满,残云、彩虹、水面反光和体积光带来丰富层次。", }, { id: "morning_fog", label: "晨雾", prompt: "晨雾:秋冬清晨低层雾气,日出前形成,日出后 1-2 小时消散。漫射柔光穿过雾气形成强散射和体积感,近实远虚,空间纵深强,易出现丁达尔效应。", }, ], }), presetCategory({ id: "sunlight", title: "光照", description: "来自 index (25).html 的太阳光照预设,用于控制直射、投影和特殊光学效果。", jsonKey: "太阳光照", required: true, minSelections: 1, maxSelections: 1, order: 20, options: [ { id: "blocked", label: "太阳遮挡", prompt: "太阳存在但被建筑物或云层遮挡,无直射光线,仅保留环境光晕和柔和漫射光。", }, { id: "yes", label: "有太阳", prompt: "出现明确太阳直射光,太阳光源可感知,方向性强,阴影清晰,明亮暖调照明。", }, { id: "blinds", label: "百叶光影", prompt: "太阳光穿过百叶窗、格栅或栏杆形成平行线性光束和等距明暗条纹,边界清晰,几何秩序强,可伴随丁达尔效应。", }, { id: "tree", label: "树影交散", prompt: "太阳光穿过树冠和枝叶,形成自然交错的叶影投影,阴影不规则、有轻微风动感,画面自然鲜活。", }, { id: "sunlight_dappled", label: "日照光斑", prompt: "阳光穿过叶片缝隙形成针孔成像般的圆形光斑,亮度高于周围阴影,随机分布并形成光暗交织的自然斑驳效果。", }, { id: "silhouette", label: "剪影投影", prompt: "强逆光或低角度光线形成明确剪影,主体边缘清晰,地面或墙面有长投影,明暗对比强烈。", }, { id: "window_grid", label: "窗格投影", prompt: "阳光穿过窗框形成矩形网格投影,光影边界清晰,带有建筑结构秩序感和室内空间层次。", }, { id: "tyndall", label: "丁达尔效应", prompt: "空气中有雾气、尘埃或水汽,光束路径可见,形成强烈体积光和空间纵深,光线穿透感明显。", }, { id: "crepuscular", label: "云隙光", prompt: "太阳光从云层缝隙穿出,形成放射状光束,天空和地面出现强烈体积光,画面有神圣、戏剧化氛围。", }, { id: "glory", label: "宝光", prompt: "逆光雾气或云雾环境中出现环状彩色光晕,围绕观察方向形成柔和神秘的光学效果。", }, ], }), presetCategory({ id: "design-style", title: "设计风格", description: "来自 index (25).html 的设计风格预设,用于控制空间、产品与软装的审美方向。", jsonKey: "设计风格", required: false, maxSelections: 1, order: 30, options: [ { id: "new_chinese", label: "新中式", prompt: "新中式设计风格,东方秩序、现代比例、克制留白与雅致材质结合。" }, { id: "french", label: "法式", prompt: "法式设计风格,优雅线条、精致雕花、浪漫柔和的空间气质。" }, { id: "italian_minimal", label: "意式极简", prompt: "意式极简设计风格,低饱和高级材质、简洁线条、克制但有质感。" }, { id: "modern", label: "现代", prompt: "现代设计风格,清晰结构、简洁界面、实用比例与干净视觉秩序。" }, { id: "light_luxury", label: "轻奢风", prompt: "轻奢风设计,金属、石材、皮革等精致材质搭配,强调高级但不过度浮夸。" }, { id: "premium_gray", label: "高级灰", prompt: "高级灰风格,低饱和灰阶体系,现代艺术感和沉稳空间气质。" }, { id: "minimalist", label: "极简主义", prompt: "极简主义风格,减少装饰、强调留白、体块比例和材质本身。" }, { id: "nordic", label: "北欧", prompt: "北欧设计风格,明亮自然、木质温度、轻盈家具和舒适生活感。" }, { id: "cream", label: "奶油风", prompt: "奶油风设计,柔和暖白、圆润体块、低对比材质和温馨包裹感。" }, { id: "wabi_sabi", label: "诧寂风", prompt: "侘寂风格,自然粗粝材质、不完美之美、东方禅意与静谧留白。" }, { id: "japanese", label: "日式", prompt: "日式设计风格,木质、纸感、低矮尺度、清爽秩序和自然光。" }, { id: "natural_wood", label: "原木风", prompt: "原木风设计,大面积木纹、自然肌理、温暖生活气息和简洁空间线条。" }, { id: "american_vintage", label: "美式复古", prompt: "美式复古风格,厚重家具、复古色调、旧木质感与温暖怀旧氛围。" }, { id: "loft", label: "Loft风", prompt: "Loft 风格,开放空间、工业结构、裸露管线、金属和混凝土材质。" }, { id: "industrial", label: "工业风", prompt: "工业风设计,黑铁、混凝土、裸露结构、粗粝材质和强烈空间个性。" }, { id: "art_deco", label: "Art Deco", prompt: "Art Deco 风格,几何装饰、金属线条、对称秩序和复古奢华感。" }, ], }), presetCategory({ id: "atmosphere", title: "氛围", description: "来自 index (25).html 的氛围预设,用于控制整体情绪、色调和商业质感。", jsonKey: "场景氛围", required: false, maxSelections: 1, order: 40, options: [ { id: "clean", label: "干净通透", prompt: "干净通透,自然光充沛,空间明亮开阔。" }, { id: "warm", label: "温暖柔和", prompt: "温暖柔和,暖色光源,生活气息浓厚。" }, { id: "cloudy_soft", label: "阴天柔光", prompt: "阴天柔光,漫射天光透过窗户进入室内,整体光线均匀柔和,无明显阴影。" }, { id: "cinematic", label: "电影感", prompt: "电影级布光,戏剧化光影,高动态光比。" }, { id: "natural_real", label: "自然纪实", prompt: "自然纪实,真实光感,最少人工干预。" }, { id: "commercial", label: "商业质感", prompt: "商业摄影,高对比强饱和,强调材质质感。" }, { id: "film", label: "胶片感", prompt: "胶片感,轻微颗粒,柔和色调,温暖复古氛围。" }, { id: "cold_gray", label: "冷调高级灰", prompt: "冷调高级灰,低饱和,现代科技感。" }, { id: "dramatic", label: "戏剧光影", prompt: "戏剧性光影,明暗对比强烈,电影叙事感。" }, { id: "dreamy", label: "梦幻朦胧", prompt: "梦幻朦胧,柔焦质感,浪漫唯美。" }, { id: "cyber", label: "赛博朋克", prompt: "赛博朋克,霓虹灯光,强烈色彩对比。" }, { id: "vintage", label: "复古怀旧", prompt: "复古怀旧,暖黄调,旧时光质感。" }, { id: "minimal_white", label: "极简留白", prompt: "极简留白,纯净空间,大量留白。" }, { id: "deep", label: "深邃静谧", prompt: "深邃静谧,暗色调为主,少量重点照明,神秘高级感。" }, ], }), presetCategory({ id: "view", title: "外景", description: "来自 index (25).html 的外景预设,用于给窗外或环境补充地点记忆点和空间层次。", jsonKey: "外景", required: false, maxSelections: 1, order: 50, options: [ { id: "shanghai", label: "上海", prompt: "窗外可见上海陆家嘴、黄浦江与外滩建筑群,现代繁华,蓝调时刻灯光与江面反射交织。" }, { id: "chongqing", label: "重庆", prompt: "窗外可见重庆洪崖洞、嘉陵江、千厮门大桥与渝中半岛天际线,传统烟火气与赛博城市感并存。" }, { id: "beijing", label: "北京", prompt: "窗外可见北京故宫中轴线、红墙黄瓦、飞檐斗拱与景山视野,庄重宏大、古典秩序强。" }, { id: "hangzhou", label: "杭州", prompt: "窗外可见杭州西湖、雷峰塔、苏堤白堤与远山水面,晨雾或夕阳下治愈、轻盈、诗意。" }, { id: "hongkong", label: "香港", prompt: "窗外可见香港维多利亚港、中环天际线和海面反射,蓝调时刻霓虹璀璨,都市感强。" }, { id: "tokyo", label: "东京", prompt: "窗外可见东京塔、晴空塔、东京湾或隅田川与现代都市群,日系现代城市氛围。" }, { id: "paris", label: "巴黎", prompt: "窗外可见巴黎埃菲尔铁塔、塞纳河、亚历山大三世桥和奥斯曼建筑群,浪漫、优雅、金色光感。" }, { id: "iceland", label: "冰岛", prompt: "窗外可见冰岛极光、冰川、黑沙滩或荒原地貌,冷调、空旷、自然奇观感强。" }, { id: "sanya", label: "海岛", prompt: "窗外可见海岛、海面、沙滩与热带植被,明亮通透,度假感和松弛感强。" }, { id: "bamboo", label: "竹林", prompt: "窗外可见竹林,竖向线条密集,自然绿意和东方静谧感明显,增加空间层次。" }, { id: "snow_mountain", label: "雪山", prompt: "窗外可见雪山远景,冷白高光、蓝灰阴影和清澈空气,空间开阔且有自然史诗感。" }, { id: "desert", label: "沙漠", prompt: "窗外可见沙漠或戈壁,暖色沙丘、低饱和大地色和宽广留白,画面安静、有地域感。" }, ], }), presetCategory({ id: "lighting-system", title: "照明系统", description: "来自 index (25).html 的照明系统预设,可多选叠加灯具与光源类型。", jsonKey: "照明系统", required: false, minSelections: 0, maxSelections: 3, order: 60, options: [ { id: "off", label: "无", prompt: "未选择人工照明系统,保持原始环境光,不额外添加灯具。" }, { id: "ceiling_spot", label: "天花射灯", prompt: "嵌入式天花防眩射灯,3000K-4000K,中高显色指数,精准下照,光斑清晰但不过曝。" }, { id: "ceiling_strip", label: "天花灯带", prompt: "悬浮吊顶嵌入式线性灯带,间接漫射洗顶,光线均匀无断点,与吊顶结构齐平隐藏安装。" }, { id: "ceiling_pendant", label: "天花吊灯", prompt: "天花悬挂式主吊灯,现代极简或轻奢造型,柔光漫射,灯体结构清晰,空间核心区域照明均匀。" }, { id: "cabinet", label: "柜体灯带", prompt: "柜体内嵌线性灯带,柔和照亮层板、展示物或收纳区域,见光不见灯,提升材质层次。" }, { id: "curtain", label: "窗帘盒灯带", prompt: "窗帘盒隐藏灯带向下或向墙面漫射,营造柔和边界光和空间包裹感。" }, { id: "table", label: "台灯", prompt: "桌面台灯或床头台灯,暖白柔光,形成局部重点照明和生活化氛围。" }, { id: "magnetic", label: "磁吸轨道灯", prompt: "磁吸轨道灯系统,线性轨道和可调灯头,精准控光,现代感强。" }, { id: "wall_wash", label: "洗墙灯", prompt: "洗墙灯均匀扫亮墙面,形成连续光带和立面层次,突出材质纹理。" }, { id: "mirror", label: "镜前灯", prompt: "镜前灯高显色柔光,无面部阴影,无偏色,均匀覆盖镜面和近景区域。" }, { id: "fireplace", label: "壁炉火焰光", prompt: "壁炉动态火焰光,2000K-2500K 暖橙低色温,柔和跳动光影,氛围感强。" }, { id: "candle", label: "烛光", prompt: "自然烛火光,多根蜡烛低色温暖橙光,柔和漫射,高对比光影和温暖生活感。" }, { id: "accent", label: "重点照明", prompt: "空间重点照明,可调角度窄光束射灯,聚焦装饰画、雕塑、软装摆件或端景墙,突出细节和层次。" }, ], }), presetCategory({ id: "render-style", title: "画面表现", description: "来自 index (25).html 的表现风格预设,用于控制最终成片语言。", jsonKey: "画面表现", required: true, minSelections: 1, maxSelections: 1, order: 70, options: [ { id: "natural_doc", label: "真实环境", prompt: "自然纪实风格,真实光感,最少后期干预,保留原生现场细节,标准人眼透视,轻微胶片颗粒。" }, { id: "cinematic_real", label: "电影级写实", prompt: "写实室内摄影,质感通透,细节丰富,电影级后期调色,标准镜头视角,精准光影,真实材质质感。" }, { id: "arch_viz", label: "建筑效果图", prompt: "建筑效果图标准,清晰真实,专业表达,无过度后期,准确空间比例,合理光影分布。" }, { id: "hollywood", label: "好莱坞", prompt: "cinematography,Wide Color Gamut,高光柔和,阴影细节丰富,电影叙事感,戏剧性光影对比。" }, { id: "cyber_film", label: "赛博朋克", prompt: "银翼杀手2049冷蓝调,高对比暗部,青色高光,赛博朋克氛围,霓虹灯反射。" }, { id: "wes_anderson", label: "韦斯安德森风", prompt: "布达佩斯大饭店粉调,柔和粉彩色系,复古浪漫氛围,对称构图感,高度风格化的色彩配置。" }, { id: "japan_fresh", label: "日式小清新", prompt: "日系小清新,偏高曝光,柔和暖色,治愈氛围,清透空气感。" }, { id: "morandi", label: "莫兰迪风", prompt: "莫兰迪色系,中性灰调,低饱和,优雅克制,画廊级配色。" }, { id: "commercial_mag", label: "商业杂志风", prompt: "高对比强饱和,杂志级商业效果图,精致时髦,封面级出片。" }, { id: "japan_film", label: "日系胶片", prompt: "日系胶片感,轻微颗粒,柔和色调,温暖复古氛围,参考日本室内设计杂志。" }, { id: "low_sat", label: "高级灰", prompt: "低饱和高级灰,高级感,当代艺术调色,参考 Architectural Digest。" }, { id: "cream_warm", label: "奶油风", prompt: "奶油暖调色系,柔和质感,温馨氛围,参考奶油风设计杂志。" }, { id: "korean", label: "韩式", prompt: "韩式风格,柔和莫兰迪粉调、极简留白、自然光渗透、干花与亚麻质感、生活方式杂志质感。" }, { id: "wabi_sabi", label: "侘寂", prompt: "侘寂风格,不完美之美、自然材质老化肌理、手工粗粝感、非对称平衡和东方禅意静谧氛围。" }, { id: "impressionist", label: "油画", prompt: "油画印象派风格,可见笔触肌理、莫奈式光色捕捉、朦胧柔焦和画布纹理。" }, ], }), ]; export const defaultStyleProConfig: StyleProConfig = { enabled: true, modelId: "", realisticFirstModelId: "", realisticSecondModelId: "", realisticFirstPrompt: DEFAULT_REALISTIC_FIRST_PROMPT, realisticSecondPrompt: DEFAULT_REALISTIC_SECOND_PROMPT, titleTextColor: "#2563eb", buttonBackgroundColor: "#ffffff", buttonTextColor: "#374151", selectedButtonBackgroundColor: "#eff6ff", selectedButtonTextColor: "#2563eb", defaultPrompt: DEFAULT_PROMPT, negativePrompt: "低清晰度,畸变,文字乱码,主体变形,产品结构错误,多余物体,水印", aspectRatio: "auto", imageSize: "2K", promptDisplayMode: "stacked", customFields: [], categories: INDEX_25_STYLE_PRO_CATEGORIES, }; function asRecord(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; } function cleanString(value: unknown, fallback = "", maxLength = 4000) { const text = String(value ?? "").trim(); return (text || fallback).slice(0, maxLength); } function cleanHexColor(value: unknown, fallback: string) { const text = cleanString(value, fallback, 32); return /^#(?:[0-9a-fA-F]{3}){1,2}$/.test(text) ? text : fallback; } function cleanId(value: unknown, fallback: string) { const text = cleanString(value, fallback, 80) .toLowerCase() .replace(/[^a-z0-9_-]+/g, "-") .replace(/^-+|-+$/g, ""); return text || fallback; } function cleanNumber(value: unknown, fallback: number, min: number, max: number) { const numeric = Number(value); if (!Number.isFinite(numeric)) return fallback; return Math.min(max, Math.max(min, Math.round(numeric))); } function cleanPromptDisplayMode(value: unknown): StyleProPromptDisplayMode { return value === "carousel" ? "carousel" : "stacked"; } function cleanCustomFieldType(value: unknown): StyleProCustomFieldType { return value === "color" || value === "textarea" ? value : "text"; } function cleanCustomField( value: unknown, index: number, fallbackIdPrefix: string ): StyleProCustomField | null { const raw = asRecord(value); const type = cleanCustomFieldType(raw.type); const id = cleanId(raw.id, `${fallbackIdPrefix}-field-${index + 1}`); const label = cleanString(raw.label, `自定义项 ${index + 1}`, 80); if (!label) return null; const fallbackMaxLength = type === "textarea" ? 800 : 160; return { id, label, description: cleanString(raw.description, "", 240) || undefined, type, placeholder: cleanString(raw.placeholder, "", 160) || undefined, defaultValue: (type === "color" ? cleanHexColor(raw.defaultValue, "") : cleanString(raw.defaultValue, "", type === "textarea" ? 800 : 160)) || undefined, promptTemplate: cleanString( raw.promptTemplate, "{{fieldLabel}}:{{value}}", 1000 ) || undefined, required: typeof raw.required === "boolean" ? raw.required : false, enabled: typeof raw.enabled === "boolean" ? raw.enabled : true, order: cleanNumber(raw.order, (index + 1) * 10, -9999, 9999), maxLength: cleanNumber(raw.maxLength, fallbackMaxLength, 1, 2000), }; } function cleanCustomFields( value: unknown, fallbackIdPrefix: string ): StyleProCustomField[] { if (!Array.isArray(value)) return []; return sortByOrder( value .map((entry, index) => cleanCustomField(entry, index, fallbackIdPrefix)) .filter((field): field is StyleProCustomField => Boolean(field)) ); } function sortByOrder(items: T[]) { return [...items].sort((a, b) => a.order - b.order || a.id.localeCompare(b.id)); } function isLegacyDefaultCategories(categories: StyleProCategory[]) { const ids = categories.map((category) => category.id).join(","); return ids === "audience,style,light,camera"; } export function normalizeStyleProConfig(value: unknown): StyleProConfig { const raw = asRecord(value); const fallback = defaultStyleProConfig; const rawCategories = Array.isArray(raw.categories) ? raw.categories : fallback.categories; const categories = rawCategories .map((categoryValue, categoryIndex) => { const categoryRaw = asRecord(categoryValue); const id = cleanId(categoryRaw.id, `category-${categoryIndex + 1}`); const rawOptions = Array.isArray(categoryRaw.options) ? categoryRaw.options : []; const maxSelections = cleanNumber(categoryRaw.maxSelections, 1, 1, 20); const minSelections = cleanNumber( categoryRaw.minSelections, categoryRaw.required === false ? 0 : 1, 0, maxSelections ); const options = rawOptions .map((optionValue, optionIndex) => { const optionRaw = asRecord(optionValue); const optionId = cleanId(optionRaw.id, `${id}-option-${optionIndex + 1}`); return { id: optionId, label: cleanString(optionRaw.label, `选项 ${optionIndex + 1}`, 80), description: cleanString(optionRaw.description, "", 240) || undefined, prompt: cleanString(optionRaw.prompt, "", 4000), jsonKey: cleanString(optionRaw.jsonKey, "", 80) || undefined, enabled: typeof optionRaw.enabled === "boolean" ? optionRaw.enabled : true, order: cleanNumber(optionRaw.order, (optionIndex + 1) * 10, -9999, 9999), } satisfies StyleProOption; }) .filter((option) => option.label && option.prompt); return { id, title: cleanString(categoryRaw.title, `分类 ${categoryIndex + 1}`, 80), description: cleanString(categoryRaw.description, "", 240) || undefined, required: typeof categoryRaw.required === "boolean" ? categoryRaw.required : true, minSelections, maxSelections, enabled: typeof categoryRaw.enabled === "boolean" ? categoryRaw.enabled : true, order: cleanNumber(categoryRaw.order, (categoryIndex + 1) * 10, -9999, 9999), customFields: cleanCustomFields(categoryRaw.customFields, id), options: sortByOrder(options), } satisfies StyleProCategory; }) .filter((category) => category.title && category.options.length > 0); const sortedCategories = sortByOrder( categories.length ? categories : fallback.categories ); return { enabled: typeof raw.enabled === "boolean" ? raw.enabled : fallback.enabled, modelId: cleanString(raw.modelId, fallback.modelId, 160), realisticFirstModelId: cleanString( raw.realisticFirstModelId, fallback.realisticFirstModelId, 160 ), realisticSecondModelId: cleanString( raw.realisticSecondModelId, fallback.realisticSecondModelId, 160 ), realisticFirstPrompt: cleanString( raw.realisticFirstPrompt, fallback.realisticFirstPrompt, 8000 ), realisticSecondPrompt: cleanString( raw.realisticSecondPrompt, fallback.realisticSecondPrompt, 8000 ), titleTextColor: cleanHexColor(raw.titleTextColor, fallback.titleTextColor), buttonBackgroundColor: cleanHexColor( raw.buttonBackgroundColor, fallback.buttonBackgroundColor ), buttonTextColor: cleanHexColor( raw.buttonTextColor, fallback.buttonTextColor ), selectedButtonBackgroundColor: cleanHexColor( raw.selectedButtonBackgroundColor, fallback.selectedButtonBackgroundColor ), selectedButtonTextColor: cleanHexColor( raw.selectedButtonTextColor, fallback.selectedButtonTextColor ), defaultPrompt: cleanString(raw.defaultPrompt, fallback.defaultPrompt, 8000), negativePrompt: cleanString(raw.negativePrompt, fallback.negativePrompt, 4000), aspectRatio: cleanString(raw.aspectRatio, fallback.aspectRatio, 32), imageSize: cleanString(raw.imageSize, fallback.imageSize, 32), promptDisplayMode: cleanPromptDisplayMode(raw.promptDisplayMode), customFields: cleanCustomFields(raw.customFields, "global"), categories: isLegacyDefaultCategories(sortedCategories) ? fallback.categories : sortedCategories, }; } export function normalizeStyleProPersistedState( value: unknown ): StyleProPersistedState { const raw = asRecord(value); const historyRaw = Array.isArray(raw.history) ? raw.history : []; const history = historyRaw .map((historyValue, index) => { const itemRaw = asRecord(historyValue); const sourceImageUrl = cleanString(itemRaw.sourceImageUrl, "", 2000); const resultImageUrl = cleanString(itemRaw.resultImageUrl, "", 2000); if (!sourceImageUrl || !resultImageUrl) return null; const selectionsRaw = asRecord(itemRaw.selections); const selections = Object.fromEntries( Object.entries(selectionsRaw).map(([key, value]) => [ cleanId(key, key), Array.isArray(value) ? value.map((entry) => cleanId(entry, "")).filter(Boolean) : [], ]) ); const customInputsRaw = asRecord(itemRaw.customInputs); const customInputCategoriesRaw = asRecord(customInputsRaw.categories); const customInputs: StyleProCustomInputMap = { global: Object.fromEntries( Object.entries(asRecord(customInputsRaw.global)) .map(([key, value]) => [ cleanId(key, key), cleanString(value, "", 2000), ]) .filter(([, value]) => Boolean(value)) ), categories: Object.fromEntries( Object.entries(customInputCategoriesRaw) .map(([categoryId, value]) => [ cleanId(categoryId, categoryId), Object.fromEntries( Object.entries(asRecord(value)) .map(([key, entryValue]) => [ cleanId(key, key), cleanString(entryValue, "", 2000), ]) .filter(([, entryValue]) => Boolean(entryValue)) ), ]) .filter(([, value]) => Object.keys(value).length > 0) ), }; const normalizedItem: StyleProHistoryItem = { id: cleanString(itemRaw.id, `style-pro-history-${index + 1}`, 120), sourceImageUrl, resultImageUrl, selections, optionLabels: Array.isArray(itemRaw.optionLabels) ? itemRaw.optionLabels .map((entry) => cleanString(entry, "", 80)) .filter(Boolean) : [], customInputs, createdAt: cleanString(itemRaw.createdAt, "", 80) || new Date(0).toISOString(), }; const generationMode = itemRaw.generationMode === "realistic" ? "realistic" : "style"; const model = cleanString(itemRaw.model, "", 120); const modelRef = cleanString(itemRaw.modelRef, "", 160); const width = cleanNumber(itemRaw.width, 0, 0, 20000); const height = cleanNumber(itemRaw.height, 0, 0, 20000); if (model) normalizedItem.model = model; if (modelRef) normalizedItem.modelRef = modelRef; normalizedItem.generationMode = generationMode; if (width) normalizedItem.width = width; if (height) normalizedItem.height = height; return normalizedItem; }) .filter((item): item is StyleProHistoryItem => Boolean(item)) .slice(0, 4); return { sourceImageUrl: cleanString(raw.sourceImageUrl, "", 2000) || undefined, history, }; } export function toPublicStyleProConfig( config: StyleProConfig ): PublicStyleProConfig { const toPublicCustomField = ({ promptTemplate: _promptTemplate, ...field }: StyleProCustomField): PublicStyleProCustomField => field; return { enabled: config.enabled, aspectRatio: config.aspectRatio, imageSize: config.imageSize, promptDisplayMode: config.promptDisplayMode, titleTextColor: config.titleTextColor, buttonBackgroundColor: config.buttonBackgroundColor, buttonTextColor: config.buttonTextColor, selectedButtonBackgroundColor: config.selectedButtonBackgroundColor, selectedButtonTextColor: config.selectedButtonTextColor, realisticModeAvailable: Boolean( config.realisticFirstModelId && config.realisticSecondModelId ), customFields: config.customFields .filter((field) => field.enabled) .map(toPublicCustomField), categories: config.categories .filter((category) => category.enabled) .map((category) => ({ ...category, customFields: category.customFields .filter((field) => field.enabled) .map(toPublicCustomField), options: category.options .filter((option) => option.enabled) .map(({ prompt: _prompt, ...option }) => option), })) .filter((category) => category.options.length > 0), }; }