diff --git a/src/features/ecommerce/EcommercePage.tsx b/src/features/ecommerce/EcommercePage.tsx index cf61780..093f1bc 100644 --- a/src/features/ecommerce/EcommercePage.tsx +++ b/src/features/ecommerce/EcommercePage.tsx @@ -3,75 +3,76 @@ 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 { - normalizeEcommerceImageMime, - summarizeRejectedImages, - validateEcommerceImageFiles, -} from "./ecommerceImageValidation"; + analyzeProductImages, + buildProductSummary, + extractSellingPoints, + generateCreativeOptions, + generateStoryboard, + generateVideoPrompts, + checkCompliance, + type AdVideoUserConfig, + type ProductSummary, + type SellingPointResult, + type CreativeOption, + type Storyboard, + type VideoPrompt, + type ComplianceCheck, +} from "../../api/adVideoPlanClient"; interface ProductClonePageProps { [key: string]: unknown; } -type ProductCloneStatus = "idle" | "ready" | "generating" | "done" | "failed"; +type ProductCloneStatus = "idle" | "ready" | "generating" | "done"; type ProductSetOutputKey = "set" | "detail" | "model" | "video"; -type CloneOutputKey = ProductSetOutputKey | "hot" | "video-outfit"; +type CloneOutputKey = ProductSetOutputKey | "hot"; type CloneSetCountKey = "selling" | "white" | "scene"; type CloneModelPanelTab = "scene" | "model"; type CloneVideoQualityKey = "standard" | "high" | "ultra"; -type ProductSetStatus = "idle" | "ready" | "generating" | "done" | "failed"; +type ProductSetStatus = "idle" | "ready" | "generating" | "done"; 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" | "failed"; -type DetailStatus = "idle" | "ready" | "generating" | "done" | "failed"; +type TryOnStatus = "idle" | "modeling" | "ready" | "generating" | "done"; +type DetailStatus = "idle" | "ready" | "generating" | "done"; 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 { @@ -101,19 +102,7 @@ interface CloneSavedSetting { requirement: string; } -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"; +type PlatformRatioModeKey = ProductSetOutputKey | "hot"; interface PlatformRatioGroup { ratios: string[]; @@ -534,6 +523,12 @@ 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; @@ -564,7 +559,6 @@ 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; @@ -585,7 +579,7 @@ const maxCloneSetTotal = 16; const maxCloneProductImages = 7; const maxCloneReferenceImages = 20; const cloneVideoDurationMin = 5; -const cloneVideoDurationMax = 45; +const cloneVideoDurationMax = 15; const cloneLatestSettingStorageKey = "omniai.clone-ai.latest-setting"; const cloneVideoQualityOptions: Array<{ key: CloneVideoQualityKey; label: string; desc: string }> = [ { key: "standard", label: "标准", desc: "快速出片" }, @@ -609,12 +603,15 @@ const tryOnModelOptions = { ethnicity: ["欧美白人", "亚洲人", "拉美裔", "非洲裔"], body: ["标准", "高挑", "微胖", "运动"], }; -const sampleResults = [ - ossAssets.ecommerce.slides.slide4, - ossAssets.ecommerce.generated, - ossAssets.ecommerce.slides.slide5, -]; -const productSetAssets = ossAssets.ecommerce.productSet; +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 productSetPreviewCards = [ { id: "main", label: "01 主图 (白底/合规)", src: productSetAssets.main }, { id: "scene", label: "02 场景展示", src: productSetAssets.scene }, @@ -622,7 +619,21 @@ const productSetPreviewCards = [ { id: "detail", label: "04 细节说明", src: productSetAssets.detail }, { id: "selling", label: "05 卖点详解", src: productSetAssets.selling }, ]; -const tryOnAssets = ossAssets.ecommerce.tryOn; +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 tryOnCards = [ { @@ -667,7 +678,18 @@ const detailModules = [ const defaultDetailModuleIds: string[] = []; const defaultCloneDetailModuleIds = ["hero", "selling", "usage", "angle", "scene", "detail"]; const cloneDetailModules = detailModules; -const detailAssets = ossAssets.ecommerce.detail; +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 detailProductSamples = [detailAssets.productA, detailAssets.productB, detailAssets.productC]; const detailGridSamples = [detailAssets.gridA, detailAssets.gridB, detailAssets.gridC, detailAssets.gridD, detailAssets.gridE, detailAssets.gridF]; @@ -686,92 +708,16 @@ function readImageDimensions(src: string): Promise<{ width: number; height: numb }); } -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, { +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), 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 { @@ -821,8 +767,6 @@ 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); @@ -840,10 +784,9 @@ 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); @@ -863,8 +806,19 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const [cloneVideoQuality, setCloneVideoQuality] = useState("high"); const [cloneVideoDuration, setCloneVideoDuration] = useState(10); const [cloneVideoSmart, setCloneVideoSmart] = useState(true); - const [videoOutfitVideoFile, setVideoOutfitVideoFile] = useState(null); - const [videoOutfitRefFile, setVideoOutfitRefFile] = useState(null); + 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 [isCloneSettingsCollapsed, setIsCloneSettingsCollapsed] = useState(false); const [requirement, setRequirement] = useState(""); const [requirementImageMentionQuery, setRequirementImageMentionQuery] = useState(null); @@ -876,8 +830,6 @@ 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]); @@ -917,44 +869,25 @@ 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 = (cloneOutput === "video-outfit" - ? Boolean(videoOutfitVideoFile && videoOutfitRefFile) - : productImages.length > 0) && status !== "generating"; + const canGenerate = 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: CSSProperties = { + const cloneVideoDurationStyle = { "--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); }; @@ -971,26 +904,45 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { }); }; - const addSetImages = async (files: File[]) => { - if (setImages.length >= 3) return; - const imageFiles = notifyRejectedImages(files); - if (!imageFiles.length) return; - 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 : "商品图上传失败"); + 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[]) => { + if (setImages.length >= 3) return; + const imageFiles = files.filter((file) => file.type.startsWith("image/")); + 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"); }; const handleSetUpload = (event: ChangeEvent) => { const files = event.target.files; if (!files?.length) return; - void addSetImages(Array.from(files)); + addSetImages(Array.from(files)); event.target.value = ""; }; @@ -998,7 +950,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { event.preventDefault(); setIsSetUploadDragging(false); const files = Array.from(event.dataTransfer.files); - if (files.length) void addSetImages(files); + if (files.length) addSetImages(files); }; const removeSetImage = (imageId: string) => { @@ -1009,34 +961,31 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { }); }; - const addProductImages = async (files: File[]) => { - const imageFiles = notifyRejectedImages(files); + const addProductImages = (files: File[]) => { + const imageFiles = files.filter((file) => file.type.startsWith("image/")); if (!imageFiles.length) return; - 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 : "商品图上传失败"); - } + 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([]); }; const handleProductUpload = (event: ChangeEvent) => { const files = event.target.files; if (!files?.length) return; - void addProductImages(Array.from(files)); + 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) void addProductImages(files); + if (files.length) addProductImages(files); }; const removeProductImage = (imageId: string) => { @@ -1062,28 +1011,24 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { }); }; - const addCloneReferenceImages = async (files: File[]) => { - const imageFiles = notifyRejectedImages(files); + const addCloneReferenceImages = (files: File[]) => { + const imageFiles = files.filter((file) => file.type.startsWith("image/")); if (!imageFiles.length) return; const remainingSlots = maxCloneReferenceImages - cloneReferenceImages.length; if (remainingSlots <= 0) return; - 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 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); }; const handleCloneReferenceUpload = (event: ChangeEvent) => { const files = event.target.files; if (!files?.length) return; - void addCloneReferenceImages(Array.from(files)); + addCloneReferenceImages(Array.from(files)); event.target.value = ""; }; @@ -1393,65 +1338,61 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const handleGarmentUpload = (event: ChangeEvent) => { const files = event.target.files; if (!files?.length) return; - 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 : "服饰图上传失败"); - } - })(); + const uploadedFiles = Array.from(files); + setGarmentImages((current) => [...current, ...createObjectImageItems(uploadedFiles, 5 - current.length, "garment")].slice(0, 5)); + setTryOnStatus("ready"); event.target.value = ""; }; const handleDetailUpload = (event: ChangeEvent) => { const files = event.target.files; if (!files?.length) return; - 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 : "详情图上传失败"); - } - })(); + const uploadedFiles = Array.from(files); + setDetailProductImages((current) => [...current, ...createObjectImageItems(uploadedFiles, 3 - current.length, "detail")].slice(0, 3)); + setDetailStatus("ready"); event.target.value = ""; }; - 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 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 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 { - 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" }); + 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 @@ -1468,32 +1409,11 @@ 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.`); } @@ -1502,32 +1422,17 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { return parts.join(" "); }; - const buildEcommerceImagePrompt = ( - outputKey: CloneOutputKey, userText: string, - pPlatform: string, pRatio: string, pLanguage: string, pMarket: string, - tryOnOptions?: EcommerceImagePromptOptions, - ): string => { + const buildEcommerceImagePrompt = (outputKey: CloneOutputKey, userText: string, pPlatform: string, pRatio: string, pLanguage: string, pMarket: string): 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."); @@ -1549,7 +1454,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { pRatio: string, pLanguage: string, pMarket: string, - setStatusFn: (status: "generating" | "done" | "idle" | "failed") => void, + setStatusFn: (status: "generating" | "done" | "idle") => void, setResultFn: (urls: string[]) => void, ): Promise => { setStatusFn("generating"); @@ -1559,10 +1464,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { setStatusFn("idle"); return; } - if (imageAbortRef.current.current) { - setStatusFn("idle"); - return; - } const generatedUrls: string[] = []; const stamp = Date.now(); @@ -1582,52 +1483,27 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { gridMode: "single", referenceUrls, }); - trackEcommerceTask(taskId); - 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; + const resultUrl = await waitForTask(taskId, { + abortRef: imageAbortRef.current, + onProgress: () => {}, + }); if (resultUrl) { - const persistedUrl = await persistGeneratedImageUrl(resultUrl, "ecommerce-generated", `ecommerce-${countKey}-${i + 1}`); - generatedUrls.push(persistedUrl); - imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl: persistedUrl }); + generatedUrls.push(resultUrl); } 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("failed"); + setStatusFn("idle"); } }; @@ -1639,23 +1515,18 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { pRatio: string, pLanguage: string, pMarket: string, - tryOnOptions?: EcommerceImagePromptOptions, - statusFn?: (status: "generating" | "done" | "idle" | "failed") => void, - resultFn?: (results: CloneResult[]) => void, + setStatusFn: (status: "generating" | "done" | "idle") => void, + setResultFn: (results: CloneResult[]) => void, ): Promise => { - statusFn?.("generating"); + setStatusFn("generating"); try { const referenceUrls = await uploadCloneImages(images); if (!referenceUrls.length) { - statusFn?.("idle"); - return; - } - if (imageAbortRef.current.current) { - statusFn?.("idle"); + setStatusFn("idle"); return; } - const prompt = buildEcommerceImagePrompt(outputKey, userText, pPlatform, pRatio, pLanguage, pMarket, tryOnOptions); + const prompt = buildEcommerceImagePrompt(outputKey, userText, pPlatform, pRatio, pLanguage, pMarket); const stamp = Date.now(); const { taskId } = await aiGenerationClient.createImageTask({ @@ -1666,126 +1537,229 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { gridMode: "single", referenceUrls, }); - trackEcommerceTask(taskId); - 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; - } + const resultUrl = await waitForTask(taskId, { + abortRef: imageAbortRef.current, + onProgress: () => {}, + }); if (resultUrl) { - 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 }); + setResultFn([{ id: `ecommerce-${stamp}`, src: resultUrl, label: selectedCloneOutput.label }]); + setStatusFn("done"); } else { - statusFn?.("idle"); - imageGen.updateTask(storeId, { status: "failed", error: "生成未返回结果" }); + setStatusFn("idle"); } } catch (err) { - if (imageAbortRef.current.current) { - statusFn?.("idle"); - return; - } if (err instanceof ServerRequestError && err.status === 402) { - resultFn?.([{ id: `ecommerce-error-402`, src: "", label: "余额不足,请充值后继续" }]); - toast.error("余额不足,请充值后继续"); - } else { - const msg = err instanceof Error ? err.message : "生成失败"; - toast.error(msg); + setResultFn([{ id: `ecommerce-error-402`, src: "", label: "余额不足,请充值后继续" }]); } - statusFn?.("failed"); + setStatusFn("idle"); } }; - const handleVideoOutfitGenerate = async () => { - if (!videoOutfitVideoFile || !videoOutfitRefFile) return; - setStatus("generating"); - try { - 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); - }); + const adVideoUploadedUrlsRef = useRef([]); - const videoDataUrl = await readAsDataUrl(videoOutfitVideoFile); - const refDataUrl = await readAsDataUrl(videoOutfitRefFile); - - 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; - } - - const { taskId } = await aiGenerationClient.createVideoEditTask({ - videoUrl: videoAsset.url, - referenceUrls: [refAsset.url], - prompt: requirement || undefined, - }); - trackEcommerceTask(taskId); - - 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) { - if (imageAbortRef.current.current) { - setStatus("idle"); - return; - } - setStatus("failed"); - toast.error(err instanceof Error ? err.message : "视频换装生成失败"); + const handleAdVideoPlan = async () => { + if (productImages.length === 0 && !requirement.trim()) { + setAdVideoError("请先上传产品图片或填写商品说明"); + return; } + setAdVideoBusy(true); + setAdVideoError(null); + setAdVideoStep("planning"); + try { + setAdVideoProgress("正在上传产品图片…"); + const imageUrls = await uploadProductImages(); + adVideoUploadedUrlsRef.current = imageUrls; + + setAdVideoProgress("正在分析产品图片…"); + const imageDesc = await analyzeProductImages(imageUrls); + + setAdVideoProgress("正在生成商品理解…"); + const summary = await buildProductSummary(imageDesc, requirement); + setAdVideoSummary(summary); + + setAdVideoProgress("正在提炼卖点…"); + const selling = await extractSellingPoints(summary); + setAdVideoSelling(selling); + + 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"); + } 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; + } + 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; + } + }; + + 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 }; - lastFailedActionRef.current = null; - if (cloneOutput === "video-outfit") { - void handleVideoOutfitGenerate(); - } else if (cloneOutput === "set") { + if (cloneOutput === "set") { void generateSetImages( productImages, cloneSetCounts, requirement, platform, ratio, language, market, @@ -1793,59 +1767,37 @@ 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, - clonePromptOptions, - (s: string) => setStatus(s as ProductCloneStatus), setResults, + (s) => 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) => { @@ -1863,14 +1815,12 @@ 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 }) => { @@ -1886,12 +1836,10 @@ 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, - { detailModules: selectedDetailModules }, - (s: string) => setDetailStatus(s as DetailStatus), + (s) => setDetailStatus(s as DetailStatus), (res) => setDetailResultUrl(res[0]?.src ?? null), ); }; @@ -1906,6 +1854,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { setSelectedProductSetPreview(null); setShowHostingModal(false); setProductImages([]); + setSelectedProductImageId(null); setIsProductUploadDragging(false); setCloneOutput("detail"); setRatio((current) => normalizeRatioForPlatform(platform, current, "detail")); @@ -1969,7 +1918,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++; @@ -1984,7 +1933,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++; @@ -2023,170 +1972,897 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { ]; const setPanel = ( - + <> +
+
+

+ 上传商品原图 + +

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

+ 生成设置 + +

+
+ 生成内容 +
+ {productSetOutputOptions.map((option) => ( + + ))} +
+
+
+ 基础设置 +
+ + + + +
+
+
+
+ ); const clonePanel = ( - { setVideoOutfitVideoFile(video); setVideoOutfitRefFile(ref); }} - onStartVideoPlan={() => setVideoPlanTrigger((n) => n + 1)} - /> + <> +
+
+ 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 ( +
+