diff --git a/.gitignore b/.gitignore index ddc64a0..65b2764 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ tmp/ *.swp *.swo coverage/ +屏幕截图 *.png diff --git a/src/api/adVideoPlanClient.ts b/src/api/adVideoPlanClient.ts index 1b245fc..3c5a608 100644 --- a/src/api/adVideoPlanClient.ts +++ b/src/api/adVideoPlanClient.ts @@ -1,8 +1,5 @@ import { buildApiUrl, buildAuthHeaders } from "./serverConnection"; -const TEXT_MODELS = ["qwen-max", "qwen-plus", "qwen-turbo"]; -const VISION_MODELS = ["qwen3.7-plus", "qwen-vl-plus", "qwen-vl-max"]; - type AbortSignalConstructorWithAny = typeof AbortSignal & { any?: (signals: AbortSignal[]) => AbortSignal; }; @@ -110,11 +107,45 @@ export interface ComplianceCheck { allow_video_generation: boolean; } +function findJsonSlice(raw: string): string { + const start = raw.search(/[\[{]/); + if (start < 0) return raw; + + const stack: string[] = []; + let inString = false; + let escaped = false; + + for (let index = start; index < raw.length; index += 1) { + const char = raw[index]; + + if (inString) { + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === "\"") { + inString = false; + } + continue; + } + + if (char === "\"") { + inString = true; + } else if (char === "{" || char === "[") { + stack.push(char === "{" ? "}" : "]"); + } else if (char === "}" || char === "]") { + if (stack.pop() !== char) break; + if (stack.length === 0) return raw.slice(start, index + 1); + } + } + + return raw.slice(start); +} + function extractJson(text: string): unknown { const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/); const raw = fenced ? fenced[1].trim() : text.trim(); - const start = raw.search(/[[{]/); - const slice = start >= 0 ? raw.slice(start) : raw; + const slice = findJsonSlice(raw); try { return JSON.parse(slice); } catch { @@ -122,9 +153,16 @@ function extractJson(text: string): unknown { } } +type ChatContent = + | string + | Array< + | { type: "image_url"; image_url: { url: string } } + | { type: "text"; text: string } + >; + interface ChatMessage { role: "system" | "user"; - content: string; + content: ChatContent; } const MAX_RETRIES = 3; @@ -171,43 +209,32 @@ async function chat( userContent: string, options?: { model?: string; signal?: AbortSignal }, ): Promise { - const candidateModels = options?.model ? [options.model] : TEXT_MODELS; - let lastError: Error | null = null; + return retryOnTransient(async () => { + const messages: ChatMessage[] = [ + { role: "system", content: systemPrompt }, + { role: "user", content: userContent }, + ]; + const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS); + const combinedSignal = combineAbortSignals(options?.signal, timeoutSignal); + const body: Record = { messages, stream: false, temperature: 0.4 }; + if (options?.model) body.model = options.model; - for (const model of candidateModels) { - try { - return await retryOnTransient(async () => { - const messages: ChatMessage[] = [ - { role: "system", content: systemPrompt }, - { role: "user", content: userContent }, - ]; - const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS); - const combinedSignal = combineAbortSignals(options?.signal, timeoutSignal); - const res = await fetch(buildApiUrl("ai/chat"), { - method: "POST", - headers: buildAuthHeaders(), - body: JSON.stringify({ model, messages, stream: false, temperature: 0.4 }), - signal: combinedSignal, - }); - if (!res.ok) { - const errBody = await res.text().catch(() => ""); - throw new Error(`AI 调用失败 (${res.status})${errBody ? `: ${errBody.slice(0, 120)}` : ""}`); - } - const payload = await res.json(); - const content: string = - payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? ""; - if (!content) throw new Error("模型未返回有效内容"); - return content; - }, options?.signal); - } catch (err) { - lastError = err instanceof Error ? err : new Error(String(err)); - if (options?.signal?.aborted) throw lastError; - // If user pinned a specific model, don't fall back to others - if (options?.model) throw lastError; - // Try next model in fallback chain + const res = await fetch(buildApiUrl("ai/chat"), { + method: "POST", + headers: buildAuthHeaders(), + body: JSON.stringify(body), + signal: combinedSignal, + }); + if (!res.ok) { + const errBody = await res.text().catch(() => ""); + throw new Error(`AI 调用失败 (${res.status})${errBody ? `: ${errBody.slice(0, 120)}` : ""}`); } - } - throw lastError ?? new Error("所有候选模型均不可用"); + const payload = await res.json(); + const content: string = + payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? ""; + if (!content) throw new Error("模型未返回有效内容"); + return content; + }, options?.signal); } async function visionChat( @@ -216,50 +243,36 @@ async function visionChat( imageUrls: string[], signal?: AbortSignal, ): Promise { - const content = [ - ...imageUrls.map((url) => ({ type: "image_url", image_url: { url } })), + const content: ChatContent = [ + ...imageUrls.map((url) => ({ type: "image_url" as const, image_url: { url } })), { type: "text", text }, ]; const messages = [ { role: "system", content: systemPrompt }, { role: "user", content }, - ]; + ] satisfies ChatMessage[]; - let lastError: Error | null = null; - for (const model of VISION_MODELS) { + return retryOnTransient(async () => { const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS); const combinedSignal = combineAbortSignals(signal, timeoutSignal); - try { - const out = await retryOnTransient(async () => { - const res = await fetch(buildApiUrl("ai/chat"), { - method: "POST", - headers: buildAuthHeaders(), - body: JSON.stringify({ model, messages, stream: false, temperature: 0.3 }), - signal: combinedSignal, - }); - if (!res.ok) { - const errBody = await res.text().catch(() => ""); - if (errBody.includes("image format")) throw new Error("IMAGE_FORMAT_FALLBACK"); - throw new Error(`图片理解调用失败 (${res.status})${errBody ? `: ${errBody.slice(0, 120)}` : ""}`); - } - const payload = await res.json(); - const result: string = - payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? ""; - if (!result) throw new Error("图片理解未返回有效内容"); - return result; - }, signal); - return out; - } catch (err) { - lastError = err instanceof Error ? err : new Error(String(err)); - if (signal?.aborted) throw lastError; - // Continue trying next vision model on transient failures, image format errors, or upstream errors - if (lastError.message === "IMAGE_FORMAT_FALLBACK") continue; - if (lastError.message.includes("图片理解调用失败")) continue; - if (isTransientError(lastError)) continue; - throw lastError; + + const res = await fetch(buildApiUrl("ai/chat"), { + method: "POST", + headers: buildAuthHeaders(), + body: JSON.stringify({ messages, stream: false, temperature: 0.3 }), + signal: combinedSignal, + }); + if (!res.ok) { + const errBody = await res.text().catch(() => ""); + if (errBody.includes("image format")) throw new Error("图片格式不受支持,请更换图片后重试"); + throw new Error(`图片理解调用失败 (${res.status})${errBody ? `: ${errBody.slice(0, 120)}` : ""}`); } - } - throw lastError ?? new Error("图片理解调用失败,所有模型均不可用"); + const payload = await res.json(); + const result: string = + payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? ""; + if (!result) throw new Error("图片理解未返回有效内容"); + return result; + }, signal); } const IMAGE_UNDERSTANDING_PROMPT = `你是电商产品图片分析专家。请分析用户提供的产品图片,识别产品主体、外观、颜色、材质、形状、尺寸感、品牌标识、关键部件、可视化卖点和适合展示的镜头角度。请用简洁的中文段落描述,不要编造图片中看不到的信息。`; diff --git a/src/api/aiGenerationClient.ts b/src/api/aiGenerationClient.ts index a69c91c..db00e1f 100644 --- a/src/api/aiGenerationClient.ts +++ b/src/api/aiGenerationClient.ts @@ -12,7 +12,7 @@ import type { WebGenerationPreviewTask } from "../types"; export interface ImageGenInput { projectId?: string; conversationId?: number; - model: string; + model?: string; prompt: string; ratio?: string; quality?: string; @@ -89,6 +89,8 @@ export interface ImageEditInput { imageUrl: string; function: string; prompt?: string; + maskUrl?: string; + ratio?: string; n?: number; } @@ -208,18 +210,18 @@ function getStoredSessionRole(): string { } function emitImageRouteDebug(label: string, payload: Record): void { - // Only emit console logs for admin users — hides enterprise routing details - if (getStoredSessionRole() === "admin") { - const entry: ImageRouteDebugEntry = { - at: new Date().toISOString(), - label, - ...payload, - }; - try { - console.log(`${label} ${JSON.stringify(entry)}`); - } catch { - console.log(label, entry); - } + // Only emit route debug for admin users; provider routing is operational data. + if (getStoredSessionRole() !== "admin") return; + + const entry: ImageRouteDebugEntry = { + at: new Date().toISOString(), + label, + ...payload, + }; + try { + console.log(`${label} ${JSON.stringify(entry)}`); + } catch { + console.log(label, entry); } if (typeof window === "undefined") return; @@ -227,7 +229,6 @@ function emitImageRouteDebug(label: string, payload: Record): v const previousEntries = Array.isArray(debugWindow.__OMNIAI_IMAGE_ROUTE_DEBUG__) ? debugWindow.__OMNIAI_IMAGE_ROUTE_DEBUG__ : []; - const entry: ImageRouteDebugEntry = { at: new Date().toISOString(), label, ...payload }; debugWindow.__OMNIAI_IMAGE_ROUTE_DEBUG__ = [...previousEntries.slice(-19), entry]; } diff --git a/src/api/serverConnection.ts b/src/api/serverConnection.ts index 4327da1..2316f72 100644 --- a/src/api/serverConnection.ts +++ b/src/api/serverConnection.ts @@ -161,7 +161,11 @@ export function clearAllUserStorage(): void { "omniai-web-profile-ui", "omniai:more-recent-tools", "omniai:generation-queue", + "omniai:generation-records.pending", + "omniai:ecommerce-video-workspace", "omniai-canvas-saved-assets", + "omniai.clone-ai.", + "omniai.ecommerce.", ]; for (let i = window.localStorage.length - 1; i >= 0; i--) { const key = window.localStorage.key(i); diff --git a/src/api/webGenerationGateway.ts b/src/api/webGenerationGateway.ts index 9a59609..53aed5b 100644 --- a/src/api/webGenerationGateway.ts +++ b/src/api/webGenerationGateway.ts @@ -50,7 +50,6 @@ export const webGenerationGateway = { const result = await aiGenerationClient.createImageTask({ projectId: params?.projectId, conversationId: params?.conversationId, - model: "gpt-image-2", prompt, ratio: params?.ratio || "16:9", quality: params?.quality || "1K", diff --git a/src/features/ecommerce/EcommercePage.tsx b/src/features/ecommerce/EcommercePage.tsx index 563cd71..d1a549a 100644 --- a/src/features/ecommerce/EcommercePage.tsx +++ b/src/features/ecommerce/EcommercePage.tsx @@ -1343,9 +1343,13 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const smartCutoutInputRef = useRef(null); const imageWorkbenchInputRef = useRef(null); const imageWorkbenchUrlInputRef = useRef(null); + const imageWorkbenchProgressRef = useRef(null); const watermarkInputRef = useRef(null); const watermarkUrlInputRef = useRef(null); const watermarkProcessTimeoutRef = useRef(null); + const translateInputRef = useRef(null); + const translateUrlInputRef = useRef(null); + const translateProcessTimeoutRef = useRef(null); const smartCutoutTransitionTimeoutRef = useRef(null); const smartCutoutPendingUrlsRef = useRef([]); const smartCutoutPaletteRef = useRef(null); @@ -1355,6 +1359,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const commandComposerWrapRef = useRef(null); const garmentInputRef = useRef(null); const detailInputRef = useRef(null); + const detailProgressRef = useRef(null); const countHoldTimeoutRef = useRef(null); const countHoldIntervalRef = useRef(null); const isAuthenticated = Boolean((_props as Record).isAuthenticated); @@ -1385,7 +1390,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const [selectedProductSetPreview, setSelectedProductSetPreview] = useState(null); const [showHostingModal, setShowHostingModal] = useState(false); const [productImages, setProductImages] = useState([]); - const [activeQuickTool, setActiveQuickTool] = useState<"cutout" | "detail" | "watermark" | "image-edit" | null>(null); + const [activeQuickTool, setActiveQuickTool] = useState<"cutout" | "detail" | "watermark" | "image-edit" | "translate" | null>(null); const [smartCutoutImage, setSmartCutoutImage] = useState(null); const [smartCutoutBatchImages, setSmartCutoutBatchImages] = useState([]); const [smartCutoutBackgroundColor, setSmartCutoutBackgroundColor] = useState("#ffffff"); @@ -1401,16 +1406,24 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { subtitle: "请稍候", }); const [watermarkImage, setWatermarkImage] = useState<{ src: string; name: string; format: string } | null>(null); - const [watermarkStatus, setWatermarkStatus] = useState<"idle" | "processing" | "done">("idle"); + const [watermarkStatus, setWatermarkStatus] = useState<"idle" | "processing" | "done" | "failed">("idle"); const [isWatermarkDragging, setIsWatermarkDragging] = useState(false); + const [watermarkResultUrl, setWatermarkResultUrl] = useState(null); + const [watermarkProgress, setWatermarkProgress] = useState(0); + const [translateImage, setTranslateImage] = useState<{ src: string; name: string; format: string } | null>(null); + const [translateStatus, setTranslateStatus] = useState<"idle" | "processing" | "done">("idle"); + const [isTranslateDragging, setIsTranslateDragging] = useState(false); + const [translateLanguage, setTranslateLanguage] = useState("zh"); const [imageWorkbenchImage, setImageWorkbenchImage] = useState<{ src: string; name: string; format: string } | null>(null); const [imageWorkbenchPrompt, setImageWorkbenchPrompt] = useState(""); const [imageWorkbenchBrushSize, setImageWorkbenchBrushSize] = useState(50); const [imageWorkbenchRatio, setImageWorkbenchRatio] = useState("1:1"); - const [imageWorkbenchStatus, setImageWorkbenchStatus] = useState<"idle" | "processing" | "done">("idle"); + const [imageWorkbenchStatus, setImageWorkbenchStatus] = useState<"idle" | "processing" | "done" | "failed">("idle"); const [isImageWorkbenchDragging, setIsImageWorkbenchDragging] = useState(false); const [imageWorkbenchMaskStrokes, setImageWorkbenchMaskStrokes] = useState }>>([]); const [imageWorkbenchBrushCursor, setImageWorkbenchBrushCursor] = useState<{ x: number; y: number } | null>(null); + const [imageWorkbenchResultUrl, setImageWorkbenchResultUrl] = useState(null); + const [imageWorkbenchProgress, setImageWorkbenchProgress] = useState(0); const [isProductUploadDragging, setIsProductUploadDragging] = useState(false); const [cloneOutput, setCloneOutput] = useState(defaultCloneOutput); const [videoHistoryVisible, setVideoHistoryVisible] = useState(false); @@ -1709,6 +1722,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const [selectedDetailModules, setSelectedDetailModules] = useState(defaultDetailModuleIds); const [detailStatus, setDetailStatus] = useState("idle"); const [detailResultUrl, setDetailResultUrl] = useState(null); + const [detailProgress, setDetailProgress] = useState(0); const productSetRatioOptions = useMemo( () => getPlatformRatioOptions(productSetPlatform, productSetOutput), [productSetOutput, productSetPlatform], @@ -1951,12 +1965,11 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { }; const closeWatermarkRemovalPage = () => { - if (watermarkProcessTimeoutRef.current !== null) { - window.clearTimeout(watermarkProcessTimeoutRef.current); - watermarkProcessTimeoutRef.current = null; - } + stopWatermarkProgress(); setActiveQuickTool(null); setWatermarkStatus("idle"); + setWatermarkResultUrl(null); + setWatermarkProgress(0); setWatermarkImage((current) => { if (current?.src) URL.revokeObjectURL(current.src); return null; @@ -1974,15 +1987,16 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { return nextImage; }); setWatermarkStatus("idle"); + setWatermarkResultUrl(null); + setWatermarkProgress(0); setActiveQuickTool("watermark"); }; const removeWatermarkImage = () => { - if (watermarkProcessTimeoutRef.current !== null) { - window.clearTimeout(watermarkProcessTimeoutRef.current); - watermarkProcessTimeoutRef.current = null; - } + stopWatermarkProgress(); setWatermarkStatus("idle"); + setWatermarkResultUrl(null); + setWatermarkProgress(0); setWatermarkImage((current) => { if (current?.src) URL.revokeObjectURL(current.src); return null; @@ -2015,31 +2029,187 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { toast.success("图片已导入"); }; - const handleWatermarkGenerate = () => { - if (!watermarkImage || watermarkStatus === "processing") return; - if (watermarkProcessTimeoutRef.current !== null) window.clearTimeout(watermarkProcessTimeoutRef.current); - setWatermarkStatus("processing"); - watermarkProcessTimeoutRef.current = window.setTimeout(() => { + const stopWatermarkProgress = () => { + if (watermarkProcessTimeoutRef.current !== null) { + window.clearInterval(watermarkProcessTimeoutRef.current); watermarkProcessTimeoutRef.current = null; - setWatermarkStatus("done"); - toast.success("去水印处理完成"); - }, 900); + } + }; + + const startWatermarkProgress = () => { + stopWatermarkProgress(); + setWatermarkProgress(0); + watermarkProcessTimeoutRef.current = window.setInterval(() => { + setWatermarkProgress((prev) => { + if (prev >= 90) { + stopWatermarkProgress(); + return 90; + } + return prev + (90 - prev) * 0.06; + }); + }, 500); + }; + + const handleWatermarkGenerate = async () => { + if (!watermarkImage || watermarkStatus === "processing") return; + setWatermarkStatus("processing"); + setWatermarkResultUrl(null); + startWatermarkProgress(); + + try { + const sourceBlob = await fetch(watermarkImage.src).then((res) => res.blob()); + const sourceMime = normalizeEcommerceImageMime(sourceBlob.type || "image/png"); + const { url: imageUrl } = await aiGenerationClient.uploadAssetBinary(sourceBlob, { + name: `watermark-source-${Date.now()}.png`, + mimeType: sourceMime, + scope: ecommerceOssScopes.productSource, + }); + + const { taskId } = await aiGenerationClient.createImageEditTask({ + imageUrl, + function: "watermark-remove", + }); + + const resultUrl = await waitForTask(taskId, { + abortRef: { current: false }, + onProgress: () => {}, + }); + + if (resultUrl) { + const persistedUrl = await persistGeneratedImageUrl(resultUrl, ecommerceOssScopes.cloneResult("watermark"), "ecommerce-watermark"); + setWatermarkResultUrl(persistedUrl); + setWatermarkStatus("done"); + stopWatermarkProgress(); + setWatermarkProgress(100); + toast.success("去水印处理完成"); + } else { + setWatermarkStatus("failed"); + stopWatermarkProgress(); + setWatermarkProgress(0); + toast.error("去水印未返回结果"); + } + } catch (err) { + setWatermarkStatus("failed"); + stopWatermarkProgress(); + setWatermarkProgress(0); + if (err instanceof ServerRequestError && err.status === 402) { + toast.error("余额不足,请充值后继续"); + } else { + toast.error(err instanceof Error ? err.message : "去水印失败"); + } + } }; const handleWatermarkDownload = () => { - if (!watermarkImage || watermarkStatus !== "done") { + if (!watermarkResultUrl || watermarkStatus !== "done") { toast.info("请先完成去水印"); return; } const link = document.createElement("a"); - const safeName = (watermarkImage.name || "watermark-result").replace(/\.[^.]+$/, "").replace(/[\\/:*?"<>|]+/g, "-"); - link.href = watermarkImage.src; + const safeName = (watermarkImage?.name || "watermark-result").replace(/\.[^.]+$/, "").replace(/[\\/:*?"<>|]+/g, "-"); + link.href = watermarkResultUrl; link.download = `${safeName || "watermark-result"}-去水印.png`; document.body.appendChild(link); link.click(); link.remove(); }; + const openImageTranslatePage = () => { + clearSmartCutoutTransition(); + setActiveQuickTool("translate"); + setComposerMenu(null); + setIsCloneSettingsCollapsed(false); + }; + + const closeImageTranslatePage = () => { + if (translateProcessTimeoutRef.current !== null) { + window.clearTimeout(translateProcessTimeoutRef.current); + translateProcessTimeoutRef.current = null; + } + setActiveQuickTool(null); + setTranslateStatus("idle"); + setTranslateImage((current) => { + if (current?.src) URL.revokeObjectURL(current.src); + return null; + }); + }; + + const addTranslateImage = (file: File) => { + const nextImage = { + src: URL.createObjectURL(file), + name: file.name, + format: getImageFileFormat(file) || "PNG / JPG / WebP", + }; + setTranslateImage((current) => { + if (current?.src) URL.revokeObjectURL(current.src); + return nextImage; + }); + setTranslateStatus("idle"); + setActiveQuickTool("translate"); + }; + + const removeTranslateImage = () => { + if (translateProcessTimeoutRef.current !== null) { + window.clearTimeout(translateProcessTimeoutRef.current); + translateProcessTimeoutRef.current = null; + } + setTranslateStatus("idle"); + setTranslateImage((current) => { + if (current?.src) URL.revokeObjectURL(current.src); + return null; + }); + }; + + const handleTranslateUpload = (event: ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + addTranslateImage(file); + event.target.value = ""; + }; + + const handleTranslateDrop = (event: DragEvent) => { + event.preventDefault(); + setIsTranslateDragging(false); + const file = Array.from(event.dataTransfer.files).find((item) => item.type.startsWith("image/")); + if (file) addTranslateImage(file); + }; + + const handleTranslateUrlImport = async () => { + const nextImage = await loadRemoteImageFromInput(translateUrlInputRef.current, "translate-source"); + if (!nextImage) return; + setTranslateImage((current) => { + if (current?.src) URL.revokeObjectURL(current.src); + return nextImage; + }); + setTranslateStatus("idle"); + toast.success("图片已导入"); + }; + + const handleTranslateGenerate = () => { + if (!translateImage || translateStatus === "processing") return; + if (translateProcessTimeoutRef.current !== null) window.clearTimeout(translateProcessTimeoutRef.current); + setTranslateStatus("processing"); + translateProcessTimeoutRef.current = window.setTimeout(() => { + translateProcessTimeoutRef.current = null; + setTranslateStatus("done"); + toast.success("图片翻译完成"); + }, 900); + }; + + const handleTranslateDownload = () => { + if (!translateImage || translateStatus !== "done") { + toast.info("请先完成图片翻译"); + return; + } + const link = document.createElement("a"); + const safeName = (translateImage.name || "translate-result").replace(/\.[^.]+$/, "").replace(/[\\/:*?"<>|]+/g, "-"); + link.href = translateImage.src; + link.download = `${safeName || "translate-result"}-翻译.png`; + document.body.appendChild(link); + link.click(); + link.remove(); + }; + const openImageWorkbenchPage = () => { clearSmartCutoutTransition(); setActiveQuickTool("image-edit"); @@ -2051,6 +2221,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const closeImageWorkbenchPage = () => { setActiveQuickTool(null); setImageWorkbenchStatus("idle"); + setImageWorkbenchResultUrl(null); setImageWorkbenchPrompt(""); setImageWorkbenchMaskStrokes([]); setImageWorkbenchBrushCursor(null); @@ -2078,6 +2249,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { return nextImage; }); setImageWorkbenchStatus("idle"); + setImageWorkbenchResultUrl(null); setImageWorkbenchMaskStrokes([]); setImageWorkbenchBrushCursor(null); clearImageWorkbenchMaskCanvas(); @@ -2087,6 +2259,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const removeImageWorkbenchImage = () => { setImageWorkbenchStatus("idle"); + setImageWorkbenchResultUrl(null); setImageWorkbenchMaskStrokes([]); setImageWorkbenchBrushCursor(null); clearImageWorkbenchMaskCanvas(); @@ -2128,16 +2301,123 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { toast.success("图片已导入"); }; - const handleImageWorkbenchGenerate = () => { + const stopWorkbenchProgress = () => { + if (imageWorkbenchProgressRef.current !== null) { + window.clearInterval(imageWorkbenchProgressRef.current); + imageWorkbenchProgressRef.current = null; + } + }; + + const startWorkbenchProgress = () => { + stopWorkbenchProgress(); + setImageWorkbenchProgress(0); + imageWorkbenchProgressRef.current = window.setInterval(() => { + setImageWorkbenchProgress((prev) => { + if (prev >= 90) { + stopWorkbenchProgress(); + return 90; + } + return prev + (90 - prev) * 0.06; + }); + }, 500); + }; + + const exportWorkbenchMask = (): string | null => { + const canvas = imageWorkbenchMaskCanvasRef.current; + if (!canvas) return null; + const ctx = canvas.getContext("2d"); + if (!ctx) return null; + const w = canvas.width; + const h = canvas.height; + const maskCanvas = document.createElement("canvas"); + maskCanvas.width = w; + maskCanvas.height = h; + const maskCtx = maskCanvas.getContext("2d")!; + maskCtx.fillStyle = "#000000"; + maskCtx.fillRect(0, 0, w, h); + const imgData = ctx.getImageData(0, 0, w, h); + const maskData = maskCtx.getImageData(0, 0, w, h); + for (let i = 3; i < imgData.data.length; i += 4) { + if (imgData.data[i] > 0) { + const pi = i - 3; + maskData.data[pi] = 255; + maskData.data[pi + 1] = 255; + maskData.data[pi + 2] = 255; + maskData.data[pi + 3] = 255; + } + } + maskCtx.putImageData(maskData, 0, 0); + return maskCanvas.toDataURL("image/png"); + }; + + const handleImageWorkbenchGenerate = async () => { if (!imageWorkbenchImage) { toast.info("请先上传图片"); return; } setImageWorkbenchStatus("processing"); - window.setTimeout(() => { - setImageWorkbenchStatus("done"); - toast.success("局部重绘已完成"); - }, 900); + setImageWorkbenchResultUrl(null); + startWorkbenchProgress(); + + try { + const sourceBlob = await fetch(imageWorkbenchImage.src).then((res) => res.blob()); + const sourceMime = normalizeEcommerceImageMime(sourceBlob.type || "image/png"); + const { url: imageUrl } = await aiGenerationClient.uploadAssetBinary(sourceBlob, { + name: `inpaint-source-${Date.now()}.png`, + mimeType: sourceMime, + scope: ecommerceOssScopes.productSource, + }); + + let maskUrl: string | undefined; + if (imageWorkbenchMaskStrokes.length > 0) { + const maskDataUrl = exportWorkbenchMask(); + if (maskDataUrl) { + const { url } = await aiGenerationClient.uploadAsset({ + dataUrl: maskDataUrl, + name: `inpaint-mask-${Date.now()}.png`, + mimeType: "image/png", + scope: ecommerceOssScopes.productSource, + }); + maskUrl = url; + } + } + + const { taskId } = await aiGenerationClient.createImageEditTask({ + imageUrl, + function: "inpaint", + prompt: imageWorkbenchPrompt || undefined, + maskUrl, + ratio: imageWorkbenchRatio, + }); + + const resultUrl = await waitForTask(taskId, { + abortRef: { current: false }, + onProgress: () => {}, + }); + + if (resultUrl) { + const persistedUrl = await persistGeneratedImageUrl(resultUrl, ecommerceOssScopes.cloneResult("inpaint"), "ecommerce-inpaint"); + setImageWorkbenchResultUrl(persistedUrl); + setImageWorkbenchStatus("done"); + stopWorkbenchProgress(); + setImageWorkbenchProgress(100); + toast.success("局部重绘已完成"); + } else { + setImageWorkbenchStatus("failed"); + stopWorkbenchProgress(); + setImageWorkbenchProgress(0); + toast.error("重绘未返回结果"); + } + } catch (err) { + setImageWorkbenchStatus("failed"); + stopWorkbenchProgress(); + setImageWorkbenchProgress(0); + if (err instanceof ServerRequestError && err.status === 402) { + toast.error("余额不足,请充值后继续"); + } else { + toast.error(err instanceof Error ? err.message : "重绘失败"); + } + } }; const syncImageWorkbenchMaskCanvas = () => { @@ -3173,8 +3453,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { return urls; }; - const IMAGE_MODEL = "gpt-image-2"; - const setCountLabels: Record = { selling: { label: "卖点图", promptDesc: "selling-point infographic image highlighting core product advantages and detail close-ups" }, white: { label: "白底图", promptDesc: "clean white-background product photo showing the item from its best angle, studio lighting, no props" }, @@ -3289,7 +3567,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const fullPrompt = userText.trim() ? `${subPrompt} Additional user requirements: ${userText.trim()}` : subPrompt; const { taskId } = await aiGenerationClient.createImageTask({ - model: IMAGE_MODEL, prompt: fullPrompt, ratio: normalizeRatioForApi(pRatio), quality: pRatio.includes("720") ? "720P" : "1080P", @@ -3373,7 +3650,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const stamp = Date.now(); const { taskId } = await aiGenerationClient.createImageTask({ - model: IMAGE_MODEL, prompt, ratio: normalizeRatioForApi(pRatio), quality: pRatio.includes("720") ? "720P" : "1080P", @@ -3607,15 +3883,46 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { ); }; + const stopDetailProgress = () => { + if (detailProgressRef.current !== null) { + window.clearInterval(detailProgressRef.current); + detailProgressRef.current = null; + } + }; + + const startDetailProgress = () => { + stopDetailProgress(); + setDetailProgress(0); + detailProgressRef.current = window.setInterval(() => { + setDetailProgress((prev) => { + if (prev >= 90) { + stopDetailProgress(); + return 90; + } + return prev + (90 - prev) * 0.06; + }); + }, 500); + }; + const handleDetailGenerate = () => { if (!canGenerateDetail) return; imageAbortRef.current = { current: false }; lastFailedActionRef.current = null; + startDetailProgress(); void generateEcommerceImage( "detail", detailProductImages, detailRequirement, detailPlatform, detailRatio, detailLanguage, detailMarket, { detailModules: selectedDetailModules }, - (s: string) => setDetailStatus(s as DetailStatus), + (s: string) => { + setDetailStatus(s as DetailStatus); + if (s === "done") { + stopDetailProgress(); + setDetailProgress(100); + } else if (s === "failed" || s === "idle") { + stopDetailProgress(); + setDetailProgress(0); + } + }, (res) => setDetailResultUrl(res[0]?.src ?? null), ); }; @@ -3681,6 +3988,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const isQuickDetailTool = isCloneTool && activeQuickTool === "detail"; const isWatermarkTool = isCloneTool && activeQuickTool === "watermark"; const isImageEditTool = isCloneTool && activeQuickTool === "image-edit"; + const isTranslateTool = isCloneTool && activeQuickTool === "translate"; const pageLabel = isSetTool ? "商品套图" : isDetail ? "A+/详情页" : isTryOn ? "AI服饰穿戴" : activeToolMeta?.label || "商品工具"; const setPrimaryLabel = setImages.length === 0 @@ -4884,6 +5192,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { { label: "图片修改", tone: "edit", icon: , onClick: openImageWorkbenchPage }, { label: "智能抠图", tone: "cutout", icon: , onClick: openSmartCutoutUpload }, { label: "去除水印", tone: "watermark", icon: , onClick: openWatermarkRemovalPage }, + { label: "图片翻译", tone: "translate", icon: , onClick: openImageTranslatePage }, ].map((item) => ( + + + + + )} ); @@ -5568,14 +5925,24 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { 正在去水印 AI 正在清理图片中的水印和文字 +
+
+
+ {Math.round(watermarkProgress)}%
- ) : watermarkStatus === "done" ? ( + ) : watermarkStatus === "done" && watermarkResultUrl ? ( <> - 去水印结果 + 去水印结果 + ) : watermarkStatus === "failed" ? ( +
+ + 去水印失败 + 请检查网络或重试,如余额不足请先充值 +
) : (
@@ -5600,6 +5967,207 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { ); + const translateLanguageOptions = [ + { value: "zh", label: "中文" }, + { value: "en", label: "English" }, + { value: "ja", label: "日本語" }, + { value: "ko", label: "한국어" }, + { value: "fr", label: "Français" }, + { value: "de", label: "Deutsch" }, + { value: "es", label: "Español" }, + { value: "pt", label: "Português" }, + { value: "ru", label: "Русский" }, + { value: "ar", label: "العربية" }, + ]; + + const translatePreview = ( +
+ + + +
+ {!translateImage ? ( +
translateInputRef.current?.click()} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + translateInputRef.current?.click(); + } + }} + onDragEnter={(event) => { + event.preventDefault(); + setIsTranslateDragging(true); + }} + onDragOver={(event) => event.preventDefault()} + onDragLeave={() => setIsTranslateDragging(false)} + onDrop={handleTranslateDrop} + > + + 点击或拖拽上传图片 + 支持 PNG / JPG / WebP,上传含文字图片后选择目标语言并点击开始翻译 +
+ ) : ( +
+
+ 原图 + 原图 +
+ +
+ 翻译结果 + {translateStatus === "processing" ? ( +
+ + 正在翻译 + AI 正在识别并翻译图片中的文字 +
+ ) : translateStatus === "done" ? ( + <> + 翻译结果 + + + ) : ( +
+ + 等待处理 + 点击开始翻译后显示结果 +
+ )} +
+ + +
+
+
+ )} +
+
+ ); + const openQuickUploadWithKeyboard = ( event: ReactKeyboardEvent, inputRef: { current: HTMLInputElement | null }, @@ -5761,6 +6329,38 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { {detailStatus === "done" && detailResultUrl ? (
A+详情页生成结果 + +
+ ) : detailStatus === "generating" ? ( +
+ + 正在生成 A+ 详情页 + AI 正在根据您的商品图和设置生成详情页素材,请稍候... +
+
+
+ {Math.round(detailProgress)}% +
+ ) : detailStatus === "failed" ? ( +
+ + 生成失败 + 请检查网络或重试,如余额不足请先充值。 +
) : detailProductImages.length ? (
@@ -5901,22 +6501,24 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { : isCloneTool ? isWatermarkTool ? watermarkPreview - : isImageEditTool - ? imageWorkbenchPreview - : isSmartCutoutTool - ? smartCutoutPreview - : isQuickDetailTool - ? ( -
- {quickDetailPreview} -
- ) - : clonePreview + : isTranslateTool + ? translatePreview + : isImageEditTool + ? imageWorkbenchPreview + : isSmartCutoutTool + ? smartCutoutPreview + : isQuickDetailTool + ? ( +
+ {quickDetailPreview} +
+ ) + : clonePreview : placeholderPreview; return (
@@ -5940,7 +6542,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { {isSetTool ? setPanel : isDetail ? detailPanel : isTryOn ? tryOnPanel : isCloneTool ? clonePanel : placeholderPanel} - {isCloneTool && !isSmartCutoutTool && !isQuickDetailTool && !isWatermarkTool && !isImageEditTool ? ( + {isCloneTool && !isSmartCutoutTool && !isQuickDetailTool && !isWatermarkTool && !isTranslateTool && !isImageEditTool ? (