Your Name 5387d6f1d2
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: add style pro canvas workspace
2026-05-16 15:06:12 +08:00

418 lines
14 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 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),
};
}