From 897e5585c04eb710a78d739ffe1579fc2129664d Mon Sep 17 00:00:00 2001 From: joey-home Date: Sun, 13 Sep 2026 22:19:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=AF=95=E7=94=A8=E8=BF=87=E7=A8=8B?= =?UTF-8?q?=E4=B8=AD=E7=9A=84=E7=BB=86=E8=8A=82=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/v2.1/trial-page.md | 8 +- .../collector/entrypoints/background.ts | 6 +- .../collector/entrypoints/sidepanel/App.tsx | 8 +- .../collector/src/collector/1688-state.ts | 9 + .../collector/src/collector/detail-images.ts | 97 +++++++++ extensions/collector/src/collector/dom.ts | 19 ++ extensions/collector/src/collector/md5.ts | 86 ++++++++ extensions/collector/src/collector/merge.ts | 2 +- .../collector/src/collector/platforms/1688.ts | 27 ++- .../collector/src/collector/platforms/ozon.ts | 3 +- .../src/collector/platforms/taobao.ts | 25 ++- .../collector/src/collector/ssr-builder.ts | 4 +- .../collector/src/collector/taobao-detail.ts | 56 ++++++ extensions/collector/wxt.config.ts | 2 + server/api/export.py | 9 +- server/api/suite.py | 61 ++++-- server/models.yaml | 0 server/schemas/suite.py | 11 +- server/services/generator.py | 82 +++++++- server/services/job_worker.py | 54 ++++- server/services/planner.py | 8 +- server/services/prompts/__init__.py | 3 +- server/services/prompts/alibaba.py | 17 +- server/services/prompts/common.py | 28 ++- server/services/prompts/google.py | 18 +- server/services/prompts/gpt.py | 22 ++- server/services/video_renderer.py | 34 ++-- server/tests/test_export.py | 22 ++- server/tests/test_generator_sizes.py | 22 +++ server/tests/test_outpaint_and_edit_prompt.py | 70 +++++++ server/tests/test_partial_dimensions.py | 44 +++++ studio/src/pages/product/PriceInfoPanel.tsx | 31 +-- studio/src/pages/trial/AiImageGenModal.tsx | 185 +++++++++++++----- studio/src/pages/trial/TrialExportPanel.tsx | 4 +- studio/src/pages/trial/TrialPricingPanel.tsx | 97 +++++---- studio/src/pages/trial/TrialSuitePanel.tsx | 179 ++++++++++++----- studio/src/pages/trial/TrialVideoMaker.tsx | 22 ++- studio/src/pricing/pricing.ts | 21 +- studio/src/services/suite.ts | 15 +- studio/tsconfig.app.tsbuildinfo | 2 +- 40 files changed, 1156 insertions(+), 257 deletions(-) create mode 100644 extensions/collector/src/collector/detail-images.ts create mode 100644 extensions/collector/src/collector/md5.ts create mode 100644 extensions/collector/src/collector/taobao-detail.ts create mode 100644 server/models.yaml create mode 100644 server/tests/test_generator_sizes.py create mode 100644 server/tests/test_outpaint_and_edit_prompt.py create mode 100644 server/tests/test_partial_dimensions.py diff --git a/docs/v2.1/trial-page.md b/docs/v2.1/trial-page.md index 524c603..071cece 100644 --- a/docs/v2.1/trial-page.md +++ b/docs/v2.1/trial-page.md @@ -56,11 +56,11 @@ ## 4. 02 价格试算(公式从 web/js/app.js 移植,单位口径不变) 输入参数(存 `product.pricing` JSON):`purchasePrice` 进货价¥(默认30)、`profitRate` 净利率%(默认100)、 -`tdPrice` 贴单费¥(默认3)、`logisticsLevel` low/high/high2(默认 low)、`lineMultiplier` 划线价倍数%(默认100,0-500)、 +`tdPrice` 贴单费¥(默认3)、`logisticsLevel` low/high/high2(默认 low)、`lineDiscountRate` 划线折扣率%(默认50,0-99.99)、 `fxRate` 汇率(`product.fx_rate` 快照,无则 `GET /api/fx` 拉取)。重量/尺寸在左列顶部维护(落 product 包装字段,变化即重算)。 > 划线价口径(v2.1 调整):原 v1 的「预留折扣空间」(划线价 = 销售价 ÷ (1-折扣%))在 >95% 区间发散且 ≥100% 无解, -> 改为「划线价倍数」:`划线价 = 销售价 × (1 + 倍数%)`。倍数 100% → 划线价 = 2× 销售价;50% → 1.5×。线性、全程可算。 +> 划线价按「划线折扣率」计算:`划线价 = 销售价 ÷ (1 - 划线折扣率)`。例如折扣率 50%、销售价 ¥50 时,划线价为 ¥100。折扣率必须 ≥ 0 且 < 100%。 **物流费**(`baseLogisticsFee` 已在 `studio/src/pricing/pricing.ts`): @@ -85,7 +85,7 @@ 平台佣金 = 销售价 × 12%(low)/ 18%(其余) ← 展示用 平台总抽成 = 销售价 × (1 - 净得率) = 15.5% / 21.5% ← 含佣金及约3.5%其它费 完全成本 = 进货价 + 物流费 + 平台总抽成 -预留后¥ = 销售价 × (1 + 划线价倍数%)(即划线价);差额 = 划线价 - 销售价 +划线价¥ = 销售价 ÷ (1 - 划线折扣率);差额 = 划线价 - 销售价 销售价₽ = 销售价¥ × 汇率;划线₽ = 划线价¥ × 汇率 ``` @@ -103,7 +103,7 @@ // product.pricing(PriceInfoPanel 字段的超集,向后兼容) { "purchasePrice": 30, "profitRate": 100, "logisticsLevel": "low", "tdPrice": 3, - "lineMultiplier": 100, "fxRate": 11.5, "weightG": 600, "dims": { "l": 20, "w": 15, "h": 10 }, + "lineDiscountRate": 50, "fxRate": 11.5, "weightG": 600, "dims": { "l": 20, "w": 15, "h": 10 }, "logisticsFee": 22.12, "receivedPrice": 60, "profitPrice": 30, "commission": 11.63, "fullCommission": 15.02, "totalCost": 67.14, "sellingPriceCny": 77.51, "linePriceCny": 155.02, diff --git a/extensions/collector/entrypoints/background.ts b/extensions/collector/entrypoints/background.ts index 4636908..152307d 100644 --- a/extensions/collector/entrypoints/background.ts +++ b/extensions/collector/entrypoints/background.ts @@ -47,11 +47,11 @@ export default defineBackground(() => { // 通用文本代理:采集引擎拉跨域资源(1688 详情 CDN / mtop API) if (msg?.action === 'fetchText') { - const headers: Record = {}; - if (msg.referer) headers['Referer'] = msg.referer; fetch(msg.url, { - headers, credentials: msg.credentials ? 'include' : 'omit', + // Referer 是受保护请求头,不能通过 headers 设置;使用 Fetch 的标准选项。 + referrer: typeof msg.referer === 'string' ? msg.referer : undefined, + referrerPolicy: 'strict-origin-when-cross-origin', }) .then(async (res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`); diff --git a/extensions/collector/entrypoints/sidepanel/App.tsx b/extensions/collector/entrypoints/sidepanel/App.tsx index 50fec5c..8d592a8 100644 --- a/extensions/collector/entrypoints/sidepanel/App.tsx +++ b/extensions/collector/entrypoints/sidepanel/App.tsx @@ -300,6 +300,11 @@ const App: React.FC = () => { } setReporting(true); try { + // 上报遵循采集图片区的勾选状态:勾选了就只传勾选项;未勾选则全量传。 + // 避免详情图过多时超过服务端的单次素材上限,同时保留“全不选 = 全量”的便捷行为。 + const imagesToReport = selectedKeys.size > 0 + ? allImages.filter(img => selectedKeys.has(img.key)) + : allImages; const data = await send<{ product_id: string }>('reportProduct', { reportBaseUrl: settings.reportBaseUrl, payload: { @@ -310,8 +315,7 @@ const App: React.FC = () => { collectedAt: result.scannedAt, }, texts: editedTexts(), - // 全量上报采集图(main/sku/detail/video),试算页需要完整素材 - images: result.images.map(img => ({ + images: imagesToReport.map(img => ({ groupKey: img.groupKey, groupName: img.groupName, variantName: img.variantName ?? null, diff --git a/extensions/collector/src/collector/1688-state.ts b/extensions/collector/src/collector/1688-state.ts index 13439b4..5dec1be 100644 --- a/extensions/collector/src/collector/1688-state.ts +++ b/extensions/collector/src/collector/1688-state.ts @@ -30,6 +30,7 @@ export interface State1688 { videos: Array<{ url: string; cover?: string }>; skus: Sku1688[]; params: Array<{ key: string; value: string }>; + detailUrl?: string; } /** 深度查找指定键(BFS + 访问标记 + 节点数上限,防大对象拖死) */ @@ -150,6 +151,13 @@ export function extract1688State(context: unknown): State1688 | null { temp.topCategoryId ? String(temp.topCategoryId) : '', ].filter(Boolean); + // 图文详情位于独立 CDN,SSR 直接给出地址;无需点击标签或滚动页面。 + const description = deepFind(context, 'description'); + const candidateDetailUrl = description?.fields?.detailUrl ?? description?.detailUrl; + const detailUrl = typeof candidateDetailUrl === 'string' && /^https:\/\//.test(candidateDetailUrl) + ? candidateDetailUrl + : undefined; + if (!title && mainImages.length === 0 && skus.length === 0) return null; return { @@ -164,5 +172,6 @@ export function extract1688State(context: unknown): State1688 | null { videos, skus, params, + detailUrl, }; } diff --git a/extensions/collector/src/collector/detail-images.ts b/extensions/collector/src/collector/detail-images.ts new file mode 100644 index 0000000..5846a16 --- /dev/null +++ b/extensions/collector/src/collector/detail-images.ts @@ -0,0 +1,97 @@ +/** + * 图文详情资源提取。接口可返回 HTML,也可返回 `var desc = '...'` 形式的脚本; + * 本模块只解析字符串和 HTML,绝不执行远端脚本。 + */ +import { dedupeKey, pickBestFromSrcset, toOriginalUrl } from './url'; +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']; + +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))) + .replace(/\\\//g, '/'); +} + +function normalizeUrl(raw: string, baseUrl: string): string | null { + const value = raw.trim().replace(/^['"]|['"]$/g, ''); + 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; + } catch { + return null; + } +} + +function addUrl(out: string[], seen: Set, raw: string | null, baseUrl: string): void { + if (!raw) return; + const url = normalizeUrl(raw, baseUrl); + if (!url || seen.has(url)) return; + seen.add(url); + out.push(url); +} + +/** 从详情 HTML 或字符串化的详情脚本中提取图片地址。 */ +export function extractDetailImageUrls(raw: string, baseUrl: string): string[] { + const urls: string[] = []; + const seen = new Set(); + const decoded = normalizeEscapedText(raw); + const doc = new DOMParser().parseFromString(decoded, 'text/html'); + + for (const el of Array.from(doc.querySelectorAll('img, source'))) { + for (const attr of IMAGE_ATTRS) addUrl(urls, seen, el.getAttribute(attr), baseUrl); + const srcset = el.getAttribute('srcset'); + if (srcset) addUrl(urls, seen, pickBestFromSrcset(srcset), baseUrl); + } + for (const el of Array.from(doc.querySelectorAll('[style]'))) { + addUrl(urls, seen, el.style.backgroundImage.match(/url\(([^)]+)\)/i)?.[1] ?? null, baseUrl); + } + + // 某些 1688 响应会把 HTML 包在 JS 字符串里;补扫图片 CDN 的 URL 字面量。 + const candidates = decoded.match(/(?:https?:)?\/\/[^\s'"<>\\]+/g) ?? []; + for (const candidate of candidates) { + const url = normalizeUrl(candidate.replace(/[),;]+$/, ''), 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); + if (hostIsImageCdn || hasImageExt) addUrl(urls, seen, url, baseUrl); + } + return urls; +} + +/** 由 background service worker 跨域读取详情接口。 */ +export function fetchDetailText(url: string, referer: string): Promise { + return new Promise((resolve, reject) => { + chrome.runtime.sendMessage( + { action: 'fetchText', url, referer, credentials: true }, + (response: { ok?: boolean; text?: string; error?: string } | undefined) => { + const runtimeError = chrome.runtime.lastError; + if (runtimeError) return reject(new Error(runtimeError.message)); + if (!response?.ok || typeof response.text !== 'string') { + return reject(new Error(response?.error || '详情接口未返回内容')); + } + resolve(response.text); + }, + ); + }); +} + +export function detailUrlsToMaterials(urls: string[], profile: SiteProfile): ImageMaterial[] { + const seen = new Set(); + const result: ImageMaterial[] = []; + for (const rawUrl of urls) { + const url = toOriginalUrl(rawUrl, profile.originalUrlRules); + const key = dedupeKey(url, profile.originalUrlRules); + if (seen.has(key)) continue; + seen.add(key); + result.push({ + key: `detail-${String(result.length + 1).padStart(3, '0')}`, + groupKey: 'detail', groupName: '详情图', url, thumbUrl: rawUrl, + index: result.length, type: 'img', + }); + } + return result; +} diff --git a/extensions/collector/src/collector/dom.ts b/extensions/collector/src/collector/dom.ts index 87aaa1a..cc5f23a 100644 --- a/extensions/collector/src/collector/dom.ts +++ b/extensions/collector/src/collector/dom.ts @@ -53,6 +53,25 @@ export function queryAllDeep(selectors: string[]): Element[] { return out; } +/** + * 淘宝等页面会把图文详情放在延迟挂载的标签页中;仅滚动页面不会触发该标签的内容加载。 + * 只匹配明确的详情标签,避免误点商品页中的普通链接或推荐内容。 + */ +export async function activateDetailTab(): Promise { + const labels = new Set(['图文详情', '商品详情', '详情']); + const candidates = Array.from(document.querySelectorAll('[role="tab"], button, [class*="tab"]')); + const tab = candidates.find((el) => { + const label = (el.textContent || '').replace(/\s+/g, ' ').trim(); + const rect = el.getBoundingClientRect(); + return labels.has(label) && rect.width > 0 && rect.height > 0 && el.getAttribute('aria-selected') !== 'true'; + }); + if (!tab) return; + + tab.click(); + // 给懒加载容器一次渲染机会,再由后续滚动加载实际图片。 + await new Promise((resolve) => setTimeout(resolve, 600)); +} + /** * 自动滚动到页面底部,触发懒加载(详情图在页面尾部,不滚不加载)。 * 有界滚动:小步分段 + 随机延迟(模拟人工浏览节奏,避免"一滚到底"的机器人特征), diff --git a/extensions/collector/src/collector/md5.ts b/extensions/collector/src/collector/md5.ts new file mode 100644 index 0000000..157367d --- /dev/null +++ b/extensions/collector/src/collector/md5.ts @@ -0,0 +1,86 @@ +/** 小型 MD5 实现,仅用于淘宝公开 H5 接口的请求签名。 */ +function add32(a: number, b: number): number { return (a + b) & 0xffffffff; } +function rol(num: number, cnt: number): number { return (num << cnt) | (num >>> (32 - cnt)); } +function cmn(q: number, a: number, b: number, x: number, s: number, t: number): number { + return add32(rol(add32(add32(a, q), add32(x, t)), s), b); +} +function ff(a: number, b: number, c: number, d: number, x: number, s: number, t: number): number { + return cmn((b & c) | (~b & d), a, b, x, s, t); +} +function gg(a: number, b: number, c: number, d: number, x: number, s: number, t: number): number { + return cmn((b & d) | (c & ~d), a, b, x, s, t); +} +function hh(a: number, b: number, c: number, d: number, x: number, s: number, t: number): number { + return cmn(b ^ c ^ d, a, b, x, s, t); +} +function ii(a: number, b: number, c: number, d: number, x: number, s: number, t: number): number { + return cmn(c ^ (b | ~d), a, b, x, s, t); +} + +function cycle(state: number[], block: number[]): void { + let [a, b, c, d] = state; + a = ff(a, b, c, d, block[0], 7, -680876936); d = ff(d, a, b, c, block[1], 12, -389564586); + c = ff(c, d, a, b, block[2], 17, 606105819); b = ff(b, c, d, a, block[3], 22, -1044525330); + a = ff(a, b, c, d, block[4], 7, -176418897); d = ff(d, a, b, c, block[5], 12, 1200080426); + c = ff(c, d, a, b, block[6], 17, -1473231341); b = ff(b, c, d, a, block[7], 22, -45705983); + a = ff(a, b, c, d, block[8], 7, 1770035416); d = ff(d, a, b, c, block[9], 12, -1958414417); + c = ff(c, d, a, b, block[10], 17, -42063); b = ff(b, c, d, a, block[11], 22, -1990404162); + a = ff(a, b, c, d, block[12], 7, 1804603682); d = ff(d, a, b, c, block[13], 12, -40341101); + c = ff(c, d, a, b, block[14], 17, -1502002290); b = ff(b, c, d, a, block[15], 22, 1236535329); + a = gg(a, b, c, d, block[1], 5, -165796510); d = gg(d, a, b, c, block[6], 9, -1069501632); + c = gg(c, d, a, b, block[11], 14, 643717713); b = gg(b, c, d, a, block[0], 20, -373897302); + a = gg(a, b, c, d, block[5], 5, -701558691); d = gg(d, a, b, c, block[10], 9, 38016083); + c = gg(c, d, a, b, block[15], 14, -660478335); b = gg(b, c, d, a, block[4], 20, -405537848); + a = gg(a, b, c, d, block[9], 5, 568446438); d = gg(d, a, b, c, block[14], 9, -1019803690); + c = gg(c, d, a, b, block[3], 14, -187363961); b = gg(b, c, d, a, block[8], 20, 1163531501); + a = gg(a, b, c, d, block[13], 5, -1444681467); d = gg(d, a, b, c, block[2], 9, -51403784); + c = gg(c, d, a, b, block[7], 14, 1735328473); b = gg(b, c, d, a, block[12], 20, -1926607734); + a = hh(a, b, c, d, block[5], 4, -378558); d = hh(d, a, b, c, block[8], 11, -2022574463); + c = hh(c, d, a, b, block[11], 16, 1839030562); b = hh(b, c, d, a, block[14], 23, -35309556); + a = hh(a, b, c, d, block[1], 4, -1530992060); d = hh(d, a, b, c, block[4], 11, 1272893353); + c = hh(c, d, a, b, block[7], 16, -155497632); b = hh(b, c, d, a, block[10], 23, -1094730640); + a = hh(a, b, c, d, block[13], 4, 681279174); d = hh(d, a, b, c, block[0], 11, -358537222); + c = hh(c, d, a, b, block[3], 16, -722521979); b = hh(b, c, d, a, block[6], 23, 76029189); + a = hh(a, b, c, d, block[9], 4, -640364487); d = hh(d, a, b, c, block[12], 11, -421815835); + c = hh(c, d, a, b, block[15], 16, 530742520); b = hh(b, c, d, a, block[2], 23, -995338651); + a = ii(a, b, c, d, block[0], 6, -198630844); d = ii(d, a, b, c, block[7], 10, 1126891415); + c = ii(c, d, a, b, block[14], 15, -1416354905); b = ii(b, c, d, a, block[5], 21, -57434055); + a = ii(a, b, c, d, block[12], 6, 1700485571); d = ii(d, a, b, c, block[3], 10, -1894986606); + c = ii(c, d, a, b, block[10], 15, -1051523); b = ii(b, c, d, a, block[1], 21, -2054922799); + a = ii(a, b, c, d, block[8], 6, 1873313359); d = ii(d, a, b, c, block[15], 10, -30611744); + c = ii(c, d, a, b, block[6], 15, -1560198380); b = ii(b, c, d, a, block[13], 21, 1309151649); + a = ii(a, b, c, d, block[4], 6, -145523070); d = ii(d, a, b, c, block[11], 10, -1120210379); + c = ii(c, d, a, b, block[2], 15, 718787259); b = ii(b, c, d, a, block[9], 21, -343485551); + state[0] = add32(a, state[0]); state[1] = add32(b, state[1]); + state[2] = add32(c, state[2]); state[3] = add32(d, state[3]); +} + +function block(input: string): number[] { + const out: number[] = []; + for (let i = 0; i < 64; i += 4) { + out[i >> 2] = input.charCodeAt(i) + (input.charCodeAt(i + 1) << 8) + + (input.charCodeAt(i + 2) << 16) + (input.charCodeAt(i + 3) << 24); + } + return out; +} + +/** 返回 UTF-8 字符串的 32 位小写 MD5。 */ +export function md5(input: string): string { + const text = unescape(encodeURIComponent(input)); + const state = [1732584193, -271733879, -1732584194, 271733878]; + let i = 64; + for (; i <= text.length; i += 64) cycle(state, block(text.slice(i - 64, i))); + const tail = new Array(16).fill(0); + const rest = text.slice(i - 64); + for (let j = 0; j < rest.length; j++) tail[j >> 2] |= rest.charCodeAt(j) << ((j % 4) << 3); + tail[rest.length >> 2] |= 0x80 << ((rest.length % 4) << 3); + if (rest.length > 55) cycle(state, tail); + tail.fill(0, rest.length > 55 ? 0 : 15); + tail[14] = text.length * 8; + cycle(state, tail); + return state.map((word) => { + let out = ''; + for (let j = 0; j < 4; j++) out += ((word >> (j * 8)) & 0xff).toString(16).padStart(2, '0'); + return out; + }).join(''); +} diff --git a/extensions/collector/src/collector/merge.ts b/extensions/collector/src/collector/merge.ts index 5260960..27f8582 100644 --- a/extensions/collector/src/collector/merge.ts +++ b/extensions/collector/src/collector/merge.ts @@ -83,7 +83,7 @@ export function finalize( const warnings: string[] = []; if (!texts.some((t) => t.kind === 'title')) warnings.push('未采集到标题'); if (images.length === 0) warnings.push('未扫描到任何图片/视频'); - if ((stats.detail ?? 0) === 0) warnings.push('详情图为 0 张,请滚动到页面底部后重新采集'); + if ((stats.detail ?? 0) === 0) warnings.push('详情图为 0 张:页面未提供可读取的图文详情数据'); return { platform: profile.id, diff --git a/extensions/collector/src/collector/platforms/1688.ts b/extensions/collector/src/collector/platforms/1688.ts index b2df583..c931640 100644 --- a/extensions/collector/src/collector/platforms/1688.ts +++ b/extensions/collector/src/collector/platforms/1688.ts @@ -2,16 +2,16 @@ * 1688 采集编排(平台文件): * ① MAIN world 桥读 window.context(模块化 SSR 状态)★主路径 * —— 主图 / SKU 全规格图 / 价格区间 / SKU 级价格库存 / 每 SKU 长宽高重量 / 销量 / 店铺 - * ② DOM 兜底 + 补充:#productAttributes 参数表(antd Descriptions)、#detail 详情图 - * 说明:不再直调 description.detailUrl 数据端点(与淘宝 mtop 同样的风控考虑), - * 详情图改为滚动加载后由 DOM 采集补齐。 + * ② description.detailUrl:直接读取图文详情,不操作用户页面 + * ③ DOM 兜底 + 补充当前已挂载内容 */ -import { autoScrollToBottom, waitForAny } from '../dom'; +import { waitForAny } from '../dom'; import { collectImages, type ImageMaterial } from '../image'; import { collectTexts, mergeTexts, type TextMaterial } from '../text'; import { toOriginalUrl } from '../url'; import { readWindowKeys } from '../../bridge/read-window'; import { extract1688State, type State1688 } from '../1688-state'; +import { detailUrlsToMaterials, extractDetailImageUrls, fetchDetailText } from '../detail-images'; import { finalize, mergeImages, type ScanResult } from '../merge'; import type { SiteProfile } from '../../profiles/types'; @@ -20,6 +20,7 @@ export async function scan1688(profile: SiteProfile, itemId: string | null): Pro let primaryImages: ImageMaterial[] = []; let source: ScanResult['source'] = 'dom'; let st: State1688 | null = null; + const detailWarnings: string[] = []; // ① 桥读 window.context const keys = await readWindowKeys(['context']); @@ -75,11 +76,22 @@ export async function scan1688(profile: SiteProfile, itemId: string | null): Pro }); }); source = 'state'; + + if (st.detailUrl) { + try { + const rawDetail = await fetchDetailText(st.detailUrl, location.href); + const detailImages = detailUrlsToMaterials(extractDetailImageUrls(rawDetail, st.detailUrl), profile); + primaryImages.push(...detailImages); + if (detailImages.length === 0) detailWarnings.push('详情接口未解析到图片'); + } catch (error) { + detailWarnings.push(`详情接口读取失败: ${error instanceof Error ? error.message : String(error)}`); + } + } else { + detailWarnings.push('页面未提供详情接口地址'); + } } - // ② DOM 兜底 + 补充(参数表 #productAttributes、详情图 #detail 在这里进结果) - // 慢速分段滚动触发懒加载(详情图不滚不加载),有界滚动防无限推荐流 - await autoScrollToBottom(); + // ③ DOM 兜底:只读取当前已经挂载的内容,不点击标签、不滚动页面。 const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 10_000); if (!anchor) console.warn('[SuiteCollector] 等待页面就绪超时(继续尝试 DOM 采集)'); const { materials: domTexts, missingRequired } = collectTexts(profile); @@ -90,6 +102,7 @@ export async function scan1688(profile: SiteProfile, itemId: string | null): Pro const texts = mergeTexts(primaryTexts, domTexts); const images = mergeImages(primaryImages, domImages, profile); const result = finalize(profile, itemId, texts, images, [], source); + if ((result.stats.detail ?? 0) === 0) result.warnings.push(...detailWarnings); if (missingRequired.length > 0) result.warnings.push(`缺少必需字段: ${missingRequired.join(', ')}`); return result; } diff --git a/extensions/collector/src/collector/platforms/ozon.ts b/extensions/collector/src/collector/platforms/ozon.ts index 8ead15f..6fcc470 100644 --- a/extensions/collector/src/collector/platforms/ozon.ts +++ b/extensions/collector/src/collector/platforms/ozon.ts @@ -5,7 +5,7 @@ * ③ 站内页 JSON API(entrypoint-api.bx) * ④ DOM data-widget 选择器兜底 + 详情图补充 */ -import { autoScrollToBottom, waitForAny } from '../dom'; +import { activateDetailTab, autoScrollToBottom, waitForAny } from '../dom'; import { collectImages, type ImageMaterial } from '../image'; import { collectTexts, mergeTexts, type TextMaterial } from '../text'; import { toOriginalUrl, toThumbUrl } from '../url'; @@ -148,6 +148,7 @@ export async function scanOzon(profile: SiteProfile, itemId: string | null): Pro // ── 路径④:DOM 采集(兜底 + 详情图补充)── // 先滚到底触发懒加载(详情图不滚不加载),有界滚动防无限推荐流 + await activateDetailTab(); await autoScrollToBottom(); const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 8_000); if (!anchor) console.warn('[SuiteCollector] 等待页面就绪超时(继续尝试 DOM 采集)'); diff --git a/extensions/collector/src/collector/platforms/taobao.ts b/extensions/collector/src/collector/platforms/taobao.ts index 2ab2e0f..2695ffa 100644 --- a/extensions/collector/src/collector/platforms/taobao.ts +++ b/extensions/collector/src/collector/platforms/taobao.ts @@ -3,15 +3,15 @@ * ① MAIN world 桥读页面全局(__ICE_APP_CONTEXT__ 等)★主路径 * (isolated world 读不到 window 变量,v1 直读是无效的) * ② DOM 兜底 + 补充 - * 说明:不再直调 mtop 签名接口(h5api.m.taobao.com / h5api.m.tmall.com), - * 仅读取页面已加载数据(SSR 全局 + DOM),避免触发平台风控。 + * 说明:图文详情通过页面已有登录态请求 mtop 描述接口;不点击标签、不滚动页面。 */ -import { autoScrollToBottom, waitForAny } from '../dom'; +import { waitForAny } from '../dom'; import { collectImages, type ImageMaterial } from '../image'; import { collectTexts, mergeTexts, type TextMaterial } from '../text'; import { readWindowKeys } from '../../bridge/read-window'; import { buildFromSSR } from '../ssr-builder'; import { taobaoStateFromBridge } from '../taobao-state'; +import { collectTaobaoDetailImages } from '../taobao-detail'; import { finalize, mergeImages, type ScanResult } from '../merge'; import type { SiteProfile } from '../../profiles/types'; @@ -19,6 +19,7 @@ export async function scanTaobao(profile: SiteProfile, _itemId: string | null): let primaryTexts: TextMaterial[] = []; let primaryImages: ImageMaterial[] = []; let source: ScanResult['source'] = 'dom'; + const detailWarnings: string[] = []; // ① 桥读页面全局 const keys = await readWindowKeys(['__ICE_APP_CONTEXT__', '__general_skupanel_cache_data', '__ICE_DATA_LOADER__']); @@ -31,9 +32,20 @@ export async function scanTaobao(profile: SiteProfile, _itemId: string | null): source = 'ssr'; } - // ② DOM 兜底 + 补充 - // 慢速分段滚动触发懒加载(详情图不滚不加载),有界滚动防无限推荐流 - await autoScrollToBottom(); + // ② 图文详情 API:使用页面已有登录态签名请求,不点击标签、不滚动页面。 + if (_itemId) { + try { + const detailImages = await collectTaobaoDetailImages(_itemId, profile); + primaryImages.push(...detailImages); + if (detailImages.length === 0) detailWarnings.push('详情接口未解析到图片'); + } catch (error) { + detailWarnings.push('详情接口读取失败: ' + (error instanceof Error ? error.message : String(error))); + } + } else { + detailWarnings.push('未识别到商品 ID'); + } + + // ③ DOM 兜底:只读取首屏已挂载内容,不点击详情标签、不滚动页面。 const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 10_000); if (!anchor) console.warn('[SuiteCollector] 等待页面就绪超时(继续尝试 DOM 采集)'); const { materials: domTexts, missingRequired } = collectTexts(profile); @@ -44,6 +56,7 @@ export async function scanTaobao(profile: SiteProfile, _itemId: string | null): const texts = mergeTexts(primaryTexts, domTexts); const images = mergeImages(primaryImages, domImages, profile); const result = finalize(profile, _itemId, texts, images, [], source); + if ((result.stats.detail ?? 0) === 0) result.warnings.push(...detailWarnings); if (missingRequired.length > 0) result.warnings.push(`缺少必需字段: ${missingRequired.join(', ')}`); return result; } diff --git a/extensions/collector/src/collector/ssr-builder.ts b/extensions/collector/src/collector/ssr-builder.ts index 8cc8ec7..099a5aa 100644 --- a/extensions/collector/src/collector/ssr-builder.ts +++ b/extensions/collector/src/collector/ssr-builder.ts @@ -139,9 +139,9 @@ export function buildFromSSR(data: SSRData, profile: SiteProfile): ScanResult { if (images.length === 0) { warnings.push('未扫描到任何图片/视频'); } - // SSR 数据里没有详情图,需要 DOM 补充 + // SSR 数据里没有详情图;由平台接口或当前已挂载 DOM 补充。 if (stats.detail === undefined) { - warnings.push('详情图需 DOM 补充:请滚动到页面底部后重新采集'); + warnings.push('详情图需补充:页面未提供可读取的图文详情数据'); } return { diff --git a/extensions/collector/src/collector/taobao-detail.ts b/extensions/collector/src/collector/taobao-detail.ts new file mode 100644 index 0000000..61b4a0e --- /dev/null +++ b/extensions/collector/src/collector/taobao-detail.ts @@ -0,0 +1,56 @@ +import { detailUrlsToMaterials, extractDetailImageUrls, fetchDetailText } from './detail-images'; +import { md5 } from './md5'; +import type { ImageMaterial } from './image'; +import type { SiteProfile } from '../profiles/types'; + +function getMtopToken(): string | null { + const match = document.cookie.match(/(?:^|;\s*)_m_h5_tk=([^;]+)/); + if (!match) return null; + try { + return decodeURIComponent(match[1]).split('_')[0] || null; + } catch { + return match[1].split('_')[0] || null; + } +} + +function mtopUrl(itemId: string, token: string): string { + const now = Date.now().toString(); + const current = new URL(location.href); + const data = JSON.stringify({ + abbucket: current.searchParams.get('abbucket') || '', + id: itemId, + ns: current.searchParams.get('ns') || '', + spm: current.searchParams.get('spm') || '', + detail_v: '3.3.0', + preferWireless: 'true', + }); + const host = current.hostname === 'detail.tmall.com' ? 'h5api.m.tmall.com' : 'h5api.m.taobao.com'; + const url = new URL('https://' + host + '/h5/mtop.taobao.detail.getdesc/7.0/'); + url.search = new URLSearchParams({ + jsv: '2.7.0', appKey: '12574478', t: now, sign: md5(token + '&' + now + '&12574478&' + data), + api: 'mtop.taobao.detail.getdesc', v: '7.0', isSec: '0', ecode: '0', + AntiFlood: 'true', AntiCreep: 'true', H5Request: 'true', ttid: '2022@tmall_litepc_9.17.0', + type: 'json', dataType: 'json', data, + }).toString(); + return url.href; +} + +/** 无滚动读取淘宝/天猫图文详情。仅使用浏览器现有登录态,不会改动页面。 */ +export async function collectTaobaoDetailImages(itemId: string, profile: SiteProfile): Promise { + const token = getMtopToken(); + if (!token) throw new Error('未找到淘宝登录态;请登录后重新采集'); + const raw = await fetchDetailText(mtopUrl(itemId, token), location.href); + let response: any; + try { + response = JSON.parse(raw); + } catch { + throw new Error('详情接口返回了无效数据'); + } + if (!response?.data?.components?.componentData) { + throw new Error(response?.ret?.[0] || '详情接口未返回图文详情'); + } + const components = response.data.components.componentData; + const richText = components.desc_richtext_pc?.model?.text; + const source = typeof richText === 'string' ? richText : JSON.stringify(components); + return detailUrlsToMaterials(extractDetailImageUrls(source, location.href), profile); +} diff --git a/extensions/collector/wxt.config.ts b/extensions/collector/wxt.config.ts index 144bbde..c031c68 100644 --- a/extensions/collector/wxt.config.ts +++ b/extensions/collector/wxt.config.ts @@ -21,6 +21,8 @@ export default defineConfig({ 'https://detail.1688.com/*', 'https://item.taobao.com/*', 'https://detail.tmall.com/*', + 'https://h5api.m.taobao.com/*', + 'https://h5api.m.tmall.com/*', 'https://*.alicdn.com/*', // 1688 详情数据 CDN(description.detailUrl) 'https://itemcdn.tmall.com/*', diff --git a/server/api/export.py b/server/api/export.py index 4badf3c..c5ee967 100644 --- a/server/api/export.py +++ b/server/api/export.py @@ -2,8 +2,8 @@ 平移自 image-suite-studio;路径按 docs/v2.1/api.md §7 定为 POST /api/export/images (前端 services/suite.ts exportImages 同款契约,注意与 ISS 的 /export-images 不同)。 -ZIP 内部按分组名建子文件夹(主图 / SKU图片 / 详情图 / 手动上传), -文件名沿用采集 key(main-001 等)+ SKU 规格名;顶层文件夹用商品标题(清洗后)。 +采集图片默认按分组名建子文件夹(主图 / SKU图片 / 详情图 / 手动上传)。生成结果可 +请求扁平导出,所有图片直接放在商品根目录;文件名沿用采集 key(main-001 等)+ SKU 规格名。 """ from __future__ import annotations @@ -51,6 +51,7 @@ class ExportImageItem(BaseModel): class ExportImagesRequest(BaseModel): title: str | None = Field(default=None, description="商品标题,用作 ZIP 顶层文件夹名") images: list[ExportImageItem] = Field(min_length=1) + flat: bool = Field(default=False, description="是否忽略图片分组,直接写入 ZIP 顶层商品目录") def _clean(name: str) -> str: @@ -159,8 +160,8 @@ async def export_images(req: ExportImagesRequest): filename = f"{stem}-{n}{ext}" used.add(filename) - group = _clean(img.groupName) or "图片" - zf.writestr(f"{root}/{group}/{filename}", data) + archive_path = f"{root}/{filename}" if req.flat else f"{root}/{_clean(img.groupName) or '图片'}/{filename}" + zf.writestr(archive_path, data) if archive.tell() > settings.export_zip_max_bytes: raise HTTPException( status_code=413, diff --git a/server/api/suite.py b/server/api/suite.py index ab820e1..d8288d9 100644 --- a/server/api/suite.py +++ b/server/api/suite.py @@ -9,6 +9,7 @@ watermark + services/prompts),任务持久化到数据库,由独立 Worker from __future__ import annotations import io +import re import tempfile import uuid import zipfile @@ -49,25 +50,39 @@ from services.suite_service import texts_to_raw, validate_model router = APIRouter(prefix="/api", tags=["suite"]) +_DIMENSION_PARAM_RE = re.compile( + r"尺寸|长宽高|长度|宽度|高度|厚度|深度|直径|dimension|length|width|height|depth|thickness|diameter", + re.IGNORECASE, +) + def _with_dimensions(raw: dict, dimensions, *, required: bool = False) -> dict: - """将表单长宽高放入提示词上下文,不依赖采集参数是否已有尺寸字段。""" + """将已填写的尺寸方向放入提示词上下文;未填写方向绝不传给绘制提示词。""" if dimensions is None: if required: - raise HTTPException(status_code=400, detail="生成尺寸标注图需要完整填写商品长、宽、高") + raise HTTPException(status_code=400, detail="生成尺寸标注图至少需要填写一项商品尺寸") return raw - values = (dimensions.length, dimensions.width, dimensions.height) - if not all(value is not None and value > 0 for value in values): + known_dimensions = { + key: value for key, value in { + "length": dimensions.length, + "width": dimensions.width, + "height": dimensions.height, + }.items() if value is not None and value > 0 + } + if not known_dimensions: if required: - raise HTTPException(status_code=400, detail="生成尺寸标注图需要完整填写商品长、宽、高") + raise HTTPException(status_code=400, detail="生成尺寸标注图至少需要填写一项商品尺寸") return raw enriched = dict(raw) - enriched["dimensions"] = { - "length": dimensions.length, - "width": dimensions.width, - "height": dimensions.height, - "unit": dimensions.unit, - } + # 表单尺寸是尺寸标注图的唯一事实来源。原始商品参数常含完整 L×W×H, + # 若继续带进 prompt,模型会把未填写的方向也画出来。 + params = raw.get("params") or [] + if isinstance(params, list): + enriched["params"] = [ + pair for pair in params + if isinstance(pair, dict) and not _DIMENSION_PARAM_RE.search(str(pair.get("key") or "")) + ] + enriched["dimensions"] = {**known_dimensions, "unit": dimensions.unit} return enriched @@ -315,7 +330,19 @@ async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditTaskResponse except ValueError as exc: raise HTTPException(status_code=400, detail="product_id 不是合法 UUID") from exc - # 单图编辑优先级高于套图;prompt_extra 清空展示文案,避免中文 UI 文案进入模型 prompt。 + # 单图编辑优先级高于套图;支持多张参考底图,保持传入顺序并去重。 + 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": + if provider_name != "pcore": + raise HTTPException(status_code=400, detail="仅扩图当前仅支持 Pcore gpt-image-2") + if len(ref_urls) != 1: + raise HTTPException(status_code=400, detail="仅扩图只能选择一张参考底图") + if req.generate_size_chart: + raise HTTPException(status_code=400, detail="仅扩图不能同时生成尺寸标注图") + + # prompt_extra 清空展示文案,避免中文 UI 文案进入模型 prompt。 is_size_chart = bool(req.generate_size_chart) task = await create_image_job( product_id=product_id, @@ -326,20 +353,24 @@ async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditTaskResponse platform="ozon", lang=spec["lang"], ratio=spec["ratio"], - style_set=5, + # 仅扩图与用户明确填写的 AI 编辑要求都不得继承默认「纯净棚拍」风格。 + style_set=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, "requirements": req.prompt, + "operation": req.mode, + # 用户写了要求时,这一轮只执行其要求,不再拼营销图、默认风格或默认俄文文案。 + "requirements_exclusive": bool(req.mode == "edit" and not is_size_chart and req.prompt.strip()), "watermark": req.watermark.model_dump() if req.watermark else None, "append": bool(req.append and product_id), "after_asset_id": req.after_asset_id, - "ref_images": [{"url": req.image_url, "group_key": "main", "variant_name": None}], + "ref_images": [{"url": url, "group_key": "main", "variant_name": None} for url in ref_urls], }, item_payloads=[{ "position": 0, "kind": "size_chart" if is_size_chart else "custom", - "title": "尺寸标注图" if is_size_chart else "AI生图", + "title": "尺寸标注图" if is_size_chart else ("仅扩图" if req.mode == "outpaint" else "AI生图"), "detail": "", "prompt_hint": "", "variant_name": None, diff --git a/server/models.yaml b/server/models.yaml new file mode 100644 index 0000000..e69de29 diff --git a/server/schemas/suite.py b/server/schemas/suite.py index 3dcaff8..5e53574 100644 --- a/server/schemas/suite.py +++ b/server/schemas/suite.py @@ -77,7 +77,7 @@ class WatermarkOptions(BaseModel): class ProductDimensions(BaseModel): - """商品长宽高;尺寸图只会使用完整的三项数据。""" + """商品长宽高;尺寸图仅标注实际提供的方向。""" length: float | None = Field(default=None, gt=0, description="长度(按 unit 单位)") width: float | None = Field(default=None, gt=0, description="宽度(按 unit 单位)") @@ -177,10 +177,15 @@ class SuiteOut(BaseModel): class ImageEditSingleRequest(BaseModel): - """POST /api/suite/image-edit:单张 AI 生图(试算页每张图的「AI 生图」入口)。""" + """POST /api/suite/image-edit:单张 AI 生图,可携带多张参考底图。""" product_id: str | None = Field(default=None, description="append=true 时回写到该商品的 generated 组") - image_url: str = Field(..., description="原图(stored_url 或 source_url,远程由服务端带 Referer 代下)") + image_url: str | None = Field(default=None, description="兼容旧客户端的单张原图 URL") + image_urls: list[str] = Field(default_factory=list, description="选中的参考底图 URL,可多张") prompt: str = Field(default="", description="用户要求(可选,作为额外生图指令)") + mode: Literal["edit", "outpaint"] = Field( + default="edit", + description="edit 为 AI 编辑;outpaint 只扩展到目标画布,不允许模型改动原图区域", + ) model: str = Field(default="nano-banana-2") append: bool = Field(default=True, description="结果是否追加为商品素材(generated 组)") after_asset_id: str | None = Field( diff --git a/server/services/generator.py b/server/services/generator.py index 88dd61c..d2ddfe7 100644 --- a/server/services/generator.py +++ b/server/services/generator.py @@ -10,6 +10,7 @@ from __future__ import annotations import asyncio import base64 +import io import logging import mimetypes import re @@ -17,6 +18,7 @@ from dataclasses import dataclass from typing import Awaitable, Callable import httpx +from PIL import Image, ImageOps from config import get_settings from services.prompts import build_context, build_prompt, type_name @@ -84,8 +86,8 @@ def _image_size(provider: str, ratio: str, is_wan: bool = True, model: str = "") # gpt-image 自定义尺寸约束:16 的倍数、长短边比 ≤ 3:1(1536x2048 合法) return "1536x2048" if ratio == "3:4" else "2048x2048" if provider == "pcore": - # pcore 的 size 是枚举(默认 1024x1024),不接受自定义尺寸;用 gpt-image 枚举:竖版 1024x1536(近似 3:4) - return "1024x1536" if ratio == "3:4" else "1024x1024" + # 竖图必须与 Ozon 的 3:4 规格一致。1024x1536 实际是 2:3,会使成图明显偏瘦高。 + return "1152x1536" if ratio == "3:4" else "1024x1024" # tongyi:万象与千问的 size 语法相同(* 分隔),档位不同 # wan2.6 系列总像素限制在 [1280², 1440²],wan2.7 的 1536*2048/2048*2048 会超限 if model.startswith("wan2.6"): @@ -141,6 +143,75 @@ async def _resolve_ref(url: str) -> str: return _bytes_to_data_uri(data, mime) +@dataclass(frozen=True) +class OutpaintCanvas: + """用于受限扩图的预处理结果。 + + ``image_data_url`` 与 ``mask_data_url`` 尺寸严格一致:蒙版不透明的区域是原图, + 透明区域才允许供应商生成。最终仍会把 ``original`` 覆盖回结果,防止供应商在 + 蒙版区域有轻微重绘。 + """ + + image_data_url: str + mask_data_url: str + original: Image.Image + box: tuple[int, int, int, int] + size: tuple[int, int] + + def restore_original(self, generated: bytes) -> bytes: + """恢复原图区域,确保输出中原图像素不受模型影响。""" + with Image.open(io.BytesIO(generated)) as result: + result = ImageOps.exif_transpose(result).convert("RGBA") + if result.size != self.size: + # 供应商偶发不遵从 size 时仍输出固定 3:4;随后会精确覆盖原图区域。 + result = ImageOps.fit(result, self.size, method=Image.Resampling.LANCZOS) + result.alpha_composite(self.original, self.box[:2]) + out = io.BytesIO() + result.save(out, format="PNG") + return out.getvalue() + + +def _png_data_url(image: Image.Image) -> str: + out = io.BytesIO() + image.save(out, format="PNG") + return _bytes_to_data_uri(out.getvalue(), "image/png") + + +async def prepare_outpaint_canvas(url: str, size: str) -> OutpaintCanvas: + """把单张参考图置于目标画布,并生成仅开放新增边缘的 PNG alpha 蒙版。""" + try: + target_width, target_height = (int(value) for value in size.lower().split("x", 1)) + except (TypeError, ValueError) as exc: + raise ValueError(f"仅扩图需要合法的宽高尺寸,收到: {size}") from exc + if target_width <= 0 or target_height <= 0: + raise ValueError(f"仅扩图需要正数宽高尺寸,收到: {size}") + + data, _ = await _resolve_ref_bytes(url) + with Image.open(io.BytesIO(data)) as opened: + source = ImageOps.exif_transpose(opened).convert("RGBA") + if source.width <= 0 or source.height <= 0: + raise ValueError("参考底图尺寸无效") + scale = min(target_width / source.width, target_height / source.height) + source_width = max(1, round(source.width * scale)) + source_height = max(1, round(source.height * scale)) + original = source.resize((source_width, source_height), Image.Resampling.LANCZOS) + + x = (target_width - original.width) // 2 + y = (target_height - original.height) // 2 + canvas = Image.new("RGBA", (target_width, target_height), (0, 0, 0, 0)) + canvas.alpha_composite(original, (x, y)) + # OpenAI/GPT Image 兼容蒙版约定:透明像素是可编辑区域;原图区域不透明。 + mask = Image.new("RGBA", (target_width, target_height), (0, 0, 0, 0)) + mask.paste((255, 255, 255, 255), (x, y, x + original.width, y + original.height)) + return OutpaintCanvas( + image_data_url=_png_data_url(canvas), + mask_data_url=_png_data_url(mask), + original=original, + box=(x, y, x + original.width, y + original.height), + size=(target_width, target_height), + ) + + # ── Provider:豆包 Seedream(火山方舟)──────────────────────────────────── async def generate_doubao(prompt: str, ref_images: list[str], size: str = "2048x2048", model: str | None = None) -> bytes: @@ -562,6 +633,7 @@ async def advance_generation( model: str, provider_task_id: str | None = None, provider_task_kind: str | None = None, + edit_mask: str | None = None, ) -> GenerationAdvance: """推进一次图片生成,不在本函数内无限轮询。 @@ -569,6 +641,8 @@ async def advance_generation( 能继续外部任务,且在等待期间可以领取其他任务。豆包和同步千问仍在一次调用内完成。 """ settings = get_settings() + if edit_mask and provider != "pcore": + raise RuntimeError("带蒙版的仅扩图当前仅支持 Pcore gpt-image-2") if provider == "doubao": return GenerationAdvance(data=await generate_doubao(prompt, ref_images, size=size, model=model)) @@ -650,6 +724,10 @@ async def advance_generation( body: dict = {"model": model, "prompt": prompt, "n": 1, "size": size, "async": True} if ref_images: body["images"] = [{"image_url": await _resolve_ref(item)} for item in ref_images] + if edit_mask: + # Pcore 的图片参数使用 JSON {image_url};蒙版沿用相同的 OpenAI 兼容对象形式。 + # 透明区域允许编辑,不透明区域保留。若供应商轻微偏移,Worker 会二次覆盖原图。 + body["mask"] = {"image_url": edit_mask} response = await client.post( f"{base}/v1/images/{kind}", headers={**headers, "Content-Type": "application/json"}, json=body, ) diff --git a/server/services/job_worker.py b/server/services/job_worker.py index e0b1e88..98d6f11 100644 --- a/server/services/job_worker.py +++ b/server/services/job_worker.py @@ -10,7 +10,7 @@ from config import get_settings from db import get_session_factory from models import ProductAsset from services.image_assets import get_image_asset_service -from services.generator import _image_size, _refs_for_job, advance_generation +from services.generator import OutpaintCanvas, _image_size, _refs_for_job, advance_generation, prepare_outpaint_canvas from services.jobs import ( ClaimedItem, claim_next_item, @@ -22,6 +22,7 @@ from services.jobs import ( recover_expired_leases, ) from services.prompts import build_context, build_prompt +from services.prompts.common import requirements_block from services.storage import get_storage from services.suite_service import append_generated_asset from services.tasks import TaskImage @@ -152,6 +153,22 @@ async def _process_slideshow_video_item(claim: ClaimedItem) -> None: await mark_item_failed(claim.item_id, str(exc)) +def _outpaint_prompt(requirements: str | None) -> str: + """仅扩图不复用套图提示词,避免默认风格、营销构图或文字污染边缘生成。""" + requested = (requirements or "").strip() + if not requested: + requested = "Continue the existing surroundings naturally and seamlessly." + return "\n\n".join([ + requirements_block(requested), + "OUTPAINT ONLY: Image 1 contains an original photo placed on a 3:4 canvas. Generate pixels only " + "in the transparent masked margins outside the original photo. Do not crop, move, resize, redraw, " + "retouch, recolor, relight, or otherwise alter any pixel of the original photo.", + "Do not add marketing layouts, product captions, overlays, props, or visible text unless the STRICT " + "REQUIREMENTS explicitly ask for them. Make the new margins blend naturally with the original edges.", + "FINAL CHECK: output exactly 3:4 and change only the newly expanded margins.", + ]) + + async def process_item(claim: ClaimedItem) -> None: """推进一次子任务。外部模型未完成时只写 provider id 并退出,让出 Worker。""" if claim.job_kind == "watermark_batch": @@ -174,18 +191,34 @@ async def process_item(claim: ClaimedItem) -> None: "pcore": "gpt-image-2", }.get(provider, settings.ark_image_model) try: - 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"), - ) refs = _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 + outpaint: OutpaintCanvas | None = None + if operation == "outpaint": + if len(refs) != 1: + raise RuntimeError("仅扩图必须且只能使用一张参考底图") + prompt = _outpaint_prompt(payload.get("requirements")) + # 首次提交时才构造图片和蒙版;之后轮询不再向供应商重复上传。 + if not claim.provider_task_id: + outpaint = await prepare_outpaint_canvas(refs[0], target_size) + refs = [outpaint.image_data_url] + 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")), + ) is_wan = provider == "tongyi" and model.lower().startswith("wan") result = await advance_generation( provider, prompt, refs, - size=_image_size(provider, claim.ratio, is_wan=is_wan, model=model), model=model, + size=target_size, model=model, provider_task_id=claim.provider_task_id, provider_task_kind=claim.provider_task_kind, + edit_mask=edit_mask, ) if result.data is None: if not result.provider_task_id or not result.provider_task_kind: @@ -203,6 +236,11 @@ async def process_item(claim: ClaimedItem) -> None: await mark_item_cancelled(claim.item_id) return + if operation == "outpaint": + # 异步任务完成时本轮未必创建过画布,按同一源图与尺寸确定性重建后覆盖原图区域。 + outpaint = outpaint or await prepare_outpaint_canvas(refs[0], target_size) + result = type(result)(data=outpaint.restore_original(result.data)) + watermark = payload.get("watermark") or {} position = int(job_item.get("position", 0)) if watermark.get("enabled"): diff --git a/server/services/planner.py b/server/services/planner.py index 77c815c..4ef5064 100644 --- a/server/services/planner.py +++ b/server/services/planner.py @@ -56,11 +56,11 @@ SYSTEM_PROMPT = """你是一名资深电商视觉策划。根据商品信息规 5. variant_name 只能从「SKU规格」列表原样照抄;没有绑定就输出 null。 ## 规划规则 -1. 套图必须均衡覆盖:白底主图、场景图、细节图;参数含可靠的长宽高/尺寸时还必须有尺寸标注图。按这个顺序先预留张数,再安排其他类型。 +1. 套图必须均衡覆盖:白底主图、场景图、细节图;参数含可靠的任一方向尺寸时还必须有尺寸标注图。按这个顺序先预留张数,再安排其他类型。 2. 白底主图(kind=white_bg)通常只出 1 张;有多个视觉差异明显的 SKU 时最多选 2 个代表款各出 1 张,variant_name 填对应规格名。**禁止因为 SKU 多而为每个 SKU 都规划主图**。仅当「生图要求」明确要求每个/全部 SKU 分别出图时才可例外。 3. 场景图(kind=lifestyle):至少 1 张,按核心使用场景出 1-2 张,每张聚焦一个场景。 4. 细节图(kind=material / key_features / selling_pt / ecommerce_detail):至少 1 张,展示关键细节、材质或结构;通常 1-2 张,每张聚焦一个卖点。 -5. 尺寸标注图(kind=size_chart):参数含长宽高/尺寸时必须出 1 张;无可靠尺寸参数时不要虚构尺寸。 +5. 尺寸标注图(kind=size_chart):参数含任一可靠尺寸时必须出 1 张;只标注已知方向,无可靠尺寸参数时不要虚构尺寸。 6. SKU 合集图(kind=sku_collection):仅在 SKU >1 且确有必要展示差异时出 1 张,优先级低于上述四类。 7. kind 枚举:white_bg / key_features / selling_pt / material / lifestyle / multi_scene / ecommerce_detail / size_chart / sku_collection / custom。 8. title 用中文短语(如「主图·粉色」「浴室壁挂」);detail 中文说明这张图展示什么;prompt_hint 用英文描述构图要点。 @@ -124,9 +124,9 @@ def _normalize_items(raw_items: list, sku_variants: list[str]) -> list[dict]: def _has_dimensions(product_info: dict) -> bool: - """仅在已采集到尺寸信息时规划尺寸图,避免模型编造数值。""" + """有任一可靠尺寸即可规划尺寸图;未给出的方向不允许模型编造。""" dimensions = product_info.get("dimensions") or {} - if isinstance(dimensions, dict) and all(dimensions.get(key) is not None for key in ("length", "width", "height")): + if isinstance(dimensions, dict) and any(dimensions.get(key) is not None for key in ("length", "width", "height")): return True for param in product_info.get("params") or []: if not isinstance(param, dict): diff --git a/server/services/prompts/__init__.py b/server/services/prompts/__init__.py index 89be7f5..68cd60d 100644 --- a/server/services/prompts/__init__.py +++ b/server/services/prompts/__init__.py @@ -36,10 +36,11 @@ def prompt_family(provider: str, model: str | None) -> str: def build_prompt(provider: str, model: str | None, type_id: str, ctx: dict, style_set: int, lang: str, extra: dict | None = None, style_prompt: str | None = None, - requirements: str | None = None) -> str: + requirements: str | None = None, requirements_exclusive: bool = False) -> str: """按模型家族构造指定图类型的完整生图 prompt。参数含义见各家族 build_prompt。""" module = _MODULE_BY_FAMILY[prompt_family(provider, model)] return module.build_prompt( type_id, ctx, style_set, lang, extra=extra, style_prompt=style_prompt, requirements=requirements, + requirements_exclusive=requirements_exclusive, ) diff --git a/server/services/prompts/alibaba.py b/server/services/prompts/alibaba.py index c9dc66d..41b47b8 100644 --- a/server/services/prompts/alibaba.py +++ b/server/services/prompts/alibaba.py @@ -96,12 +96,13 @@ def _prompt_ecommerce_detail(ctx: dict, style: dict, lang: str) -> str: def _prompt_size_chart(ctx: dict, style: dict, lang: str) -> str: dims = params_line_for_output(ctx, lang) + annotation = ctx.get("dimension_annotation_instruction") or "Do not invent measurement directions or values." return ( f"Product size chart infographic for \"{ctx['title']}\": product shown in clean front and side views " - f"on light background, with thin measurement annotation lines (arrows) marking length, width and height, " + f"on light background, with thin measurement annotation lines (arrows) only for supplied dimensions, " f"measurement values rendered next to each line" + (f" (known specs: {dims})" if dims else "") - + f", small caption row, precise technical drawing aesthetic. {style['tone']}. " + + f". {annotation} Small caption row, precise technical drawing aesthetic. {style['tone']}. " f"{TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}" ) @@ -136,7 +137,8 @@ _PROMPT_BUILDERS = { def build_prompt(type_id: str, ctx: dict, style_set: int, lang: str, extra: dict | None = None, - style_prompt: str | None = None, requirements: str | None = None) -> str: + style_prompt: str | None = None, requirements: str | None = None, + requirements_exclusive: bool = False) -> str: """构造指定图类型的完整生图 prompt(主体参考语义)。 extra: 方案项信息 {title, detail, prompt_hint}——custom 类型必需, @@ -145,6 +147,15 @@ def build_prompt(type_id: str, ctx: dict, style_set: int, lang: str, extra: dict requirements: 生图要求(最高优先级,强制约束),置于 prompt 最前面, 声明覆盖一切冲突指令,用户可在此输入强制要求。 """ + if requirements_exclusive: + return " ".join([ + requirements_block(requirements), + "Execute only the strict requirements above. Do not add marketing layouts, captions, overlays, " + "props, default studio styling, or visible text unless explicitly requested.", + "Preserve the reference image exactly unless the strict requirements explicitly request a " + "specific change. When they do, make only that requested change and preserve everything else.", + "Do not make any change that was not explicitly required.", + ]) style = resolve_style(style_set, style_prompt) extra = extra or {} if type_id == "custom": diff --git a/server/services/prompts/common.py b/server/services/prompts/common.py index a806bae..0bd3bfe 100644 --- a/server/services/prompts/common.py +++ b/server/services/prompts/common.py @@ -134,11 +134,28 @@ def build_context(raw: dict, fallback_name: str = "", fallback_desc: str = "") - break dimensions = raw.get("dimensions") or {} - dimension_values = (dimensions.get("length"), dimensions.get("width"), dimensions.get("height")) if isinstance(dimensions, dict) else () - dimensions_line = "" - if len(dimension_values) == 3 and all(value is not None for value in dimension_values): - unit = dimensions.get("unit") or "mm" - dimensions_line = "L × W × H: " + " × ".join(str(value) for value in dimension_values) + f" {unit}" + known_dimensions: list[tuple[str, object]] = [] + if isinstance(dimensions, dict): + known_dimensions = [ + (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" + 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] + dimension_annotation_instruction = ( + "STRICT MEASUREMENT OUTPUT RULE: render exactly " + + str(len(known_labels)) + + " measurement arrows and labels, only for: " + + ", ".join(known_labels) + + ". The only permitted dimension values are: " + + dimensions_line + + ". Do not infer product measurements. Do not draw, label, or show an arrow for: " + + ", ".join(missing_labels or ["any additional direction"]) + + ", depth, thickness, diameter, or any other unlisted dimension." + if known_dimensions else "" + ) params_line = "; ".join( f"{p.get('key')}: {p.get('value')}" for p in (raw.get("params") or [])[:8] ) @@ -158,6 +175,7 @@ def build_context(raw: dict, fallback_name: str = "", fallback_desc: str = "") - dimensions_line, ))), "price": raw.get("price") or "", + "dimension_annotation_instruction": dimension_annotation_instruction, } def selling_point_lines(ctx: dict, lang: str, max_n: int = 3) -> str: diff --git a/server/services/prompts/google.py b/server/services/prompts/google.py index 58cf2e2..b0f90e7 100644 --- a/server/services/prompts/google.py +++ b/server/services/prompts/google.py @@ -98,12 +98,13 @@ def _task_ecommerce_detail(ctx: dict, lang: str) -> str: def _task_size_chart(ctx: dict, lang: str) -> str: dims = params_line_for_output(ctx, lang) + annotation = ctx.get("dimension_annotation_instruction") or "Do not invent measurement directions or values." return ( "Create a size chart: the unchanged product in clean front and side views on a light background, " - "thin measurement annotation lines (arrows) marking length, width and height with values beside " + "thin measurement annotation lines (arrows) only for supplied dimensions with values beside " "each line" + (f" (known specs: {dims})" if dims else "") - + ", small caption row, precise technical-drawing aesthetic." + + f". {annotation} Small caption row, precise technical-drawing aesthetic." ) def _task_sku_collection(ctx: dict, lang: str) -> str: @@ -128,8 +129,19 @@ _TASK_BUILDERS = { def build_prompt(type_id: str, ctx: dict, style_set: int, lang: str, extra: dict | None = None, - style_prompt: str | None = None, requirements: str | None = None) -> str: + style_prompt: str | None = None, requirements: str | None = None, + requirements_exclusive: bool = False) -> str: """构造指定图类型的 prompt:要求块 → 指令 → 主体锁 → 锚定 → 风格 → 文案 → 画质 → 提醒。""" + if requirements_exclusive: + return "\n\n".join([ + requirements_block(requirements), + "Edit the first image only as required above. Do not add marketing layouts, captions, overlays, " + "props, default studio styling, or visible text unless explicitly requested above.", + "PROTECTION: preserve the first image exactly unless the strict requirements explicitly request " + "a specific change. When they do, make only that requested change and preserve everything else.", + "Final check: follow the strict requirements over every default behavior; do not make changes " + "that were not explicitly required.", + ]) style = resolve_style(style_set, style_prompt) extra = extra or {} hint = (extra.get("prompt_hint") or "").strip() diff --git a/server/services/prompts/gpt.py b/server/services/prompts/gpt.py index 75b382d..a2b6342 100644 --- a/server/services/prompts/gpt.py +++ b/server/services/prompts/gpt.py @@ -129,12 +129,13 @@ def _task_ecommerce_detail(ctx: dict, lang: str) -> str: def _task_size_chart(ctx: dict, lang: str) -> str: dims = params_line_for_output(ctx, lang) + annotation = ctx.get("dimension_annotation_instruction") or "Do not invent measurement directions or values." return ( "TASK: Measurement chart. Show the unchanged product in clean front and side views on a light " - "background; overlay thin technical annotation lines (arrows) marking length, width and height " - "with measurement values rendered beside each line" + "background; overlay thin technical annotation lines (arrows) only for the supplied dimensions, " + "with each measurement value rendered beside its line" + (f" (known specs: {dims})" if dims else "") - + "; precise technical-drawing aesthetic, small caption row." + + f". {annotation}; precise technical-drawing aesthetic, small caption row." ) def _task_sku_collection(ctx: dict, lang: str) -> str: @@ -160,8 +161,21 @@ _TASK_BUILDERS = { def build_prompt(type_id: str, ctx: dict, style_set: int, lang: str, extra: dict | None = None, - style_prompt: str | None = None, requirements: str | None = None) -> str: + style_prompt: str | None = None, requirements: str | None = None, + requirements_exclusive: bool = False) -> str: """构造指定图类型的 edits 语义 prompt:要求块 → 编辑指令 → 保真锁 → 锚定 → 风格 → 文案 → 画质 → 终检。""" + if requirements_exclusive: + # 单张 AI 编辑:用户要求是唯一的画面意图,绝不能被套图的营销图/棚拍/俄文文案覆盖。 + return "\n\n".join([ + requirements_block(requirements), + "TASK: Edit Image 1 only according to the STRICT REQUIREMENTS above. Do not add marketing " + "layouts, captions, overlays, props, default studio styling, or any visible text unless the " + "STRICT REQUIREMENTS explicitly ask for them.", + "PROTECTION: preserve Image 1 exactly unless the STRICT REQUIREMENTS explicitly request a " + "specific change. When they do, make only that requested change and preserve everything else.", + "FINAL CHECK: follow the STRICT REQUIREMENTS over every default behavior. Preserve the product " + "and do not make any change that is not explicitly required.", + ]) style = resolve_style(style_set, style_prompt) extra = extra or {} hint = (extra.get("prompt_hint") or "").strip() diff --git a/server/services/video_renderer.py b/server/services/video_renderer.py index 2cee34b..3aee63b 100644 --- a/server/services/video_renderer.py +++ b/server/services/video_renderer.py @@ -58,11 +58,16 @@ async def _run_process( should_cancel: Callable[[], Awaitable[bool]], timeout_seconds: int, ) -> None: - process = await asyncio.create_subprocess_exec( - *args, - stdout=asyncio.subprocess.DEVNULL, - stderr=asyncio.subprocess.PIPE, - ) + try: + process = await asyncio.create_subprocess_exec( + *args, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.PIPE, + ) + except FileNotFoundError as exc: + raise VideoRenderError( + f"未找到视频合成工具「{args[0]}」。请安装 FFmpeg,并配置 VIDEO_FFMPEG_PATH 为其可执行文件的绝对路径。" + ) from exc try: waited = 0.0 while process.returncode is None: @@ -91,13 +96,18 @@ async def _run_process( async def _probe(path: Path) -> dict: settings = get_settings() - process = await asyncio.create_subprocess_exec( - settings.video_ffprobe_path, - "-v", "error", "-show_entries", "format=duration,size:stream=codec_name,width,height,pix_fmt", - "-of", "json", str(path), - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) + try: + process = await asyncio.create_subprocess_exec( + settings.video_ffprobe_path, + "-v", "error", "-show_entries", "format=duration,size:stream=codec_name,width,height,pix_fmt", + "-of", "json", str(path), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + except FileNotFoundError as exc: + raise VideoRenderError( + f"未找到视频校验工具「{settings.video_ffprobe_path}」。请安装 FFmpeg,并配置 VIDEO_FFPROBE_PATH 为其可执行文件的绝对路径。" + ) from exc stdout, stderr = await process.communicate() if process.returncode != 0: raise VideoRenderError(f"无法校验视频输出:{stderr.decode('utf-8', errors='replace')[-400:]}") diff --git a/server/tests/test_export.py b/server/tests/test_export.py index 60b69f1..fa41cb5 100644 --- a/server/tests/test_export.py +++ b/server/tests/test_export.py @@ -1,8 +1,10 @@ """单图导出响应头回归测试。""" from __future__ import annotations +import io import sys import unittest +import zipfile from pathlib import Path from unittest.mock import AsyncMock, patch @@ -10,7 +12,7 @@ SERVER_DIR = Path(__file__).resolve().parents[1] if str(SERVER_DIR) not in sys.path: sys.path.insert(0, str(SERVER_DIR)) -from api.export import ExportImageItem, export_image +from api.export import ExportImageItem, ExportImagesRequest, export_image, export_images class ExportImageTests(unittest.IsolatedAsyncioTestCase): @@ -27,6 +29,24 @@ class ExportImageTests(unittest.IsolatedAsyncioTestCase): self.assertIn('filename="_____-__.png"', disposition) self.assertIn("filename*=UTF-8''%E5%B0%BA%E5%AF%B8%E6%A0%87%E6%B3%A8%E5%9B%BE-%E8%93%9D%E8%89%B2.png", disposition) + async def test_flat_export_places_generated_images_in_product_root(self) -> None: + request = ExportImagesRequest( + title="商品", + flat=True, + images=[ + ExportImageItem(url="https://example.com/single.png", groupName="单张生图", key="single"), + ExportImageItem(url="https://example.com/suite.png", groupName="套图结果", key="suite"), + ], + ) + with patch("api.export._download", new=AsyncMock(return_value=(b"image", "image/png"))): + response = await export_images(request) + payload = b"".join([chunk async for chunk in response.body_iterator]) + + self.assertEqual( + sorted(zipfile.ZipFile(io.BytesIO(payload)).namelist()), + ["商品/single.png", "商品/suite.png"], + ) + if __name__ == "__main__": unittest.main() diff --git a/server/tests/test_generator_sizes.py b/server/tests/test_generator_sizes.py new file mode 100644 index 0000000..5f3ed30 --- /dev/null +++ b/server/tests/test_generator_sizes.py @@ -0,0 +1,22 @@ +"""平台目标比例到供应商尺寸参数的映射。""" +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +SERVER_DIR = Path(__file__).resolve().parents[1] +if str(SERVER_DIR) not in sys.path: + sys.path.insert(0, str(SERVER_DIR)) + +from services.generator import _image_size + + +class ImageSizeTests(unittest.TestCase): + def test_pcore_ozon_size_is_exactly_three_to_four(self) -> None: + width, height = map(int, _image_size("pcore", "3:4").split("x")) + self.assertEqual(width * 4, height * 3) + + +if __name__ == "__main__": + unittest.main() diff --git a/server/tests/test_outpaint_and_edit_prompt.py b/server/tests/test_outpaint_and_edit_prompt.py new file mode 100644 index 0000000..96b0715 --- /dev/null +++ b/server/tests/test_outpaint_and_edit_prompt.py @@ -0,0 +1,70 @@ +"""仅扩图的像素保护与单张 AI 编辑提示词回归测试。""" +from __future__ import annotations + +import asyncio +import base64 +import io +import sys +import unittest +from pathlib import Path + +from PIL import Image + +SERVER_DIR = Path(__file__).resolve().parents[1] +if str(SERVER_DIR) not in sys.path: + sys.path.insert(0, str(SERVER_DIR)) + +from services.generator import prepare_outpaint_canvas +from services.prompts import build_prompt + + +def _data_url(image: Image.Image) -> str: + buffer = io.BytesIO() + image.save(buffer, format="PNG") + return "data:image/png;base64," + base64.b64encode(buffer.getvalue()).decode() + + +class OutpaintTests(unittest.TestCase): + def test_outpaint_restores_original_region_after_provider_response(self) -> None: + original = Image.new("RGB", (400, 400), (220, 20, 60)) + canvas = asyncio.run(prepare_outpaint_canvas(_data_url(original), "1152x1536")) + + self.assertEqual(canvas.size, (1152, 1536)) + self.assertEqual(canvas.box, (0, 192, 1152, 1344)) + + # 模拟供应商把整张图都重绘成蓝色;服务端合成后中心原图必须仍为红色。 + generated = Image.new("RGB", canvas.size, (0, 0, 255)) + restored = Image.open(io.BytesIO(canvas.restore_original(_png_bytes(generated)))).convert("RGB") + self.assertEqual(restored.getpixel((576, 768)), (220, 20, 60)) + self.assertEqual(restored.getpixel((576, 80)), (0, 0, 255)) + + def test_expanded_margin_is_transparent_in_mask(self) -> None: + original = Image.new("RGB", (400, 400), (1, 2, 3)) + canvas = asyncio.run(prepare_outpaint_canvas(_data_url(original), "1152x1536")) + mask_bytes = base64.b64decode(canvas.mask_data_url.split(",", 1)[1]) + mask = Image.open(io.BytesIO(mask_bytes)).convert("RGBA") + self.assertEqual(mask.getpixel((576, 80))[3], 0) + self.assertEqual(mask.getpixel((576, 768))[3], 255) + + +def _png_bytes(image: Image.Image) -> bytes: + buffer = io.BytesIO() + image.save(buffer, format="PNG") + return buffer.getvalue() + + +class ExclusiveEditPromptTests(unittest.TestCase): + def test_user_edit_requirements_do_not_inherit_marketing_or_light_studio_prompt(self) -> None: + prompt = build_prompt( + "pcore", "gpt-image-2", "custom", {"title": "product"}, 5, "ru", + requirements="背景必须是纯黑色,其他不要改变", + requirements_exclusive=True, + ) + self.assertIn("背景必须是纯黑色", prompt) + self.assertNotIn("e-commerce marketing image", prompt) + self.assertNotIn("plain light background", prompt) + self.assertNotIn("Render concise Russian marketing text", prompt) + + +if __name__ == "__main__": + unittest.main() diff --git a/server/tests/test_partial_dimensions.py b/server/tests/test_partial_dimensions.py new file mode 100644 index 0000000..6399e72 --- /dev/null +++ b/server/tests/test_partial_dimensions.py @@ -0,0 +1,44 @@ +"""部分尺寸的尺寸标注图:仅传入已知方向,绝不补造缺失方向。""" +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +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 _with_dimensions +from schemas.suite import ProductDimensions +from services.prompts.common import build_context +from services.prompts.gpt import _task_size_chart + + +class PartialDimensionsTests(unittest.TestCase): + def test_only_known_direction_is_passed_to_prompt_context(self) -> None: + raw = _with_dimensions( + {"title": "测试商品", "params": [ + {"key": "产品尺寸", "value": "12.5×8×4 cm"}, + {"key": "材质", "value": "棉"}, + ]}, + ProductDimensions(width=8, height=4, unit="cm"), + required=True, + ) + + self.assertEqual(raw["dimensions"], {"width": 8, "height": 4, "unit": "cm"}) + self.assertEqual(raw["params"], [{"key": "材质", "value": "棉"}]) + context = build_context(raw) + prompt = _task_size_chart(context, "en") + self.assertIn("width: 8.0 cm; height: 4.0 cm", prompt) + self.assertIn("render exactly 2 measurement arrows and labels, only for: width, height", prompt) + self.assertIn("Do not draw, label, or show an arrow for: length", prompt) + self.assertNotIn("12.5×8×4", prompt) + + def test_size_chart_still_requires_at_least_one_dimension(self) -> None: + with self.assertRaisesRegex(Exception, "至少需要填写一项"): + _with_dimensions({"title": "测试商品"}, ProductDimensions(unit="mm"), required=True) + + +if __name__ == "__main__": + unittest.main() diff --git a/studio/src/pages/product/PriceInfoPanel.tsx b/studio/src/pages/product/PriceInfoPanel.tsx index 5189dcf..d1e0ab8 100644 --- a/studio/src/pages/product/PriceInfoPanel.tsx +++ b/studio/src/pages/product/PriceInfoPanel.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; import { Col, InputNumber, message, Radio, Row, Space, Tag, Typography } from 'antd'; -import { calculatePricing, LogisticsLevel, validateLogisticsLevel } from '@/pricing/pricing'; +import { calculatePricing, legacyLineMultiplierToDiscountRate, LogisticsLevel, validateLogisticsLevel } from '@/pricing/pricing'; import { getFxRate } from '@/services/fx'; import { ProductDetail } from '@/services/product'; import { apiErrorMessage } from '@/services/api'; @@ -33,16 +33,18 @@ function dimsCm(product: ProductDetail): { l: number; w: number; h: number } { } /** - * 售价信息:售价/划线价(CNY)+ 定价参数(进货价/净利率/物流等级/汇率/划线价倍数)。 + * 售价信息:售价/划线价(CNY)+ 定价参数(进货价/净利率/物流等级/汇率/划线折扣率)。 * 重量、尺寸直接读「主要信息」的包装字段,不重复填写。 - * 任一定价参数变化即重算并回填售价(= 销售价)与划线价(= 销售价 × (1 + 倍数%))。 + * 任一定价参数变化即重算并回填售价与划线价(= 销售价 ÷ (1 - 划线折扣率))。 */ export default function PriceInfoPanel({ product, onSave }: Props) { const pricing = (product.pricing ?? {}) as PricingPayload; const [purchasePrice, setPurchasePrice] = useState(pricing.purchasePrice ?? 30); const [profitRate, setProfitRate] = useState(pricing.profitRate ?? 100); const [level, setLevel] = useState((pricing.logisticsLevel as LogisticsLevel) ?? 'low'); - const [multiplier, setMultiplier] = useState(pricing.lineMultiplier ?? 100); + const [discountRate, setDiscountRate] = useState( + pricing.lineDiscountRate ?? legacyLineMultiplierToDiscountRate(pricing.lineMultiplier ?? 100), + ); const [fxRate, setFxRate] = useState(product.fx_rate ?? pricing.fxRate ?? 0); // 币种固定人民币:历史数据(默认 RUB)打开编辑页时纠正一次 @@ -67,7 +69,7 @@ export default function PriceInfoPanel({ product, onSave }: Props) { const tdPrice = pricing.tdPrice ?? 3; /** 用当前参数(可被 patch 覆盖)实时计算,不落库 */ - const compute = (patch: Partial<{ purchasePrice: number; profitRate: number; level: LogisticsLevel; multiplier: number; fxRate: number }> = {}) => + const compute = (patch: Partial<{ purchasePrice: number; profitRate: number; level: LogisticsLevel; discountRate: number; fxRate: number }> = {}) => calculatePricing({ purchasePrice: patch.purchasePrice ?? purchasePrice, profitRate: patch.profitRate ?? profitRate, @@ -75,17 +77,17 @@ export default function PriceInfoPanel({ product, onSave }: Props) { weightG: weightGrams(product), dims: dimsCm(product), tdPrice, - lineMultiplier: patch.multiplier ?? multiplier, + lineDiscountRate: patch.discountRate ?? discountRate, fxRate: patch.fxRate ?? fxRate, }); /** 重算并落库:定价参数 + 售价/划线价一并保存 */ - const recalc = (patch: Partial<{ purchasePrice: number; profitRate: number; level: LogisticsLevel; multiplier: number; fxRate: number }> = {}) => { + const recalc = (patch: Partial<{ purchasePrice: number; profitRate: number; level: LogisticsLevel; discountRate: number; fxRate: number }> = {}) => { const p = { purchasePrice: patch.purchasePrice ?? purchasePrice, profitRate: patch.profitRate ?? profitRate, level: patch.level ?? level, - multiplier: patch.multiplier ?? multiplier, + discountRate: patch.discountRate ?? discountRate, fxRate: patch.fxRate ?? fxRate, }; const r = compute(patch); @@ -98,7 +100,7 @@ export default function PriceInfoPanel({ product, onSave }: Props) { weightG: weightGrams(product), dims: dimsCm(product), tdPrice, - lineMultiplier: p.multiplier, + lineDiscountRate: p.discountRate, fxRate: p.fxRate, logisticsFee: r.logisticsFee, fullCommission: r.fullCommission, @@ -189,15 +191,16 @@ export default function PriceInfoPanel({ product, onSave }: Props) { /> - 划线价倍数 % + 划线折扣率 % { - setMultiplier(v ?? 0); - recalc({ multiplier: v ?? 0 }); + setDiscountRate(v ?? 0); + recalc({ discountRate: v ?? 0 }); }} /> diff --git a/studio/src/pages/trial/AiImageGenModal.tsx b/studio/src/pages/trial/AiImageGenModal.tsx index 2a3c6d2..223dc84 100644 --- a/studio/src/pages/trial/AiImageGenModal.tsx +++ b/studio/src/pages/trial/AiImageGenModal.tsx @@ -1,5 +1,5 @@ import { Fragment, useEffect, useRef, useState } from 'react'; -import { Alert, Button, Checkbox, Image, Input, InputNumber, message, Select, Space, Typography } from 'antd'; +import { Alert, Button, Checkbox, Image, Input, InputNumber, message, Radio, Select, Space, Typography } from 'antd'; import { CloseOutlined } from '@ant-design/icons'; import { apiErrorMessage } from '@/services/api'; import { DEFAULT_IMAGE_MODEL, IMAGE_MODEL_OPTIONS, cancelSuite, getSuite, imageEditSingle, ProductDimensions, WatermarkPayload } from '@/services/suite'; @@ -11,6 +11,8 @@ interface Props { productId: string; /** 待生成的源图(采集图或生成图) */ source: { url: string; name: string; afterAssetId?: string } | null; + /** 可作为参考底图的商品图片;当前点开的图片会默认选中。 */ + referenceOptions: Array<{ url: string; name: string }>; onClose: () => void; /** 生成成功回调(服务端 append 后刷新素材列表) */ onGenerated?: (url: string) => void; @@ -24,15 +26,17 @@ interface Props { * 单张 AI 图生图弹窗:输入要求 + 选模型 → 生成。 * 对应 POST /api/suite/image-edit(docs/v2.1/api.md §6,服务端 Phase B 实现)。 */ -export default function AiImageGenModal({ open, productId, source, onClose, onGenerated, onSubmitted, watermark }: Props) { +export default function AiImageGenModal({ open, productId, source, referenceOptions, onClose, onGenerated, onSubmitted, watermark }: Props) { const [prompt, setPrompt] = useState(''); const [model, setModel] = useState(DEFAULT_IMAGE_MODEL); + const [mode, setMode] = useState<'edit' | 'outpaint'>('edit'); const [dimensions, setDimensions] = useState({ unit: 'mm' }); const [generateSizeChart, setGenerateSizeChart] = useState(false); const [generating, setGenerating] = useState(false); const [resultUrl, setResultUrl] = useState(''); const [taskId, setTaskId] = useState(''); const [taskError, setTaskError] = useState(''); + const [selectedReferenceUrls, setSelectedReferenceUrls] = useState>(() => new Set()); const pollTimerRef = useRef | null>(null); const pollAbortRef = useRef(null); const pollRunRef = useRef(0); @@ -50,14 +54,16 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe useEffect(() => { if (open && !activeTaskRef.current) { setPrompt(''); + setMode('edit'); setDimensions({ unit: 'mm' }); setGenerateSizeChart(false); setResultUrl(''); setGenerating(false); setTaskId(''); setTaskError(''); + setSelectedReferenceUrls(source?.url ? new Set([source.url]) : new Set()); } - }, [open]); + }, [open, source?.url]); useEffect(() => () => stopPolling(), []); @@ -130,10 +136,18 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe const onGenerate = async () => { if (!source) return; - const hasCompleteDimensions = [dimensions.length, dimensions.width, dimensions.height] - .every((value) => typeof value === 'number' && value > 0); - if (generateSizeChart && !hasCompleteDimensions) { - message.warning('生成尺寸标注图前,请完整填写商品长、宽、高'); + if (selectedReferenceUrls.size === 0) { + 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; } setGenerating(true); @@ -142,14 +156,15 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe try { const r = await imageEditSingle({ product_id: productId, - image_url: source.url, + image_urls: Array.from(selectedReferenceUrls), prompt: prompt.trim(), + mode, model, append: true, after_asset_id: source.afterAssetId, watermark: watermark?.enabled ? watermark : undefined, generate_size_chart: generateSizeChart, - dimensions: hasCompleteDimensions ? dimensions : null, + dimensions: hasAnyDimensions ? dimensions : null, }); setTaskId(r.suite_id); message.info('已提交后台生成,可继续操作;进度将在「生成结果」区域显示'); @@ -181,6 +196,36 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe } }; + const referenceImages = Array.from( + new Map( + [...referenceOptions, ...(source ? [{ url: source.url, name: source.name }] : [])] + .filter((image) => image.url) + .map((image) => [image.url, image]), + ).values(), + ); + + 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') => { + setMode(nextMode); + if (nextMode === 'outpaint') { + setGenerateSizeChart(false); + setModel('gpt-image-2@pcore'); + setSelectedReferenceUrls((previous) => { + const first = previous.values().next().value as string | undefined; + return new Set(first ? [first] : (source?.url ? [source.url] : [])); + }); + } + }; + return ( <> {open && ( @@ -209,52 +254,106 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe onClick={(e) => e.stopPropagation()} >
- AI 生图(图生图) +
+ AI 生图(图生图) + changeMode(e.target.value)} + > + AI 编辑 + 仅扩图至 3:4 + +
+
+
+ 参考底图{mode === 'outpaint' ? '(仅限单张)' : '(可多选)'} + 已选 {selectedReferenceUrls.size} 张 +
+
+ {referenceImages.map((image) => { + const selected = selectedReferenceUrls.has(image.url); + return ( + + ); + })} +
+
原图
-
- 商品尺寸(长 × 宽 × 高) -
- {(['length', 'width', 'height'] as const).map((key, index) => ( - - setDimensions((prev) => ({ ...prev, [key]: typeof value === 'number' ? value : undefined }))} - placeholder={['长', '宽', '高'][index]} + {mode === 'edit' &&
+ setGenerateSizeChart(e.target.checked)} + disabled={generating} + > + 生成尺寸标注图(未知方向留空) + +
} + {mode === 'edit' && generateSizeChart && ( +
+ 商品尺寸(长 × 宽 × 高) +
+ {(['length', 'width', 'height'] as const).map((key, index) => ( + + setDimensions((prev) => ({ ...prev, [key]: typeof value === 'number' ? value : undefined }))} + placeholder={['长', '宽', '高'][index]} + disabled={generating} + /> + {index < 2 && ×} + + ))} + setDimensions((prev) => ({ ...prev, unit }))} - options={[{ value: 'mm', label: 'mm' }, { value: 'cm', label: 'cm' }]} - disabled={generating} - /> +
-
+ )} setPrompt(e.target.value)} - placeholder={'生图要求(可选),例如:把背景换成纯白色摄影棚,保留商品细节与比例;留空则按原图生成'} + placeholder={mode === 'outpaint' + ? '扩展区域要求(可选),例如:将新增边缘扩展为纯黑背景;原图区域不会被改动' + : '编辑要求(可选)。填写后会只执行此要求,不会附加营销图、俄文文字或默认棚拍风格'} disabled={generating} /> + {mode === 'outpaint' && ( + + 系统会生成 3:4 画布,只允许 AI 生成新增边缘;完成后会重新覆盖原图区域,确保原图不被改动。 + + )}