418 lines
14 KiB
TypeScript
418 lines
14 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 StyleProCategory = {
|
||
id: string;
|
||
title: string;
|
||
description?: string;
|
||
required: boolean;
|
||
minSelections: number;
|
||
maxSelections: number;
|
||
enabled: boolean;
|
||
order: number;
|
||
options: StyleProOption[];
|
||
};
|
||
|
||
export type StyleProConfig = {
|
||
enabled: boolean;
|
||
modelId: string;
|
||
defaultPrompt: string;
|
||
negativePrompt: string;
|
||
aspectRatio: string;
|
||
imageSize: string;
|
||
categories: StyleProCategory[];
|
||
};
|
||
|
||
export type PublicStyleProOption = Omit<StyleProOption, "prompt">;
|
||
|
||
export type PublicStyleProCategory = Omit<StyleProCategory, "options"> & {
|
||
options: PublicStyleProOption[];
|
||
};
|
||
|
||
export type PublicStyleProConfig = Omit<
|
||
StyleProConfig,
|
||
"modelId" | "defaultPrompt" | "negativePrompt" | "categories"
|
||
> & {
|
||
categories: PublicStyleProCategory[];
|
||
};
|
||
|
||
export type StyleProSelectionMap = Record<string, string[]>;
|
||
|
||
export type StyleProHistoryItem = {
|
||
id: string;
|
||
sourceImageUrl: string;
|
||
resultImageUrl: string;
|
||
selections: StyleProSelectionMap;
|
||
optionLabels: string[];
|
||
createdAt: string;
|
||
model?: string;
|
||
modelRef?: string;
|
||
width?: number;
|
||
height?: number;
|
||
};
|
||
|
||
export type StyleProPersistedState = {
|
||
sourceImageUrl?: string;
|
||
history: StyleProHistoryItem[];
|
||
};
|
||
|
||
const DEFAULT_PROMPT = [
|
||
"你是专业商业视觉风格化摄影师。",
|
||
"请以用户上传的图片作为主体参考,保持主体结构、数量、相对位置和核心识别特征一致。",
|
||
"可以根据用户选择调整光线、材质、氛围、机位与画面质感。",
|
||
"不要新增无关主体,不要改变产品类别,不要破坏原始构图逻辑。",
|
||
].join("\n");
|
||
|
||
export const defaultStyleProConfig: StyleProConfig = {
|
||
enabled: true,
|
||
modelId: "",
|
||
defaultPrompt: DEFAULT_PROMPT,
|
||
negativePrompt: "低清晰度,畸变,文字乱码,主体变形,产品结构错误,多余物体,水印",
|
||
aspectRatio: "auto",
|
||
imageSize: "2K",
|
||
categories: [
|
||
{
|
||
id: "audience",
|
||
title: "人群选择",
|
||
description: "定义画面服务的人群与生活方式基调。",
|
||
required: true,
|
||
minSelections: 1,
|
||
maxSelections: 1,
|
||
enabled: true,
|
||
order: 10,
|
||
options: [
|
||
{
|
||
id: "young-family",
|
||
label: "有孩家庭",
|
||
description: "温暖、亲近、真实生活气息。",
|
||
prompt:
|
||
"目标人群为有孩家庭,画面需要温暖、亲近、可信,呈现真实生活场景中的舒适使用感。",
|
||
jsonKey: "audience",
|
||
enabled: true,
|
||
order: 10,
|
||
},
|
||
{
|
||
id: "young-couple",
|
||
label: "年轻情侣 / 夫妻",
|
||
description: "轻松、共享空间、现代审美。",
|
||
prompt:
|
||
"目标人群为年轻情侣或年轻夫妻,画面强调共享空间、轻松陪伴和现代审美。",
|
||
jsonKey: "audience",
|
||
enabled: true,
|
||
order: 20,
|
||
},
|
||
{
|
||
id: "solo-youth",
|
||
label: "独居青年",
|
||
description: "自我表达、松弛、精致小空间。",
|
||
prompt:
|
||
"目标人群为独居青年,画面强调自我表达、松弛感、精致小空间和个人生活方式。",
|
||
jsonKey: "audience",
|
||
enabled: true,
|
||
order: 30,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "style",
|
||
title: "主题风格",
|
||
description: "确定整体视觉方向。",
|
||
required: true,
|
||
minSelections: 1,
|
||
maxSelections: 1,
|
||
enabled: true,
|
||
order: 20,
|
||
options: [
|
||
{
|
||
id: "modern-natural",
|
||
label: "自然生活",
|
||
description: "自然光、柔和材质、干净舒服。",
|
||
prompt:
|
||
"主题风格为自然生活方式,使用自然光、柔和材质、干净舒适的空间层次,避免过度商业棚拍感。",
|
||
jsonKey: "style",
|
||
enabled: true,
|
||
order: 10,
|
||
},
|
||
{
|
||
id: "commercial-premium",
|
||
label: "商业高级",
|
||
description: "精致布光、高质感、成片感强。",
|
||
prompt:
|
||
"主题风格为商业高级视觉,画面需要精致布光、高质感材质、清晰主体和成熟广告成片感。",
|
||
jsonKey: "style",
|
||
enabled: true,
|
||
order: 20,
|
||
},
|
||
{
|
||
id: "retro-warm",
|
||
label: "复古暖调",
|
||
description: "胶片感、温暖、怀旧但干净。",
|
||
prompt:
|
||
"主题风格为复古暖调,保留干净商业画质,同时加入温暖胶片质感和轻微怀旧氛围。",
|
||
jsonKey: "style",
|
||
enabled: true,
|
||
order: 30,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "light",
|
||
title: "光氛选择",
|
||
description: "定义光线时间和明暗关系。",
|
||
required: true,
|
||
minSelections: 1,
|
||
maxSelections: 1,
|
||
enabled: true,
|
||
order: 30,
|
||
options: [
|
||
{
|
||
id: "sunrise-edge",
|
||
label: "日出边缘光",
|
||
description: "短暂临界时段,明暗对比强。",
|
||
prompt:
|
||
"时间效果:太阳上边缘接触地平线至完全跃出地平线的 1-3 分钟;光线特征:直射光强但不刺眼,红橙光占比高,色温约 1800K-2500K,带有明显体积感;视觉特征:以主体为核心视点,地面形成长阴影,画面暖调饱满,明暗对比强烈。",
|
||
jsonKey: "light",
|
||
enabled: true,
|
||
order: 10,
|
||
},
|
||
{
|
||
id: "soft-window",
|
||
label: "自然窗光",
|
||
description: "柔和、真实、生活感。",
|
||
prompt:
|
||
"光线为自然窗光,柔和漫射,保留真实阴影层次,画面舒适通透,避免硬阴影和过曝。",
|
||
jsonKey: "light",
|
||
enabled: true,
|
||
order: 20,
|
||
},
|
||
{
|
||
id: "cinematic",
|
||
label: "电影氛围光",
|
||
description: "方向明确,层次和空间感强。",
|
||
prompt:
|
||
"光线为电影氛围光,方向明确,明暗层次清晰,空间纵深强,主体边缘有细腻轮廓光。",
|
||
jsonKey: "light",
|
||
enabled: true,
|
||
order: 30,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: "camera",
|
||
title: "机位选择",
|
||
description: "控制视角和镜头语言。",
|
||
required: false,
|
||
minSelections: 0,
|
||
maxSelections: 1,
|
||
enabled: true,
|
||
order: 40,
|
||
options: [
|
||
{
|
||
id: "wide-perspective",
|
||
label: "广角透视",
|
||
description: "空间开阔,纵深明显。",
|
||
prompt:
|
||
"机位使用广角透视,空间开阔,纵深明显,但主体比例保持自然,不产生夸张畸变。",
|
||
jsonKey: "camera",
|
||
enabled: true,
|
||
order: 10,
|
||
},
|
||
{
|
||
id: "eye-level",
|
||
label: "平视机位",
|
||
description: "稳定真实,适合商品主体。",
|
||
prompt:
|
||
"机位使用平视视角,画面稳定真实,主体呈现清晰完整,适合商业商品展示。",
|
||
jsonKey: "camera",
|
||
enabled: true,
|
||
order: 20,
|
||
},
|
||
{
|
||
id: "close-detail",
|
||
label: "近景细节",
|
||
description: "突出材质和光影细节。",
|
||
prompt:
|
||
"机位偏近景细节,突出材质、纹理、边缘光和主体工艺感,同时保留可识别的整体结构。",
|
||
jsonKey: "camera",
|
||
enabled: true,
|
||
order: 30,
|
||
},
|
||
],
|
||
},
|
||
],
|
||
};
|
||
|
||
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 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 sortByOrder<T extends { order: number; id: string }>(items: T[]) {
|
||
return [...items].sort((a, b) => a.order - b.order || a.id.localeCompare(b.id));
|
||
}
|
||
|
||
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),
|
||
options: sortByOrder(options),
|
||
} satisfies StyleProCategory;
|
||
})
|
||
.filter((category) => category.title && category.options.length > 0);
|
||
|
||
return {
|
||
enabled: typeof raw.enabled === "boolean" ? raw.enabled : fallback.enabled,
|
||
modelId: cleanString(raw.modelId, fallback.modelId, 160),
|
||
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),
|
||
categories: sortByOrder(categories.length ? categories : fallback.categories),
|
||
};
|
||
}
|
||
|
||
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 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)
|
||
: [],
|
||
createdAt:
|
||
cleanString(itemRaw.createdAt, "", 80) || new Date(0).toISOString(),
|
||
};
|
||
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;
|
||
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 {
|
||
return {
|
||
enabled: config.enabled,
|
||
aspectRatio: config.aspectRatio,
|
||
imageSize: config.imageSize,
|
||
categories: config.categories
|
||
.filter((category) => category.enabled)
|
||
.map((category) => ({
|
||
...category,
|
||
options: category.options
|
||
.filter((option) => option.enabled)
|
||
.map(({ prompt: _prompt, ...option }) => option),
|
||
}))
|
||
.filter((category) => category.options.length > 0),
|
||
};
|
||
}
|