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; export type PublicStyleProCategory = Omit & { options: PublicStyleProOption[]; }; export type PublicStyleProConfig = Omit< StyleProConfig, "modelId" | "defaultPrompt" | "negativePrompt" | "categories" > & { categories: PublicStyleProCategory[]; }; export type StyleProSelectionMap = Record; 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 { 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 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(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), }; }