feat(ai-image): AI生图开发
This commit is contained in:
@@ -5,3 +5,4 @@ export * from './watermarks';
|
||||
export * from './media';
|
||||
export * from './product-editor';
|
||||
export * from './product-ai';
|
||||
export * from './online-products';
|
||||
|
||||
@@ -27,9 +27,10 @@ export const completeMediaUploadIntentRequestSchema = z
|
||||
.strict();
|
||||
export const createMediaAccessUrlRequestSchema = z
|
||||
.object({
|
||||
purpose: z.literal('preview'),
|
||||
purpose: z.enum(['preview', 'download']),
|
||||
})
|
||||
.strict();
|
||||
export type CreateMediaAccessUrlRequest = z.infer<typeof createMediaAccessUrlRequestSchema>;
|
||||
export const importProductImageRequestSchema = z.object({
|
||||
url: z.url().max(2048).refine((value) => {
|
||||
try {
|
||||
@@ -42,6 +43,50 @@ export const importProductImageRequestSchema = z.object({
|
||||
}, '仅支持 HTTPS 的 JPG 或 PNG 图片链接'),
|
||||
}).strict();
|
||||
export type ImportProductImageRequest = z.infer<typeof importProductImageRequestSchema>;
|
||||
export const createAspectRatioDerivativeRequestSchema = z.object({
|
||||
preset: z.literal('3:4'),
|
||||
fit: z.literal('contain'),
|
||||
product_id: z.uuid().optional(),
|
||||
}).strict();
|
||||
export type CreateAspectRatioDerivativeRequest = z.infer<typeof createAspectRatioDerivativeRequestSchema>;
|
||||
export const createWatermarkDerivativeRequestSchema = z.object({
|
||||
watermark_template_id: z.uuid(),
|
||||
product_id: z.uuid().optional(),
|
||||
}).strict();
|
||||
export type CreateWatermarkDerivativeRequest = z.infer<typeof createWatermarkDerivativeRequestSchema>;
|
||||
export const createMediaAiEditRequestSchema = z.object({
|
||||
product_id: z.uuid(),
|
||||
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'),
|
||||
translate_text: z.boolean().default(false),
|
||||
target_language: z.enum(['ru', 'ar', 'en']).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),
|
||||
style_prompt: z.string().trim().max(2000).default(''),
|
||||
dimensions: z.object({
|
||||
length: z.number().positive().max(100000).optional(),
|
||||
width: z.number().positive().max(100000).optional(),
|
||||
height: z.number().positive().max(100000).optional(),
|
||||
unit: z.enum(['cm', 'mm']).default('cm'),
|
||||
}).strict().optional(),
|
||||
generate_size_chart: z.boolean().default(false),
|
||||
}).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'] });
|
||||
if (input.mode === 'outpaint' && (input.ratio !== '3:4' || input.reference_asset_ids.length || input.generate_size_chart))
|
||||
ctx.addIssue({ code: 'custom', message: '仅扩图只支持单张主图与 3:4 比例', path: ['mode'] });
|
||||
if (input.mode === 'edit' && input.background === 'reference' && !input.reference_asset_ids.length)
|
||||
ctx.addIssue({ code: 'custom', message: '参考背景需要一张辅助图', path: ['reference_asset_ids'] });
|
||||
if (input.generate_size_chart && !input.dimensions
|
||||
|| input.generate_size_chart && input.dimensions
|
||||
&& ![input.dimensions.length, input.dimensions.width, input.dimensions.height].some((value) => value && value > 0))
|
||||
ctx.addIssue({ code: 'custom', message: '尺寸标注图需要至少填写一个尺寸', path: ['dimensions'] });
|
||||
});
|
||||
export type CreateMediaAiEditRequest = z.input<typeof createMediaAiEditRequestSchema>;
|
||||
export type MediaAiEditSettings = z.output<typeof createMediaAiEditRequestSchema>;
|
||||
|
||||
export type CreateMediaUploadIntentRequest = z.infer<
|
||||
typeof createMediaUploadIntentRequestSchema
|
||||
@@ -82,6 +127,59 @@ export type ProductUploadImage = MediaAsset & {
|
||||
preview_url_expires_at: string;
|
||||
};
|
||||
export type ProductImportedImage = ProductUploadImage & { source_url: string };
|
||||
export type AspectRatioDerivative = {
|
||||
id: string;
|
||||
status: 'queued' | 'running' | 'succeeded' | 'failed';
|
||||
source_asset_id: string;
|
||||
output_asset: ProductUploadImage | null;
|
||||
error_code: string | null;
|
||||
};
|
||||
export type WatermarkDerivative = AspectRatioDerivative & {
|
||||
watermark_template_id: string;
|
||||
watermark_template_version: number;
|
||||
};
|
||||
/** 商品图片处理记录只返回可重新选用的成功结果。 */
|
||||
export type ProductImageDerivativeRecord = {
|
||||
id: string;
|
||||
kind: 'aspect_ratio_3_4' | 'watermark';
|
||||
source_asset_id: string;
|
||||
created_at: string;
|
||||
output_asset: ProductUploadImage;
|
||||
};
|
||||
export type MediaAiEdit = {
|
||||
id: string;
|
||||
status: 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled';
|
||||
source_asset_id: string;
|
||||
output_asset: ProductUploadImage | null;
|
||||
error_code: string | null;
|
||||
failure_source: 'pcore' | 'atlas' | null;
|
||||
failure_stage: MediaAiEditFailureStage | null;
|
||||
failure_detail: string | null;
|
||||
};
|
||||
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';
|
||||
status: 'queued' | 'running' | 'succeeded' | 'partial' | 'failed' | 'cancelled';
|
||||
phase: 'waiting_provider' | null;
|
||||
total: number;
|
||||
completed: number;
|
||||
output_asset_ids: string[];
|
||||
error_code: string | null;
|
||||
failure_source: 'pcore' | 'atlas' | null;
|
||||
failure_stage: MediaAiEditFailureStage | null;
|
||||
failure_detail: string | null;
|
||||
provider_error_raw: string | null;
|
||||
created_at: string;
|
||||
completed_at: string | null;
|
||||
};
|
||||
export const listProductImageTasksQuerySchema = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||
cursor: z.string().optional(),
|
||||
});
|
||||
export type ListProductImageTasksQuery = z.infer<typeof listProductImageTasksQuerySchema>;
|
||||
|
||||
/** POST /api/media-assets/{asset_id}/access-urls 响应(短时预览 URL) */
|
||||
export type MediaAccessUrl = {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const listOnlineProductsQuerySchema = z
|
||||
.object({
|
||||
store_id: z.uuid().optional(),
|
||||
cursor: z.string().min(1).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||
q: z.string().trim().max(200).optional(),
|
||||
visibility: z
|
||||
.enum(['all', 'visible', 'invisible', 'unknown'])
|
||||
.default('all'),
|
||||
source: z.enum(['atlas_published', 'external_sync']).optional(),
|
||||
moderation_status: z.string().trim().max(100).optional(),
|
||||
include_archived: z
|
||||
.union([
|
||||
z.boolean(),
|
||||
z
|
||||
.enum(['true', 'false'])
|
||||
.transform((value) => value === 'true'),
|
||||
])
|
||||
.default(false),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const onlineProductSyncRequestSchema = z
|
||||
.object({
|
||||
reason: z.literal('manual_refresh').optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const listOnlineProductSyncRunsQuerySchema = z
|
||||
.object({
|
||||
cursor: z.string().min(1).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type ListOnlineProductsQuery = z.infer<
|
||||
typeof listOnlineProductsQuerySchema
|
||||
>;
|
||||
export type ListOnlineProductSyncRunsQuery = z.infer<
|
||||
typeof listOnlineProductSyncRunsQuerySchema
|
||||
>;
|
||||
export type OnlineProductSource = 'atlas_published' | 'external_sync';
|
||||
export type OnlineProductSyncStatus =
|
||||
'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled';
|
||||
|
||||
export type OnlineProduct = {
|
||||
id: string;
|
||||
store_id: string;
|
||||
platform_id: string;
|
||||
remote_product_id: string;
|
||||
offer_id: string;
|
||||
remote_sku: string | null;
|
||||
name: string | null;
|
||||
currency_code: string | null;
|
||||
selling_price: string | null;
|
||||
list_price: string | null;
|
||||
visible: boolean | null;
|
||||
remote_state: string | null;
|
||||
moderation_status: string | null;
|
||||
validation_status: string | null;
|
||||
stock_summary: { total: number; has_data: boolean };
|
||||
image: {
|
||||
url: string | null;
|
||||
width_px: number | null;
|
||||
height_px: number | null;
|
||||
};
|
||||
errors: Array<{ code?: string; message: string }>;
|
||||
visibility_details: Record<string, unknown>;
|
||||
source: OnlineProductSource;
|
||||
atlas_origin: {
|
||||
collection_item_id: string;
|
||||
product_id: string;
|
||||
variant_id: string;
|
||||
} | null;
|
||||
last_synced_at: string;
|
||||
missing_since: string | null;
|
||||
archived_at: string | null;
|
||||
};
|
||||
|
||||
export type OnlineProductSyncRun = {
|
||||
id: string;
|
||||
store_id: string;
|
||||
status: OnlineProductSyncStatus;
|
||||
trigger_source: 'manual' | 'scheduled' | 'recovery';
|
||||
cursor_started: string | null;
|
||||
cursor_finished: string | null;
|
||||
listed_count: number;
|
||||
upserted_count: number;
|
||||
failed_count: number;
|
||||
created_at: string;
|
||||
started_at: string | null;
|
||||
completed_at: string | null;
|
||||
error: { code: string; message: string } | null;
|
||||
};
|
||||
|
||||
export type OnlineProductsMeta = {
|
||||
next_cursor: string | null;
|
||||
has_more: boolean;
|
||||
store: { id: string; name: string };
|
||||
last_successful_sync_at: string | null;
|
||||
last_sync_status: OnlineProductSyncStatus | null;
|
||||
};
|
||||
@@ -540,7 +540,7 @@ export const saveProductEditorDraftRequestSchema = z
|
||||
.max(20),
|
||||
purchase_cost_cny: z
|
||||
.string()
|
||||
.regex(/^\d+(\.\d{1,4})?$/)
|
||||
.regex(/^\d+(\.\d{1,2})?$/)
|
||||
.nullable(),
|
||||
title: nullableText(1_000).optional(),
|
||||
tags: z
|
||||
|
||||
@@ -7,6 +7,13 @@ const opacity = z
|
||||
const scaleRatio = z
|
||||
.string()
|
||||
.regex(/^(0\.([1-9]|[1-9]\d{1,3})|1(\.\d{1,4})?|2(\.0{1,4})?)$/, '缩放比例必须在 0.1 到 2 之间');
|
||||
export const WATERMARK_IMAGE_SIZE_MIN_PX = 10;
|
||||
export const WATERMARK_IMAGE_SIZE_DEFAULT_PX = 80;
|
||||
export const WATERMARK_IMAGE_SIZE_MAX_PX = 200;
|
||||
export const WATERMARK_BORDER_RADIUS_MAX_PX = 100;
|
||||
const imageSizePx = z.number().int().min(WATERMARK_IMAGE_SIZE_MIN_PX).max(WATERMARK_IMAGE_SIZE_MAX_PX);
|
||||
const borderRadiusPx = z.number().int().min(0).max(WATERMARK_BORDER_RADIUS_MAX_PX);
|
||||
const positionCoordinate = z.number().finite().min(0).max(1);
|
||||
export type WatermarkPosition = z.infer<typeof watermarkPositionSchema>;
|
||||
export const watermarkPositionSchema = z.enum([
|
||||
'top_left', 'top_center', 'top_right', 'middle_left', 'center',
|
||||
@@ -18,13 +25,19 @@ const textConfig = z.object({
|
||||
font_code: z.string().trim().min(1).max(120),
|
||||
font_size_px: z.number().int().min(12).max(60),
|
||||
opacity,
|
||||
position: watermarkPositionSchema,
|
||||
position_x: positionCoordinate.optional(),
|
||||
position_y: positionCoordinate.optional(),
|
||||
position: watermarkPositionSchema.optional(),
|
||||
}).strict();
|
||||
const imageConfig = z.object({
|
||||
image_asset_id: uuid,
|
||||
scale_ratio: scaleRatio,
|
||||
image_size_px: imageSizePx.optional(),
|
||||
border_radius_px: borderRadiusPx.optional(),
|
||||
scale_ratio: scaleRatio.optional(),
|
||||
opacity,
|
||||
position: watermarkPositionSchema,
|
||||
position_x: positionCoordinate.optional(),
|
||||
position_y: positionCoordinate.optional(),
|
||||
position: watermarkPositionSchema.optional(),
|
||||
}).strict();
|
||||
|
||||
export const createWatermarkTemplateRequestSchema = z.object({
|
||||
@@ -38,6 +51,15 @@ export const createWatermarkTemplateRequestSchema = z.object({
|
||||
if (value.kind === 'image' && !('image_asset_id' in value.config)) {
|
||||
context.addIssue({ code: 'custom', path: ['config'], message: '图片水印需要完整图片配置' });
|
||||
}
|
||||
if (value.kind === 'image' && 'image_asset_id' in value.config
|
||||
&& value.config.image_size_px === undefined && value.config.scale_ratio === undefined) {
|
||||
context.addIssue({ code: 'custom', path: ['config'], message: '图片水印需要提供像素尺寸' });
|
||||
}
|
||||
const hasX = value.config.position_x !== undefined;
|
||||
const hasY = value.config.position_y !== undefined;
|
||||
if (hasX !== hasY || (!hasX && !value.config.position)) {
|
||||
context.addIssue({ code: 'custom', path: ['config'], message: '需要同时提供水平和垂直坐标,或提供旧版九宫格位置' });
|
||||
}
|
||||
});
|
||||
|
||||
const partialConfig = z.object({
|
||||
@@ -47,6 +69,10 @@ const partialConfig = z.object({
|
||||
font_size_px: z.number().int().min(12).max(60).optional(),
|
||||
opacity: opacity.optional(),
|
||||
scale_ratio: scaleRatio.optional(),
|
||||
image_size_px: imageSizePx.optional(),
|
||||
border_radius_px: borderRadiusPx.optional(),
|
||||
position_x: positionCoordinate.optional(),
|
||||
position_y: positionCoordinate.optional(),
|
||||
position: watermarkPositionSchema.optional(),
|
||||
}).strict();
|
||||
|
||||
@@ -94,6 +120,12 @@ export type WatermarkTemplateConfig = {
|
||||
image_asset?: WatermarkTemplateImageAsset;
|
||||
opacity?: string;
|
||||
scale_ratio?: string;
|
||||
image_size_px?: number;
|
||||
border_radius_px?: number;
|
||||
/** 0 为左/上边缘,1 为右/下边缘,按水印可移动范围归一化。 */
|
||||
position_x: number;
|
||||
position_y: number;
|
||||
/** 兼容旧版九宫格客户端的近似位置;精确渲染以坐标为准。 */
|
||||
position: WatermarkPosition;
|
||||
};
|
||||
|
||||
@@ -102,6 +134,7 @@ export type WatermarkTemplate = {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: 'text' | 'image';
|
||||
is_default: boolean;
|
||||
current_version: number;
|
||||
config: WatermarkTemplateConfig;
|
||||
created_at: string;
|
||||
|
||||
Reference in New Issue
Block a user