819 lines
36 KiB
TypeScript
819 lines
36 KiB
TypeScript
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),
|
||
};
|
||
}
|