Your Name d72ca0b344
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix: polish style pro model visibility and billing
2026-05-20 14:26:24 +08:00

819 lines
36 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.

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<StyleProOption, "prompt">;
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<string, string[]>;
export type StyleProCustomInputMap = {
global?: Record<string, string>;
categories?: Record<string, Record<string, string>>;
};
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<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
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<T extends { order: number; id: string }>(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),
};
}