feat(edit): AI套图生成、图片切七牛
This commit is contained in:
@@ -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;
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user