feat(edit): AI套图生成、图片切七牛

This commit is contained in:
2026-10-04 20:47:47 +08:00
parent 696112e74d
commit e20cd7547f
60 changed files with 6241 additions and 312 deletions
+184
View File
@@ -0,0 +1,184 @@
import { z } from 'zod';
import type { MediaAiEdit } from './media';
import type { Operation } from './stores';
export const imageSuitePlanErrorMessages: Record<string, string> = {
DEEPSEEK_NOT_CONFIGURED:
'DeepSeek 未配置,请检查 Worker 的 API Key 和模型名称',
DEEPSEEK_REJECTED: 'DeepSeek 拒绝请求,请检查 API Key、模型名称和账户状态',
DEEPSEEK_RETRYABLE: 'DeepSeek 当前繁忙或限流,请稍后重新规划',
DEEPSEEK_UNAVAILABLE: '暂时无法连接 DeepSeek,请稍后重新规划',
DEEPSEEK_EMPTY_RESULT: 'DeepSeek 未返回方案内容,请稍后重新规划',
DEEPSEEK_OUTPUT_TRUNCATED: 'DeepSeek 方案过长,请精简资料后重新规划',
DEEPSEEK_INVALID_JSON: 'DeepSeek 返回格式异常,请重新规划',
AI_SUITE_INVALID_PLAN:
'AI 方案未通过张数、主题或资料校验,请调整资料后重新规划',
AI_SUITE_PLAN_SCHEMA_INVALID: 'AI 方案字段不符合要求,请重新规划',
AI_SUITE_PLAN_COUNT_MISMATCH:
'AI 返回的图片张数与本次预算不一致,请重新规划',
AI_SUITE_PLAN_MAIN_IMAGE_INVALID: 'AI 方案第一张不是白底主图,请重新规划',
AI_SUITE_PLAN_DUPLICATE_THEME: 'AI 方案出现重复主题,请重新规划',
AI_SUITE_PLAN_DIMENSIONS_UNVERIFIED:
'AI 安排了尺寸图,但缺少带单位的实物尺寸',
AI_SUITE_PLAN_SAVE_FAILED: 'AI 规划结果保存失败,请稍后重新规划',
AI_SUITE_PLAN_FAILED: '智能规划失败,请重试或继续使用当前方案',
};
export const imageSuiteCategories = [
'white',
'scene',
'selling',
'other',
] as const;
export const imageSuiteTypes = [
'white_background',
'lifestyle',
'selling_point',
'detail',
'material',
'feature',
'dimensions',
'comparison',
'accessories',
'usage',
'custom',
] as const;
export const imageSuiteShotSchema = z
.object({
id: z.uuid(),
category: z.enum(imageSuiteCategories),
type: z.enum(imageSuiteTypes),
theme: z.string().trim().min(1).max(80),
description: z.string().trim().min(1).max(1000),
evidence: z.string().trim().max(500).default(''),
reference_asset_ids: z.array(z.uuid()).min(1).max(3).optional(),
})
.strict()
.superRefine((shot, ctx) => {
const categories = {
white_background: 'white',
lifestyle: 'scene',
selling_point: 'selling',
detail: 'selling',
material: 'selling',
feature: 'selling',
dimensions: 'other',
comparison: 'other',
accessories: 'other',
usage: 'other',
custom: 'other',
};
if (categories[shot.type] !== shot.category)
ctx.addIssue({
code: 'custom',
path: ['type'],
message: '图片类型与分类不一致',
});
if (
shot.reference_asset_ids &&
new Set(shot.reference_asset_ids).size !==
shot.reference_asset_ids.length
)
ctx.addIssue({
code: 'custom',
path: ['reference_asset_ids'],
message: '参考图片不能重复',
});
});
export const imageSuiteInputSchema = z
.object({
variant_id: z.uuid(),
reference_asset_ids: z.array(z.uuid()).min(1).max(8),
product_name: z.string().trim().min(1).max(1000),
color: z.string().trim().max(500).default(''),
dimensions: z.string().trim().max(500).default(''),
weight: z.string().trim().max(500).default(''),
collected_info: z.string().trim().max(10000).default(''),
selling_points: z.string().trim().max(5000).default(''),
ratio: z.enum(['3:4', '1:1', '4:3']).default('3:4'),
language: z.enum(['ru', 'zh', 'en', 'ar']).default('ru'),
style_set: z.number().int().min(1).max(5).default(1),
style_prompt: z.string().trim().max(2000).default(''),
})
.strict()
.refine(
(input) =>
new Set(input.reference_asset_ids).size ===
input.reference_asset_ids.length,
{
path: ['reference_asset_ids'],
message: '底图不能重复',
},
);
export const imageSuitePlanInputSchema = imageSuiteInputSchema.safeExtend({
image_count: z.number().int().min(1).max(20).default(4),
});
export const createImageSuiteSchema = imageSuiteInputSchema
.safeExtend({ shots: z.array(imageSuiteShotSchema).min(1).max(20) })
.superRefine((input, ctx) => {
if (
new Set(input.shots.map((shot) => shot.id)).size !==
input.shots.length
)
ctx.addIssue({
code: 'custom',
path: ['shots'],
message: '逐张方案 ID 不能重复',
});
for (const category of imageSuiteCategories)
if (
input.shots.filter((shot) => shot.category === category)
.length > 5
)
ctx.addIssue({
code: 'custom',
path: ['shots'],
message: '每类最多生成 5 张',
});
input.shots.forEach((shot, index) => {
if (
shot.reference_asset_ids &&
shot.reference_asset_ids[0] !== input.reference_asset_ids[0]
)
ctx.addIssue({
code: 'custom',
path: ['shots', index, 'reference_asset_ids'],
message: '每张图片必须使用已选主体图作为第一张参考图',
});
if (
shot.reference_asset_ids?.some(
(id) => !input.reference_asset_ids.includes(id),
)
)
ctx.addIssue({
code: 'custom',
path: ['shots', index, 'reference_asset_ids'],
message: '逐张参考图必须来自已选底图',
});
if (shot.type === 'dimensions' && !/\d/.test(input.dimensions))
ctx.addIssue({
code: 'custom',
path: ['shots', index, 'type'],
message: '请先填写有依据的实物尺寸',
});
});
});
export type ImageSuiteInput = z.infer<typeof imageSuiteInputSchema>;
export type ImageSuitePlanInput = z.infer<typeof imageSuitePlanInputSchema>;
export type ImageSuiteShot = z.infer<typeof imageSuiteShotSchema>;
export type CreateImageSuiteRequest = z.infer<typeof createImageSuiteSchema>;
export type ImageSuite = {
id: string;
product_id: string;
variant_id: string;
purpose: 'plan' | 'generate';
status:
'queued' | 'running' | 'succeeded' | 'partial' | 'failed' | 'cancelled';
input: ImageSuiteInput | ImageSuitePlanInput;
shots: ImageSuiteShot[];
items: Array<MediaAiEdit & { shot_id: string; edit_id: string }>;
error_code: string | null;
created_at: string;
completed_at: string | null;
operation: Operation;
};
+2
View File
@@ -5,8 +5,10 @@ export * from './watermarks';
export * from './media';
export * from './product-editor';
export * from './product-ai';
export * from './ozon-hashtags';
export * from './online-products';
export * from './pricing';
export * from './pricing-admin';
export * from './category-commissions';
export * from './image-suite';
+12 -3
View File
@@ -59,9 +59,9 @@ export const createMediaAiEditRequestSchema = z.object({
reference_asset_ids: z.array(z.uuid()).max(2).default([]),
prompt: z.string().trim().max(2000).default(''),
mode: z.enum(['edit', 'outpaint']).default('edit'),
ratio: z.enum(['3:4', '1:1', '4:5']).default('3:4'),
ratio: z.enum(['3:4', '1:1', '4:5', '4:3']).default('3:4'),
translate_text: z.boolean().default(false),
target_language: z.enum(['ru', 'ar', 'en']).default('ru'),
target_language: z.enum(['ru', 'ar', 'en', 'zh']).default('ru'),
background: z.enum(['auto', 'white', 'solid', 'scene', 'reference']).default('auto'),
background_description: z.string().trim().max(500).default(''),
style_set: z.number().int().min(1).max(5).default(2),
@@ -73,6 +73,15 @@ export const createMediaAiEditRequestSchema = z.object({
unit: z.enum(['cm', 'mm']).default('cm'),
}).strict().optional(),
generate_size_chart: z.boolean().default(false),
suite_context: z.object({
type: z.string().max(80),
product_name: z.string().max(1000),
color: z.string().max(500),
dimensions: z.string().max(500),
weight: z.string().max(500),
collected_info: z.string().max(10000),
selling_points: z.string().max(5000),
}).strict().optional(),
}).strict().superRefine((input, ctx) => {
if (new Set(input.reference_asset_ids).size !== input.reference_asset_ids.length)
ctx.addIssue({ code: 'custom', message: '辅助参考图不能重复', path: ['reference_asset_ids'] });
@@ -158,7 +167,7 @@ export type MediaAiEdit = {
};
export type MediaAiEditFailureStage = 'task_setup' | 'input_preparation' | 'provider_submission'
| 'provider_polling' | 'result_download' | 'result_processing' | 'result_storage';
/** 商品页右下角任务列表的统一图片任务模型;套图任务后续使用同一结构。 */
/** 商品页任务入口统一展示单图编辑和整套生图。 */
export type ProductImageTask = {
id: string;
kind: 'single_ai_edit' | 'ai_suite';
+32
View File
@@ -0,0 +1,32 @@
export const OZON_HASHTAG_LIMIT = 30;
export const OZON_HASHTAG_MAX_LENGTH = 30;
/** 标签数组的每项只表示一个主题,空格分隔仅用于最终 Ozon 属性值。 */
export function ozonHashtagProblem(tags: readonly string[]): string | null {
if (tags.length > OZON_HASHTAG_LIMIT)
return `主题标签最多 ${OZON_HASHTAG_LIMIT} 个`;
for (const [index, tag] of tags.entries()) {
const label = `第 ${index + 1} 个主题标签`;
if (!tag.startsWith('#'))
return `${label}必须以 # 开头,例如 #спортивный_стиль`;
if (/\s/u.test(tag))
return `${label}内不能有空格,多词请用下划线连接;不同标签用空格分隔`;
if (tag.slice(1).includes('#'))
return `${label}包含多个 #,请用空格分隔不同的标签`;
if (!/^#[\p{L}\p{N}_]+$/u.test(tag))
return `${label}只能包含开头的 #、字母、数字和下划线`;
if ([...tag].length > OZON_HASHTAG_MAX_LENGTH)
return `${label}最多 ${OZON_HASHTAG_MAX_LENGTH} 个字符(含 #)`;
}
return null;
}
/** 仅整理 AI 标签的常见格式偏差,不截断主题,也不用于改写卖家的草稿。 */
export function normalizeAiOzonHashtag(value: string): string | null {
const body = value
.trim()
.replace(/^#/, '')
.replace(/[\s-]+/gu, '_');
const tag = `#${body}`;
return ozonHashtagProblem([tag]) ? null : tag;
}