3 Commits

Author SHA1 Message Date
joey5628 0fa7b6bb8f feat: 修改 400问题 2026-09-28 22:39:54 +08:00
joey5628 7f1503294f feat: AI生图功能丰富 2026-09-27 22:41:33 +08:00
joey5628 1c4fe1a836 feat: 视觉风格修改 2026-09-16 22:03:36 +08:00
28 changed files with 738 additions and 379 deletions
+1 -1
View File
@@ -48,7 +48,7 @@ pnpm -C extensions/collector build # 产物 .output/chrome-mv3
- **默认生图模型 = `gpt-image-2@pcore`**(`studio/src/services/suite.ts` 的 `DEFAULT_IMAGE_MODEL`)。 - **默认生图模型 = `gpt-image-2@pcore`**(`studio/src/services/suite.ts` 的 `DEFAULT_IMAGE_MODEL`)。
- **同名模型多中转的路由约定**:前端模型 value 用「api模型@provider」后缀区分(目前仅 pcore 用到),服务端 `schemas/suite.py` 的 `MODEL_PROVIDERS` + `api_model()`(剥后缀)处理;白名单 `PCORE_MODELS`。 - **同名模型多中转的路由约定**:前端模型 value 用「api模型@provider」后缀区分(目前仅 pcore 用到),服务端 `schemas/suite.py` 的 `MODEL_PROVIDERS` + `api_model()`(剥后缀)处理;白名单 `PCORE_MODELS`。
- **pcore 协议要点**(`generator.py` 的 `_pcore_request`/`_pcore_poll_task`):无参考图走 `POST /v1/images/generations`,带参考图走 `/v1/images/edits` 且 images 必须是 **`[{image_url: dataURI}]` 对象数组**(纯字符串报错);均为异步:提交拿 `id` → 轮询 `GET /v1/images/{kind}/{task_id}` → `completed` 后 `data[0].url` 取图。`size` 是枚举(1024x1536/1024x1024),不接受自定义尺寸。new_api 系中转常见「HTTP 200/503 + 错误体」,错误解析已兼容嵌套 `error.{code,message}`;「No available channel」类错误不重试直接失败。 - **pcore 协议要点**(`generator.py` 的 `_pcore_request`/`_pcore_poll_task`):无参考图走 `POST /v1/images/generations`,带参考图走 `/v1/images/edits`;对于 `gpt-image-2`,`images` 必须传 **`[{"image_url": "data:image/...;base64,..."}]` 对象数组**。Pcore 通用文档也描述字符串数组,但该模型通道会异步校验 `images[].image_url`,以实际错误为准。均为异步:提交拿 `id` → 轮询 `GET /v1/images/{kind}/{task_id}` → `completed` 后 `data[0].url` 取图。`size` 需使用模型支持的尺寸或比例。new_api 系中转常见「HTTP 200/503 + 错误体」,错误解析已兼容嵌套 `error.{code,message}`;「No available channel」类错误不重试直接失败。
- **速率**:image2 分组 RPM 70 / RPD 1.2K(日限);当前独立 Worker 单进程逐图领取任务,远碰不到 RPM。pcore 账户余额 $5,按张扣费。 - **速率**:image2 分组 RPM 70 / RPD 1.2K(日限);当前独立 Worker 单进程逐图领取任务,远碰不到 RPM。pcore 账户余额 $5,按张扣费。
## 4. AI 规划张数策略(`planner.py`) ## 4. AI 规划张数策略(`planner.py`)
@@ -301,7 +301,7 @@ const App: React.FC = () => {
setReporting(true); setReporting(true);
try { try {
// 上报遵循采集图片区的勾选状态:勾选了就只传勾选项;未勾选则全量传。 // 上报遵循采集图片区的勾选状态:勾选了就只传勾选项;未勾选则全量传。
// 避免详情图过多时超过服务端的单次素材上限,同时保留“全不选 = 全量”的便捷行为。 // 超过 100 张时,background 会自动分批写入同一个商品,用户无需手动拆分。
const imagesToReport = selectedKeys.size > 0 const imagesToReport = selectedKeys.size > 0
? allImages.filter(img => selectedKeys.has(img.key)) ? allImages.filter(img => selectedKeys.has(img.key))
: allImages; : allImages;
+5 -5
View File
@@ -38,27 +38,27 @@ export const STYLE_SET_OPTIONS = [
{ {
value: 1, value: 1,
label: '北欧极简', label: '北欧极简',
prompt: '北欧极简风:浅灰或米白背景,柔和漫射光,低饱和色调,画面留白充足,构图克制干净', prompt: 'Nordic editorial e-commerce style for contextual and marketing images only: warm ivory or light greige base, soft window daylight with a subtle warm practical-light accent, low-saturation natural palette, pale wood and tactile linen details, spacious orderly composition, and at most one or two restrained lifestyle props. Keep the product exact and prominent. When text is requested, reserve a clean top text-safe zone with a clear headline-first hierarchy. Never override a task-mandated pure-white background, no-text, or no-props instruction',
}, },
{ {
value: 2, value: 2,
label: '清新明亮', label: '清新明亮',
prompt: '清新明亮风:明亮的白色到浅蓝渐变背景,高调光线,色彩明快通透,整体轻盈干净', prompt: 'Fresh, bright, family-friendly e-commerce style for contextual and marketing images only: crisp white with pale sky-blue, mint, or other accents sampled from the product palette; high-key daylight, luminous but natural color, soft clean shadows, airy mobile-first composition, and a cheerful polished retail mood. When text is requested, use a generous top text-safe zone, one bold headline plus one short supporting line or pill, and small simple accent icons only when they clarify a real feature. Never override a task-mandated pure-white background, no-text, or no-props instruction',
}, },
{ {
value: 3, value: 3,
label: '高级感深色', label: '高级感深色',
prompt: '高级质感风:深灰或炭黑背景,戏剧性侧光打光,突出商品材质与光泽,沉稳高级', prompt: 'Premium dark editorial e-commerce style for contextual and marketing images only: deep charcoal, graphite, or near-black setting; sculpted side and rim light; controlled contrast; realistic material texture and sheen; one restrained tonal prop at most; refined, quiet, high-end mood. Let a single product-derived color accent carry the composition. When text is requested, keep a large high-contrast headline in a deliberate text-safe area and a minimal icon or specification row. Never override a task-mandated pure-white background, no-text, or no-props instruction',
}, },
{ {
value: 4, value: 4,
label: '暖调生活', label: '暖调生活',
prompt: '温暖生活风:暖米色背景,暖色灯光氛围,温馨的家居质感,亲和力强', prompt: 'Warm, cozy lifestyle e-commerce style for contextual and marketing images only: a believable cream, ivory, or warm-beige home setting; soft golden lamp light balanced with gentle window daylight; tactile bedding, knit, or natural wood texture; comforting and approachable atmosphere. Stage one simple usage story with the product as the unquestioned hero; use only category-relevant props and, if appropriate, a natural hand or person interacting with it. For family or children products, a softly styled nursery or bedroom is suitable. When text is requested, use a top headline zone, bold rounded sans-serif lettering, a short secondary pill or line, and small friendly feature icons. Never override a task-mandated pure-white background, no-text, or no-props instruction',
}, },
{ {
value: 5, value: 5,
label: '纯净棚拍', label: '纯净棚拍',
prompt: '标准电商棚拍:纯色浅背景,均匀的正面柔光,无杂物干扰,商品居中突出', prompt: 'Clean conversion-focused e-commerce studio style: seamless pure white or very light neutral background only where the task permits, even frontal softbox lighting with a subtle natural contact shadow, faithful color, crisp edge separation, and product filling the frame at a readable scale. No decorative props, lifestyle scenery, artificial reflections, or visual clutter. If the task explicitly requires copy or specifications, use a precise grid, a clear headline-first hierarchy, and sparse line icons; otherwise show no text. Never alter the product or contradict a task-specific background instruction',
}, },
] as const; ] as const;
+52 -2
View File
@@ -5,6 +5,8 @@
import type { TextMaterial } from '../collector/merge'; import type { TextMaterial } from '../collector/merge';
export interface ReportProductPayload { export interface ReportProductPayload {
/** 后续批次写入首次上报创建的商品;调用方首次上报无需提供。 */
product_id?: string;
source: { source: {
platform: string; platform: string;
itemId: string | null; itemId: string | null;
@@ -22,16 +24,24 @@ export interface ReportProductPayload {
}>; }>;
/** 下载源图时需带的 Referer origin(如 https://detail.1688.com) */ /** 下载源图时需带的 Referer origin(如 https://detail.1688.com) */
refererOrigin?: string; refererOrigin?: string;
/** 多批上报时,仅最后一批启动素材下载,避免同一商品出现并发重复下载。 */
process_assets?: boolean;
} }
export interface ReportProductResult { export interface ReportProductResult {
product_id: string; product_id: string;
stage?: string; stage?: string;
assets_queued?: number; assets_queued?: number;
assets_skipped?: number;
} }
/** 上报采集结果到 ozon-seller-kit 后台(由 background 转发,绕 CORS) */ /**
export async function reportProduct( * 后端单次最多接收 100 个素材;插件在此自动拆批,用户无需手动删减或分批上报。
* 所有批次共用首次返回的 product_id,且只在最后一批启动下载队列。
*/
const MATERIALS_BATCH_SIZE = 100;
async function postMaterials(
reportBaseUrl: string, reportBaseUrl: string,
payload: ReportProductPayload, payload: ReportProductPayload,
): Promise<ReportProductResult> { ): Promise<ReportProductResult> {
@@ -44,3 +54,43 @@ export async function reportProduct(
if (!res.ok) throw new Error(data?.detail || `上报失败 HTTP ${res.status}`); if (!res.ok) throw new Error(data?.detail || `上报失败 HTTP ${res.status}`);
return data as ReportProductResult; return data as ReportProductResult;
} }
/** 上报采集结果到 ozon-seller-kit 后台(由 background 转发,绕 CORS)。 */
export async function reportProduct(
reportBaseUrl: string,
payload: ReportProductPayload,
): Promise<ReportProductResult> {
const batches = payload.images.length > 0
? Array.from({ length: Math.ceil(payload.images.length / MATERIALS_BATCH_SIZE) }, (_, index) =>
payload.images.slice(index * MATERIALS_BATCH_SIZE, (index + 1) * MATERIALS_BATCH_SIZE),
)
: [[]];
let productId = payload.product_id;
let lastResult: ReportProductResult | null = null;
let assetsQueued = 0;
let assetsSkipped = 0;
for (let index = 0; index < batches.length; index += 1) {
try {
const result = await postMaterials(reportBaseUrl, {
...payload,
product_id: productId,
// 文本仅需在首批写入;后续批次只追加素材,避免覆盖人工编辑的信息。
texts: index === 0 ? payload.texts : [],
images: batches[index],
process_assets: index === batches.length - 1,
});
productId = result.product_id;
lastResult = result;
assetsQueued += result.assets_queued ?? 0;
assetsSkipped += result.assets_skipped ?? 0;
} catch (error) {
const prefix = batches.length > 1 ? `第 ${index + 1}/${batches.length} 批上报失败:` : '';
throw new Error(prefix + (error instanceof Error ? error.message : String(error)));
}
}
if (!lastResult) throw new Error('上报未返回商品信息');
return { ...lastResult, assets_queued: assetsQueued, assets_skipped: assetsSkipped };
}
@@ -7,20 +7,64 @@ import type { ImageMaterial } from './image';
import type { SiteProfile } from '../profiles/types'; import type { SiteProfile } from '../profiles/types';
const IMAGE_ATTRS = ['src', 'data-src', 'data-lazyload-src', 'data-original', 'data-lazy', 'data-ks-lazyload']; const IMAGE_ATTRS = ['src', 'data-src', 'data-lazyload-src', 'data-original', 'data-lazy', 'data-ks-lazyload'];
const IMAGE_EXTENSION_RE = /\.(?:avif|bmp|gif|jpe?g|png|webp)$/i;
const IMAGE_EXTENSION_IN_PATH_RE = /\.(?:avif|bmp|gif|jpe?g|png|webp)\//i;
function normalizeEscapedText(raw: string): string { function normalizeEscapedText(raw: string): string {
return raw return raw
.replace(/\\u([0-9a-f]{4})/gi, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16))) .replace(/\\u([0-9a-f]{4})/gi, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)))
.replace(/\\x([0-9a-f]{2})/gi, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16))) .replace(/\\x([0-9a-f]{2})/gi, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)))
// 1688/淘宝详情接口常把 HTML 放在 JSON/JS 字符串中。若不还原 \",DOMParser
// 会把它编码为 %22,并可能把后面的真实 https URL 误解析成当前 CDN 的路径。
.replace(/\\(["'])/g, '$1')
.replace(/\\\//g, '/'); .replace(/\\\//g, '/');
} }
function trimImageCandidate(value: string): string {
return value
.trim()
.replace(/[),;]+$/, '')
// “...jpg/” 是详情字符串错误拼接的常见形态,不是有效图片路径。
.replace(/(\.(?:avif|bmp|gif|jpe?g|png|webp))\/+$/i, '$1');
}
/**
* 修复被外层 CDN 路径/转义引号包裹的图片地址,例如:
* https://itemcdn.tmall.com/%22https://cbu01.alicdn.com/img/...jpg/
* 仅在检测到两个 URL 且内层是标准图片路径时取内层,正常带签名参数的 URL 保持原样。
*/
function recoverEmbeddedImageUrl(value: string): string | null {
let decoded = value;
try {
decoded = decodeURIComponent(value);
} catch {
// 非法百分号编码时仍按原文解析。
}
const matches = decoded.match(/https?:\/\/[^\s'"<>\\]+/gi) ?? [];
if (matches.length < 2) return null;
for (const match of matches.reverse()) {
const candidate = trimImageCandidate(match);
try {
const url = new URL(candidate);
if (/^https?:$/i.test(url.protocol) && IMAGE_EXTENSION_RE.test(url.pathname)) return url.href;
} catch {
// 继续检查更外层的候选。
}
}
return null;
}
function normalizeUrl(raw: string, baseUrl: string): string | null { function normalizeUrl(raw: string, baseUrl: string): string | null {
const value = raw.trim().replace(/^['"]|['"]$/g, ''); const cleaned = raw.trim().replace(/^['"]|['"]$/g, '');
const value = recoverEmbeddedImageUrl(cleaned) ?? cleaned;
if (!value || /^data:|^javascript:|^blob:/i.test(value)) return null; if (!value || /^data:|^javascript:|^blob:/i.test(value)) return null;
try { try {
const url = new URL(value, baseUrl); const url = new URL(value, baseUrl);
return /^https?:$/i.test(url.protocol) ? url.href : null; if (!/^https?:$/i.test(url.protocol)) return null;
// 不允许图片扩展名后仍跟路径段(如 .jpg/),该 URL 不会指向图片资源。
if (IMAGE_EXTENSION_IN_PATH_RE.test(url.pathname)) return null;
return url.href;
} catch { } catch {
return null; return null;
} }
@@ -53,7 +97,7 @@ export function extractDetailImageUrls(raw: string, baseUrl: string): string[] {
// 某些 1688 响应会把 HTML 包在 JS 字符串里;补扫图片 CDN 的 URL 字面量。 // 某些 1688 响应会把 HTML 包在 JS 字符串里;补扫图片 CDN 的 URL 字面量。
const candidates = decoded.match(/(?:https?:)?\/\/[^\s'"<>\\]+/g) ?? []; const candidates = decoded.match(/(?:https?:)?\/\/[^\s'"<>\\]+/g) ?? [];
for (const candidate of candidates) { for (const candidate of candidates) {
const url = normalizeUrl(candidate.replace(/[),;]+$/, ''), baseUrl); const url = normalizeUrl(trimImageCandidate(candidate), baseUrl);
if (!url) continue; if (!url) continue;
const hostIsImageCdn = /(?:alicdn|taobaocdn|tmallcdn)\.com$/i.test(new URL(url).hostname); const hostIsImageCdn = /(?:alicdn|taobaocdn|tmallcdn)\.com$/i.test(new URL(url).hostname);
const hasImageExt = /\.(?:avif|bmp|gif|jpe?g|png|webp)(?:[?#]|$)/i.test(url); const hasImageExt = /\.(?:avif|bmp|gif|jpe?g|png|webp)(?:[?#]|$)/i.test(url);
+21 -2
View File
@@ -82,6 +82,23 @@ def _is_image(url: str, ctype: str) -> bool:
return bool(re.search(r"\.(jpe?g|png|webp|gif|bmp)(\?|$)", url, re.IGNORECASE)) return bool(re.search(r"\.(jpe?g|png|webp|gif|bmp)(\?|$)", url, re.IGNORECASE))
def _image_content_type(data: bytes, url: str, ctype: str) -> str | None:
"""存储对象无扩展名且 MIME 为 octet-stream 时,按图片文件头识别格式。"""
if ctype.split(";")[0].strip().lower().startswith("image/"):
return ctype
if data.startswith(b"\xff\xd8\xff"):
return "image/jpeg"
if data.startswith(b"\x89PNG\r\n\x1a\n"):
return "image/png"
if data.startswith((b"GIF87a", b"GIF89a")):
return "image/gif"
if data.startswith(b"RIFF") and data[8:12] == b"WEBP":
return "image/webp"
if data.startswith(b"BM"):
return "image/bmp"
return ctype if _is_image(url, ctype) else None
def _download_disposition(filename: str) -> str: def _download_disposition(filename: str) -> str:
"""生成兼容中文文件名的下载响应头。 """生成兼容中文文件名的下载响应头。
@@ -138,7 +155,8 @@ async def export_images(req: ExportImagesRequest):
raise HTTPException(status_code=504, detail=str(exc)) from exc raise HTTPException(status_code=504, detail=str(exc)) from exc
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
continue # 单张下载失败不中断整包 continue # 单张下载失败不中断整包
if not _is_image(img.url, ctype): ctype = _image_content_type(data, img.url, ctype)
if ctype is None:
continue continue
total_source_bytes += len(data) total_source_bytes += len(data)
if total_source_bytes > settings.export_zip_max_bytes: if total_source_bytes > settings.export_zip_max_bytes:
@@ -192,7 +210,8 @@ async def export_image(img: ExportImageItem):
raise HTTPException(status_code=504, detail=str(exc)) from exc raise HTTPException(status_code=504, detail=str(exc)) from exc
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=422, detail="图片下载失败") from exc raise HTTPException(status_code=422, detail="图片下载失败") from exc
if not _is_image(img.url, ctype): ctype = _image_content_type(data, img.url, ctype)
if ctype is None:
raise HTTPException(status_code=400, detail="该地址不是图片") raise HTTPException(status_code=400, detail="该地址不是图片")
ext = _ext(img.url, ctype) ext = _ext(img.url, ctype)
+2 -1
View File
@@ -202,7 +202,8 @@ async def create_materials(
await db.commit() await db.commit()
await db.refresh(product) await db.refresh(product)
if queued: # 多批上报的最后一批可能全被去重;仍要扫描此前批次留下的 pending 素材。
if req.process_assets:
background.add_task(process_product_assets, str(product.id)) background.add_task(process_product_assets, str(product.id))
return MaterialsResponse( return MaterialsResponse(
product_id=str(product.id), product_id=str(product.id),
+1
View File
@@ -179,6 +179,7 @@ async def list_assets(
"width": a.width, "width": a.width,
"height": a.height, "height": a.height,
"error": a.error, "error": a.error,
"created_at": a.created_at,
} }
for a in rows for a in rows
] ]
+22 -4
View File
@@ -316,12 +316,15 @@ async def download_suite_zip(suite_id: str):
async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditTaskResponse: async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditTaskResponse:
"""提交单张图生图任务;立即返回任务 ID,实际生成在统一队列异步执行。""" """提交单张图生图任务;立即返回任务 ID,实际生成在统一队列异步执行。"""
settings = get_settings() settings = get_settings()
provider_name = resolve_provider(req.model, None, settings.image_provider) structured_edit = bool(req.main_image_url and req.main_image_url.strip())
if structured_edit and req.model not in {"gpt-image-2", "gpt-image-2@pcore"}:
raise HTTPException(status_code=400, detail="单张 AI 编辑仅支持 Pcore gpt-image-2")
provider_name = "pcore" if structured_edit else resolve_provider(req.model, None, settings.image_provider)
if provider_name not in GENERATORS: if provider_name not in GENERATORS:
raise HTTPException(status_code=400, detail=f"未知 provider: {provider_name}") raise HTTPException(status_code=400, detail=f"未知 provider: {provider_name}")
validate_model(provider_name, req.model) validate_model(provider_name, req.model)
spec = {"lang": "ru", "ratio": "3:4"} # 试算页固定 Ozon 规格(俄文图内文案 · 3:4) spec = {"lang": req.target_language if structured_edit else "ru", "ratio": req.ratio if structured_edit else "3:4"}
model = api_model(req.model) # 剥 @provider 后缀,供应商侧只认裸模型名 model = api_model(req.model) # 剥 @provider 后缀,供应商侧只认裸模型名
product_id = (req.product_id or "").strip() or None product_id = (req.product_id or "").strip() or None
if product_id: if product_id:
@@ -331,6 +334,11 @@ async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditTaskResponse
raise HTTPException(status_code=400, detail="product_id 不是合法 UUID") from exc raise HTTPException(status_code=400, detail="product_id 不是合法 UUID") from exc
# 单图编辑优先级高于套图;支持多张参考底图,保持传入顺序并去重。 # 单图编辑优先级高于套图;支持多张参考底图,保持传入顺序并去重。
if structured_edit:
ref_urls = list(dict.fromkeys(url.strip() for url in [req.main_image_url, *req.reference_image_urls] if url and url.strip()))
if len(ref_urls) > 3:
raise HTTPException(status_code=400, detail="辅助参考图最多两张")
else:
ref_urls = list(dict.fromkeys(url for url in [*req.image_urls, req.image_url] if url)) ref_urls = list(dict.fromkeys(url for url in [*req.image_urls, req.image_url] if url))
if not ref_urls: if not ref_urls:
raise HTTPException(status_code=400, detail="请至少选择一张参考底图") raise HTTPException(status_code=400, detail="请至少选择一张参考底图")
@@ -341,6 +349,10 @@ async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditTaskResponse
raise HTTPException(status_code=400, detail="仅扩图只能选择一张参考底图") raise HTTPException(status_code=400, detail="仅扩图只能选择一张参考底图")
if req.generate_size_chart: if req.generate_size_chart:
raise HTTPException(status_code=400, detail="仅扩图不能同时生成尺寸标注图") raise HTTPException(status_code=400, detail="仅扩图不能同时生成尺寸标注图")
if structured_edit and req.ratio != "3:4":
raise HTTPException(status_code=400, detail="仅扩图只支持 3:4")
if structured_edit and req.mode == "edit" and req.background == "reference" and len(ref_urls) < 2:
raise HTTPException(status_code=400, detail="参考图背景需要选择一张辅助参考图")
# prompt_extra 清空展示文案,避免中文 UI 文案进入模型 prompt。 # prompt_extra 清空展示文案,避免中文 UI 文案进入模型 prompt。
is_size_chart = bool(req.generate_size_chart) is_size_chart = bool(req.generate_size_chart)
@@ -354,10 +366,16 @@ async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditTaskResponse
lang=spec["lang"], lang=spec["lang"],
ratio=spec["ratio"], ratio=spec["ratio"],
# 仅扩图与用户明确填写的 AI 编辑要求都不得继承默认「纯净棚拍」风格。 # 仅扩图与用户明确填写的 AI 编辑要求都不得继承默认「纯净棚拍」风格。
style_set=0 if req.mode == "outpaint" or bool(req.prompt.strip()) else 5, style_set=req.style_set if structured_edit and req.mode == "edit" else (0 if req.mode == "outpaint" or bool(req.prompt.strip()) else 5),
request_payload={ request_payload={
"context": _with_dimensions({"title": "AI 精修"}, req.dimensions, required=is_size_chart), "context": _with_dimensions({"title": "AI 精修"}, req.dimensions, required=is_size_chart),
"style_prompt": None, "style_prompt": req.style_prompt if structured_edit else None,
"single_edit": {
"translate_text": req.translate_text,
"target_language": req.target_language,
"background": req.background,
"background_description": req.background_description,
} if structured_edit and req.mode == "edit" else None,
"requirements": req.prompt, "requirements": req.prompt,
"operation": req.mode, "operation": req.mode,
# 用户写了要求时,这一轮只执行其要求,不再拼营销图、默认风格或默认俄文文案。 # 用户写了要求时,这一轮只执行其要求,不再拼营销图、默认风格或默认俄文文案。
+1
View File
@@ -33,6 +33,7 @@ class MaterialsRequest(BaseModel):
texts: list[TextMaterial] = Field(default_factory=list) texts: list[TextMaterial] = Field(default_factory=list)
images: list[ImageMaterial] = Field(default_factory=list) images: list[ImageMaterial] = Field(default_factory=list)
refererOrigin: str | None = None # 下载源图时需带的 Referer refererOrigin: str | None = None # 下载源图时需带的 Referer
process_assets: bool = True # 插件多批上报时,仅最后一批触发下载队列
class MaterialsResponse(BaseModel): class MaterialsResponse(BaseModel):
+13 -2
View File
@@ -82,7 +82,7 @@ class ProductDimensions(BaseModel):
length: float | None = Field(default=None, gt=0, description="长度(按 unit 单位)") length: float | None = Field(default=None, gt=0, description="长度(按 unit 单位)")
width: float | None = Field(default=None, gt=0, description="宽度(按 unit 单位)") width: float | None = Field(default=None, gt=0, description="宽度(按 unit 单位)")
height: float | None = Field(default=None, gt=0, description="高度(按 unit 单位)") height: float | None = Field(default=None, gt=0, description="高度(按 unit 单位)")
unit: Literal["mm", "cm"] = Field(default="mm", description="尺寸单位") unit: Literal["mm", "cm"] = Field(default="cm", description="尺寸单位")
class PlanItem(BaseModel): class PlanItem(BaseModel):
@@ -148,11 +148,13 @@ class SuiteCreateResponse(BaseModel):
class SuiteImageOut(BaseModel): class SuiteImageOut(BaseModel):
item_id: str item_id: str
asset_id: str | None = None
type_id: str type_id: str
name: str name: str
url: str url: str
status: str status: str
error: str | None = None error: str | None = None
completed_at: datetime | None = None
class SuiteOut(BaseModel): class SuiteOut(BaseModel):
@@ -179,6 +181,8 @@ class SuiteOut(BaseModel):
class ImageEditSingleRequest(BaseModel): class ImageEditSingleRequest(BaseModel):
"""POST /api/suite/image-edit:单张 AI 生图,可携带多张参考底图。""" """POST /api/suite/image-edit:单张 AI 生图,可携带多张参考底图。"""
product_id: str | None = Field(default=None, description="append=true 时回写到该商品的 generated 组") product_id: str | None = Field(default=None, description="append=true 时回写到该商品的 generated 组")
main_image_url: str | None = Field(default=None, description="本次编辑的主图;新界面必须显式传入")
reference_image_urls: list[str] = Field(default_factory=list, max_length=2, description="最多两张辅助参考图,按选择顺序传给模型")
image_url: str | None = Field(default=None, description="兼容旧客户端的单张原图 URL") image_url: str | None = Field(default=None, description="兼容旧客户端的单张原图 URL")
image_urls: list[str] = Field(default_factory=list, description="选中的参考底图 URL,可多张") image_urls: list[str] = Field(default_factory=list, description="选中的参考底图 URL,可多张")
prompt: str = Field(default="", description="用户要求(可选,作为额外生图指令)") prompt: str = Field(default="", description="用户要求(可选,作为额外生图指令)")
@@ -186,7 +190,14 @@ class ImageEditSingleRequest(BaseModel):
default="edit", default="edit",
description="edit 为 AI 编辑;outpaint 只扩展到目标画布,不允许模型改动原图区域", description="edit 为 AI 编辑;outpaint 只扩展到目标画布,不允许模型改动原图区域",
) )
model: str = Field(default="nano-banana-2") model: str = Field(default="gpt-image-2@pcore")
ratio: Literal["3:4", "1:1", "4:5"] = Field(default="3:4", description="输出画幅")
target_language: Literal["ru", "ar", "en"] = Field(default="ru", description="图内文字的目标语言")
translate_text: bool = Field(default=False, description="在本次生图调用中识别、翻译并重新排版图中文字")
style_set: int = Field(default=2, ge=1, le=5)
style_prompt: str | None = Field(default=None, description="用户修改后的风格提示词")
background: Literal["auto", "white", "solid", "scene", "reference"] = Field(default="auto")
background_description: str | None = Field(default=None, description="纯色或场景背景的具体要求")
append: bool = Field(default=True, description="结果是否追加为商品素材(generated 组)") append: bool = Field(default=True, description="结果是否追加为商品素材(generated 组)")
after_asset_id: str | None = Field( after_asset_id: str | None = Field(
default=None, default=None,
+9 -5
View File
@@ -81,19 +81,21 @@ DEFAULT_REF_COUNT = 2 # 每次生图最多带的参考图数(正面 1 张 +
def _image_size(provider: str, ratio: str, is_wan: bool = True, model: str = "") -> str: def _image_size(provider: str, ratio: str, is_wan: bool = True, model: str = "") -> str:
"""平台比例 → provider 尺寸参数。3:4 竖版(Ozon),1:1 方图(国内)。""" """平台比例 → provider 尺寸参数。3:4 竖版(Ozon),1:1 方图(国内)。"""
if provider == "doubao": if provider == "doubao":
return "1536x2048" if ratio == "3:4" else "2048x2048" return {"3:4": "1536x2048", "4:5": "1536x1920"}.get(ratio, "2048x2048")
if provider == "rightapi": if provider == "rightapi":
# gpt-image 自定义尺寸约束:16 的倍数、长短边比 ≤ 3:1(1536x2048 合法) # gpt-image 自定义尺寸约束:16 的倍数、长短边比 ≤ 3:1(1536x2048 合法)
return "1536x2048" if ratio == "3:4" else "2048x2048" return {"3:4": "1536x2048", "4:5": "1536x1920"}.get(ratio, "2048x2048")
if provider == "pcore": if provider == "pcore":
# 竖图必须与 Ozon 的 3:4 规格一致。1024x1536 实际是 2:3,会使成图明显偏瘦高。 # 竖图必须与 Ozon 的 3:4 规格一致。1024x1536 实际是 2:3,会使成图明显偏瘦高。
return "1152x1536" if ratio == "3:4" else "1024x1024" return {"3:4": "1152x1536", "4:5": "1280x1600"}.get(ratio, "1024x1024")
# tongyi:万象与千问的 size 语法相同(* 分隔),档位不同 # tongyi:万象与千问的 size 语法相同(* 分隔),档位不同
# wan2.6 系列总像素限制在 [1280², 1440²],wan2.7 的 1536*2048/2048*2048 会超限 # wan2.6 系列总像素限制在 [1280², 1440²],wan2.7 的 1536*2048/2048*2048 会超限
if model.startswith("wan2.6"): if model.startswith("wan2.6"):
return "1152*1536" if ratio == "3:4" else "1440*1440" return {"3:4": "1152*1536", "4:5": "1280*1600"}.get(ratio, "1440*1440")
if ratio == "3:4": if ratio == "3:4":
return "1536*2048" if is_wan else "768*1024" return "1536*2048" if is_wan else "768*1024"
if ratio == "4:5":
return "1536*1920" if is_wan else "960*1200"
return "2048*2048" if is_wan else "1024*1024" return "2048*2048" if is_wan else "1024*1024"
_DOUBAO_ANTI_AI = ( _DOUBAO_ANTI_AI = (
@@ -520,7 +522,8 @@ async def _pcore_request(s, prompt: str, ref_images: list[str], size: str, model
kind = "edits" if ref_images else "generations" kind = "edits" if ref_images else "generations"
body: dict = {"model": model, "prompt": prompt, "n": 1, "size": size, "async": True} body: dict = {"model": model, "prompt": prompt, "n": 1, "size": size, "async": True}
if ref_images: if ref_images:
# pcore 的 edits 要求 images 为 [{image_url: ...}] 对象数组(实测纯字符串报 images[].image_url is required) # Pcore 的 gpt-image-2 通道实际要求 OpenAI 兼容对象数组。
# 虽然通用文档描述字符串数组也可用,该通道会在异步任务中校验 images[].image_url。
body["images"] = [{"image_url": await _resolve_ref(u)} for u in ref_images] body["images"] = [{"image_url": await _resolve_ref(u)} for u in ref_images]
async with httpx.AsyncClient(timeout=max(s.request_timeout, 600), verify=False) as client: async with httpx.AsyncClient(timeout=max(s.request_timeout, 600), verify=False) as client:
@@ -723,6 +726,7 @@ async def advance_generation(
kind = "edits" if ref_images else "generations" kind = "edits" if ref_images else "generations"
body: dict = {"model": model, "prompt": prompt, "n": 1, "size": size, "async": True} body: dict = {"model": model, "prompt": prompt, "n": 1, "size": size, "async": True}
if ref_images: if ref_images:
# 与 _pcore_request 保持同一经 gpt-image-2 实际验证的形状,避免 Worker 路径漂移。
body["images"] = [{"image_url": await _resolve_ref(item)} for item in ref_images] body["images"] = [{"image_url": await _resolve_ref(item)} for item in ref_images]
if edit_mask: if edit_mask:
# Pcore 的图片参数使用 JSON {image_url};蒙版沿用相同的 OpenAI 兼容对象形式。 # Pcore 的图片参数使用 JSON {image_url};蒙版沿用相同的 OpenAI 兼容对象形式。
+36 -1
View File
@@ -6,6 +6,8 @@ import logging
import socket import socket
import uuid import uuid
import httpx
from config import get_settings from config import get_settings
from db import get_session_factory from db import get_session_factory
from models import ProductAsset from models import ProductAsset
@@ -23,6 +25,7 @@ from services.jobs import (
) )
from services.prompts import build_context, build_prompt from services.prompts import build_context, build_prompt
from services.prompts.common import requirements_block from services.prompts.common import requirements_block
from services.prompts.single_edit import build_single_edit_prompt
from services.storage import get_storage from services.storage import get_storage
from services.suite_service import append_generated_asset from services.suite_service import append_generated_asset
from services.tasks import TaskImage from services.tasks import TaskImage
@@ -31,6 +34,10 @@ from services.video_renderer import VideoRenderCancelled, render_slideshow
log = logging.getLogger("background.worker") log = logging.getLogger("background.worker")
# 异步供应商任务已经持久化了 task id 后,轮询网络失败不能把任务判为失败。
# 下一次轮询复用同一 task id,不会再次提交/计费。
PROVIDER_POLL_RETRY_DELAY_SECONDS = 30
def _worker_owner() -> str: def _worker_owner() -> str:
return f"{socket.gethostname()}-{uuid.uuid4().hex[:10]}" return f"{socket.gethostname()}-{uuid.uuid4().hex[:10]}"
@@ -191,7 +198,11 @@ async def process_item(claim: ClaimedItem) -> None:
"pcore": "gpt-image-2", "pcore": "gpt-image-2",
}.get(provider, settings.ark_image_model) }.get(provider, settings.ark_image_model)
try: try:
refs = _refs_for_job(list(payload.get("ref_images") or []), job_item) single_edit = payload.get("single_edit")
refs = (
[image["url"] for image in payload.get("ref_images") or []]
if single_edit else _refs_for_job(list(payload.get("ref_images") or []), job_item)
)
operation = payload.get("operation", "edit") operation = payload.get("operation", "edit")
target_size = _image_size(provider, claim.ratio, is_wan=provider == "tongyi" and model.lower().startswith("wan"), model=model) target_size = _image_size(provider, claim.ratio, is_wan=provider == "tongyi" and model.lower().startswith("wan"), model=model)
edit_mask: str | None = None edit_mask: str | None = None
@@ -207,6 +218,15 @@ async def process_item(claim: ClaimedItem) -> None:
edit_mask = outpaint.mask_data_url edit_mask = outpaint.mask_data_url
else: else:
context = build_context(payload.get("context") or {}, fallback_name="") context = build_context(payload.get("context") or {}, fallback_name="")
if single_edit:
prompt = build_single_edit_prompt(
requirements=payload.get("requirements") or "", config=single_edit,
context=context, style_set=claim.style_set,
style_prompt=payload.get("style_prompt"), ratio=claim.ratio,
is_size_chart=job_item.get("kind") == "size_chart",
reference_count=len(refs) - 1,
)
else:
prompt_extra = job_item.get("prompt_extra") or job_item prompt_extra = job_item.get("prompt_extra") or job_item
prompt = build_prompt( prompt = build_prompt(
provider, model, job_item.get("kind", "custom"), context, claim.style_set, claim.lang, provider, model, job_item.get("kind", "custom"), context, claim.style_set, claim.lang,
@@ -274,6 +294,21 @@ async def process_item(claim: ClaimedItem) -> None:
claim.item_id, result_url=result_url, source_url=source_url or None, claim.item_id, result_url=result_url, source_url=source_url or None,
watermark_config=watermark_config, generated_asset_id=asset_id, watermark_config=watermark_config, generated_asset_id=asset_id,
) )
except (httpx.TimeoutException, httpx.TransportError) as exc:
if claim.provider_task_id and claim.provider_task_kind:
log.warning(
"供应商轮询网络暂时失败,%ds 后重试同一任务 %s:%s",
PROVIDER_POLL_RETRY_DELAY_SECONDS, claim.provider_task_id, exc.__class__.__name__,
)
await mark_waiting_provider(
claim.item_id,
claim.provider_task_id,
claim.provider_task_kind,
delay_seconds=PROVIDER_POLL_RETRY_DELAY_SECONDS,
)
return
log.exception("后台图片任务 %s/%s 网络请求失败", claim.job_id, claim.item_id)
await mark_item_failed(claim.item_id, str(exc) or exc.__class__.__name__)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
log.exception("后台图片任务 %s/%s 执行失败", claim.job_id, claim.item_id) log.exception("后台图片任务 %s/%s 执行失败", claim.job_id, claim.item_id)
await mark_item_failed(claim.item_id, str(exc)) await mark_item_failed(claim.item_id, str(exc))
+2 -1
View File
@@ -489,11 +489,12 @@ def suite_view(job: BackgroundJob, items: list[BackgroundJobItem]) -> dict[str,
"created_at": job.created_at, "started_at": job.started_at, "completed_at": job.completed_at, "created_at": job.created_at, "started_at": job.started_at, "completed_at": job.completed_at,
"images": [ "images": [
{ {
"asset_id": str(item.generated_asset_id) if item.generated_asset_id else None,
"type_id": (item.payload or {}).get("kind", "custom"), "type_id": (item.payload or {}).get("kind", "custom"),
"item_id": str(item.id), "item_id": str(item.id),
"name": (item.payload or {}).get("title", "AI 生图"), "name": (item.payload or {}).get("title", "AI 生图"),
"url": item.result_url or "", "status": item.status, "url": item.result_url or "", "status": item.status,
"error": item.error, "error": item.error, "completed_at": item.completed_at,
} }
for item in items for item in items
], ],
+6 -6
View File
@@ -12,27 +12,27 @@ import re
STYLE_SETS: dict[int, dict] = { STYLE_SETS: dict[int, dict] = {
1: { 1: {
"name": "北欧极简", "name": "北欧极简",
"tone": "Nordic minimalism: light gray or warm off-white background, soft diffused light, low-saturation palette, generous negative space, restrained and clean composition", "tone": "Nordic editorial e-commerce style for contextual and marketing images only: warm ivory or light greige base, soft window daylight with a subtle warm practical-light accent, low-saturation natural palette, pale wood and tactile linen details, spacious orderly composition, and at most one or two restrained lifestyle props. Keep the product exact and prominent. When text is requested, reserve a clean top text-safe zone with a clear headline-first hierarchy. Never override a task-mandated pure-white background, no-text, or no-props instruction",
"bg": "", "bg": "",
}, },
2: { 2: {
"name": "清新明亮", "name": "清新明亮",
"tone": "Fresh and bright: white-to-pale-blue gradient background, high-key lighting, clear luminous colors, airy and uncluttered composition", "tone": "Fresh, bright, family-friendly e-commerce style for contextual and marketing images only: crisp white with pale sky-blue, mint, or other accents sampled from the product palette; high-key daylight, luminous but natural color, soft clean shadows, airy mobile-first composition, and a cheerful polished retail mood. When text is requested, use a generous top text-safe zone, one bold headline plus one short supporting line or pill, and small simple accent icons only when they clarify a real feature. Never override a task-mandated pure-white background, no-text, or no-props instruction",
"bg": "", "bg": "",
}, },
3: { 3: {
"name": "高级感深色", "name": "高级感深色",
"tone": "Premium dark aesthetic: deep gray or charcoal background, dramatic side lighting, emphasize material texture and sheen, refined and restrained mood", "tone": "Premium dark editorial e-commerce style for contextual and marketing images only: deep charcoal, graphite, or near-black setting; sculpted side and rim light; controlled contrast; realistic material texture and sheen; one restrained tonal prop at most; refined, quiet, high-end mood. Let a single product-derived color accent carry the composition. When text is requested, keep a large high-contrast headline in a deliberate text-safe area and a minimal icon or specification row. Never override a task-mandated pure-white background, no-text, or no-props instruction",
"bg": "", "bg": "",
}, },
4: { 4: {
"name": "暖调生活", "name": "暖调生活",
"tone": "Warm lifestyle: warm beige background, cozy warm lighting, inviting home-living texture, approachable and natural atmosphere", "tone": "Warm, cozy lifestyle e-commerce style for contextual and marketing images only: a believable cream, ivory, or warm-beige home setting; soft golden lamp light balanced with gentle window daylight; tactile bedding, knit, or natural wood texture; comforting and approachable atmosphere. Stage one simple usage story with the product as the unquestioned hero; use only category-relevant props and, if appropriate, a natural hand or person interacting with it. For family or children products, a softly styled nursery or bedroom is suitable. When text is requested, use a top headline zone, bold rounded sans-serif lettering, a short secondary pill or line, and small friendly feature icons. Never override a task-mandated pure-white background, no-text, or no-props instruction",
"bg": "", "bg": "",
}, },
5: { 5: {
"name": "纯净棚拍", "name": "纯净棚拍",
"tone": "Clean e-commerce studio: plain light background, even frontal softbox lighting, no distracting props, product centered and prominent", "tone": "Clean conversion-focused e-commerce studio style: seamless pure white or very light neutral background only where the task permits, even frontal softbox lighting with a subtle natural contact shadow, faithful color, crisp edge separation, and product filling the frame at a readable scale. No decorative props, lifestyle scenery, artificial reflections, or visual clutter. If the task explicitly requires copy or specifications, use a precise grid, a clear headline-first hierarchy, and sparse line icons; otherwise show no text. Never alter the product or contradict a task-specific background instruction",
"bg": "", "bg": "",
}, },
} }
@@ -140,7 +140,7 @@ def build_context(raw: dict, fallback_name: str = "", fallback_desc: str = "") -
(label, dimensions[key]) for key, label in (("length", "length"), ("width", "width"), ("height", "height")) (label, dimensions[key]) for key, label in (("length", "length"), ("width", "width"), ("height", "height"))
if dimensions.get(key) is not None if dimensions.get(key) is not None
] ]
unit = (dimensions.get("unit") or "mm") if isinstance(dimensions, dict) else "mm" unit = (dimensions.get("unit") or "cm") if isinstance(dimensions, dict) else "cm"
dimensions_line = "; ".join(f"{label}: {value} {unit}" for label, value in known_dimensions) dimensions_line = "; ".join(f"{label}: {value} {unit}" for label, value in known_dimensions)
known_labels = [label for label, _ in known_dimensions] known_labels = [label for label, _ in known_dimensions]
missing_labels = [label for label in ("length", "width", "height") if label not in known_labels] missing_labels = [label for label in ("length", "width", "height") if label not in known_labels]
+68
View File
@@ -0,0 +1,68 @@
"""单张 AI 编辑提示词:一次图像模型调用完成编辑、翻译和排版。"""
from __future__ import annotations
from services.prompts.common import requirements_block, resolve_style
LANGUAGES = {"ru": "Russian", "ar": "Arabic", "en": "English"}
BACKGROUNDS = {
"auto": "Keep the original background unless the user's requirements ask to change it.",
"white": "Replace only the background with clean pure white; retain natural contact shadows.",
"solid": "Replace only the background with a clean solid color as described below.",
"scene": "Replace only the background with the described realistic scene. Keep the product as the hero.",
"reference": "Use Image 2 for background and lighting direction. Do not copy its product.",
}
def build_single_edit_prompt(
*, requirements: str, config: dict, context: dict, style_set: int,
style_prompt: str | None, ratio: str, is_size_chart: bool, reference_count: int,
) -> str:
"""Image 1 always supplies the product; following images are supporting references only."""
language = LANGUAGES.get(config.get("target_language"), "Russian")
background = config.get("background") or "auto"
sections = [
requirements_block(requirements),
"Create exactly ONE finished e-commerce image. Image 1 is the fixed main product image. "
"Preserve the product's shape, proportions, color, material, logo and distinguishing details. "
"Do not replace it with products from any other image.",
]
if reference_count:
sections.append(
f"Images 2 through {reference_count + 1} are auxiliary references only. "
"Use them for product detail, material, scene or lighting as appropriate; "
"Image 1 remains the source of truth for the product."
)
sections.append("BACKGROUND: " + BACKGROUNDS.get(background, BACKGROUNDS["auto"]))
if config.get("background_description"):
sections.append("Background details: " + str(config["background_description"]).strip())
if config.get("translate_text"):
sections.append(
f"In this same image-generation pass, read the visible marketing text in Image 1, "
f"translate its meaning into {language}, replace the source-language marketing copy, "
"and redesign the text layout for a polished, legible composition. "
"For Arabic, use correct right-to-left typesetting. Keep factual values and units exact. "
"Preserve text printed on the physical product and its branding unless the user explicitly asks to edit them. "
"Do not add unsupported claims."
)
else:
sections.append(
"Do not invent marketing copy or translate existing image text. "
"Preserve text printed on the physical product unless explicitly requested otherwise."
)
if is_size_chart:
annotation = context.get("dimension_annotation_instruction")
if annotation:
sections.append(
"Create a clean product measurement diagram with legible dimension arrows. "
+ str(annotation)
)
style = resolve_style(style_set, style_prompt)
sections.append("VISUAL STYLE: " + str(style["tone"]))
sections.append(
f"OUTPUT: exactly {ratio} portrait or square composition as specified. "
"Apply the user's explicit background and text instructions before style preferences. "
"Do not add a watermark or extra panels."
)
return "\n\n".join(part for part in sections if part)
+19
View File
@@ -8,6 +8,8 @@ import zipfile
from pathlib import Path from pathlib import Path
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
from fastapi import HTTPException
SERVER_DIR = Path(__file__).resolve().parents[1] SERVER_DIR = Path(__file__).resolve().parents[1]
if str(SERVER_DIR) not in sys.path: if str(SERVER_DIR) not in sys.path:
sys.path.insert(0, str(SERVER_DIR)) sys.path.insert(0, str(SERVER_DIR))
@@ -16,6 +18,23 @@ from api.export import ExportImageItem, ExportImagesRequest, export_image, expor
class ExportImageTests(unittest.IsolatedAsyncioTestCase): class ExportImageTests(unittest.IsolatedAsyncioTestCase):
async def test_extensionless_image_with_generic_mime_can_be_downloaded(self) -> None:
image = ExportImageItem(url="https://example.com/assets/opaque-key", key="upload")
png = b"\x89PNG\r\n\x1a\n" + b"image bytes"
with patch("api.export._download", new=AsyncMock(return_value=(png, "application/octet-stream"))):
response = await export_image(image)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.media_type, "image/png")
self.assertIn('filename="upload.png"', response.headers["content-disposition"])
async def test_extensionless_non_image_is_rejected(self) -> None:
image = ExportImageItem(url="https://example.com/assets/opaque-key")
with patch("api.export._download", new=AsyncMock(return_value=(b"not an image", "application/octet-stream"))):
with self.assertRaises(HTTPException) as raised:
await export_image(image)
self.assertEqual(raised.exception.status_code, 400)
async def test_chinese_filename_uses_rfc5987_header(self) -> None: async def test_chinese_filename_uses_rfc5987_header(self) -> None:
image = ExportImageItem( image = ExportImageItem(
url="https://example.com/image.png", url="https://example.com/image.png",
+42
View File
@@ -7,6 +7,8 @@ from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import httpx
SERVER_DIR = Path(__file__).resolve().parents[1] SERVER_DIR = Path(__file__).resolve().parents[1]
if str(SERVER_DIR) not in sys.path: if str(SERVER_DIR) not in sys.path:
sys.path.insert(0, str(SERVER_DIR)) sys.path.insert(0, str(SERVER_DIR))
@@ -58,6 +60,46 @@ class WorkerCancellationTests(unittest.IsolatedAsyncioTestCase):
cancelled.assert_awaited_once_with(claim.item_id) cancelled.assert_awaited_once_with(claim.item_id)
waiting.assert_not_awaited() waiting.assert_not_awaited()
async def test_provider_poll_timeout_keeps_existing_task_for_retry(self) -> None:
claim = ClaimedItem(
job_id="00000000-0000-0000-0000-000000000001",
item_id="00000000-0000-0000-0000-000000000002",
job_kind="image_edit",
product_id=None,
provider="pcore",
model="gpt-image-2",
platform="ozon",
lang="ru",
ratio="3:4",
style_set=5,
request_payload={"context": {"title": "test"}, "ref_images": []},
item_payload={"kind": "custom", "title": "AI生图"},
provider_task_id="provider-task",
provider_task_kind="edits",
)
settings = SimpleNamespace(
image_provider="pcore",
dashscope_model="wan2.7-image-pro",
rightapi_image_model="gpt-image-2",
ark_image_model="doubao-seedream-4-5-251128",
)
with (
patch("services.job_worker.get_settings", return_value=settings),
patch("services.job_worker.build_context", return_value={}),
patch("services.job_worker.build_prompt", return_value="prompt"),
patch("services.job_worker._refs_for_job", return_value=[]),
patch("services.job_worker.advance_generation", new=AsyncMock(side_effect=httpx.ConnectTimeout("timeout"))),
patch("services.job_worker.mark_waiting_provider", new=AsyncMock()) as waiting,
patch("services.job_worker.mark_item_failed", new=AsyncMock()) as failed,
):
await process_item(claim)
waiting.assert_awaited_once_with(
claim.item_id, "provider-task", "edits", delay_seconds=30,
)
failed.assert_not_awaited()
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+81
View File
@@ -0,0 +1,81 @@
"""单张编辑的新参数必须进入同一次生图任务,且主图顺序与尺寸准确。"""
from __future__ import annotations
import sys
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from fastapi import HTTPException
SERVER_DIR = Path(__file__).resolve().parents[1]
if str(SERVER_DIR) not in sys.path:
sys.path.insert(0, str(SERVER_DIR))
from api.suite import suite_image_edit, _with_dimensions
from schemas.suite import ImageEditSingleRequest, ProductDimensions
from services.generator import _image_size
from services.prompts.common import build_context
from services.prompts.single_edit import build_single_edit_prompt
class SingleEditRequestTests(unittest.IsolatedAsyncioTestCase):
async def test_main_and_two_auxiliary_images_keep_order_in_one_item(self) -> None:
request = ImageEditSingleRequest(
main_image_url="main", reference_image_urls=["detail", "scene"],
prompt="替换背景", model="gpt-image-2@pcore", translate_text=True,
target_language="ar", background="reference", ratio="4:5",
)
job = AsyncMock(return_value=SimpleNamespace(id="task-id"))
with patch("api.suite.create_image_job", job):
response = await suite_image_edit(request)
self.assertEqual(response.suite_id, "task-id")
payload = job.await_args.kwargs
self.assertEqual(payload["ratio"], "4:5")
self.assertEqual(payload["lang"], "ar")
self.assertEqual(payload["provider"], "pcore")
self.assertEqual(payload["model"], "gpt-image-2")
self.assertEqual(len(payload["item_payloads"]), 1)
self.assertEqual([image["url"] for image in payload["request_payload"]["ref_images"]], ["main", "detail", "scene"])
self.assertTrue(payload["request_payload"]["single_edit"]["translate_text"])
async def test_structured_edit_rejects_other_provider(self) -> None:
with self.assertRaises(HTTPException) as raised:
await suite_image_edit(ImageEditSingleRequest(
main_image_url="main", model="nano-banana-2",
))
self.assertEqual(raised.exception.status_code, 400)
class SingleEditPromptTests(unittest.TestCase):
def test_translation_background_and_partial_dimensions_share_one_prompt(self) -> None:
context = build_context(_with_dimensions(
{"title": "AI 精修"}, ProductDimensions(width=8, unit="cm"), required=True,
))
prompt = build_single_edit_prompt(
requirements="保留商品颜色", config={"translate_text": True, "target_language": "ar", "background": "scene", "background_description": "warm kitchen"},
context=context, style_set=2, style_prompt=None, ratio="3:4", is_size_chart=True, reference_count=2,
)
self.assertIn("Image 1 is the fixed main product image", prompt)
self.assertIn("Images 2 through 3 are auxiliary", prompt)
self.assertIn("Arabic", prompt)
self.assertIn("right-to-left", prompt)
self.assertIn("warm kitchen", prompt)
self.assertIn("width: 8.0 cm", prompt)
self.assertIn("Do not draw, label, or show an arrow for: length, height", prompt)
def test_four_to_five_size_does_not_fall_back_to_square(self) -> None:
for provider, is_wan, model in (
("pcore", True, "gpt-image-2"),
("rightapi", True, "gpt-image-2"),
("doubao", True, "seedream"),
("tongyi", True, "wan2.6-image"),
("tongyi", False, "qwen-image-3.0-pro"),
):
with self.subTest(provider=provider, model=model):
width, height = map(int, _image_size(provider, "4:5", is_wan, model).replace("*", "x").split("x"))
self.assertEqual(width * 5, height * 4)
if __name__ == "__main__":
unittest.main()
+22
View File
@@ -22,8 +22,10 @@ project_pnpm() {
BACKEND_PID="" BACKEND_PID=""
STUDIO_PID="" STUDIO_PID=""
WORKER_SUPERVISOR_PID="" WORKER_SUPERVISOR_PID=""
WORKER_SOURCE_WATCH_PID=""
COLLECTOR_WATCH_PID="" COLLECTOR_WATCH_PID=""
COLLECTOR_STAMP="" COLLECTOR_STAMP=""
WORKER_SOURCE_STAMP=""
CLEANUP_DONE=false CLEANUP_DONE=false
stop_process_tree() { stop_process_tree() {
@@ -44,8 +46,10 @@ cleanup() {
stop_process_tree "$BACKEND_PID" stop_process_tree "$BACKEND_PID"
stop_process_tree "$STUDIO_PID" stop_process_tree "$STUDIO_PID"
stop_process_tree "$WORKER_SUPERVISOR_PID" stop_process_tree "$WORKER_SUPERVISOR_PID"
stop_process_tree "$WORKER_SOURCE_WATCH_PID"
stop_process_tree "$COLLECTOR_WATCH_PID" stop_process_tree "$COLLECTOR_WATCH_PID"
[[ -n "$COLLECTOR_STAMP" ]] && rm -f "$COLLECTOR_STAMP" 2>/dev/null [[ -n "$COLLECTOR_STAMP" ]] && rm -f "$COLLECTOR_STAMP" 2>/dev/null
[[ -n "$WORKER_SOURCE_STAMP" ]] && rm -f "$WORKER_SOURCE_STAMP" 2>/dev/null
if [[ -f "$INSTANCE_PID_FILE" ]] && [[ "$(<"$INSTANCE_PID_FILE")" == "$$" ]]; then if [[ -f "$INSTANCE_PID_FILE" ]] && [[ "$(<"$INSTANCE_PID_FILE")" == "$$" ]]; then
rm -f "$INSTANCE_PID_FILE" rm -f "$INSTANCE_PID_FILE"
fi fi
@@ -189,6 +193,24 @@ supervise_worker &
WORKER_SUPERVISOR_PID=$! WORKER_SUPERVISOR_PID=$!
echo "后台任务 Worker 守护进程已启动(PID: $WORKER_SUPERVISOR_PID)" echo "后台任务 Worker 守护进程已启动(PID: $WORKER_SUPERVISOR_PID)"
# Uvicorn 的热更新只管理 Web 进程;Worker 独立运行,代码更新后必须主动重启,
# 否则它会继续用内存中已导入的旧 generator.py 提交模型任务。
WORKER_SOURCE_STAMP="$(mktemp /tmp/osk-worker-source.XXXXXX)"
watch_worker_source() {
while true; do
sleep 2
if [[ -n "$(find server -type f \( -name '*.py' -o -name '*.yaml' \) -newer "$WORKER_SOURCE_STAMP" -print -quit 2>/dev/null)" ]]; then
touch "$WORKER_SOURCE_STAMP"
echo "🔧 检测到 Worker 源码改动,正在重启 Worker 以应用新版本…"
for worker_pid in $(pgrep -P "$WORKER_SUPERVISOR_PID" 2>/dev/null); do
kill -TERM "$worker_pid" 2>/dev/null || true
done
fi
done
}
watch_worker_source &
WORKER_SOURCE_WATCH_PID=$!
if ! command -v corepack >/dev/null 2>&1; then if ! command -v corepack >/dev/null 2>&1; then
echo "⚠️ 未找到 Corepack,本次跳过 studio 前端(可手动:corepack pnpm@${PNPM_VERSION} -C studio dev)" echo "⚠️ 未找到 Corepack,本次跳过 studio 前端(可手动:corepack pnpm@${PNPM_VERSION} -C studio dev)"
# CI=true:node_modules 与 lockfile 不一致时自动重建,不弹确认 # CI=true:node_modules 与 lockfile 不一致时自动重建,不弹确认
+15 -2
View File
@@ -22,7 +22,6 @@ interface Props {
export default function CopyPanel({ product, onSave, leftSpan = 10, rightSpan = 14 }: Props) { export default function CopyPanel({ product, onSave, leftSpan = 10, rightSpan = 14 }: Props) {
const [models, setModels] = useState<AiModelOption[]>([]); const [models, setModels] = useState<AiModelOption[]>([]);
const [model, setModel] = useState<string>(''); const [model, setModel] = useState<string>('');
const [sourceText, setSourceText] = useState('');
const [generating, setGenerating] = useState(false); const [generating, setGenerating] = useState(false);
const [result, setResult] = useState<CopyResponse | null>(null); const [result, setResult] = useState<CopyResponse | null>(null);
@@ -41,8 +40,12 @@ export default function CopyPanel({ product, onSave, leftSpan = 10, rightSpan =
return parts.join('\n\n'); return parts.join('\n\n');
}, [raw]); }, [raw]);
// 文案模块的“采集信息”是可人工整理的 AI 输入。优先恢复用户上次保存的内容;
// 只有从未编辑过时才从当前采集数据自动拼出默认值。
const savedSourceText = typeof raw.copy_source_text === 'string' ? raw.copy_source_text : null;
const [sourceText, setSourceText] = useState(() => savedSourceText ?? defaultSource);
useEffect(() => { useEffect(() => {
if (!sourceText && defaultSource) setSourceText(defaultSource);
getAiModels() getAiModels()
.then((r) => { .then((r) => {
setModels(r.models); setModels(r.models);
@@ -52,6 +55,12 @@ export default function CopyPanel({ product, onSave, leftSpan = 10, rightSpan =
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
/** 与价格试算一致:用户完成输入(失焦)后将编辑后的 AI 输入落到当前商品。 */
const saveSourceText = async () => {
if (sourceText === savedSourceText) return;
await onSave({ raw: { ...raw, copy_source_text: sourceText } });
};
const onGenerate = async () => { const onGenerate = async () => {
if (sourceText.trim().length < 10) { if (sourceText.trim().length < 10) {
message.warning('请先粘贴至少 10 字的商品资料'); message.warning('请先粘贴至少 10 字的商品资料');
@@ -115,8 +124,12 @@ export default function CopyPanel({ product, onSave, leftSpan = 10, rightSpan =
rows={16} rows={16}
value={sourceText} value={sourceText}
onChange={(e) => setSourceText(e.target.value)} onChange={(e) => setSourceText(e.target.value)}
onBlur={() => { void saveSourceText(); }}
placeholder="采集的商品资料(可编辑后生成)" placeholder="采集的商品资料(可编辑后生成)"
/> />
<Text type="secondary" style={{ display: 'block', marginTop: 6, fontSize: 12 }}>
修改后失去焦点会自动保存
</Text>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}> <div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<Select <Select
style={{ width: 180 }} style={{ width: 180 }}
@@ -0,0 +1,40 @@
.single-ai-drawer .ant-drawer-body { padding: 18px 22px; background: #f8f9fb; }
.single-ai-drawer .ant-drawer-footer { padding: 12px 22px; }
.single-ai-title { display: flex; gap: 12px; align-items: baseline; font-weight: 650; }
.single-ai-title .ant-typography { font-size: 12px; font-weight: normal; }
.single-ai-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(310px, .82fr); gap: 18px; align-items: start; }
.single-ai-media, .single-ai-settings { display: grid; gap: 14px; min-width: 0; }
.single-ai-section { background: #fff; border: 1px solid #e9ebef; border-radius: 12px; padding: 16px; min-width: 0; }
.single-ai-section-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-bottom: 12px; }
.single-ai-section-head strong { font-size: 14px; }
.single-ai-section-head .ant-typography { font-size: 12px; }
.single-ai-reference-grid { display: grid; grid-template-columns: 2fr 1fr; grid-template-rows: repeat(2, 1fr); aspect-ratio: 3 / 2; gap: 8px; min-height: 220px; }
.single-ai-reference-grid.outpaint { grid-template-columns: 1fr; }
.single-ai-main-image, .single-ai-aux-image { position: relative; min-width: 0; min-height: 0; overflow: hidden; border-radius: 9px; background: #f3f4f6; }
.single-ai-main-image { grid-row: span 2; }
.single-ai-main-image img, .single-ai-aux-image img { width: 100%; height: 100%; display: block; object-fit: contain; }
.single-ai-main-image > span, .single-ai-aux-image > span { position: absolute; left: 8px; bottom: 8px; border-radius: 5px; padding: 3px 7px; background: rgba(20, 24, 33, .7); color: #fff; font-size: 11px; }
.single-ai-aux-image > .ant-btn { position: absolute; top: 6px; right: 6px; }
.single-ai-aux-image > button:not(.ant-btn) { width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; border: 1px dashed #c9ccd3; background: #fafafa; cursor: pointer; color: #737984; }
.single-ai-aux-image > button:not(.ant-btn) span { position: static; background: none; color: inherit; }
.single-ai-aux-image > .single-ai-change-ref { position: absolute !important; right: 6px; bottom: 6px; width: auto !important; height: auto !important; padding: 3px 7px; border: 0 !important; border-radius: 5px; background: rgba(20, 24, 33, .7) !important; color: white !important; font-size: 11px; }
.single-ai-result-frame { width: min(100%, 300px); max-height: 440px; margin: 0 auto 10px; overflow: hidden; background: #f7f8fa; border: 1px dashed #d9dde4; border-radius: 9px; display: flex; justify-content: center; align-items: center; }
.single-ai-result-frame .ant-image { display: block; width: 100%; height: 100%; }
.single-ai-result-frame .ant-image img { display: block; width: 100%; height: 100%; object-fit: contain; }
.single-ai-empty { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 18px; text-align: center; color: #808793; font-size: 12px; }
.single-ai-empty strong { color: #30343b; font-size: 14px; }
.single-ai-sparkle { color: #25ab65; font-size: 32px; }
.single-ai-settings .ant-typography-secondary { display: block; margin-top: 6px; font-size: 12px; }
.single-ai-style-grid { display: flex; flex-wrap: wrap; gap: 7px; }
.single-ai-style-grid button { border: 1px solid #dce0e6; background: #fff; border-radius: 18px; padding: 5px 12px; cursor: pointer; }
.single-ai-style-grid button.selected { border-color: #20a260; color: #16874f; background: #effaf3; }
.single-ai-dimensions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)) 74px; gap: 7px; margin-bottom: 8px; }
.single-ai-dimensions .ant-input-number { width: 100%; }
.single-ai-advanced { display: grid; gap: 8px; }
.single-ai-picker { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; max-height: 420px; overflow: auto; }
.single-ai-picker button { min-width: 0; padding: 4px; border: 1px solid #ddd; background: #fff; border-radius: 8px; cursor: pointer; }
.single-ai-picker img { display: block; width: 100%; aspect-ratio: 1; object-fit: contain; }
.single-ai-picker span { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 12px; }
.single-ai-footer { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
.single-ai-footer > div { display: flex; gap: 8px; }
@media (max-width: 760px) { .single-ai-layout { grid-template-columns: 1fr; } .single-ai-footer > .ant-typography { display: none; } .single-ai-footer { justify-content: flex-end; } }
+149 -296
View File
@@ -1,46 +1,61 @@
import { Fragment, useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Alert, Button, Checkbox, Image, Input, InputNumber, message, Radio, Select, Space, Typography } from 'antd'; import { Alert, Button, Checkbox, Drawer, Image, Input, InputNumber, message, Modal, Radio, Select, Spin, Switch, Typography } from 'antd';
import { CloseOutlined } from '@ant-design/icons'; import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import { apiErrorMessage } from '@/services/api'; import { apiErrorMessage } from '@/services/api';
import { DEFAULT_IMAGE_MODEL, IMAGE_MODEL_OPTIONS, cancelSuite, getSuite, imageEditSingle, ProductDimensions, WatermarkPayload } from '@/services/suite'; import {
DEFAULT_IMAGE_MODEL, STYLE_SET_OPTIONS, cancelSuite, getSuite,
imageEditSingle, ProductDimensions, WatermarkPayload,
} from '@/services/suite';
import './AiImageGenModal.css';
const { Text } = Typography; const { Text } = Typography;
type ImageOption = { url: string; name: string };
type Ratio = '3:4' | '1:1' | '4:5';
type Language = 'ru' | 'ar' | 'en';
type Background = 'auto' | 'white' | 'solid' | 'scene' | 'reference';
interface Props { interface Props {
open: boolean; open: boolean;
productId: string; productId: string;
/** 待生成的源图(采集图或生成图) */ source: (ImageOption & { afterAssetId?: string }) | null;
source: { url: string; name: string; afterAssetId?: string } | null; referenceOptions: ImageOption[];
/** 可作为参考底图的商品图片;当前点开的图片会默认选中。 */
referenceOptions: Array<{ url: string; name: string }>;
onClose: () => void; onClose: () => void;
/** 生成成功回调(服务端 append 后刷新素材列表) */
onGenerated?: (url: string) => void; onGenerated?: (url: string) => void;
/** 提交成功后交由试算页统一任务区跟踪,避免弹窗与页面对同一任务重复轮询。 */
onSubmitted?: (suiteId: string) => void; onSubmitted?: (suiteId: string) => void;
/** 由服务端加到本次单张生图的水印;关闭时不传。 */
watermark?: WatermarkPayload; watermark?: WatermarkPayload;
} }
/**
* 单张 AI 图生图弹窗:输入要求 + 选模型 → 生成。
* 对应 POST /api/suite/image-edit(docs/v2.1/api.md §6,服务端 Phase B 实现)。
*/
export default function AiImageGenModal({ open, productId, source, referenceOptions, onClose, onGenerated, onSubmitted, watermark }: Props) { export default function AiImageGenModal({ open, productId, source, referenceOptions, onClose, onGenerated, onSubmitted, watermark }: Props) {
const [auxiliary, setAuxiliary] = useState<(ImageOption | null)[]>([null, null]);
const [choosingSlot, setChoosingSlot] = useState<number | null>(null);
const [prompt, setPrompt] = useState(''); const [prompt, setPrompt] = useState('');
const [model, setModel] = useState<string>(DEFAULT_IMAGE_MODEL);
const [mode, setMode] = useState<'edit' | 'outpaint'>('edit'); const [mode, setMode] = useState<'edit' | 'outpaint'>('edit');
const [dimensions, setDimensions] = useState<ProductDimensions>({ unit: 'mm' }); const [ratio, setRatio] = useState<Ratio>('3:4');
const [generateSizeChart, setGenerateSizeChart] = useState(false); const [translateText, setTranslateText] = useState(false);
const [language, setLanguage] = useState<Language>('ru');
const [background, setBackground] = useState<Background>('auto');
const [backgroundDescription, setBackgroundDescription] = useState('');
const [styleSet, setStyleSet] = useState<number>(2);
const [stylePrompts, setStylePrompts] = useState<Record<number, string>>({});
const [showStylePrompt, setShowStylePrompt] = useState(false);
const [dimensions, setDimensions] = useState<ProductDimensions>({ unit: 'cm' });
const [sizeChartOverride, setSizeChartOverride] = useState<boolean | null>(null);
const [generating, setGenerating] = useState(false); const [generating, setGenerating] = useState(false);
const [resultUrl, setResultUrl] = useState(''); const [resultUrl, setResultUrl] = useState('');
const [resultRatio, setResultRatio] = useState<Ratio>('3:4');
const [taskId, setTaskId] = useState(''); const [taskId, setTaskId] = useState('');
const [taskError, setTaskError] = useState(''); const [taskError, setTaskError] = useState('');
const [selectedReferenceUrls, setSelectedReferenceUrls] = useState<Set<string>>(() => new Set()); const [advancedOpen, setAdvancedOpen] = useState(false);
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pollAbortRef = useRef<AbortController | null>(null); const pollAbortRef = useRef<AbortController | null>(null);
const pollRunRef = useRef(0); const pollRunRef = useRef(0);
const activeTaskRef = useRef<string | null>(null); const activeTaskRef = useRef<string | null>(null);
const previousSourceRef = useRef<string | null>(null);
const hasDimensions = [dimensions.length, dimensions.width, dimensions.height]
.some((value) => typeof value === 'number' && value > 0);
const generateSizeChart = hasDimensions && (sizeChartOverride ?? true);
const stylePrompt = stylePrompts[styleSet] ?? STYLE_SET_OPTIONS.find((item) => item.value === styleSet)?.prompt ?? '';
const stopPolling = () => { const stopPolling = () => {
pollRunRef.current += 1; pollRunRef.current += 1;
@@ -50,21 +65,25 @@ export default function AiImageGenModal({ open, productId, source, referenceOpti
pollAbortRef.current = null; pollAbortRef.current = null;
}; };
// 每次打开重置(保留模型选择,方便连续精修);后台任务运行时不重置,以免丢失跟踪。
useEffect(() => { useEffect(() => {
if (open && !activeTaskRef.current) { if (!open || !source || activeTaskRef.current || previousSourceRef.current === source.url) return;
previousSourceRef.current = source.url;
setAuxiliary([null, null]);
setPrompt(''); setPrompt('');
setMode('edit'); setMode('edit');
setDimensions({ unit: 'mm' }); setRatio('3:4');
setGenerateSizeChart(false); setTranslateText(false);
setLanguage('ru');
setBackground('auto');
setBackgroundDescription('');
setDimensions({ unit: 'cm' });
setSizeChartOverride(null);
setResultUrl(''); setResultUrl('');
setGenerating(false); setResultRatio('3:4');
setTaskId('');
setTaskError(''); setTaskError('');
setSelectedReferenceUrls(source?.url ? new Set([source.url]) : new Set()); setTaskId('');
}
}, [open, source?.url]); }, [open, source?.url]);
useEffect(() => { if (!open) previousSourceRef.current = null; }, [open]);
useEffect(() => () => stopPolling(), []); useEffect(() => () => stopPolling(), []);
const pollTask = (suiteId: string) => { const pollTask = (suiteId: string) => {
@@ -73,12 +92,8 @@ export default function AiImageGenModal({ open, productId, source, referenceOpti
const controller = new AbortController(); const controller = new AbortController();
pollAbortRef.current = controller; pollAbortRef.current = controller;
activeTaskRef.current = suiteId; activeTaskRef.current = suiteId;
const startedAt = Date.now();
let failures = 0; let failures = 0;
const schedule = (delay: number) => { pollTimerRef.current = setTimeout(() => void poll(), delay); };
const schedule = (delayMs: number) => {
pollTimerRef.current = setTimeout(() => void poll(), delayMs);
};
const finish = () => { const finish = () => {
if (runId !== pollRunRef.current) return; if (runId !== pollRunRef.current) return;
if (pollTimerRef.current) clearTimeout(pollTimerRef.current); if (pollTimerRef.current) clearTimeout(pollTimerRef.current);
@@ -100,34 +115,24 @@ export default function AiImageGenModal({ open, productId, source, referenceOpti
if (result) { if (result) {
setResultUrl(result.url); setResultUrl(result.url);
setTaskError(suite.error || ''); setTaskError(suite.error || '');
message.success('生成完成,已追加到「生成结果」');
onGenerated?.(result.url); onGenerated?.(result.url);
} else if (suite.status === 'cancelled') { message.success('单张图片生成完成');
setTaskError('任务已取消');
message.info('任务已取消');
} else { } else {
const detail = suite.error || '生成失败,未返回图片'; const detail = suite.status === 'cancelled' ? '任务已取消' : suite.error || '生成失败,未返回图片';
setTaskError(detail); setTaskError(detail);
message.error(detail); if (suite.status !== 'cancelled') message.error(detail);
} }
return; return;
} }
// 单张任务最长可达 5 分钟;超时只提示,继续跟踪,避免后台完成但前端丢结果。
if (Date.now() - startedAt > 6 * 60_000) {
setTaskError('任务耗时较长,仍在后台生成;可关闭弹窗,结果完成后会自动回写。');
}
schedule(3000); schedule(3000);
} catch (e) { } catch {
if (controller.signal.aborted || runId !== pollRunRef.current) return; if (controller.signal.aborted || runId !== pollRunRef.current) return;
failures += 1; failures += 1;
if (failures >= 3) { if (failures >= 3) {
finish(); finish();
const detail = '连续查询任务进度失败;后台任务可能仍在执行,稍后可在「生成结果」查看。'; setTaskError('查询进度失败,任务仍可能在后台运行;可在生成结果区域查看。');
setTaskError(detail);
message.error(detail);
return; return;
} }
// 失败后指数退避:3 秒、6 秒、12 秒;下一次只在本次请求完成后才会发出。
schedule(Math.min(3000 * (2 ** failures), 12_000)); schedule(Math.min(3000 * (2 ** failures), 12_000));
} }
}; };
@@ -135,50 +140,37 @@ export default function AiImageGenModal({ open, productId, source, referenceOpti
}; };
const onGenerate = async () => { const onGenerate = async () => {
if (!source) return; if (!source?.url || generating) return;
if (selectedReferenceUrls.size === 0) { if (mode === 'edit' && background === 'reference' && !auxiliary.some(Boolean)) {
message.warning('请至少选择一张参考底图'); message.warning('请选择一张辅助参考图作为背景参考');
return;
}
if (mode === 'outpaint' && selectedReferenceUrls.size !== 1) {
message.warning('仅扩图只能选择一张参考底图');
return;
}
const hasAnyDimensions = [dimensions.length, dimensions.width, dimensions.height]
.some((value) => typeof value === 'number' && value > 0);
if (generateSizeChart && !hasAnyDimensions) {
message.warning('生成尺寸标注图前,请至少填写一项商品尺寸');
return; return;
} }
setGenerating(true); setGenerating(true);
setResultUrl(''); setResultUrl('');
setResultRatio(mode === 'outpaint' ? '3:4' : ratio);
setTaskError(''); setTaskError('');
try { try {
const r = await imageEditSingle({ const response = await imageEditSingle({
product_id: productId, product_id: productId,
image_urls: Array.from(selectedReferenceUrls), main_image_url: source.url,
prompt: prompt.trim(), reference_image_urls: mode === 'outpaint' ? [] : auxiliary.flatMap((item) => item ? [item.url] : []),
mode, prompt: prompt.trim(), mode, model: DEFAULT_IMAGE_MODEL,
model, ratio: mode === 'outpaint' ? '3:4' : ratio, translate_text: mode === 'edit' && translateText,
append: true, target_language: language, style_set: styleSet, style_prompt: stylePrompt,
after_asset_id: source.afterAssetId, background, background_description: backgroundDescription.trim() || null,
generate_size_chart: mode === 'edit' && generateSizeChart,
dimensions: hasDimensions ? dimensions : null,
append: true, after_asset_id: source.afterAssetId,
watermark: watermark?.enabled ? watermark : undefined, watermark: watermark?.enabled ? watermark : undefined,
generate_size_chart: generateSizeChart,
dimensions: hasAnyDimensions ? dimensions : null,
}); });
setTaskId(r.suite_id); setTaskId(response.suite_id);
message.info('已提交后台生成,可继续操作;进度将在「生成结果」区域显示'); onSubmitted?.(response.suite_id);
if (onSubmitted) { pollTask(response.suite_id);
onSubmitted(r.suite_id); } catch (error) {
onClose(); const detail = apiErrorMessage(error);
} else { setTaskError(detail);
// 独立使用该组件时仍保留自身轮询能力。
pollTask(r.suite_id);
}
} catch (e) {
message.error(apiErrorMessage(e));
setTaskError(apiErrorMessage(e));
setGenerating(false); setGenerating(false);
message.error(detail);
} }
}; };
@@ -190,228 +182,89 @@ export default function AiImageGenModal({ open, productId, source, referenceOpti
activeTaskRef.current = null; activeTaskRef.current = null;
setGenerating(false); setGenerating(false);
setTaskId(''); setTaskId('');
setTaskError('任务已取消;已完成的图片会保留在生成结果中。'); setTaskError('任务已取消');
} catch (e) { } catch (error) { message.error(apiErrorMessage(error)); }
message.error(apiErrorMessage(e));
}
}; };
const referenceImages = Array.from( const choices = Array.from(new Map(referenceOptions.filter((item) => item.url && item.url !== source?.url)
new Map( .map((item) => [item.url, item])).values());
[...referenceOptions, ...(source ? [{ url: source.url, name: source.name }] : [])] const chooseReference = (item: ImageOption) => {
.filter((image) => image.url) if (choosingSlot === null) return;
.map((image) => [image.url, image]), setAuxiliary((previous) => previous.map((selected, index) =>
).values(), index === choosingSlot ? item : selected?.url === item.url ? null : selected));
); setChoosingSlot(null);
const toggleReference = (url: string) => {
if (generating) return;
setSelectedReferenceUrls((previous) => {
if (mode === 'outpaint') return new Set([url]);
const next = new Set(previous);
if (next.has(url)) next.delete(url); else next.add(url);
return next;
});
}; };
const changeMode = (nextMode: 'edit' | 'outpaint') => { return <>
setMode(nextMode); <Drawer className="single-ai-drawer" title={<div className="single-ai-title">单张 AI 编辑 <Text type="secondary">每次生成 1 张</Text></div>}
if (nextMode === 'outpaint') { placement="right" width="min(1040px, 100vw)" open={open} onClose={onClose} destroyOnHidden={false}
setGenerateSizeChart(false); footer={<div className="single-ai-footer"><Text type="secondary">生成结果会同时保存到「生成结果」</Text><div>
setModel('gpt-image-2@pcore'); {generating && taskId && <Button danger onClick={() => void onCancelTask()}>取消任务</Button>}
setSelectedReferenceUrls((previous) => { <Button onClick={onClose}>关闭</Button>
const first = previous.values().next().value as string | undefined; <Button type="primary" loading={generating} disabled={!source} onClick={() => void onGenerate()}>{resultUrl ? '重新生成 1 张' : '生成 1 张'}</Button>
return new Set(first ? [first] : (source?.url ? [source.url] : [])); </div></div>}>
}); <div className="single-ai-layout">
} <div className="single-ai-media">
}; <section className="single-ai-section">
<div className="single-ai-section-head"><strong>主图与辅助参考</strong><Text type="secondary">{mode === 'outpaint' ? '仅使用主图' : '主图固定 · 辅图最多 2 张'}</Text></div>
return ( <div className={`single-ai-reference-grid${mode === 'outpaint' ? ' outpaint' : ''}`}>
<> <div className="single-ai-main-image"><img src={source?.url} alt={source?.name || '主图'} /><span>主图 · 编辑底图</span></div>
{open && ( {mode === 'edit' && auxiliary.map((item, index) => <div className="single-ai-aux-image" key={index}>
<div {item ? <><img src={item.url} alt={item.name} /><span>辅图 {index + 1}</span><Button size="small" icon={<DeleteOutlined />} title="移除辅图" disabled={generating} onClick={() => setAuxiliary((previous) => previous.map((value, i) => i === index ? null : value))} /></>
style={{ : <button type="button" disabled={generating} onClick={() => setChoosingSlot(index)}><PlusOutlined /><span>添加辅图 {index + 1}</span></button>}
position: 'fixed', {item && <button className="single-ai-change-ref" type="button" disabled={generating} onClick={() => setChoosingSlot(index)}>更换</button>}
inset: 0, </div>)}
background: 'rgba(0,0,0,0.45)',
zIndex: 1000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
onClick={onClose}
>
<div
style={{
background: '#fff',
borderRadius: 10,
padding: 20,
width: 760,
maxWidth: '92vw',
maxHeight: '88vh',
overflow: 'auto',
}}
onClick={(e) => e.stopPropagation()}
>
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Text strong style={{ fontSize: 15 }}>AI 生图(图生图)</Text>
<Radio.Group
size="small"
value={mode}
buttonStyle="solid"
disabled={generating}
onChange={(e) => changeMode(e.target.value)}
>
<Radio.Button value="edit">AI 编辑</Radio.Button>
<Radio.Button value="outpaint">仅扩图至 3:4</Radio.Button>
</Radio.Group>
</div> </div>
<Button type="text" shape="circle" icon={<CloseOutlined />} title="关闭" aria-label="关闭" onClick={onClose} /> </section>
<section className="single-ai-section">
<div className="single-ai-section-head"><strong>AI 生成结果</strong><Text type="secondary">按 {resultUrl || generating ? resultRatio : mode === 'outpaint' ? '3:4' : ratio} 展示</Text></div>
<div className="single-ai-result-frame" style={{ aspectRatio: (resultUrl || generating ? resultRatio : mode === 'outpaint' ? '3:4' : ratio).replace(':', ' / ') }}>
{resultUrl ? <Image src={resultUrl} alt="AI 生成结果" /> : generating ? <div className="single-ai-empty"><Spin size="large" /><strong>正在生成图片</strong><span>完成后会直接展示在这里</span></div>
: <div className="single-ai-empty"><span className="single-ai-sparkle">✦</span><strong>生成的图片会展示在这里</strong><span>主图是编辑底图,生成结果单独显示</span></div>}
</div> </div>
<div style={{ marginBottom: 14 }}> {resultUrl && <a href={resultUrl} target="_blank" rel="noreferrer">打开原图</a>}
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12, marginBottom: 7 }}> {taskError && <Alert style={{ marginTop: 10 }} type="error" showIcon message={taskError} />}
<Text strong style={{ fontSize: 13 }}>参考底图{mode === 'outpaint' ? '(仅限单张)' : '(可多选)'}</Text> </section>
<Text type="secondary" style={{ fontSize: 12 }}>已选 {selectedReferenceUrls.size} 张</Text>
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(6, minmax(0, 1fr))', gap: 8, maxHeight: 190, overflowY: 'auto', padding: 2 }}> <div className="single-ai-settings">
{referenceImages.map((image) => { <section className="single-ai-section"><div className="single-ai-section-head"><strong>编辑要求</strong></div>
const selected = selectedReferenceUrls.has(image.url); <Input.TextArea rows={4} value={prompt} disabled={generating} onChange={(event) => setPrompt(event.target.value)} placeholder={mode === 'outpaint' ? '描述新增边缘的画面;原图区域会保持不变' : '描述要改什么,例如:保留商品外观,换成温暖的客厅背景,突出材质质感'} />
return ( </section>
<button {mode === 'edit' && <section className="single-ai-section"><div className="single-ai-section-head"><strong>图中文字翻译</strong><Switch checked={translateText} disabled={generating} onChange={setTranslateText} /></div>
key={image.url} {translateText && <><Select value={language} disabled={generating} onChange={setLanguage} options={[{ value: 'ru', label: '俄文(默认)' }, { value: 'ar', label: '阿拉伯语' }, { value: 'en', label: '英语' }]} style={{ width: '100%' }} />
type="button" <Text type="secondary">生成时识别并翻译图片文案,重新排版;不会额外发起识图请求。</Text></>}
title={`${selected ? '取消选择' : '选择'}:${image.name}`} </section>}
onClick={() => toggleReference(image.url)} {mode === 'edit' && <section className="single-ai-section"><div className="single-ai-section-head"><strong>背景</strong></div>
disabled={generating} <Select value={background} disabled={generating} onChange={setBackground} style={{ width: '100%' }} options={[
style={{ position: 'relative', padding: 2, border: `2px solid ${selected ? '#1677ff' : '#e5e7eb'}`, borderRadius: 7, background: selected ? '#e6f4ff' : '#fff', cursor: generating ? 'not-allowed' : 'pointer', aspectRatio: '1 / 1', overflow: 'hidden' }} { value: 'auto', label: '保持原背景 / 按描述' }, { value: 'white', label: '纯白背景' },
> { value: 'solid', label: '指定纯色' }, { value: 'scene', label: '更换场景' }, { value: 'reference', label: '参考首张辅图背景' },
<img src={image.url} alt={image.name} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block', borderRadius: 4 }} /> ]} />
{selected && <span style={{ position: 'absolute', top: 3, right: 3, width: 18, height: 18, borderRadius: '50%', background: '#1677ff', color: '#fff', fontSize: 12, lineHeight: '18px' }}>✓</span>} {['solid', 'scene', 'reference'].includes(background) && <Input style={{ marginTop: 9 }} value={backgroundDescription} disabled={generating} onChange={(event) => setBackgroundDescription(event.target.value)} placeholder={background === 'solid' ? '例如:浅米色' : '补充背景要求(可选)'} />}
</button> </section>}
); {mode === 'edit' && <section className="single-ai-section"><div className="single-ai-section-head"><strong>视觉风格</strong></div>
})} <div className="single-ai-style-grid">{STYLE_SET_OPTIONS.map((item) => <button key={item.value} type="button" className={styleSet === item.value ? 'selected' : ''} disabled={generating} onClick={() => setStyleSet(item.value)}>{item.label}</button>)}</div>
</div> <Button type="link" size="small" style={{ paddingLeft: 0, marginTop: 6 }} onClick={() => setShowStylePrompt(!showStylePrompt)}>{showStylePrompt ? '收起风格提示词' : '查看 / 修改风格提示词'}</Button>
</div> {showStylePrompt && <Input.TextArea rows={4} value={stylePrompt} disabled={generating} onChange={(event) => setStylePrompts((previous) => ({ ...previous, [styleSet]: event.target.value }))} />}
<div style={{ display: 'flex', gap: 16 }}> </section>}
<div style={{ width: 260, flexShrink: 0 }}> {mode === 'edit' && <section className="single-ai-section"><div className="single-ai-section-head"><strong>商品尺寸</strong><Text type="secondary">默认 cm</Text></div>
<Image <div className="single-ai-dimensions">{(['length', 'width', 'height'] as const).map((key, index) => <InputNumber key={key} min={0} precision={2} controls={false} disabled={generating} value={dimensions[key]} placeholder={['长', '宽', '高'][index]} onChange={(value) => setDimensions((previous) => ({ ...previous, [key]: typeof value === 'number' ? value : undefined }))} />)}
src={source?.url} <Select value={dimensions.unit ?? 'cm'} disabled={generating} onChange={(unit: 'mm' | 'cm') => setDimensions((previous) => ({ ...previous, unit }))} options={[{ value: 'cm', label: 'cm' }, { value: 'mm', label: 'mm' }]} /></div>
alt="当前图片" {hasDimensions && <Checkbox checked={generateSizeChart} disabled={generating} onChange={(event) => setSizeChartOverride(event.target.checked)}>生成尺寸标注图</Checkbox>}
style={{ borderRadius: 8, objectFit: 'contain', maxHeight: 300 }} <Text type="secondary">填写尺寸后默认生成标注图;只标注已填写的方向。</Text>
/> </section>}
</div> <section className="single-ai-section"><div className="single-ai-section-head"><strong>画面比例</strong></div>
<div style={{ flex: 1, minWidth: 0 }}> <Radio.Group optionType="button" buttonStyle="solid" value={mode === 'outpaint' ? '3:4' : ratio} disabled={generating || mode === 'outpaint'} onChange={(event) => setRatio(event.target.value as Ratio)} options={['3:4', '1:1', '4:5']} />
{mode === 'edit' && <div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: generateSizeChart ? 12 : 0 }}> <Button type="link" size="small" style={{ display: 'block', paddingLeft: 0, marginTop: 8 }} onClick={() => setAdvancedOpen(!advancedOpen)}>{advancedOpen ? '收起生成方式' : '生成方式'}</Button>
<Checkbox {advancedOpen && <div className="single-ai-advanced">
checked={generateSizeChart} <Select value={mode} disabled={generating} onChange={(value: 'edit' | 'outpaint') => setMode(value)} options={[{ value: 'edit', label: 'AI 编辑' }, { value: 'outpaint', label: '仅扩图至 3:4(保留原图)' }]} />
onChange={(e) => setGenerateSizeChart(e.target.checked)} <Text type="secondary">使用 Pcore 中转 · gpt-image-2{mode === 'outpaint' ? ';原图区域会覆盖回生成结果。' : ''}</Text>
disabled={generating}
>
生成尺寸标注图(未知方向留空)
</Checkbox>
</div>} </div>}
{mode === 'edit' && generateSizeChart && ( </section>
<div style={{ marginBottom: 12 }}>
<Text type="secondary" style={{ display: 'block', fontSize: 12, marginBottom: 4 }}>商品尺寸(长 × 宽 × 高)</Text>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{(['length', 'width', 'height'] as const).map((key, index) => (
<Fragment key={key}>
<InputNumber
min={0}
precision={2}
controls={false}
style={{ width: '100%' }}
value={dimensions[key]}
onChange={(value) => setDimensions((prev) => ({ ...prev, [key]: typeof value === 'number' ? value : undefined }))}
placeholder={['长', '宽', '高'][index]}
disabled={generating}
/>
{index < 2 && <Text type="secondary" style={{ fontSize: 16 }}>×</Text>}
</Fragment>
))}
<Select
style={{ width: 76, flexShrink: 0 }}
value={dimensions.unit ?? 'mm'}
onChange={(unit: 'mm' | 'cm') => setDimensions((prev) => ({ ...prev, unit }))}
options={[{ value: 'mm', label: 'mm' }, { value: 'cm', label: 'cm' }]}
disabled={generating}
/>
</div> </div>
</div> </div>
)} </Drawer>
<Input.TextArea <Modal title={`选择辅助参考图 ${choosingSlot === null ? '' : choosingSlot + 1}`} open={choosingSlot !== null} footer={null} onCancel={() => setChoosingSlot(null)}>
rows={4} <div className="single-ai-picker">{choices.length ? choices.map((item) => <button type="button" key={item.url} onClick={() => chooseReference(item)}><img src={item.url} alt={item.name} /><span>{item.name}</span></button>) : <Text type="secondary">暂无其他商品图片可选</Text>}</div>
value={prompt} </Modal>
onChange={(e) => setPrompt(e.target.value)} </>;
placeholder={mode === 'outpaint'
? '扩展区域要求(可选),例如:将新增边缘扩展为纯黑背景;原图区域不会被改动'
: '编辑要求(可选)。填写后会只执行此要求,不会附加营销图、俄文文字或默认棚拍风格'}
disabled={generating}
/>
{mode === 'outpaint' && (
<Text type="secondary" style={{ display: 'block', fontSize: 12, marginTop: 6 }}>
系统会生成 3:4 画布,只允许 AI 生成新增边缘;完成后会重新覆盖原图区域,确保原图不被改动。
</Text>
)}
<div style={{ marginTop: 12, display: 'flex', justifyContent: 'flex-end' }}>
<div style={{ display: 'flex', width: '100%', gap: 8 }}>
<Select
style={{ flex: 1, minWidth: 0 }}
popupMatchSelectWidth={false}
value={model}
onChange={setModel}
disabled={generating}
options={IMAGE_MODEL_OPTIONS
.filter((m) => mode !== 'outpaint' || m.value === 'gpt-image-2@pcore')
.map((m) => ({ value: m.value, label: m.label, desc: m.desc }))}
optionRender={(option) => (
<div>
<div>{option.label}</div>
<div style={{ fontSize: 11, color: '#999' }}>
{(option as { data?: { desc?: string } }).data?.desc}
</div>
</div>
)}
/>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'stretch', gap: 4, flexShrink: 0 }}>
<Button type="primary" loading={generating} onClick={onGenerate}>
提交任务
</Button>
</div>
</div>
</div>
<Alert
style={{ marginTop: 16 }}
type={taskError && !generating ? 'error' : 'info'}
showIcon
message={
generating
? `后台任务生成中${taskId ? `(${taskId.slice(0, 8)})` : ''},可关闭弹窗继续操作`
: taskError || '生成结果会自动追加到「生成结果」;GPT 系列单张通常需要 1-5 分钟'
}
/>
{generating && taskId && (
<Button danger type="link" size="small" onClick={() => void onCancelTask()} style={{ marginTop: 4, paddingLeft: 0 }}>
取消此任务
</Button>
)}
</div>
</div>
{resultUrl && (
<div style={{ marginTop: 16 }}>
<Space style={{ marginBottom: 8 }}>
<Text strong>生成结果</Text>
<a href={resultUrl} target="_blank" rel="noreferrer">
打开原图
</a>
</Space>
<Image src={resultUrl} style={{ borderRadius: 8, maxHeight: 360, objectFit: 'contain' }} />
</div>
)}
</div>
</div>
)}
</>
);
} }
@@ -164,6 +164,11 @@ interface GeneratedImageGridProps {
export function GeneratedImageGrid({ images, selectedKeys, downloadingKeys, preview, onToggle, onDelete, onDownload, onEdit }: GeneratedImageGridProps) { export function GeneratedImageGrid({ images, selectedKeys, downloadingKeys, preview, onToggle, onDelete, onDownload, onEdit }: GeneratedImageGridProps) {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const { current, visible } = useVisiblePage(images, page); const { current, visible } = useVisiblePage(images, page);
// 新完成的图片经过时间倒序会进入首位;无论用户此前停在哪一页,都直接展示最新结果。
const newestKey = images[0]?.key;
useEffect(() => {
setPage(1);
}, [newestKey]);
useEffect(() => { useEffect(() => {
if (current !== page) setPage(current); if (current !== page) setPage(current);
}, [current, page]); }, [current, page]);
+43 -34
View File
@@ -161,7 +161,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
const [styleSet, setStyleSet] = useState(1); const [styleSet, setStyleSet] = useState(1);
const [stylePrompts, setStylePrompts] = useState<Record<number, string>>({}); const [stylePrompts, setStylePrompts] = useState<Record<number, string>>({});
const [requirements, setRequirements] = useState(''); const [requirements, setRequirements] = useState('');
const [dimensions, setDimensions] = useState<ProductDimensions>({ unit: 'mm' }); const [dimensions, setDimensions] = useState<ProductDimensions>({ unit: 'cm' });
const [model, setModel] = useState<string>(DEFAULT_IMAGE_MODEL); const [model, setModel] = useState<string>(DEFAULT_IMAGE_MODEL);
const [watermark, setWatermark] = useState<WatermarkPayload>(loadWatermark); const [watermark, setWatermark] = useState<WatermarkPayload>(loadWatermark);
@@ -215,15 +215,18 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
const suiteImages = (suite?.images ?? []) const suiteImages = (suite?.images ?? [])
.filter((img) => img.status === 'done') .filter((img) => img.status === 'done')
.map((img, index) => { .map((img, index) => {
const asset = generatedAssets.find((a) => assetUrl(a) === img.url); const asset = generatedAssets.find((a) => a.id === img.asset_id)
|| generatedAssets.find((a) => assetUrl(a) === img.url);
return { return {
key: `suite-${suite?.id ?? 'current'}-${index}`, // 素材已回写时与历史记录共用同一个 key,避免同一张图显示两次。
key: asset ? `asset-${asset.id}` : `suite-${suite?.id ?? 'current'}-${img.item_id || index}`,
url: img.url, url: img.url,
name: img.name, name: img.name,
groupName: '套图结果', groupName: '生成结果',
variantName: null as string | null, variantName: null as string | null,
assetId: asset?.id, assetId: asset?.id,
source: 'suite' as const, source: asset ? 'history' as const : 'suite' as const,
createdAt: asset?.created_at || img.completed_at || suite?.completed_at || suite?.created_at || undefined,
}; };
}); });
const historyImages = generatedAssets.map((asset) => ({ const historyImages = generatedAssets.map((asset) => ({
@@ -236,10 +239,21 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
source: 'history' as const, source: 'history' as const,
createdAt: asset.created_at, createdAt: asset.created_at,
})); }));
return [...suiteImages, ...historyImages].filter((img) => !hiddenResultKeys.has(img.key)); const unique = new Map<string, ResultImage>();
// 已落库的素材带有最终 created_at,后写入可覆盖同 key 的临时 suite 结果。
[...suiteImages, ...historyImages].forEach((image) => unique.set(image.key, image));
return [...unique.values()]
.filter((img) => !hiddenResultKeys.has(img.key))
.sort((left, right) => {
const timestamp = (value?: string) => {
const parsed = value ? Date.parse(value) : Number.NaN;
return Number.isFinite(parsed) ? parsed : 0;
};
const rightTime = timestamp(right.createdAt);
const leftTime = timestamp(left.createdAt);
return rightTime - leftTime || right.key.localeCompare(left.key);
});
}, [generatedAssets, hiddenResultKeys, suite]); }, [generatedAssets, hiddenResultKeys, suite]);
const suiteResultImages = resultImages.filter((img) => img.source === 'suite');
const historyResultImages = resultImages.filter((img) => img.source === 'history');
const otherActiveSuiteTasks = activeSuiteTasks.filter((task) => task.id !== suite?.id); const otherActiveSuiteTasks = activeSuiteTasks.filter((task) => task.id !== suite?.id);
const selectedResultCount = resultImages.filter((img) => selectedResultKeys.has(img.key)).length; const selectedResultCount = resultImages.filter((img) => selectedResultKeys.has(img.key)).length;
const selectedWatermarkItems = resultImages.filter((img) => selectedResultKeys.has(img.key) && img.assetId); const selectedWatermarkItems = resultImages.filter((img) => selectedResultKeys.has(img.key) && img.assetId);
@@ -789,6 +803,12 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
}); });
const name = cleanFilename(img.name || img.key) || '生图结果'; const name = cleanFilename(img.name || img.key) || '生图结果';
const suffix = (() => { const suffix = (() => {
const mimeExt: Record<string, string> = {
'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp',
'image/gif': '.gif', 'image/bmp': '.bmp',
};
const fromMime = mimeExt[blob.type.split(';')[0].toLowerCase()];
if (fromMime) return fromMime;
try { try {
return new URL(img.url, window.location.origin).pathname.match(/\.(jpe?g|png|webp|gif|bmp)$/i)?.[0] ?? '.jpg'; return new URL(img.url, window.location.origin).pathname.match(/\.(jpe?g|png|webp|gif|bmp)$/i)?.[0] ?? '.jpg';
} catch { } catch {
@@ -1216,7 +1236,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
))} ))}
<Select <Select
style={{ width: 76, flexShrink: 0 }} style={{ width: 76, flexShrink: 0 }}
value={dimensions.unit ?? 'mm'} value={dimensions.unit ?? 'cm'}
onChange={(unit: 'mm' | 'cm') => setDimensions((prev) => ({ ...prev, unit }))} onChange={(unit: 'mm' | 'cm') => setDimensions((prev) => ({ ...prev, unit }))}
options={[{ value: 'mm', label: 'mm' }, { value: 'cm', label: 'cm' }]} options={[{ value: 'mm', label: 'mm' }, { value: 'cm', label: 'cm' }]}
/> />
@@ -1380,8 +1400,21 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
) : null} ) : null}
</Space> </Space>
</div> </div>
<FailedSuiteImages images={suite.images.filter((image) => image.status === 'failed')} />
{suite.error && <Alert type="warning" showIcon message={suite.error} style={{ marginTop: 8 }} />}
</>
)}
{resultImages.length > 0 && (
<div style={suite ? { borderTop: '1px dashed #e5e7eb', paddingTop: 12, marginTop: 14 } : {}}>
<Text strong>
全部生成结果
<Text type="secondary" style={{ fontWeight: 400, fontSize: 11, marginLeft: 8 }}>
(按完成时间倒序 · 最新结果在第一页首位 · {resultImages.length} 张)
</Text>
</Text>
<div style={{ marginTop: 10 }}>
<GeneratedImageGrid <GeneratedImageGrid
images={suiteResultImages} images={resultImages}
selectedKeys={selectedResultKeys} selectedKeys={selectedResultKeys}
downloadingKeys={downloadingResultKeys} downloadingKeys={downloadingResultKeys}
preview={imagePreviewConfig} preview={imagePreviewConfig}
@@ -1390,30 +1423,6 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
onDownload={(image) => void handleDownloadResultImage(image)} onDownload={(image) => void handleDownloadResultImage(image)}
onEdit={(image) => setGenModal({ url: assetById.get(image.assetId || '')?.original_url || image.url, name: image.name, afterAssetId: image.assetId })} onEdit={(image) => setGenModal({ url: assetById.get(image.assetId || '')?.original_url || image.url, name: image.name, afterAssetId: image.assetId })}
/> />
<FailedSuiteImages images={suite.images.filter((image) => image.status === 'failed')} />
{suite.error && <Alert type="warning" showIcon message={suite.error} style={{ marginTop: 8 }} />}
</>
)}
{/* 单张 AI 生图结果(采集图片区「AI 生图」生成,回写 generated 组) */}
{historyResultImages.length > 0 && (
<div style={suite ? { borderTop: '1px dashed #e5e7eb', paddingTop: 12, marginTop: 14 } : {}}>
<Text strong>
生图记录
<Text type="secondary" style={{ fontWeight: 400, fontSize: 11, marginLeft: 8 }}>
(一键生成与单张生图的落库历史,刷新后仍保留 · {historyResultImages.length} 张)
</Text>
</Text>
<div style={{ marginTop: 10 }}>
<GeneratedImageGrid
images={historyResultImages}
selectedKeys={selectedResultKeys}
downloadingKeys={downloadingResultKeys}
preview={imagePreviewConfig}
onToggle={toggleResultKey}
onDelete={(image) => image.assetId && void handleDeleteAsset(image.assetId, [image.key])}
onDownload={(image) => void handleDownloadResultImage(image)}
onEdit={(image) => setGenModal({ url: assetById.get(image.assetId || '')?.original_url || image.url, name: image.name, afterAssetId: image.assetId })}
/>
</div> </div>
</div> </div>
)} )}
+1 -1
View File
@@ -98,7 +98,7 @@ export default function TrialVideoMaker({ productId, assets }: { productId: stri
{jobs.filter((job) => job.status !== 'cancelled').length === 0 ? <Text type="secondary" style={{ display: 'block', marginTop: 10, fontSize: 12 }}>生成的轮播视频会保存在这里,可随时下载。</Text> : <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(230px, 1fr))', alignItems: 'start', gap: 10, marginTop: 12 }}>{jobs.filter((job) => job.status !== 'cancelled').slice(0, 8).map((job) => <VideoCard key={job.id} job={job} removing={removingId === job.id} downloading={downloadingId === job.id} onCancel={(id) => void cancel(id)} onDelete={(id) => void remove(id)} onRetry={(id) => void retry(id)} onDownload={(id) => void download(id)} />)}</div>} {jobs.filter((job) => job.status !== 'cancelled').length === 0 ? <Text type="secondary" style={{ display: 'block', marginTop: 10, fontSize: 12 }}>生成的轮播视频会保存在这里,可随时下载。</Text> : <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(230px, 1fr))', alignItems: 'start', gap: 10, marginTop: 12 }}>{jobs.filter((job) => job.status !== 'cancelled').slice(0, 8).map((job) => <VideoCard key={job.id} job={job} removing={removingId === job.id} downloading={downloadingId === job.id} onCancel={(id) => void cancel(id)} onDelete={(id) => void remove(id)} onRetry={(id) => void retry(id)} onDownload={(id) => void download(id)} />)}</div>}
<Modal open={open} onCancel={() => setOpen(false)} onOk={() => void submit()} okText={`合成视频(${selected.length}/5)`} okButtonProps={{ disabled: selected.length < 2, loading: submitting }} width={900} title="选择轮播图片"> <Modal open={open} onCancel={() => setOpen(false)} onOk={() => void submit()} okText={`合成视频(${selected.length}/5)`} okButtonProps={{ disabled: selected.length < 2, loading: submitting }} width={900} title="选择轮播图片">
<Alert type="info" showIcon message="AI 生成图排在前面;点击图片按播放顺序选择。" style={{ marginBottom: 12 }} /> <Alert type="info" showIcon message="AI 生成图排在前面;点击图片按播放顺序选择。" style={{ marginBottom: 12 }} />
{candidates.length < 2 ? <Empty description="至少需要两张已转存图片" /> : <div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, minmax(0, 1fr))', gap: 10, maxHeight: 430, overflowY: 'auto' }}>{candidates.map((asset) => { const index = selected.indexOf(asset.id); return <div key={asset.id} onClick={() => toggle(asset.id)} style={{ cursor: 'pointer', border: `2px solid ${index >= 0 ? '#1677ff' : 'transparent'}`, borderRadius: 7, padding: 2, position: 'relative' }}><Image preview={false} loading="lazy" src={urlOf(asset)} width="100%" style={{ aspectRatio: '3 / 4', objectFit: 'cover', borderRadius: 5 }} />{index >= 0 && <span style={{ position: 'absolute', top: 6, right: 6, background: '#1677ff', color: '#fff', borderRadius: 12, minWidth: 22, textAlign: 'center', lineHeight: '22px' }}>{index + 1}</span>}<div style={{ fontSize: 11, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{asset.group_key === 'generated' ? 'AI 图 · ' : ''}{nameOf(asset)}</div></div>; })}</div>} {candidates.length < 2 ? <Empty description="至少需要两张已转存图片" /> : <div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, minmax(0, 1fr))', gap: 10, maxHeight: 510, overflowY: 'auto' }}>{candidates.map((asset) => { const index = selected.indexOf(asset.id); return <div key={asset.id} onClick={() => toggle(asset.id)} style={{ cursor: 'pointer', border: `2px solid ${index >= 0 ? '#1677ff' : 'transparent'}`, borderRadius: 7, padding: 2, position: 'relative' }}><Image preview={false} loading="lazy" src={urlOf(asset)} width="100%" style={{ aspectRatio: '3 / 4', objectFit: 'cover', borderRadius: 5 }} />{index >= 0 && <span style={{ position: 'absolute', top: 6, right: 6, background: '#1677ff', color: '#fff', borderRadius: 12, minWidth: 22, textAlign: 'center', lineHeight: '22px' }}>{index + 1}</span>}<div style={{ fontSize: 11, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{asset.group_key === 'generated' ? 'AI 图 · ' : ''}{nameOf(asset)}</div></div>; })}</div>}
{selected.length > 0 && <Text type="secondary" style={{ display: 'block', marginTop: 10 }}>播放顺序即选中序号;取消后重新选择可调整顺序。</Text>} {selected.length > 0 && <Text type="secondary" style={{ display: 'block', marginTop: 10 }}>播放顺序即选中序号;取消后重新选择可调整顺序。</Text>}
</Modal> </Modal>
</div>; </div>;
+11 -1
View File
@@ -11,7 +11,17 @@ const apiClient: AxiosInstance = axios.create({
apiClient.interceptors.response.use( apiClient.interceptors.response.use(
(response) => response, (response) => response,
(error) => { async (error) => {
// responseType: 'blob' 也会把 JSON 错误体包装成 Blob,先还原 detail。
const data = error.response?.data;
const contentType = error.response?.headers?.['content-type'] || data?.type || '';
if (data instanceof Blob && contentType.includes('json')) {
try {
error.response.data = JSON.parse(await data.text());
} catch {
// 保留原始响应,由调用方按普通请求错误处理。
}
}
if (envConfig.debug) { if (envConfig.debug) {
console.error('请求失败:', error.response?.data || error.message); console.error('请求失败:', error.response?.data || error.message);
} }
+18 -6
View File
@@ -54,11 +54,11 @@ export interface ProductDimensions {
/** 视觉风格(默认提示词可在页面上改写,随生成请求覆盖后端模板) */ /** 视觉风格(默认提示词可在页面上改写,随生成请求覆盖后端模板) */
export const STYLE_SET_OPTIONS = [ export const STYLE_SET_OPTIONS = [
{ value: 1, label: '北欧极简', prompt: 'Nordic minimalism: light gray or warm off-white background, soft diffused light, low-saturation palette, generous negative space, restrained and clean composition' }, { value: 1, label: '北欧极简', prompt: 'Nordic editorial e-commerce style for contextual and marketing images only: warm ivory or light greige base, soft window daylight with a subtle warm practical-light accent, low-saturation natural palette, pale wood and tactile linen details, spacious orderly composition, and at most one or two restrained lifestyle props. Keep the product exact and prominent. When text is requested, reserve a clean top text-safe zone with a clear headline-first hierarchy. Never override a task-mandated pure-white background, no-text, or no-props instruction' },
{ value: 2, label: '清新明亮', prompt: 'Fresh and bright: white-to-pale-blue gradient background, high-key lighting, clear luminous colors, airy and uncluttered composition' }, { value: 2, label: '清新明亮', prompt: 'Fresh, bright, family-friendly e-commerce style for contextual and marketing images only: crisp white with pale sky-blue, mint, or other accents sampled from the product palette; high-key daylight, luminous but natural color, soft clean shadows, airy mobile-first composition, and a cheerful polished retail mood. When text is requested, use a generous top text-safe zone, one bold headline plus one short supporting line or pill, and small simple accent icons only when they clarify a real feature. Never override a task-mandated pure-white background, no-text, or no-props instruction' },
{ value: 3, label: '高级感深色', prompt: 'Premium dark aesthetic: deep gray or charcoal background, dramatic side lighting, emphasize material texture and sheen, refined and restrained mood' }, { value: 3, label: '高级感深色', prompt: 'Premium dark editorial e-commerce style for contextual and marketing images only: deep charcoal, graphite, or near-black setting; sculpted side and rim light; controlled contrast; realistic material texture and sheen; one restrained tonal prop at most; refined, quiet, high-end mood. Let a single product-derived color accent carry the composition. When text is requested, keep a large high-contrast headline in a deliberate text-safe area and a minimal icon or specification row. Never override a task-mandated pure-white background, no-text, or no-props instruction' },
{ value: 4, label: '暖调生活', prompt: 'Warm lifestyle: warm beige background, cozy warm lighting, inviting home-living texture, approachable and natural atmosphere' }, { value: 4, label: '暖调生活', prompt: 'Warm, cozy lifestyle e-commerce style for contextual and marketing images only: a believable cream, ivory, or warm-beige home setting; soft golden lamp light balanced with gentle window daylight; tactile bedding, knit, or natural wood texture; comforting and approachable atmosphere. Stage one simple usage story with the product as the unquestioned hero; use only category-relevant props and, if appropriate, a natural hand or person interacting with it. For family or children products, a softly styled nursery or bedroom is suitable. When text is requested, use a top headline zone, bold rounded sans-serif lettering, a short secondary pill or line, and small friendly feature icons. Never override a task-mandated pure-white background, no-text, or no-props instruction' },
{ value: 5, label: '纯净棚拍', prompt: 'Clean e-commerce studio: plain light background, even frontal softbox lighting, no distracting props, product centered and prominent' }, { value: 5, label: '纯净棚拍', prompt: 'Clean conversion-focused e-commerce studio style: seamless pure white or very light neutral background only where the task permits, even frontal softbox lighting with a subtle natural contact shadow, faithful color, crisp edge separation, and product filling the frame at a readable scale. No decorative props, lifestyle scenery, artificial reflections, or visual clutter. If the task explicitly requires copy or specifications, use a precise grid, a clear headline-first hierarchy, and sparse line icons; otherwise show no text. Never alter the product or contradict a task-specific background instruction' },
] as const; ] as const;
/** 试算页固定目标平台 Ozon:俄文文案 · 3:4 图片 */ /** 试算页固定目标平台 Ozon:俄文文案 · 3:4 图片 */
@@ -130,11 +130,13 @@ export interface SuiteGeneratePayload {
export interface SuiteImageInfo { export interface SuiteImageInfo {
item_id: string; item_id: string;
asset_id?: string | null;
type_id: string; type_id: string;
name: string; name: string;
url: string; url: string;
status: string; status: string;
error?: string | null; error?: string | null;
completed_at?: string | null;
} }
export interface SuiteInfo { export interface SuiteInfo {
@@ -198,7 +200,10 @@ export async function downloadSuiteZip(suiteId: string): Promise<Blob> {
export interface ImageEditSinglePayload { export interface ImageEditSinglePayload {
product_id?: string; product_id?: string;
/** 兼容旧接口的单张底图;新界面使用 image_urls。 */ /** 明确的生成底图;辅助图始终在其后。 */
main_image_url?: string;
reference_image_urls?: string[];
/** 兼容旧接口的单张底图;新界面使用 main_image_url。 */
image_url?: string; image_url?: string;
/** 选中的参考底图,可多张。 */ /** 选中的参考底图,可多张。 */
image_urls?: string[]; image_urls?: string[];
@@ -206,6 +211,13 @@ export interface ImageEditSinglePayload {
/** edit 为常规 AI 编辑;outpaint 只允许生成扩展到 3:4 的新增边缘。 */ /** edit 为常规 AI 编辑;outpaint 只允许生成扩展到 3:4 的新增边缘。 */
mode?: 'edit' | 'outpaint'; mode?: 'edit' | 'outpaint';
model: string; model: string;
ratio?: '3:4' | '1:1' | '4:5';
target_language?: 'ru' | 'ar' | 'en';
translate_text?: boolean;
style_set?: number;
style_prompt?: string | null;
background?: 'auto' | 'white' | 'solid' | 'scene' | 'reference';
background_description?: string | null;
append?: boolean; append?: boolean;
/** 插入锚点:新素材排在该素材之后(方便与原图对比) */ /** 插入锚点:新素材排在该素材之后(方便与原图对比) */
after_asset_id?: string; after_asset_id?: string;