diff --git a/docs/v2.1/HANDOFF.md b/docs/v2.1/HANDOFF.md index e41dd49..f282438 100644 --- a/docs/v2.1/HANDOFF.md +++ b/docs/v2.1/HANDOFF.md @@ -48,7 +48,7 @@ pnpm -C extensions/collector build # 产物 .output/chrome-mv3 - **默认生图模型 = `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`。 -- **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,按张扣费。 ## 4. AI 规划张数策略(`planner.py`) diff --git a/extensions/collector/entrypoints/sidepanel/App.tsx b/extensions/collector/entrypoints/sidepanel/App.tsx index 8d592a8..fcc82ae 100644 --- a/extensions/collector/entrypoints/sidepanel/App.tsx +++ b/extensions/collector/entrypoints/sidepanel/App.tsx @@ -301,7 +301,7 @@ const App: React.FC = () => { setReporting(true); try { // 上报遵循采集图片区的勾选状态:勾选了就只传勾选项;未勾选则全量传。 - // 避免详情图过多时超过服务端的单次素材上限,同时保留“全不选 = 全量”的便捷行为。 + // 超过 100 张时,background 会自动分批写入同一个商品,用户无需手动拆分。 const imagesToReport = selectedKeys.size > 0 ? allImages.filter(img => selectedKeys.has(img.key)) : allImages; diff --git a/extensions/collector/src/api/report.ts b/extensions/collector/src/api/report.ts index d9c2ce9..ea9d549 100644 --- a/extensions/collector/src/api/report.ts +++ b/extensions/collector/src/api/report.ts @@ -5,6 +5,8 @@ import type { TextMaterial } from '../collector/merge'; export interface ReportProductPayload { + /** 后续批次写入首次上报创建的商品;调用方首次上报无需提供。 */ + product_id?: string; source: { platform: string; itemId: string | null; @@ -22,16 +24,24 @@ export interface ReportProductPayload { }>; /** 下载源图时需带的 Referer origin(如 https://detail.1688.com) */ refererOrigin?: string; + /** 多批上报时,仅最后一批启动素材下载,避免同一商品出现并发重复下载。 */ + process_assets?: boolean; } export interface ReportProductResult { product_id: string; stage?: string; 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, payload: ReportProductPayload, ): Promise { @@ -44,3 +54,43 @@ export async function reportProduct( if (!res.ok) throw new Error(data?.detail || `上报失败 HTTP ${res.status}`); return data as ReportProductResult; } + +/** 上报采集结果到 ozon-seller-kit 后台(由 background 转发,绕 CORS)。 */ +export async function reportProduct( + reportBaseUrl: string, + payload: ReportProductPayload, +): Promise { + 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 }; +} diff --git a/extensions/collector/src/collector/detail-images.ts b/extensions/collector/src/collector/detail-images.ts index 5846a16..19361ec 100644 --- a/extensions/collector/src/collector/detail-images.ts +++ b/extensions/collector/src/collector/detail-images.ts @@ -7,20 +7,64 @@ import type { ImageMaterial } from './image'; import type { SiteProfile } from '../profiles/types'; 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 { return raw .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))) + // 1688/淘宝详情接口常把 HTML 放在 JSON/JS 字符串中。若不还原 \",DOMParser + // 会把它编码为 %22,并可能把后面的真实 https URL 误解析成当前 CDN 的路径。 + .replace(/\\(["'])/g, '$1') .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 { - 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; try { 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 { return null; } @@ -53,7 +97,7 @@ export function extractDetailImageUrls(raw: string, baseUrl: string): string[] { // 某些 1688 响应会把 HTML 包在 JS 字符串里;补扫图片 CDN 的 URL 字面量。 const candidates = decoded.match(/(?:https?:)?\/\/[^\s'"<>\\]+/g) ?? []; for (const candidate of candidates) { - const url = normalizeUrl(candidate.replace(/[),;]+$/, ''), baseUrl); + const url = normalizeUrl(trimImageCandidate(candidate), baseUrl); if (!url) continue; const hostIsImageCdn = /(?:alicdn|taobaocdn|tmallcdn)\.com$/i.test(new URL(url).hostname); const hasImageExt = /\.(?:avif|bmp|gif|jpe?g|png|webp)(?:[?#]|$)/i.test(url); diff --git a/server/api/materials.py b/server/api/materials.py index 133819e..da82f18 100644 --- a/server/api/materials.py +++ b/server/api/materials.py @@ -202,7 +202,8 @@ async def create_materials( await db.commit() await db.refresh(product) - if queued: + # 多批上报的最后一批可能全被去重;仍要扫描此前批次留下的 pending 素材。 + if req.process_assets: background.add_task(process_product_assets, str(product.id)) return MaterialsResponse( product_id=str(product.id), diff --git a/server/api/suite.py b/server/api/suite.py index d8288d9..69370e9 100644 --- a/server/api/suite.py +++ b/server/api/suite.py @@ -316,12 +316,15 @@ async def download_suite_zip(suite_id: str): async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditTaskResponse: """提交单张图生图任务;立即返回任务 ID,实际生成在统一队列异步执行。""" 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: raise HTTPException(status_code=400, detail=f"未知 provider: {provider_name}") 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 后缀,供应商侧只认裸模型名 product_id = (req.product_id or "").strip() or None if product_id: @@ -331,7 +334,12 @@ async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditTaskResponse raise HTTPException(status_code=400, detail="product_id 不是合法 UUID") from exc # 单图编辑优先级高于套图;支持多张参考底图,保持传入顺序并去重。 - ref_urls = list(dict.fromkeys(url for url in [*req.image_urls, req.image_url] if url)) + 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)) if not ref_urls: raise HTTPException(status_code=400, detail="请至少选择一张参考底图") if req.mode == "outpaint": @@ -341,6 +349,10 @@ async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditTaskResponse raise HTTPException(status_code=400, detail="仅扩图只能选择一张参考底图") if req.generate_size_chart: 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。 is_size_chart = bool(req.generate_size_chart) @@ -354,10 +366,16 @@ async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditTaskResponse lang=spec["lang"], ratio=spec["ratio"], # 仅扩图与用户明确填写的 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={ "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, "operation": req.mode, # 用户写了要求时,这一轮只执行其要求,不再拼营销图、默认风格或默认俄文文案。 diff --git a/server/schemas/collection.py b/server/schemas/collection.py index fa25e62..39c6afc 100644 --- a/server/schemas/collection.py +++ b/server/schemas/collection.py @@ -33,6 +33,7 @@ class MaterialsRequest(BaseModel): texts: list[TextMaterial] = Field(default_factory=list) images: list[ImageMaterial] = Field(default_factory=list) refererOrigin: str | None = None # 下载源图时需带的 Referer + process_assets: bool = True # 插件多批上报时,仅最后一批触发下载队列 class MaterialsResponse(BaseModel): diff --git a/server/schemas/suite.py b/server/schemas/suite.py index 90c59bc..5411358 100644 --- a/server/schemas/suite.py +++ b/server/schemas/suite.py @@ -82,7 +82,7 @@ class ProductDimensions(BaseModel): length: 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 单位)") - unit: Literal["mm", "cm"] = Field(default="mm", description="尺寸单位") + unit: Literal["mm", "cm"] = Field(default="cm", description="尺寸单位") class PlanItem(BaseModel): @@ -181,6 +181,8 @@ class SuiteOut(BaseModel): class ImageEditSingleRequest(BaseModel): """POST /api/suite/image-edit:单张 AI 生图,可携带多张参考底图。""" 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_urls: list[str] = Field(default_factory=list, description="选中的参考底图 URL,可多张") prompt: str = Field(default="", description="用户要求(可选,作为额外生图指令)") @@ -188,7 +190,14 @@ class ImageEditSingleRequest(BaseModel): default="edit", 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 组)") after_asset_id: str | None = Field( default=None, diff --git a/server/services/generator.py b/server/services/generator.py index d2ddfe7..4b7ce25 100644 --- a/server/services/generator.py +++ b/server/services/generator.py @@ -81,19 +81,21 @@ DEFAULT_REF_COUNT = 2 # 每次生图最多带的参考图数(正面 1 张 + def _image_size(provider: str, ratio: str, is_wan: bool = True, model: str = "") -> str: """平台比例 → provider 尺寸参数。3:4 竖版(Ozon),1:1 方图(国内)。""" if provider == "doubao": - return "1536x2048" if ratio == "3:4" else "2048x2048" + return {"3:4": "1536x2048", "4:5": "1536x1920"}.get(ratio, "2048x2048") if provider == "rightapi": # 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": # 竖图必须与 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 语法相同(* 分隔),档位不同 # wan2.6 系列总像素限制在 [1280², 1440²],wan2.7 的 1536*2048/2048*2048 会超限 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": 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" _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" body: dict = {"model": model, "prompt": prompt, "n": 1, "size": size, "async": True} 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] 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" body: dict = {"model": model, "prompt": prompt, "n": 1, "size": size, "async": True} if ref_images: + # 与 _pcore_request 保持同一经 gpt-image-2 实际验证的形状,避免 Worker 路径漂移。 body["images"] = [{"image_url": await _resolve_ref(item)} for item in ref_images] if edit_mask: # Pcore 的图片参数使用 JSON {image_url};蒙版沿用相同的 OpenAI 兼容对象形式。 diff --git a/server/services/job_worker.py b/server/services/job_worker.py index 98d6f11..674684a 100644 --- a/server/services/job_worker.py +++ b/server/services/job_worker.py @@ -6,6 +6,8 @@ import logging import socket import uuid +import httpx + from config import get_settings from db import get_session_factory from models import ProductAsset @@ -23,6 +25,7 @@ from services.jobs import ( ) from services.prompts import build_context, build_prompt 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.suite_service import append_generated_asset from services.tasks import TaskImage @@ -31,6 +34,10 @@ from services.video_renderer import VideoRenderCancelled, render_slideshow log = logging.getLogger("background.worker") +# 异步供应商任务已经持久化了 task id 后,轮询网络失败不能把任务判为失败。 +# 下一次轮询复用同一 task id,不会再次提交/计费。 +PROVIDER_POLL_RETRY_DELAY_SECONDS = 30 + def _worker_owner() -> str: return f"{socket.gethostname()}-{uuid.uuid4().hex[:10]}" @@ -191,7 +198,11 @@ async def process_item(claim: ClaimedItem) -> None: "pcore": "gpt-image-2", }.get(provider, settings.ark_image_model) 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") target_size = _image_size(provider, claim.ratio, is_wan=provider == "tongyi" and model.lower().startswith("wan"), model=model) edit_mask: str | None = None @@ -207,12 +218,21 @@ async def process_item(claim: ClaimedItem) -> None: edit_mask = outpaint.mask_data_url else: context = build_context(payload.get("context") or {}, fallback_name="") - prompt_extra = job_item.get("prompt_extra") or job_item - prompt = build_prompt( - provider, model, job_item.get("kind", "custom"), context, claim.style_set, claim.lang, - extra=prompt_extra, style_prompt=payload.get("style_prompt"), requirements=payload.get("requirements"), - requirements_exclusive=bool(payload.get("requirements_exclusive")), - ) + 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 = build_prompt( + provider, model, job_item.get("kind", "custom"), context, claim.style_set, claim.lang, + extra=prompt_extra, style_prompt=payload.get("style_prompt"), requirements=payload.get("requirements"), + requirements_exclusive=bool(payload.get("requirements_exclusive")), + ) is_wan = provider == "tongyi" and model.lower().startswith("wan") result = await advance_generation( provider, prompt, refs, @@ -274,6 +294,21 @@ async def process_item(claim: ClaimedItem) -> None: claim.item_id, result_url=result_url, source_url=source_url or None, 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 log.exception("后台图片任务 %s/%s 执行失败", claim.job_id, claim.item_id) await mark_item_failed(claim.item_id, str(exc)) diff --git a/server/services/prompts/common.py b/server/services/prompts/common.py index f5ab503..5f27537 100644 --- a/server/services/prompts/common.py +++ b/server/services/prompts/common.py @@ -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")) 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) known_labels = [label for label, _ in known_dimensions] missing_labels = [label for label in ("length", "width", "height") if label not in known_labels] diff --git a/server/services/prompts/single_edit.py b/server/services/prompts/single_edit.py new file mode 100644 index 0000000..9bcbc6a --- /dev/null +++ b/server/services/prompts/single_edit.py @@ -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) diff --git a/server/tests/test_job_worker.py b/server/tests/test_job_worker.py index 7feb86b..70bd993 100644 --- a/server/tests/test_job_worker.py +++ b/server/tests/test_job_worker.py @@ -7,6 +7,8 @@ from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock, patch +import httpx + SERVER_DIR = Path(__file__).resolve().parents[1] if str(SERVER_DIR) not in sys.path: sys.path.insert(0, str(SERVER_DIR)) @@ -58,6 +60,46 @@ class WorkerCancellationTests(unittest.IsolatedAsyncioTestCase): cancelled.assert_awaited_once_with(claim.item_id) 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__": unittest.main() diff --git a/server/tests/test_single_image_edit.py b/server/tests/test_single_image_edit.py new file mode 100644 index 0000000..9695dcf --- /dev/null +++ b/server/tests/test_single_image_edit.py @@ -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() diff --git a/start.command b/start.command index 10e388f..dbca46b 100755 --- a/start.command +++ b/start.command @@ -22,8 +22,10 @@ project_pnpm() { BACKEND_PID="" STUDIO_PID="" WORKER_SUPERVISOR_PID="" +WORKER_SOURCE_WATCH_PID="" COLLECTOR_WATCH_PID="" COLLECTOR_STAMP="" +WORKER_SOURCE_STAMP="" CLEANUP_DONE=false stop_process_tree() { @@ -44,8 +46,10 @@ cleanup() { stop_process_tree "$BACKEND_PID" stop_process_tree "$STUDIO_PID" stop_process_tree "$WORKER_SUPERVISOR_PID" + stop_process_tree "$WORKER_SOURCE_WATCH_PID" stop_process_tree "$COLLECTOR_WATCH_PID" [[ -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 rm -f "$INSTANCE_PID_FILE" fi @@ -189,6 +193,24 @@ supervise_worker & 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 echo "⚠️ 未找到 Corepack,本次跳过 studio 前端(可手动:corepack pnpm@${PNPM_VERSION} -C studio dev)" # CI=true:node_modules 与 lockfile 不一致时自动重建,不弹确认 diff --git a/studio/src/pages/product/CopyPanel.tsx b/studio/src/pages/product/CopyPanel.tsx index 5ce8f6d..0703704 100644 --- a/studio/src/pages/product/CopyPanel.tsx +++ b/studio/src/pages/product/CopyPanel.tsx @@ -22,7 +22,6 @@ interface Props { export default function CopyPanel({ product, onSave, leftSpan = 10, rightSpan = 14 }: Props) { const [models, setModels] = useState([]); const [model, setModel] = useState(''); - const [sourceText, setSourceText] = useState(''); const [generating, setGenerating] = useState(false); const [result, setResult] = useState(null); @@ -41,8 +40,12 @@ export default function CopyPanel({ product, onSave, leftSpan = 10, rightSpan = return parts.join('\n\n'); }, [raw]); + // 文案模块的“采集信息”是可人工整理的 AI 输入。优先恢复用户上次保存的内容; + // 只有从未编辑过时才从当前采集数据自动拼出默认值。 + const savedSourceText = typeof raw.copy_source_text === 'string' ? raw.copy_source_text : null; + const [sourceText, setSourceText] = useState(() => savedSourceText ?? defaultSource); + useEffect(() => { - if (!sourceText && defaultSource) setSourceText(defaultSource); getAiModels() .then((r) => { setModels(r.models); @@ -52,6 +55,12 @@ export default function CopyPanel({ product, onSave, leftSpan = 10, rightSpan = // 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 () => { if (sourceText.trim().length < 10) { message.warning('请先粘贴至少 10 字的商品资料'); @@ -115,8 +124,12 @@ export default function CopyPanel({ product, onSave, leftSpan = 10, rightSpan = rows={16} value={sourceText} onChange={(e) => setSourceText(e.target.value)} + onBlur={() => { void saveSourceText(); }} placeholder="采集的商品资料(可编辑后生成)" /> + + 修改后失去焦点会自动保存 +
setDimensions((prev) => ({ ...prev, unit }))} - options={[{ value: 'mm', label: 'mm' }, { value: 'cm', label: 'cm' }]} - disabled={generating} - /> -
- - )} - setPrompt(e.target.value)} - placeholder={mode === 'outpaint' - ? '扩展区域要求(可选),例如:将新增边缘扩展为纯黑背景;原图区域不会被改动' - : '编辑要求(可选)。填写后会只执行此要求,不会附加营销图、俄文文字或默认棚拍风格'} - disabled={generating} - /> - {mode === 'outpaint' && ( - - 系统会生成 3:4 画布,只允许 AI 生成新增边缘;完成后会重新覆盖原图区域,确保原图不被改动。 - - )} -
-
- + 生成时识别并翻译图片文案,重新排版;不会额外发起识图请求。} + } + {mode === 'edit' &&
背景
+ setBackgroundDescription(event.target.value)} placeholder={background === 'solid' ? '例如:浅米色' : '补充背景要求(可选)'} />} +
} + {mode === 'edit' &&
视觉风格
+
{STYLE_SET_OPTIONS.map((item) => )}
+ + {showStylePrompt && setStylePrompts((previous) => ({ ...previous, [styleSet]: event.target.value }))} />} +
} + {mode === 'edit' &&
商品尺寸默认 cm
+
{(['length', 'width', 'height'] as const).map((key, index) => setDimensions((previous) => ({ ...previous, [key]: typeof value === 'number' ? value : undefined }))} />)} + setMode(value)} options={[{ value: 'edit', label: 'AI 编辑' }, { value: 'outpaint', label: '仅扩图至 3:4(保留原图)' }]} /> + 使用 Pcore 中转 · gpt-image-2{mode === 'outpaint' ? ';原图区域会覆盖回生成结果。' : ''} +
} +
+
+
+ + setChoosingSlot(null)}> +
{choices.length ? choices.map((item) => ) : 暂无其他商品图片可选}
+
+ ; } diff --git a/studio/src/pages/trial/TrialSuitePanel.tsx b/studio/src/pages/trial/TrialSuitePanel.tsx index eaf4c34..8e175b4 100644 --- a/studio/src/pages/trial/TrialSuitePanel.tsx +++ b/studio/src/pages/trial/TrialSuitePanel.tsx @@ -161,7 +161,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr const [styleSet, setStyleSet] = useState(1); const [stylePrompts, setStylePrompts] = useState>({}); const [requirements, setRequirements] = useState(''); - const [dimensions, setDimensions] = useState({ unit: 'mm' }); + const [dimensions, setDimensions] = useState({ unit: 'cm' }); const [model, setModel] = useState(DEFAULT_IMAGE_MODEL); const [watermark, setWatermark] = useState(loadWatermark); @@ -1230,7 +1230,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr ))}