From aebe0ff82772b8f8cb0b289e3a1abd4a56bb2ff3 Mon Sep 17 00:00:00 2001 From: Stringadmin Date: Fri, 5 Jun 2026 16:08:47 +0800 Subject: [PATCH] chore: re-upload current web project code --- src/features/ecommerce/EcommercePage.tsx | 2134 ++++++----------- .../workbench/WorkbenchSelectChips.tsx | 26 +- src/styles/pages/agent.css | 12 - src/styles/pages/assets.css | 21 - src/styles/pages/avatar-console.css | 27 - src/styles/pages/canvas.css | 91 - src/styles/pages/community.css | 49 - src/styles/pages/ecommerce.css | 429 +--- src/styles/pages/image-workbench.css | 25 - src/styles/pages/more-tools.css | 20 - src/styles/pages/more.css | 36 - src/styles/pages/profile.css | 26 +- src/styles/pages/provider-health.css | 37 - src/styles/pages/size-template.css | 21 - src/styles/pages/studio-layout.css | 16 - src/styles/pages/subtitle-removal.css | 12 - src/styles/pages/workbench.css | 134 -- src/styles/shell/app-shell.css | 46 - src/styles/themes/dark-green.css | 121 - src/styles/tokens.css | 11 - 20 files changed, 806 insertions(+), 2488 deletions(-) diff --git a/src/features/ecommerce/EcommercePage.tsx b/src/features/ecommerce/EcommercePage.tsx index 093f1bc..cf61780 100644 --- a/src/features/ecommerce/EcommercePage.tsx +++ b/src/features/ecommerce/EcommercePage.tsx @@ -3,76 +3,75 @@ import { CloudUploadOutlined, CloseOutlined, FileImageOutlined, + FrownOutlined, LoadingOutlined, MenuFoldOutlined, MenuUnfoldOutlined, QuestionCircleOutlined, + ReloadOutlined, SettingOutlined, SkinOutlined, } from "@ant-design/icons"; import { useEffect, useRef, useState, type CSSProperties, type ChangeEvent, type DragEvent, type ReactNode } from "react"; +import { ossAssets } from "../../data/ossAssets"; import { EcommerceProgressBar } from "./EcommerceProgressBar"; - -const OSS_MUBAN = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/muban"; -const ecommerceGenerated = `${OSS_MUBAN}/ecommerce-carousel-generated.png`; -const ecommerceSlide4 = `${OSS_MUBAN}/slide-4.png`; -const ecommerceSlide5 = `${OSS_MUBAN}/slide-5.png`; import ImageMentionMenu, { getImageMentionQuery, insertImageMentionValue, type MentionImageOption } from "./ImageMentionMenu"; import EcommerceVideoWorkspace from "./EcommerceVideoWorkspace"; +import EcommerceVideoHistoryPanel from "./panels/EcommerceVideoHistoryPanel"; +import EcommerceDetailPanel from "./panels/EcommerceDetailPanel"; +import EcommerceSetPanel from "./panels/EcommerceSetPanel"; +import EcommerceTryOnPanel from "./panels/EcommerceTryOnPanel"; +import EcommerceClonePanel from "./panels/EcommerceClonePanel"; import { aiGenerationClient } from "../../api/aiGenerationClient"; import { ServerRequestError } from "../../api/serverConnection"; import { waitForTask } from "../../api/taskSubscription"; +import { toast } from "../../components/toast/toastStore"; +import { useGenerationTasks } from "../../hooks/useGenerationTasks"; +import { useAppStore } from "../../stores"; import { - analyzeProductImages, - buildProductSummary, - extractSellingPoints, - generateCreativeOptions, - generateStoryboard, - generateVideoPrompts, - checkCompliance, - type AdVideoUserConfig, - type ProductSummary, - type SellingPointResult, - type CreativeOption, - type Storyboard, - type VideoPrompt, - type ComplianceCheck, -} from "../../api/adVideoPlanClient"; + normalizeEcommerceImageMime, + summarizeRejectedImages, + validateEcommerceImageFiles, +} from "./ecommerceImageValidation"; interface ProductClonePageProps { [key: string]: unknown; } -type ProductCloneStatus = "idle" | "ready" | "generating" | "done"; +type ProductCloneStatus = "idle" | "ready" | "generating" | "done" | "failed"; type ProductSetOutputKey = "set" | "detail" | "model" | "video"; -type CloneOutputKey = ProductSetOutputKey | "hot"; +type CloneOutputKey = ProductSetOutputKey | "hot" | "video-outfit"; type CloneSetCountKey = "selling" | "white" | "scene"; type CloneModelPanelTab = "scene" | "model"; type CloneVideoQualityKey = "standard" | "high" | "ultra"; -type ProductSetStatus = "idle" | "ready" | "generating" | "done"; +type ProductSetStatus = "idle" | "ready" | "generating" | "done" | "failed"; type ProductKitToolKey = "set" | "detail" | "wear" | "clone"; type CloneBasicSelectKey = "platform" | "market" | "language" | "ratio"; type CloneModelSelectKey = "gender" | "age" | "ethnicity" | "body"; type CloneReferenceMode = "upload" | "link"; type CloneReplicateLevelKey = "style" | "high"; type TryOnModelSource = "ai" | "library"; -type TryOnStatus = "idle" | "modeling" | "ready" | "generating" | "done"; -type DetailStatus = "idle" | "ready" | "generating" | "done"; +type TryOnStatus = "idle" | "modeling" | "ready" | "generating" | "done" | "failed"; +type DetailStatus = "idle" | "ready" | "generating" | "done" | "failed"; interface CloneImageItem { id: string; src: string; name: string; + file?: File; width?: number; height?: number; format?: string; + mimeType?: string; + ossKey?: string; } interface CloneResult { id: string; src: string; label: string; + type?: "image" | "video"; } interface CloneSavedSetting { @@ -102,7 +101,19 @@ interface CloneSavedSetting { requirement: string; } -type PlatformRatioModeKey = ProductSetOutputKey | "hot"; +interface EcommerceImagePromptOptions { + gender?: string; + age?: string; + ethnicity?: string; + body?: string; + appearance?: string; + scenes?: string[]; + customScene?: string; + smartScene?: boolean; + detailModules?: string[]; +} + +type PlatformRatioModeKey = ProductSetOutputKey | "hot" | "video-outfit"; interface PlatformRatioGroup { ratios: string[]; @@ -523,12 +534,6 @@ const formatUploadedImageRatio = (image?: CloneImageItem) => { if (!image.width || !image.height) return `上传图片\u00a0\u00a0\u00a0原图比例${format}`; return `上传图片 ${image.width}×${image.height}px\u00a0\u00a0\u00a0${formatAspectRatio(image.width, image.height)}${format}`; }; -const formatProductImageSpec = (image?: CloneImageItem | null) => { - if (!image) return "等待上传"; - const format = image.format ? ` · ${image.format}` : ""; - if (!image.width || !image.height) return `正在识别尺寸${format}`; - return `${image.width}×${image.height}px · ${formatAspectRatio(image.width, image.height)}${format}`; -}; const defaultMarketLanguageOption = marketLanguageOptions[0]!; const normalizeMarket = (value: string) => marketLanguageOptions.some((option) => option.country === value) ? value : defaultMarketLanguageOption.country; @@ -559,6 +564,7 @@ const productSetOutputOptions: Array<{ key: ProductSetOutputKey; label: string } const cloneOutputOptions: Array<{ key: CloneOutputKey; label: string }> = [ ...productSetOutputOptions, { key: "hot", label: "爆款图复刻" }, + { key: "video-outfit", label: "视频换装" }, ]; const cloneSetCountOptions: Array<{ key: CloneSetCountKey; @@ -579,7 +585,7 @@ const maxCloneSetTotal = 16; const maxCloneProductImages = 7; const maxCloneReferenceImages = 20; const cloneVideoDurationMin = 5; -const cloneVideoDurationMax = 15; +const cloneVideoDurationMax = 45; const cloneLatestSettingStorageKey = "omniai.clone-ai.latest-setting"; const cloneVideoQualityOptions: Array<{ key: CloneVideoQualityKey; label: string; desc: string }> = [ { key: "standard", label: "标准", desc: "快速出片" }, @@ -603,15 +609,12 @@ const tryOnModelOptions = { ethnicity: ["欧美白人", "亚洲人", "拉美裔", "非洲裔"], body: ["标准", "高挑", "微胖", "运动"], }; -const sampleResults = [ecommerceSlide4, ecommerceGenerated, ecommerceSlide5]; -const productSetAssets = { - main: "https://xiuxiu-pro.meitudata.com/poster/6e3eebacad8d5e47e1896ee7d54827bc.png?imageView2/2/w/800/format/webp/q/80/ignore-error/1", - scene: "https://xiuxiu-pro.meitudata.com/poster/21225fc86b28d9e4d85636483c67408e.png?imageView2/2/w/400/format/webp/q/80/ignore-error/1", - model: "https://xiuxiu-pro.meitudata.com/poster/4b8e6d1bd0996be52822dd1fac73cffd.png?imageView2/2/w/400/format/webp/q/80/ignore-error/1", - detail: "https://xiuxiu-pro.meitudata.com/poster/29dd195a450ee5a7f7451ded6680e969.png?imageView2/2/w/400/format/webp/q/80/ignore-error/1", - selling: "https://xiuxiu-pro.meitudata.com/poster/66bdef541b67588e8db2a03b39dc815b.jpg?imageView2/2/w/400/format/webp/q/80/ignore-error/1", - hosting: "https://xiuxiu-pro-new.meitudata.com/poster/50c17a98c77fac4d0523c8cbdf0d33ca.jpg?imageView2/2/format/webp/q/80/ignore-error/1", -}; +const sampleResults = [ + ossAssets.ecommerce.slides.slide4, + ossAssets.ecommerce.generated, + ossAssets.ecommerce.slides.slide5, +]; +const productSetAssets = ossAssets.ecommerce.productSet; const productSetPreviewCards = [ { id: "main", label: "01 主图 (白底/合规)", src: productSetAssets.main }, { id: "scene", label: "02 场景展示", src: productSetAssets.scene }, @@ -619,21 +622,7 @@ const productSetPreviewCards = [ { id: "detail", label: "04 细节说明", src: productSetAssets.detail }, { id: "selling", label: "05 卖点详解", src: productSetAssets.selling }, ]; -const tryOnAssets = { - dressA: "https://xiuxiu-pro-new.meitudata.com/poster/133ca2d6c13bac6cfaa11fa29a155551.jpg?imageView2/2/w/280/format/webp/q/80/ignore-error/1", - dressB: "https://xiuxiu-pro-new.meitudata.com/poster/a661006820e888d9df13023075096e94.jpg?imageView2/2/w/280/format/webp/q/80/ignore-error/1", - modelWoman: "https://xiuxiu-pro-new.meitudata.com/poster/f806c6afaf6f38f634c156c5b6058201.png?imageView2/2/w/280/format/webp/q/80/ignore-error/1", - modelMan: "https://xiuxiu-pro-new.meitudata.com/poster/8c26503c67dc695e25e420e48caf4cde.png?imageView2/2/w/280/format/webp/q/80/ignore-error/1", - modelAsian: "https://xiuxiu-pro-new.meitudata.com/poster/0f2a7c92707312ec74647d66f15a6ef9.jpg?imageView2/2/w/280/format/webp/q/80/ignore-error/1", - tryA: "https://xiuxiu-pro-new.meitudata.com/poster/7f77e0866f05ff723959e1f48830713c.jpg?imageView2/2/w/345/format/webp/q/80/ignore-error/1", - tryB: "https://xiuxiu-pro-new.meitudata.com/poster/0b951004eabcdd7cae595dfdb4c7f8c3.jpg?imageView2/2/w/345/format/webp/q/80/ignore-error/1", - jacket: "https://xiuxiu-pro-new.meitudata.com/poster/fdbf10b4c92af5b1986444cdd9affaa5.png?imageView2/2/w/280/format/webp/q/80/ignore-error/1", - jacketResultA: "https://xiuxiu-pro-new.meitudata.com/poster/b1152bb292323b87696dd2f6e518e818.jpg?imageView2/2/w/345/format/webp/q/80/ignore-error/1", - jacketResultB: "https://xiuxiu-pro-new.meitudata.com/poster/1c1e757702108fef92d85be0c2802c01.jpg?imageView2/2/w/345/format/webp/q/80/ignore-error/1", - hat: "https://xiuxiu-pro-new.meitudata.com/poster/278af735b076ab812888802d3e3db0b8.jpg?imageView2/2/w/280/format/webp/q/80/ignore-error/1", - hatResultA: "https://xiuxiu-pro-new.meitudata.com/poster/a3ba241b7aa6060869b096d3f10e5db4.jpg?imageView2/2/w/345/format/webp/q/80/ignore-error/1", - hatResultB: "https://xiuxiu-pro-new.meitudata.com/poster/01ed1ae80a187c70c682bb6d0ec6fa68.jpg?imageView2/2/w/345/format/webp/q/80/ignore-error/1", -}; +const tryOnAssets = ossAssets.ecommerce.tryOn; const tryOnCards = [ { @@ -678,18 +667,7 @@ const detailModules = [ const defaultDetailModuleIds: string[] = []; const defaultCloneDetailModuleIds = ["hero", "selling", "usage", "angle", "scene", "detail"]; const cloneDetailModules = detailModules; -const detailAssets = { - productA: "https://xiuxiu-pro.meitudata.com/poster/182676711565ee98e20cf92d766d1643.png?imageView2/2/format/webp/q/80/ignore-error/1", - productB: "https://xiuxiu-pro.meitudata.com/poster/ba6312cbc3a32ceb8966f9ea20b9ee9c.png?imageView2/2/format/webp/q/80/ignore-error/1", - productC: "https://xiuxiu-pro.meitudata.com/poster/7ee5753a3141fa12cda155126c8225d3.png?imageView2/2/format/webp/q/80/ignore-error/1", - longPage: "https://xiuxiu-pro.meitudata.com/poster/19ef313484fc87c9bdd3cd52ce2a5947.png?imageView2/2/format/webp/q/80/ignore-error/1", - gridA: "https://xiuxiu-pro.meitudata.com/poster/e74e8d920ac0f87020f90457d42a7153.png?imageView2/2/format/webp/q/80/ignore-error/1", - gridB: "https://xiuxiu-pro.meitudata.com/poster/1652064f17c5c2b32ce287244b505c15.png?imageView2/2/format/webp/q/80/ignore-error/1", - gridC: "https://xiuxiu-pro.meitudata.com/poster/dd8abace327edf61d8a8e2d7db42cfbe.png?imageView2/2/format/webp/q/80/ignore-error/1", - gridD: "https://xiuxiu-pro.meitudata.com/poster/7dc397f1cb76a35f7f0ed3c3ce78ba81.png?imageView2/2/format/webp/q/80/ignore-error/1", - gridE: "https://xiuxiu-pro.meitudata.com/poster/1199bd8b968a5162752e1ee2b093d315.png?imageView2/2/format/webp/q/80/ignore-error/1", - gridF: "https://xiuxiu-pro.meitudata.com/poster/7a8cdb3693418df9915741960f8f5aa8.png?imageView2/2/format/webp/q/80/ignore-error/1", -}; +const detailAssets = ossAssets.ecommerce.detail; const detailProductSamples = [detailAssets.productA, detailAssets.productB, detailAssets.productC]; const detailGridSamples = [detailAssets.gridA, detailAssets.gridB, detailAssets.gridC, detailAssets.gridD, detailAssets.gridE, detailAssets.gridF]; @@ -708,16 +686,92 @@ function readImageDimensions(src: string): Promise<{ width: number; height: numb }); } -function createObjectImageItems(files: File[], limit: number, prefix: string) { - return Array.from(files) - .filter((file) => file.type.startsWith("image/")) - .slice(0, limit) - .map((file, index) => ({ - id: `${prefix}-${Date.now()}-${index}`, - src: URL.createObjectURL(file), +const blobToDataUrl = (blob: Blob): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result || "")); + reader.onerror = () => reject(reader.error || new Error("文件读取失败")); + reader.readAsDataURL(blob); + }); + +async function createUploadedImageItems(files: File[], limit: number, prefix: string): Promise { + const selectedFiles = Array.from(files).slice(0, limit); + const stamp = Date.now(); + + const items = await Promise.all(selectedFiles.map(async (file, index) => { + const localPreviewUrl = URL.createObjectURL(file); + let dimensions: { width?: number; height?: number } = {}; + try { + dimensions = await readImageDimensions(localPreviewUrl); + } catch { + dimensions = {}; + } finally { + URL.revokeObjectURL(localPreviewUrl); + } + + const mimeType = normalizeEcommerceImageMime(file.type); + const uploadBlob = file.type === mimeType ? file : new Blob([file], { type: mimeType }); + const { url, ossKey } = await aiGenerationClient.uploadAssetBinary(uploadBlob, { name: file.name, + mimeType, + scope: "ecommerce-product", + }); + + return { + id: `${prefix}-${stamp}-${index}`, + src: url, + name: file.name, + file, format: getImageFileFormat(file), - })); + mimeType, + ossKey, + ...dimensions, + }; + })); + + return items; +} + +async function persistGeneratedImageUrl(sourceUrl: string, scope: string, namePrefix: string): Promise { + if (!sourceUrl) return sourceUrl; + try { + if (sourceUrl.startsWith("data:")) { + const { url } = await aiGenerationClient.uploadAsset({ + dataUrl: sourceUrl, + name: `${namePrefix}-${Date.now()}.png`, + scope, + }); + return url || sourceUrl; + } + + if (sourceUrl.startsWith("blob:")) { + const rawBlob = await fetch(sourceUrl).then((res) => res.blob()); + const mimeType = normalizeEcommerceImageMime(rawBlob.type); + const blob = rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType }); + const { url } = await aiGenerationClient.uploadAssetBinary(blob, { + name: `${namePrefix}-${Date.now()}.png`, + mimeType, + scope, + }); + return url; + } + + const { url } = await aiGenerationClient.uploadAssetByUrl({ + sourceUrl, + name: `${namePrefix}-${Date.now()}`, + scope, + }); + return url || sourceUrl; + } catch { + return sourceUrl; + } +} + +function notifyRejectedImages(files: File[]): File[] { + const { accepted, rejected } = validateEcommerceImageFiles(files); + const message = summarizeRejectedImages(rejected); + if (message) toast.error(message); + return accepted; } function isCloneSavedSetting(item: unknown): item is CloneSavedSetting { @@ -767,6 +821,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const detailInputRef = useRef(null); const countHoldTimeoutRef = useRef(null); const countHoldIntervalRef = useRef(null); + const imageGen = useGenerationTasks({ sourceView: "ecommerce" }); + const appUsage = useAppStore((s) => s.usage); const latestCloneSettingRef = useRef(null); const skipInitialCloneAutoSaveRef = useRef(true); const skipNextCloneAutoSaveRef = useRef(false); @@ -784,9 +840,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const [selectedProductSetPreview, setSelectedProductSetPreview] = useState<{ src: string; label: string } | null>(null); const [showHostingModal, setShowHostingModal] = useState(false); const [productImages, setProductImages] = useState([]); - const [selectedProductImageId, setSelectedProductImageId] = useState(null); const [isProductUploadDragging, setIsProductUploadDragging] = useState(false); const [cloneOutput, setCloneOutput] = useState("detail"); + const [videoHistoryVisible, setVideoHistoryVisible] = useState(false); + const [videoPlanTrigger, setVideoPlanTrigger] = useState(0); const [openCloneBasicSelect, setOpenCloneBasicSelect] = useState(null); const [openCloneModelSelect, setOpenCloneModelSelect] = useState(null); const [cloneModelSelectDropUp, setCloneModelSelectDropUp] = useState(false); @@ -806,19 +863,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const [cloneVideoQuality, setCloneVideoQuality] = useState("high"); const [cloneVideoDuration, setCloneVideoDuration] = useState(10); const [cloneVideoSmart, setCloneVideoSmart] = useState(true); - const [adVideoStep, setAdVideoStep] = useState<"idle" | "planning" | "planned" | "rendering">("idle"); - const [adVideoBusy, setAdVideoBusy] = useState(false); - const [adVideoError, setAdVideoError] = useState(null); - const [adVideoProgress, setAdVideoProgress] = useState(""); - const [adVideoSummary, setAdVideoSummary] = useState(null); - const [adVideoSelling, setAdVideoSelling] = useState(null); - const [adVideoCreatives, setAdVideoCreatives] = useState([]); - const [adVideoStoryboard, setAdVideoStoryboard] = useState(null); - const [adVideoPrompts, setAdVideoPrompts] = useState([]); - const [adVideoCompliance, setAdVideoCompliance] = useState(null); - const [adVideoScenes, setAdVideoScenes] = useState< - Array<{ sceneId: number; taskId?: string; status: string; progress: number; resultUrl?: string | null; error?: string }> - >([]); + const [videoOutfitVideoFile, setVideoOutfitVideoFile] = useState(null); + const [videoOutfitRefFile, setVideoOutfitRefFile] = useState(null); const [isCloneSettingsCollapsed, setIsCloneSettingsCollapsed] = useState(false); const [requirement, setRequirement] = useState(""); const [requirementImageMentionQuery, setRequirementImageMentionQuery] = useState(null); @@ -830,6 +876,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const [status, setStatus] = useState("idle"); const [results, setResults] = useState([]); const imageAbortRef = useRef({ current: false }); + const activeEcommerceTaskIdsRef = useRef>(new Set()); + const lastFailedActionRef = useRef<(() => void) | null>(null); const [garmentImages, setGarmentImages] = useState([]); const [modelSource, setModelSource] = useState("ai"); const [modelGender, setModelGender] = useState(tryOnModelOptions.gender[0]); @@ -869,25 +917,44 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const selectedProductSetOutput = productSetOutputOptions.find((option) => option.key === productSetOutput) ?? productSetOutputOptions[0]!; const selectedCloneOutput = cloneOutputOptions.find((option) => option.key === cloneOutput) ?? cloneOutputOptions[1]!; - const selectedProductImage = productImages.find((image) => image.id === selectedProductImageId) ?? productImages[0] ?? null; - const selectedProductImageIndex = selectedProductImage - ? productImages.findIndex((image) => image.id === selectedProductImage.id) - : -1; - const selectedProductImageLabel = selectedProductImageIndex >= 0 ? `商品图 ${selectedProductImageIndex + 1}` : "商品图"; - const selectedProductImageSpec = formatProductImageSpec(selectedProductImage); - const isProductImageLimitReached = productImages.length >= maxCloneProductImages; const productSetPreviewReady = productSetStatus === "done"; const cloneSetTotal = Object.values(cloneSetCounts).reduce((sum, value) => sum + value, 0); const canGenerateSet = setImages.length > 0 && productSetStatus !== "generating"; - const canGenerate = productImages.length > 0 && status !== "generating"; + const canGenerate = (cloneOutput === "video-outfit" + ? Boolean(videoOutfitVideoFile && videoOutfitRefFile) + : productImages.length > 0) && status !== "generating"; const canGenerateTryOn = garmentImages.length > 0 && tryOnStatus !== "generating" && tryOnStatus !== "modeling"; const canGenerateDetail = detailProductImages.length > 0 && detailStatus !== "generating"; const cloneVideoDurationProgress = ((cloneVideoDuration - cloneVideoDurationMin) / (cloneVideoDurationMax - cloneVideoDurationMin)) * 100; - const cloneVideoDurationStyle = { + const cloneVideoDurationStyle: CSSProperties = { "--clone-video-duration-progress": `${cloneVideoDurationProgress}%`, } as CSSProperties; + const trackEcommerceTask = (taskId: string) => { + activeEcommerceTaskIdsRef.current.add(taskId); + }; + + const untrackEcommerceTask = (taskId: string) => { + activeEcommerceTaskIdsRef.current.delete(taskId); + }; + + const handleCancelGenerate = () => { + imageAbortRef.current.current = true; + const taskIds = Array.from(activeEcommerceTaskIdsRef.current); + activeEcommerceTaskIdsRef.current.clear(); + taskIds.forEach((taskId) => { + aiGenerationClient.cancelTask(taskId).catch(() => {}); + }); + lastFailedActionRef.current = null; + if (productSetStatus === "generating") setProductSetStatus("idle"); + if (status === "generating") setStatus("idle"); + if (detailStatus === "generating") setDetailStatus("idle"); + if (tryOnStatus === "generating") setTryOnStatus("idle"); + if (tryOnStatus === "modeling") setTryOnStatus("ready"); + toast.info("\u5df2\u53d6\u6d88\u751f\u6210"); + }; + const syncRequirementMentionQuery = (value: string, selectionStart: number | null | undefined) => { setRequirementImageMentionQuery(ecommerceMentionImages.length ? getImageMentionQuery(value, selectionStart) : null); }; @@ -904,45 +971,26 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { }); }; - useEffect(() => { - if (!productImages.length) { - if (selectedProductImageId !== null) setSelectedProductImageId(null); - return; - } - if (!selectedProductImageId || !productImages.some((image) => image.id === selectedProductImageId)) { - setSelectedProductImageId(productImages[0].id); - } - }, [productImages, selectedProductImageId]); - - useEffect(() => { - productImages - .filter((item) => !item.width || !item.height) - .forEach((item) => { - readImageDimensions(item.src) - .then(({ width, height }) => { - setProductImages((current) => - current.map((currentItem) => (currentItem.id === item.id ? { ...currentItem, width, height } : currentItem)), - ); - }) - .catch(() => undefined); - }); - }, [productImages]); - - const addSetImages = (files: File[]) => { + const addSetImages = async (files: File[]) => { if (setImages.length >= 3) return; - const imageFiles = files.filter((file) => file.type.startsWith("image/")); + const imageFiles = notifyRejectedImages(files); if (!imageFiles.length) return; - setSetImages((current) => { - const nextImages = createObjectImageItems(imageFiles, 3 - current.length, "set"); - return nextImages.length ? [...current, ...nextImages].slice(0, 3) : current; - }); - setProductSetStatus("ready"); + try { + const nextImages = await createUploadedImageItems(imageFiles, 3 - setImages.length, "set"); + setSetImages((current) => { + if (current.length >= 3) return current; + return nextImages.length ? [...current, ...nextImages].slice(0, 3) : current; + }); + setProductSetStatus("ready"); + } catch (err) { + toast.error(err instanceof Error ? err.message : "商品图上传失败"); + } }; const handleSetUpload = (event: ChangeEvent) => { const files = event.target.files; if (!files?.length) return; - addSetImages(Array.from(files)); + void addSetImages(Array.from(files)); event.target.value = ""; }; @@ -950,7 +998,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { event.preventDefault(); setIsSetUploadDragging(false); const files = Array.from(event.dataTransfer.files); - if (files.length) addSetImages(files); + if (files.length) void addSetImages(files); }; const removeSetImage = (imageId: string) => { @@ -961,31 +1009,34 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { }); }; - const addProductImages = (files: File[]) => { - const imageFiles = files.filter((file) => file.type.startsWith("image/")); + const addProductImages = async (files: File[]) => { + const imageFiles = notifyRejectedImages(files); if (!imageFiles.length) return; - setProductImages((current) => { - if (current.length >= maxCloneProductImages) return current; - const nextImages = createObjectImageItems(imageFiles, maxCloneProductImages - current.length, "product"); - return nextImages.length ? [...current, ...nextImages].slice(0, maxCloneProductImages) : current; - }); - setStatus("ready"); - setResults([]); + try { + const nextImages = await createUploadedImageItems(imageFiles, maxCloneProductImages - productImages.length, "product"); + setProductImages((current) => { + if (current.length >= maxCloneProductImages) return current; + return nextImages.length ? [...current, ...nextImages].slice(0, maxCloneProductImages) : current; + }); + setStatus("ready"); + setResults([]); + } catch (err) { + toast.error(err instanceof Error ? err.message : "商品图上传失败"); + } }; const handleProductUpload = (event: ChangeEvent) => { const files = event.target.files; if (!files?.length) return; - addProductImages(Array.from(files)); + void addProductImages(Array.from(files)); event.target.value = ""; }; const handleProductDrop = (event: DragEvent) => { event.preventDefault(); setIsProductUploadDragging(false); - if (isProductImageLimitReached) return; const files = Array.from(event.dataTransfer.files); - if (files.length) addProductImages(files); + if (files.length) void addProductImages(files); }; const removeProductImage = (imageId: string) => { @@ -1011,24 +1062,28 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { }); }; - const addCloneReferenceImages = (files: File[]) => { - const imageFiles = files.filter((file) => file.type.startsWith("image/")); + const addCloneReferenceImages = async (files: File[]) => { + const imageFiles = notifyRejectedImages(files); if (!imageFiles.length) return; const remainingSlots = maxCloneReferenceImages - cloneReferenceImages.length; if (remainingSlots <= 0) return; - const nextImages = createObjectImageItems(imageFiles, remainingSlots, "reference"); - if (!nextImages.length) return; - setCloneReferenceImages((current) => { - if (current.length >= maxCloneReferenceImages) return current; - return nextImages.length ? [...current, ...nextImages].slice(0, maxCloneReferenceImages) : current; - }); - hydrateCloneReferenceImageMeta(nextImages); + try { + const nextImages = await createUploadedImageItems(imageFiles, remainingSlots, "reference"); + if (!nextImages.length) return; + setCloneReferenceImages((current) => { + if (current.length >= maxCloneReferenceImages) return current; + return nextImages.length ? [...current, ...nextImages].slice(0, maxCloneReferenceImages) : current; + }); + hydrateCloneReferenceImageMeta(nextImages); + } catch (err) { + toast.error(err instanceof Error ? err.message : "参考图上传失败"); + } }; const handleCloneReferenceUpload = (event: ChangeEvent) => { const files = event.target.files; if (!files?.length) return; - addCloneReferenceImages(Array.from(files)); + void addCloneReferenceImages(Array.from(files)); event.target.value = ""; }; @@ -1338,61 +1393,65 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const handleGarmentUpload = (event: ChangeEvent) => { const files = event.target.files; if (!files?.length) return; - const uploadedFiles = Array.from(files); - setGarmentImages((current) => [...current, ...createObjectImageItems(uploadedFiles, 5 - current.length, "garment")].slice(0, 5)); - setTryOnStatus("ready"); + const uploadedFiles = notifyRejectedImages(Array.from(files)); + if (!uploadedFiles.length) { + event.target.value = ""; + return; + } + void (async () => { + try { + const nextImages = await createUploadedImageItems(uploadedFiles, 5 - garmentImages.length, "garment"); + setGarmentImages((current) => [...current, ...nextImages].slice(0, 5)); + setTryOnStatus("ready"); + } catch (err) { + toast.error(err instanceof Error ? err.message : "服饰图上传失败"); + } + })(); event.target.value = ""; }; const handleDetailUpload = (event: ChangeEvent) => { const files = event.target.files; if (!files?.length) return; - const uploadedFiles = Array.from(files); - setDetailProductImages((current) => [...current, ...createObjectImageItems(uploadedFiles, 3 - current.length, "detail")].slice(0, 3)); - setDetailStatus("ready"); + const uploadedFiles = notifyRejectedImages(Array.from(files)); + if (!uploadedFiles.length) { + event.target.value = ""; + return; + } + void (async () => { + try { + const nextImages = await createUploadedImageItems(uploadedFiles, 3 - detailProductImages.length, "detail"); + setDetailProductImages((current) => [...current, ...nextImages].slice(0, 3)); + setDetailStatus("ready"); + } catch (err) { + toast.error(err instanceof Error ? err.message : "详情图上传失败"); + } + })(); event.target.value = ""; }; - const buildAdVideoConfig = (): AdVideoUserConfig => ({ - platform, - aspectRatio: ratio.includes("9:16") || ratio.includes("9:16") ? "9:16" : ratio.includes("16:9") || ratio.includes("16:9") ? "16:9" : "1:1", - durationSeconds: cloneVideoDuration, - style: "痛点解决", - language, - market, - needVoiceover: true, - needSubtitle: true, - conversionFocus: "conversion", - }); - - const uploadProductImages = async (): Promise => { - const SUPPORTED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]); - const urls: string[] = []; - for (const item of productImages) { - try { - const resp = await fetch(item.src); - const rawBlob = await resp.blob(); - const mimeType = SUPPORTED_IMAGE_TYPES.has(rawBlob.type) ? rawBlob.type : "image/png"; - const blob = rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType }); - const { url } = await aiGenerationClient.uploadAssetBinary(blob, { name: item.name, mimeType, scope: "ecommerce-product" }); - urls.push(url); - } catch { - // skip images that fail to upload - } - } - return urls; - }; + const blobToDataUrl = (blob: Blob): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result || "")); + reader.onerror = () => reject(reader.error || new Error("文件读取失败")); + reader.readAsDataURL(blob); + }); const uploadCloneImages = async (images: CloneImageItem[]): Promise => { - const SUPPORTED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]); const urls: string[] = []; for (const item of images) { try { - const resp = await fetch(item.src); - const rawBlob = await resp.blob(); - const mimeType = SUPPORTED_IMAGE_TYPES.has(rawBlob.type) ? rawBlob.type : "image/png"; - const blob = rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType }); - const { url } = await aiGenerationClient.uploadAssetBinary(blob, { name: item.name, mimeType, scope: "ecommerce-product" }); + if (!item.file && item.src.startsWith("blob:")) { + throw new Error("本地预览图缺少原始文件,无法上传"); + } + const rawBlob = item.file ?? (item.src.startsWith("data:") ? null : await (await fetch(item.src)).blob()); + const mimeType = normalizeEcommerceImageMime( + rawBlob?.type || item.src.match(/^data:([^;,]+)/)?.[1] || "image/png", + ); + const blob = rawBlob ? (rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType })) : null; + const dataUrl = item.src.startsWith("data:") ? item.src : await blobToDataUrl(blob!); + const { url } = await aiGenerationClient.uploadAsset({ dataUrl, name: item.name, mimeType, scope: "ecommerce-product" }); urls.push(url); } catch { // skip images that fail to upload @@ -1409,11 +1468,32 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { scene: { label: "场景图", promptDesc: "lifestyle scene image showing the product in a realistic usage environment with natural surroundings" }, }; + const buildDetailModulePrompt = (moduleIds: string[]): string => { + if (!moduleIds.length) { + return "Generate a complete A+ detail layout with hero, selling points, usage scene, product detail, and specification modules."; + } + + const selectedModules = cloneDetailModules.filter((module) => moduleIds.includes(module.id)); + if (!selectedModules.length) return ""; + + const moduleList = selectedModules.map((module) => `${module.title}: ${module.desc}`).join("; "); + return `Only generate these selected A+ detail modules, no extra modules: ${moduleList}. Keep the output focused even if only one or two modules are selected.`; + }; + const buildSetSubPrompt = (countKey: CloneSetCountKey, index: number, totalCount: number, pPlatform: string, pRatio: string, pLanguage: string, pMarket: string): string => { const info = setCountLabels[countKey]; const parts: string[] = []; parts.push(`Generate an e-commerce ${info.label.toLowerCase()} for a product listing.`); parts.push(info.promptDesc); + if (countKey === "white") { + parts.push("The output must be a clean white-background product image. Do not use lifestyle backgrounds, props, text overlays, or people."); + } + if (countKey === "scene") { + parts.push("The output must be a realistic usage scene image. Keep the product clearly visible and preserve its shape, color, and key details."); + } + if (countKey === "selling") { + parts.push("The output must be a selling-point graphic with clear hierarchy, concise copy, and product detail callouts."); + } if (totalCount > 1) { parts.push(`This is variant ${index + 1} of ${totalCount} — vary the angle, composition, or emphasis to make each distinct.`); } @@ -1422,17 +1502,32 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { return parts.join(" "); }; - const buildEcommerceImagePrompt = (outputKey: CloneOutputKey, userText: string, pPlatform: string, pRatio: string, pLanguage: string, pMarket: string): string => { + const buildEcommerceImagePrompt = ( + outputKey: CloneOutputKey, userText: string, + pPlatform: string, pRatio: string, pLanguage: string, pMarket: string, + tryOnOptions?: EcommerceImagePromptOptions, + ): string => { const parts: string[] = []; if (outputKey === "detail") { parts.push("Generate a professional A+ detail page hero image for an e-commerce product listing."); parts.push("Create a high-impact first-screen visual that combines the product photo with key selling points, usage scenes, and detailed specifications in a cohesive layout."); parts.push(`Platform: ${pPlatform}. Aspect ratio: ${pRatio}. Language/copy: ${pLanguage}. Market: ${pMarket}.`); + if (outputKey === "detail" && tryOnOptions?.detailModules) parts.push(buildDetailModulePrompt(tryOnOptions.detailModules)); parts.push("Follow platform A+ page best practices — clear hierarchy, professional typography, high visual impact."); } else if (outputKey === "model") { parts.push("Generate model/try-on lifestyle images for an e-commerce product listing."); parts.push("Show the product being used or worn by a model in attractive lifestyle settings."); parts.push(`Platform: ${pPlatform}. Aspect ratio: ${pRatio}. Language/copy: ${pLanguage}. Market: ${pMarket}.`); + if (tryOnOptions) { + if (tryOnOptions.gender) parts.push(`Model gender: ${tryOnOptions.gender}.`); + if (tryOnOptions.age) parts.push(`Model age: ${tryOnOptions.age}.`); + if (tryOnOptions.ethnicity) parts.push(`Model ethnicity: ${tryOnOptions.ethnicity}.`); + if (tryOnOptions.body) parts.push(`Model body type: ${tryOnOptions.body}.`); + if (tryOnOptions.appearance) parts.push(`Model appearance details: ${tryOnOptions.appearance}.`); + if (tryOnOptions.scenes?.length) parts.push(`Background scenes: ${tryOnOptions.scenes.join(", ")}.`); + if (tryOnOptions.customScene) parts.push(`Custom background scene: ${tryOnOptions.customScene}.`); + if (tryOnOptions.smartScene) parts.push("Use smart scene matching to select the best background context."); + } parts.push("Model should appear natural and appealing. Background should complement the product. Image must meet platform standards."); } else if (outputKey === "hot") { parts.push("Generate a high-conversion e-commerce product image that closely replicates the style and composition of the reference image while adapting it to the target platform."); @@ -1454,7 +1549,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { pRatio: string, pLanguage: string, pMarket: string, - setStatusFn: (status: "generating" | "done" | "idle") => void, + setStatusFn: (status: "generating" | "done" | "idle" | "failed") => void, setResultFn: (urls: string[]) => void, ): Promise => { setStatusFn("generating"); @@ -1464,6 +1559,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { setStatusFn("idle"); return; } + if (imageAbortRef.current.current) { + setStatusFn("idle"); + return; + } const generatedUrls: string[] = []; const stamp = Date.now(); @@ -1483,27 +1582,52 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { gridMode: "single", referenceUrls, }); + trackEcommerceTask(taskId); - const resultUrl = await waitForTask(taskId, { - abortRef: imageAbortRef.current, - onProgress: () => {}, - }); + const storeId = imageGen.submitTask({ title: `${setCountLabels[countKey].label} ${i + 1}`, type: "image", status: "running", progress: 5, prompt: fullPrompt, sourceView: "ecommerce", taskId }); + + let resultUrl: string | null = null; + try { + resultUrl = await waitForTask(taskId, { + abortRef: imageAbortRef.current, + onProgress: () => {}, + }); + } finally { + untrackEcommerceTask(taskId); + } + + if (imageAbortRef.current.current) break; if (resultUrl) { - generatedUrls.push(resultUrl); + const persistedUrl = await persistGeneratedImageUrl(resultUrl, "ecommerce-generated", `ecommerce-${countKey}-${i + 1}`); + generatedUrls.push(persistedUrl); + imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl: persistedUrl }); } else { generatedUrls.push(""); + imageGen.updateTask(storeId, { status: "failed", error: "生成未返回结果" }); } } } + if (imageAbortRef.current.current) { + setStatusFn("idle"); + return; + } setResultFn(generatedUrls); setStatusFn(generatedUrls.some(Boolean) ? "done" : "idle"); } catch (err) { + if (imageAbortRef.current.current) { + setStatusFn("idle"); + return; + } if (err instanceof ServerRequestError && err.status === 402) { setResultFn([]); + toast.error("余额不足,请充值后继续"); + } else { + const msg = err instanceof Error ? err.message : "生成失败"; + toast.error(msg); } - setStatusFn("idle"); + setStatusFn("failed"); } }; @@ -1515,18 +1639,23 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { pRatio: string, pLanguage: string, pMarket: string, - setStatusFn: (status: "generating" | "done" | "idle") => void, - setResultFn: (results: CloneResult[]) => void, + tryOnOptions?: EcommerceImagePromptOptions, + statusFn?: (status: "generating" | "done" | "idle" | "failed") => void, + resultFn?: (results: CloneResult[]) => void, ): Promise => { - setStatusFn("generating"); + statusFn?.("generating"); try { const referenceUrls = await uploadCloneImages(images); if (!referenceUrls.length) { - setStatusFn("idle"); + statusFn?.("idle"); + return; + } + if (imageAbortRef.current.current) { + statusFn?.("idle"); return; } - const prompt = buildEcommerceImagePrompt(outputKey, userText, pPlatform, pRatio, pLanguage, pMarket); + const prompt = buildEcommerceImagePrompt(outputKey, userText, pPlatform, pRatio, pLanguage, pMarket, tryOnOptions); const stamp = Date.now(); const { taskId } = await aiGenerationClient.createImageTask({ @@ -1537,229 +1666,126 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { gridMode: "single", referenceUrls, }); + trackEcommerceTask(taskId); - const resultUrl = await waitForTask(taskId, { - abortRef: imageAbortRef.current, - onProgress: () => {}, - }); + const storeId = imageGen.submitTask({ title: `电商${outputKey}图`, type: "image", status: "running", progress: 5, prompt, sourceView: "ecommerce", taskId }); + + let resultUrl: string | null = null; + try { + resultUrl = await waitForTask(taskId, { + abortRef: imageAbortRef.current, + onProgress: () => {}, + }); + } finally { + untrackEcommerceTask(taskId); + } + + if (imageAbortRef.current.current) { + statusFn?.("idle"); + return; + } if (resultUrl) { - setResultFn([{ id: `ecommerce-${stamp}`, src: resultUrl, label: selectedCloneOutput.label }]); - setStatusFn("done"); + const persistedUrl = await persistGeneratedImageUrl(resultUrl, "ecommerce-generated", `ecommerce-${outputKey}`); + resultFn?.([{ id: `ecommerce-${stamp}`, src: persistedUrl, label: selectedCloneOutput.label }]); + statusFn?.("done"); + imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl: persistedUrl }); } else { - setStatusFn("idle"); + statusFn?.("idle"); + imageGen.updateTask(storeId, { status: "failed", error: "生成未返回结果" }); } } catch (err) { + if (imageAbortRef.current.current) { + statusFn?.("idle"); + return; + } if (err instanceof ServerRequestError && err.status === 402) { - setResultFn([{ id: `ecommerce-error-402`, src: "", label: "余额不足,请充值后继续" }]); + resultFn?.([{ id: `ecommerce-error-402`, src: "", label: "余额不足,请充值后继续" }]); + toast.error("余额不足,请充值后继续"); + } else { + const msg = err instanceof Error ? err.message : "生成失败"; + toast.error(msg); } - setStatusFn("idle"); + statusFn?.("failed"); } }; - const adVideoUploadedUrlsRef = useRef([]); - - const handleAdVideoPlan = async () => { - if (productImages.length === 0 && !requirement.trim()) { - setAdVideoError("请先上传产品图片或填写商品说明"); - return; - } - setAdVideoBusy(true); - setAdVideoError(null); - setAdVideoStep("planning"); + const handleVideoOutfitGenerate = async () => { + if (!videoOutfitVideoFile || !videoOutfitRefFile) return; + setStatus("generating"); try { - setAdVideoProgress("正在上传产品图片…"); - const imageUrls = await uploadProductImages(); - adVideoUploadedUrlsRef.current = imageUrls; + const readAsDataUrl = (file: File): Promise => new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(new Error("文件读取失败")); + reader.readAsDataURL(file); + }); - setAdVideoProgress("正在分析产品图片…"); - const imageDesc = await analyzeProductImages(imageUrls); + const videoDataUrl = await readAsDataUrl(videoOutfitVideoFile); + const refDataUrl = await readAsDataUrl(videoOutfitRefFile); - setAdVideoProgress("正在生成商品理解…"); - const summary = await buildProductSummary(imageDesc, requirement); - setAdVideoSummary(summary); + const videoAsset = await aiGenerationClient.uploadAsset({ + dataUrl: videoDataUrl, name: videoOutfitVideoFile.name, + mimeType: videoOutfitVideoFile.type || "video/mp4", scope: "video-outfit", + }); + const refAsset = await aiGenerationClient.uploadAsset({ + dataUrl: refDataUrl, name: videoOutfitRefFile.name, + mimeType: videoOutfitRefFile.type || "image/png", scope: "video-outfit", + }); + if (imageAbortRef.current.current) { + setStatus("idle"); + return; + } - setAdVideoProgress("正在提炼卖点…"); - const selling = await extractSellingPoints(summary); - setAdVideoSelling(selling); + const { taskId } = await aiGenerationClient.createVideoEditTask({ + videoUrl: videoAsset.url, + referenceUrls: [refAsset.url], + prompt: requirement || undefined, + }); + trackEcommerceTask(taskId); - const config = buildAdVideoConfig(); - setAdVideoProgress("正在生成广告创意…"); - const creatives = await generateCreativeOptions(selling, config); - setAdVideoCreatives(creatives); - const chosen = creatives[0]; - if (!chosen) throw new Error("未能生成有效的广告创意"); - - setAdVideoProgress("正在生成视频分镜…"); - const storyboard = await generateStoryboard(chosen, summary, config); - setAdVideoStoryboard(storyboard); - - setAdVideoProgress("正在生成镜头提示词…"); - const prompts = await generateVideoPrompts(storyboard, summary); - setAdVideoPrompts(prompts); - - setAdVideoProgress("正在进行合规检查…"); - const compliance = await checkCompliance(summary, selling, storyboard); - setAdVideoCompliance(compliance); - - setAdVideoStep("planned"); + const { waitForTask } = await import("../../api/taskSubscription"); + let resultUrl: string | null = null; + try { + resultUrl = await waitForTask(taskId, { abortRef: imageAbortRef.current }); + } finally { + untrackEcommerceTask(taskId); + } + if (imageAbortRef.current.current) { + setStatus("idle"); + return; + } + if (resultUrl) { + setResults([{ id: crypto.randomUUID(), src: resultUrl, label: "换装视频" }]); + } + setStatus("done"); } catch (err) { - setAdVideoError(err instanceof Error ? err.message : "广告策划生成失败"); - setAdVideoStep("idle"); - } finally { - setAdVideoBusy(false); - setAdVideoProgress(""); - } - }; - - const pollAdVideoTask = async (sceneId: number, taskId: string) => { - for (let i = 0; i < 150; i++) { - await new Promise((r) => setTimeout(r, 4000)); - let st; - try { - st = await aiGenerationClient.getTaskStatus(taskId); - } catch { - continue; + if (imageAbortRef.current.current) { + setStatus("idle"); + return; } - setAdVideoScenes((prev) => - prev.map((s) => - s.sceneId === sceneId - ? { ...s, status: st.status === "cancelled" ? "failed" : st.status, progress: st.progress, resultUrl: st.resultUrl } - : s, - ), - ); - if (st.status === "completed" || st.status === "failed" || st.status === "cancelled") return; + setStatus("failed"); + toast.error(err instanceof Error ? err.message : "视频换装生成失败"); } }; - const handleAdVideoRender = async () => { - if (!adVideoStoryboard) return; - setAdVideoStep("rendering"); - setAdVideoError(null); - const referenceUrl = adVideoUploadedUrlsRef.current[0]; - setAdVideoScenes( - adVideoStoryboard.scenes.map((s) => ({ sceneId: s.scene_id, status: "idle", progress: 0 })), - ); - for (const scene of adVideoStoryboard.scenes) { - const prompt = adVideoPrompts.find((p) => p.scene_id === scene.scene_id); - const positivePrompt = prompt?.positive_prompt || scene.visual_description; - const sceneDuration = Number.parseInt(scene.duration, 10) || 5; - try { - const { taskId } = await aiGenerationClient.createVideoTask({ - model: "happyhorse-1.0-i2v", - prompt: positivePrompt, - ratio: buildAdVideoConfig().aspectRatio, - duration: sceneDuration, - imageUrl: referenceUrl, - referenceUrls: referenceUrl ? [referenceUrl] : undefined, - }); - setAdVideoScenes((prev) => - prev.map((s) => (s.sceneId === scene.scene_id ? { ...s, taskId, status: "pending" } : s)), - ); - void pollAdVideoTask(scene.scene_id, taskId); - } catch (err) { - setAdVideoScenes((prev) => - prev.map((s) => - s.sceneId === scene.scene_id - ? { ...s, status: "failed", error: err instanceof Error ? err.message : "提交失败" } - : s, - ), - ); - } - } - }; - - const renderAdVideoCompliance = () => { - if (!adVideoCompliance) return null; - const level = adVideoCompliance.risk_level; - const canRender = adVideoCompliance.allow_video_generation; - const rendering = adVideoStep === "rendering"; - return ( -
- - 合规风险:{level === "low" ? "低" : level === "medium" ? "中" : "高"} - - {adVideoCompliance.issues.length > 0 ? ( -
    - {adVideoCompliance.issues.map((issue, i) => ( -
  • {issue.field}:{issue.problem} → {issue.suggestion}
  • - ))} -
- ) : null} - -
- ); - }; - - const renderAdVideoPlan = () => { - if (adVideoStep !== "planned" && adVideoStep !== "rendering") return null; - return ( -
- {adVideoSummary ? ( -
- {adVideoSummary.product_name} · {adVideoSummary.category} -

{adVideoSummary.appearance}

-
- {adVideoSummary.selling_points.map((sp, i) => ( - {sp} - ))} -
-
- ) : null} - {adVideoCreatives[0] ? ( -
- 广告创意:{adVideoCreatives[0].creative_type} -

{adVideoCreatives[0].hook}

-
- ) : null} - {adVideoStoryboard ? ( -
- 分镜:{adVideoStoryboard.video_title} -
- {adVideoStoryboard.scenes.map((scene) => { - const sceneVideo = adVideoScenes.find((s) => s.sceneId === scene.scene_id); - return ( -
-
- 镜头 {scene.scene_id} · {scene.duration} - {sceneVideo ? ( - - {sceneVideo.status === "completed" - ? "完成" - : sceneVideo.status === "failed" - ? "失败" - : sceneVideo.status === "idle" - ? "等待" - : `${sceneVideo.progress}%`} - - ) : null} -
-

{scene.visual_description}

- {sceneVideo?.resultUrl ? ( -
- ); - })} -
-
- ) : null} - {renderAdVideoCompliance()} -
- ); - }; - const handleGenerate = () => { if (!canGenerate) return; + + if ((appUsage?.balanceCents ?? 0) <= 0) { + toast.error("积分不足,请充值后继续"); + return; + } + + if (cloneOutput === "set" && cloneSetTotal > 5) { + if (!window.confirm(`将生成 ${cloneSetTotal} 张图片,可能消耗较多积分,是否继续?`)) return; + } + imageAbortRef.current = { current: false }; - if (cloneOutput === "set") { + lastFailedActionRef.current = null; + if (cloneOutput === "video-outfit") { + void handleVideoOutfitGenerate(); + } else if (cloneOutput === "set") { void generateSetImages( productImages, cloneSetCounts, requirement, platform, ratio, language, market, @@ -1767,37 +1793,59 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { (urls) => setProductSetResultImages(urls), ); } else { + const clonePromptOptions: EcommerceImagePromptOptions | undefined = + cloneOutput === "model" + ? { + gender: cloneModelGender, + age: cloneModelAge, + ethnicity: cloneModelEthnicity, + body: cloneModelBody, + appearance: cloneModelAppearance, + scenes: selectedCloneModelScenes, + customScene: cloneModelCustomScene, + } + : cloneOutput === "detail" + ? { detailModules: selectedCloneDetailModules } + : undefined; void generateEcommerceImage( cloneOutput, productImages, requirement, platform, ratio, language, market, - (s) => setStatus(s as ProductCloneStatus), setResults, + clonePromptOptions, + (s: string) => setStatus(s as ProductCloneStatus), setResults, ); + lastFailedActionRef.current = () => handleGenerate(); } }; const handleGenerateModel = () => { imageAbortRef.current = { current: false }; + lastFailedActionRef.current = null; setTryOnStatus("modeling"); void generateEcommerceImage( "model", garmentImages, requirement, platform, ratio, language, market, + { gender: modelGender, age: modelAge, ethnicity: modelEthnicity, body: modelBody, appearance, scenes: selectedScenes, smartScene }, (s) => { if (s === "done") setTryOnStatus("ready"); else setTryOnStatus(s as TryOnStatus); }, () => { setTryOnStatus("ready"); }, ); + lastFailedActionRef.current = () => handleGenerateModel(); }; const handleTryOnGenerate = () => { if (!canGenerateTryOn) return; imageAbortRef.current = { current: false }; + lastFailedActionRef.current = null; void generateEcommerceImage( "model", garmentImages, requirement, platform, ratio, language, market, + { gender: modelGender, age: modelAge, ethnicity: modelEthnicity, body: modelBody, appearance, scenes: selectedScenes, smartScene }, (s) => setTryOnStatus(s as TryOnStatus), (res) => setTryOnResultImages(res.map((r) => r.src).filter(Boolean)), ); + lastFailedActionRef.current = () => handleTryOnGenerate(); }; const toggleScene = (scene: string) => { @@ -1815,12 +1863,14 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const handleSetGenerate = () => { if (!canGenerateSet) return; imageAbortRef.current = { current: false }; + lastFailedActionRef.current = null; void generateSetImages( setImages, cloneSetCounts, productSetRequirement, productSetPlatform, productSetRatio, productSetLanguage, productSetMarket, (s) => setProductSetStatus(s as ProductSetStatus), (urls) => setProductSetResultImages(urls), ); + lastFailedActionRef.current = () => handleSetGenerate(); }; const openProductSetPreview = (card: { src: string; label: string }) => { @@ -1836,10 +1886,12 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const handleDetailGenerate = () => { if (!canGenerateDetail) return; imageAbortRef.current = { current: false }; + lastFailedActionRef.current = null; void generateEcommerceImage( "detail", detailProductImages, detailRequirement, detailPlatform, getPlatformDefaultRatio(detailPlatform), detailLanguage, detailMarket, - (s) => setDetailStatus(s as DetailStatus), + { detailModules: selectedDetailModules }, + (s: string) => setDetailStatus(s as DetailStatus), (res) => setDetailResultUrl(res[0]?.src ?? null), ); }; @@ -1854,7 +1906,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { setSelectedProductSetPreview(null); setShowHostingModal(false); setProductImages([]); - setSelectedProductImageId(null); setIsProductUploadDragging(false); setCloneOutput("detail"); setRatio((current) => normalizeRatioForPlatform(platform, current, "detail")); @@ -1918,7 +1969,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { for (let i = 0; i < count; i++) { setPreviewCards.push({ id: `${countKey}-${i}`, - src: productSetResultImages[setIndex] ?? productSetPreviewCards[setIndex % productSetPreviewCards.length]?.src ?? "", + src: productSetResultImages[setIndex] || productSetPreviewCards[setIndex % productSetPreviewCards.length]?.src || "", label: `${info.label}${count > 1 ? ` ${i + 1}` : ""}`, }); setIndex++; @@ -1933,7 +1984,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { for (let i = 0; i < count; i++) { clonePreviewCards.push({ id: `${countKey}-${i}`, - src: results[cloneIndex]?.src ?? productSetPreviewCards[cloneIndex % productSetPreviewCards.length]?.src ?? "", + src: results[cloneIndex]?.src || productSetPreviewCards[cloneIndex % productSetPreviewCards.length]?.src || "", label: `${info.label}${count > 1 ? ` ${i + 1}` : ""}`, }); cloneIndex++; @@ -1972,897 +2023,170 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { ]; const setPanel = ( - <> -
-
-

- 上传商品原图 - -

- - - {setImages.length ? ( -
- {setImages.map((item) => ( -
- {item.name} - - -
- ))} -
- ) : null} -
- -
-

- 生成设置 - -

-
- 生成内容 -
- {productSetOutputOptions.map((option) => ( - - ))} -
-
-
- 基础设置 -
- - - - -
-
-
-
- + ); const clonePanel = ( - <> -
-
- AI - 电商生成 -
- -
-

- - 上传商品原图 -

-
{ - if (isProductImageLimitReached) return; - productInputRef.current?.click(); - }} - onKeyDown={(event) => { - if (event.target !== event.currentTarget) return; - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - if (isProductImageLimitReached) return; - productInputRef.current?.click(); - } - }} - onDragEnter={(event) => { - event.preventDefault(); - if (isProductImageLimitReached) return; - setIsProductUploadDragging(true); - }} - onDragOver={(event) => event.preventDefault()} - onDragLeave={() => setIsProductUploadDragging(false)} - onDrop={handleProductDrop} - > -
- - - - 拖拽或点击上传 - - {isProductImageLimitReached ? ( - "已达上限" - ) : ( - <> - - 上传图片 - - )} - - 同一产品,最多 7 张 -
- {productImages.length ? ( -
event.stopPropagation()} aria-live="polite"> -
- {`当前预览:${selectedProductImageLabel}`} -
-
- - {selectedProductImageLabel} - {selectedProductImageSpec} - -
-
- ) : null} - {productImages.length ? ( -
-
- 已上传素材 - {productImages.length}/{maxCloneProductImages} -
-
- {productImages.map((item, index) => ( -
- - -
- ))} -
-
- ) : null} -
- -
- -
-

- - 生成设置 -

-
- 生成内容 -
- {cloneOutputOptions.map((option) => ( - - ))} -
-
-
- 基础设置 -
- {cloneBasicSelects.map((item) => { - const hasMultipleOptions = item.options.length > 1; - const isOpen = hasMultipleOptions && openCloneBasicSelect === item.key; - return ( -
-