import { AppstoreOutlined, CloudUploadOutlined, CloseOutlined, DeleteOutlined, FileImageOutlined, FolderOpenOutlined, FrownOutlined, GlobalOutlined, LoadingOutlined, MenuFoldOutlined, MenuUnfoldOutlined, QuestionCircleOutlined, ReloadOutlined, SettingOutlined, SkinOutlined, TableOutlined, } from "@ant-design/icons"; import { useEffect, useMemo, useRef, useState, type CSSProperties, type ChangeEvent, type DragEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; import { useTypewriter } from "../../hooks/useTypewriter"; import "../../styles/pages/ecommerce.css"; import "../../styles/pages/local-theme-parity.css"; import { ossAssets } from "../../data/ossAssets"; import { EcommerceProgressBar } from "./EcommerceProgressBar"; 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 aliexpressLogo from "../../assets/platform-logos/aliexpress.webp"; import amazonLogo from "../../assets/platform-logos/amazon.webp"; import douyinLogo from "../../assets/platform-logos/douyin.webp"; import ebayLogo from "../../assets/platform-logos/ebay.webp"; import instagramLogo from "../../assets/platform-logos/instagram.webp"; import jdLogo from "../../assets/platform-logos/jd.webp"; import lazadaLogo from "../../assets/platform-logos/lazada.webp"; import pinduoduoLogo from "../../assets/platform-logos/pinduoduo.webp"; import shopeeLogo from "../../assets/platform-logos/shopee.webp"; import taobaoLogo from "../../assets/platform-logos/taobao.webp"; import tiktokShopLogo from "../../assets/platform-logos/tiktok-shop.webp"; const smartCutoutColorPresets = [ "#ffffff", "#111111", "#ff3131", "#ff7a1a", "#f7c600", "#29b34a", "#25a9e0", "#438df5", "#9029d9", "#8aa3ad", "#6b7b86", "#f46f7b", "#ff9451", "#f7d34f", "#55c66f", "#73c7f3", "#6dabf5", "#b45adb", "#bcc8ce", "#aeb7bd", "#ffbec4", "#ffd1ac", "#f8e69d", "#91de9e", "#b7e5fb", "#b9d9fb", "#d7abe8", "#dfe5e8", "#d7dde0", "#ffe2e4", "#ffe5d1", "#f8efcf", "#c9efcf", "#d8f0fb", "#d8eafa", "#ead2f1", ]; const smartCutoutSizeOptions = [ { key: "original", label: "原尺寸", icon: "image", frameWidth: "min(520px, 78%)", frameAspect: "auto", imageMaxWidth: "78%", imageMaxHeight: "310px" }, { key: "trim", label: "裁剪到边缘", icon: "crop", frameWidth: "min(420px, 70%)", frameAspect: "auto", imageMaxWidth: "92%", imageMaxHeight: "360px" }, { key: "one-inch", label: "一寸头像", sizeLabel: "295*413", icon: "portrait", frameWidth: "min(290px, 50%)", frameAspect: "295 / 413", imageMaxWidth: "86%", imageMaxHeight: "86%", outputWidth: 295, outputHeight: 413 }, { key: "two-inch", label: "二寸头像", sizeLabel: "413*579", icon: "portrait", frameWidth: "min(320px, 54%)", frameAspect: "413 / 579", imageMaxWidth: "86%", imageMaxHeight: "86%", outputWidth: 413, outputHeight: 579 }, { key: "taobao-1-1", label: "淘宝1:1主图", sizeLabel: "800*800", icon: "shop", frameWidth: "min(430px, 72%)", frameAspect: "800 / 800", imageMaxWidth: "82%", imageMaxHeight: "82%", outputWidth: 800, outputHeight: 800 }, { key: "taobao-3-4", label: "淘宝3:4主图", sizeLabel: "750*1000", icon: "shop", frameWidth: "min(330px, 56%)", frameAspect: "750 / 1000", imageMaxWidth: "82%", imageMaxHeight: "82%", outputWidth: 750, outputHeight: 1000 }, { key: "pdd-main", label: "拼多多主图", sizeLabel: "800*800", icon: "pdd", frameWidth: "min(430px, 72%)", frameAspect: "800 / 800", imageMaxWidth: "82%", imageMaxHeight: "82%", outputWidth: 800, outputHeight: 800 }, { key: "xiaohongshu-cover", label: "小红书封面", sizeLabel: "1242*1660", icon: "text", frameWidth: "min(330px, 56%)", frameAspect: "1242 / 1660", imageMaxWidth: "82%", imageMaxHeight: "82%", outputWidth: 1242, outputHeight: 1660 }, { key: "ratio-1-1", label: "1:1", icon: "square", frameWidth: "min(430px, 72%)", frameAspect: "1 / 1", imageMaxWidth: "82%", imageMaxHeight: "82%" }, { key: "ratio-3-2", label: "3:2", icon: "landscape", frameWidth: "min(520px, 78%)", frameAspect: "3 / 2", imageMaxWidth: "82%", imageMaxHeight: "82%" }, { key: "ratio-2-3", label: "2:3", icon: "portrait-ratio", frameWidth: "min(330px, 56%)", frameAspect: "2 / 3", imageMaxWidth: "82%", imageMaxHeight: "82%" }, { key: "ratio-4-3", label: "4:3", icon: "landscape", frameWidth: "min(520px, 78%)", frameAspect: "4 / 3", imageMaxWidth: "82%", imageMaxHeight: "82%" }, { key: "ratio-3-4", label: "3:4", icon: "portrait-ratio", frameWidth: "min(330px, 56%)", frameAspect: "3 / 4", imageMaxWidth: "82%", imageMaxHeight: "82%" }, { key: "ratio-16-9", label: "16:9", icon: "wide", frameWidth: "min(560px, 82%)", frameAspect: "16 / 9", imageMaxWidth: "82%", imageMaxHeight: "82%" }, { key: "ratio-9-16", label: "9:16", icon: "phone", frameWidth: "min(260px, 46%)", frameAspect: "9 / 16", imageMaxWidth: "82%", imageMaxHeight: "82%" }, ] as const; type SmartCutoutSizeKey = (typeof smartCutoutSizeOptions)[number]["key"]; type SmartCutoutImageItem = { src: string; name: string; originalSrc?: string }; const clampNumber = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); const normalizeHexColor = (value: string) => { const clean = value.trim().replace(/^#/, ""); if (!/^[0-9a-fA-F]{6}$/.test(clean)) return null; return `#${clean.toLowerCase()}`; }; const hexToRgb = (value: string) => { const normalized = normalizeHexColor(value); if (!normalized) return null; const numeric = Number.parseInt(normalized.slice(1), 16); return { r: (numeric >> 16) & 255, g: (numeric >> 8) & 255, b: numeric & 255, }; }; const rgbToHex = (r: number, g: number, b: number) => `#${[r, g, b].map((item) => clampNumber(Math.round(item), 0, 255).toString(16).padStart(2, "0")).join("")}`; const parseSmartCutoutAspect = (aspect: string) => { const match = aspect.match(/(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)/); if (!match) return null; const width = Number(match[1]); const height = Number(match[2]); if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return null; return width / height; }; const parseSmartCutoutPercent = (value: string, fallback: number) => { const numeric = Number(value.replace("%", "")); if (!Number.isFinite(numeric)) return fallback; return clampNumber(numeric / 100, 0.05, 1); }; const hsvToRgb = (h: number, s: number, v: number) => { const hue = ((h % 360) + 360) % 360; const saturation = clampNumber(s, 0, 100) / 100; const value = clampNumber(v, 0, 100) / 100; const chroma = value * saturation; const x = chroma * (1 - Math.abs(((hue / 60) % 2) - 1)); const match = value - chroma; const [red, green, blue] = hue < 60 ? [chroma, x, 0] : hue < 120 ? [x, chroma, 0] : hue < 180 ? [0, chroma, x] : hue < 240 ? [0, x, chroma] : hue < 300 ? [x, 0, chroma] : [chroma, 0, x]; return { r: (red + match) * 255, g: (green + match) * 255, b: (blue + match) * 255, }; }; const hexToHsv = (value: string) => { const rgb = hexToRgb(value) ?? { r: 255, g: 255, b: 255 }; const red = rgb.r / 255; const green = rgb.g / 255; const blue = rgb.b / 255; const max = Math.max(red, green, blue); const min = Math.min(red, green, blue); const delta = max - min; const hue = delta === 0 ? 0 : max === red ? 60 * (((green - blue) / delta) % 6) : max === green ? 60 * ((blue - red) / delta + 2) : 60 * ((red - green) / delta + 4); return { h: Math.round((hue + 360) % 360), s: max === 0 ? 0 : Math.round((delta / max) * 100), v: Math.round(max * 100), }; }; import tmallLogo from "../../assets/platform-logos/tmall.webp"; 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"; interface ProductClonePageProps { [key: string]: unknown; } type ProductCloneStatus = "idle" | "ready" | "generating" | "done" | "failed"; type ProductSetOutputKey = "set" | "detail" | "model" | "video"; 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" | "failed"; type ProductKitToolKey = "set" | "detail" | "wear" | "clone"; type ComposerMenuKey = "mode" | "platform" | "language" | "ratio" | "settings"; 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"; 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 { id: string; name: string; savedAt: string; output: CloneOutputKey; platform: string; market: string; language: string; ratio: string; setCounts: Record; detailModules: string[]; modelPanelTab: CloneModelPanelTab; modelScenes: string[]; modelCustomScene: string; modelGender: string; modelAge: string; modelEthnicity: string; modelBody: string; modelAppearance: string; videoQuality: CloneVideoQualityKey; videoDurationSeconds: number; videoSmart: boolean; referenceMode?: CloneReferenceMode; replicateLevel?: CloneReplicateLevelKey; requirement: string; } interface EcommerceHistoryRecord { id: string; title: string; createdAt: number; output: CloneOutputKey; platform: string; market: string; language: string; ratio: string; requirement: string; productImages: CloneImageItem[]; results: CloneResult[]; setResultImages: string[]; setCounts: Record; detailModules: string[]; modelScenes: string[]; referenceImages: CloneImageItem[]; replicateLevel: CloneReplicateLevelKey; } 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[]; defaultRatio: string; } const sideTools: Array<{ key: ProductKitToolKey; label: string; icon: ReactNode }> = [ { key: "set", label: "商品套图", icon: }, { key: "detail", label: "A+详情", icon: }, { key: "wear", label: "服饰穿搭", icon: }, { key: "clone", label: "电商AI作图", icon: }, ]; const platformSpecOptions: Array<{ label: string; ratios: string[]; defaultRatio: string; ratioGroups?: Partial>; specs: string[]; tip?: string; aliases?: string[]; }> = [ { label: "淘宝/天猫", ratios: ["娣樺疂涓诲浘 / SKU 鍥?800脳800px", "璇︽儏椤靛 750px", "璇︽儏椤靛 790px"], defaultRatio: "娣樺疂涓诲浘 / SKU 鍥?800脳800px", ratioGroups: { set: { ratios: ["1000脳1000px\u00a0\u00a0\u00a01锛?", "800脳800px\u00a0\u00a0\u00a01锛?"], defaultRatio: "1000脳1000px\u00a0\u00a0\u00a01锛?", }, detail: { ratios: [ "750脳1000px\u00a0\u00a0\u00a03锛?", "790脳1053px\u00a0\u00a0\u00a03锛?", "750脳1125px\u00a0\u00a0\u00a02锛?", "790脳1185px\u00a0\u00a0\u00a02锛?", ], defaultRatio: "750脳1000px\u00a0\u00a0\u00a03锛?", }, model: { ratios: ["750脳1000px\u00a0\u00a0\u00a03锛?"], defaultRatio: "750脳1000px\u00a0\u00a0\u00a03锛?", }, video: { ratios: ["1080脳1920px\u00a0\u00a0\u00a09锛?6", "1080脳1440px\u00a0\u00a0\u00a03锛?", "1080脳1080px\u00a0\u00a0\u00a01锛?"], defaultRatio: "1080脳1920px\u00a0\u00a0\u00a09锛?6", }, hot: { ratios: ["800脳800px\u00a0\u00a0\u00a01锛?"], defaultRatio: "800脳800px\u00a0\u00a0\u00a01锛?", }, }, specs: ["涓诲浘 / SKU 鍥?800脳800px锛屸墹3MB", "璇︽儏椤靛 750px 鎴?790px锛屽崟寮犻珮鈮?546px"], tip: "寤鸿涓诲浘 200-400KB JPG锛岃秴杩?500KB 浼氬奖鍝嶅姞杞介€熷害銆?", }, { label: "京东", ratios: ["浜笢涓诲浘 / SKU 鍥?800脳800px", "璇︽儏椤靛 750px", "棣栧浘涓讳綋鍗犳瘮 鈮?0%"], defaultRatio: "浜笢涓诲浘 / SKU 鍥?800脳800px", ratioGroups: { set: { ratios: ["1000脳1000px\u00a0\u00a0\u00a01锛?"], defaultRatio: "1000脳1000px\u00a0\u00a0\u00a01锛?", }, detail: { ratios: [ "750脳1000px\u00a0\u00a0\u00a03锛?", "990脳1320px\u00a0\u00a0\u00a03锛?", "750脳1125px\u00a0\u00a0\u00a02锛?", "990脳1485px\u00a0\u00a0\u00a02锛?", ], defaultRatio: "750脳1000px\u00a0\u00a0\u00a03锛?", }, model: { ratios: ["750脳1125px\u00a0\u00a0\u00a02锛?", "990脳1485px\u00a0\u00a0\u00a02锛?"], defaultRatio: "750脳1125px\u00a0\u00a0\u00a02锛?", }, video: { ratios: ["1080脳1920px\u00a0\u00a0\u00a09锛?6", "1920脳1080px\u00a0\u00a0\u00a016锛?"], defaultRatio: "1080脳1920px\u00a0\u00a0\u00a09锛?6", }, hot: { ratios: ["800脳800px\u00a0\u00a0\u00a01锛?"], defaultRatio: "800脳800px\u00a0\u00a0\u00a01锛?", }, }, specs: ["涓诲浘 / SKU 鍥?800脳800px锛岀櫧搴曪紝鈮?MB", "璇︽儏椤靛 750px锛岄鍥句富浣撳崰姣?鈮?0%"], }, { label: "拼多多", ratios: ["涓诲浘 750脳352px", "涓诲浘 800脳800px", "璇︽儏椤靛 750px"], defaultRatio: "涓诲浘 750脳352px", ratioGroups: { set: { ratios: ["800脳800px\u00a0\u00a0\u00a01锛?", "750脳1000px\u00a0\u00a0\u00a03锛?"], defaultRatio: "800脳800px\u00a0\u00a0\u00a01锛?", }, detail: { ratios: ["750脳1000px\u00a0\u00a0\u00a03锛?", "750脳1125px\u00a0\u00a0\u00a02锛?"], defaultRatio: "750脳1000px\u00a0\u00a0\u00a03锛?", }, model: { ratios: ["750脳1000px\u00a0\u00a0\u00a03锛?"], defaultRatio: "750脳1000px\u00a0\u00a0\u00a03锛?", }, video: { ratios: ["1080脳1920px\u00a0\u00a0\u00a09锛?6"], defaultRatio: "1080脳1920px\u00a0\u00a0\u00a09锛?6", }, hot: { ratios: ["800脳800px\u00a0\u00a0\u00a01锛?"], defaultRatio: "800脳800px\u00a0\u00a0\u00a01锛?", }, }, specs: ["涓诲浘 750脳352px 鎴?800脳800px锛屸墹1MB", "璇︽儏椤靛 750px锛岃姹傜函鐧藉簳銆佹棤姘村嵃銆佹棤鎷兼帴"], }, { label: "抖音电商", ratios: ["鐭棰?1080脳1920px"], defaultRatio: "鐭棰?1080脳1920px", ratioGroups: { video: { ratios: ["1080脳1920px\u00a0\u00a0\u00a09锛?6"], defaultRatio: "1080脳1920px\u00a0\u00a0\u00a09锛?6", }, hot: { ratios: ["800脳800px\u00a0\u00a0\u00a01锛?"], defaultRatio: "800脳800px\u00a0\u00a0\u00a01锛?", }, }, specs: ["鐭棰?1080脳1920px锛?:16", "30s 鍐呮渶浣?"], }, { label: "亚马逊 Amazon", ratios: ["涓诲浘 鈮?600脳1600px", "寤鸿 2000脳2000px+", "鏈€灏?500脳500px"], defaultRatio: "涓诲浘 鈮?600脳1600px", ratioGroups: { set: { ratios: ["1600脳1600px\u00a0\u00a0\u00a01锛?"], defaultRatio: "1600脳1600px\u00a0\u00a0\u00a01锛?", }, detail: { ratios: ["1600脳1600px\u00a0\u00a0\u00a01锛?", "1200脳1800px\u00a0\u00a0\u00a02锛?", "1200脳1600px\u00a0\u00a0\u00a03锛?"], defaultRatio: "1200脳1800px\u00a0\u00a0\u00a02锛?", }, model: { ratios: ["1200脳1800px\u00a0\u00a0\u00a02锛?"], defaultRatio: "1200脳1800px\u00a0\u00a0\u00a02锛?", }, video: { ratios: ["1920脳1080px\u00a0\u00a0\u00a016锛?"], defaultRatio: "1920脳1080px\u00a0\u00a0\u00a016锛?", }, hot: { ratios: ["1600脳1600px\u00a0\u00a0\u00a01锛?"], defaultRatio: "1600脳1600px\u00a0\u00a0\u00a01锛?", }, }, specs: ["涓诲浘 1600脳1600px+锛岀函鐧藉簳锛屸墹10MB", "鏈€灏?500脳500px锛屽缓璁?2000px+ 浠ユ敮鎸佺缉鏀?"], aliases: ["浜氶┈閫?"], }, { label: "Shopee", ratios: ["鍟嗗搧涓诲浘 1024脳1024px", "鍩虹涓诲浘 800脳800px"], defaultRatio: "鍟嗗搧涓诲浘 1024脳1024px", ratioGroups: { set: { ratios: ["800脳800px\u00a0\u00a0\u00a01锛?"], defaultRatio: "800脳800px\u00a0\u00a0\u00a01锛?", }, detail: { ratios: ["750脳1000px\u00a0\u00a0\u00a03锛?", "750脳1125px\u00a0\u00a0\u00a02锛?"], defaultRatio: "750脳1000px\u00a0\u00a0\u00a03锛?", }, model: { ratios: ["750脳1000px\u00a0\u00a0\u00a03锛?"], defaultRatio: "750脳1000px\u00a0\u00a0\u00a03锛?", }, video: { ratios: ["1080脳1920px\u00a0\u00a0\u00a09锛?6"], defaultRatio: "1080脳1920px\u00a0\u00a0\u00a09锛?6", }, hot: { ratios: ["800脳800px\u00a0\u00a0\u00a01锛?"], defaultRatio: "800脳800px\u00a0\u00a0\u00a01锛?", }, }, specs: ["鍟嗗搧涓诲浘鎺ㄨ崘 1024脳1024px锛屽熀纭€ 800脳800px", "鈮?MB锛岀櫧搴曟垨娴呰壊搴?"], aliases: ["铏剧毊 Shopee/Lazada", "铏剧毊"], }, { label: "Lazada", ratios: ["鍟嗗搧涓诲浘 800脳800px"], defaultRatio: "鍟嗗搧涓诲浘 800脳800px", ratioGroups: { set: { ratios: ["800脳800px\u00a0\u00a0\u00a01锛?"], defaultRatio: "800脳800px\u00a0\u00a0\u00a01锛?", }, detail: { ratios: ["750脳1000px\u00a0\u00a0\u00a03锛?", "750脳1125px\u00a0\u00a0\u00a02锛?"], defaultRatio: "750脳1000px\u00a0\u00a0\u00a03锛?", }, model: { ratios: ["750脳1000px\u00a0\u00a0\u00a03锛?"], defaultRatio: "750脳1000px\u00a0\u00a0\u00a03锛?", }, video: { ratios: ["1080脳1920px\u00a0\u00a0\u00a09锛?6"], defaultRatio: "1080脳1920px\u00a0\u00a0\u00a09锛?6", }, hot: { ratios: ["800脳800px\u00a0\u00a0\u00a01锛?"], defaultRatio: "800脳800px\u00a0\u00a0\u00a01锛?", }, }, specs: ["鍟嗗搧涓诲浘 800脳800px锛?:1"], }, { label: "Instagram", ratios: ["甯栧瓙 1080脳1350px", "甯栧瓙 1080脳1080px", "Stories / Reels 1080脳1920px", "澶村儚 320脳320px"], defaultRatio: "甯栧瓙 1080脳1350px", ratioGroups: { set: { ratios: ["1080脳1080px\u00a0\u00a0\u00a01锛?", "1080脳1350px\u00a0\u00a0\u00a04锛?"], defaultRatio: "1080脳1080px\u00a0\u00a0\u00a01锛?", }, detail: { ratios: ["1080脳1350px\u00a0\u00a0\u00a04锛?"], defaultRatio: "1080脳1350px\u00a0\u00a0\u00a04锛?", }, model: { ratios: ["1080脳1350px\u00a0\u00a0\u00a04锛?"], defaultRatio: "1080脳1350px\u00a0\u00a0\u00a04锛?", }, video: { ratios: ["1080脳1920px\u00a0\u00a0\u00a09锛?6", "1080脳1350px\u00a0\u00a0\u00a04锛?"], defaultRatio: "1080脳1920px\u00a0\u00a0\u00a09锛?6", }, }, specs: ["甯栧瓙 1080脳1350px 鎴?1080脳1080px", "Stories / Reels 灏侀潰 1080脳1920px锛屽ご鍍?320脳320px"], tip: "寤鸿 鈮?MB JPG銆?", aliases: ["Instagram Reels"], }, { label: "速卖通", ratios: ["涓诲浘 800脳800px", "涓诲浘 1000脳1000px+"], defaultRatio: "涓诲浘 800脳800px", ratioGroups: { set: { ratios: ["1000脳1000px\u00a0\u00a0\u00a01锛?"], defaultRatio: "1000脳1000px\u00a0\u00a0\u00a01锛?", }, detail: { ratios: ["750脳1125px\u00a0\u00a0\u00a02锛?", "750脳1000px\u00a0\u00a0\u00a03锛?"], defaultRatio: "750脳1125px\u00a0\u00a0\u00a02锛?", }, model: { ratios: ["750脳1125px\u00a0\u00a0\u00a02锛?"], defaultRatio: "750脳1125px\u00a0\u00a0\u00a02锛?", }, video: { ratios: ["1080脳1920px\u00a0\u00a0\u00a09锛?6", "1920脳1080px\u00a0\u00a0\u00a016锛?"], defaultRatio: "1080脳1920px\u00a0\u00a0\u00a09锛?6", }, hot: { ratios: ["800脳800px\u00a0\u00a0\u00a01锛?"], defaultRatio: "800脳800px\u00a0\u00a0\u00a01锛?", }, }, specs: ["涓诲浘寤鸿 800脳800px 鎴栨洿楂橈紝1:1", "閫傚悎璺ㄥ鐢靛晢涓诲浘銆丼KU 鍥惧拰鍦烘櫙鍥?"], }, { label: "eBay", ratios: ["鍟嗗搧鍥?1:1", "鐧藉簳澶氳搴﹀睍绀哄浘 1:1"], defaultRatio: "鍟嗗搧鍥?1:1", ratioGroups: { set: { ratios: ["1600脳1600px\u00a0\u00a0\u00a01锛?"], defaultRatio: "1600脳1600px\u00a0\u00a0\u00a01锛?", }, detail: { ratios: ["1000脳1500px\u00a0\u00a0\u00a02锛?", "1200脳1600px\u00a0\u00a0\u00a03锛?"], defaultRatio: "1000脳1500px\u00a0\u00a0\u00a02锛?", }, model: { ratios: ["1000脳1500px\u00a0\u00a0\u00a02锛?"], defaultRatio: "1000脳1500px\u00a0\u00a0\u00a02锛?", }, video: { ratios: ["1920脳1080px\u00a0\u00a0\u00a016锛?", "1080脳1920px\u00a0\u00a0\u00a09锛?6"], defaultRatio: "1920脳1080px\u00a0\u00a0\u00a016锛?", }, hot: { ratios: ["1600脳1600px\u00a0\u00a0\u00a01锛?"], defaultRatio: "1600脳1600px\u00a0\u00a0\u00a01锛?", }, }, specs: ["鍟嗗搧鍥惧缓璁?1:1锛屼富浣撴竻鏅板眳涓?", "閫傚悎鐧藉簳涓诲浘鍜屽瑙掑害灞曠ず鍥?"], }, { label: "TikTok Shop", ratios: ["鍟嗗搧涓诲浘 1:1", "鐭棰?/ 绔栫増灏侀潰 9:16"], defaultRatio: "鍟嗗搧涓诲浘 1:1", ratioGroups: { set: { ratios: ["1280脳1280px\u00a0\u00a0\u00a01锛?"], defaultRatio: "1280脳1280px\u00a0\u00a0\u00a01锛?", }, detail: { ratios: ["1080脳1350px\u00a0\u00a0\u00a04锛?"], defaultRatio: "1080脳1350px\u00a0\u00a0\u00a04锛?", }, model: { ratios: ["1080脳1350px\u00a0\u00a0\u00a04锛?"], defaultRatio: "1080脳1350px\u00a0\u00a0\u00a04锛?", }, video: { ratios: ["1080脳1920px\u00a0\u00a0\u00a09锛?6"], defaultRatio: "1080脳1920px\u00a0\u00a0\u00a09锛?6", }, hot: { ratios: ["800脳800px\u00a0\u00a0\u00a01锛?"], defaultRatio: "800脳800px\u00a0\u00a0\u00a01锛?", }, }, specs: ["鍟嗗搧涓诲浘寤鸿 1:1", "鐭棰?绔栫増灏侀潰寤鸿 9:16"], }, ]; const platformOptions = platformSpecOptions.map((option) => option.label); const getPlatformLogoSources = (value: string) => { const normalized = value.toLowerCase(); if (value.includes("淘宝") || value.includes("天猫") || value.includes("娣樺疂") || value.includes("澶╃尗")) return [taobaoLogo, tmallLogo]; if (value.includes("京东") || value.includes("浜笢")) return [jdLogo]; if (value.includes("拼多多") || value.includes("鎷煎澶")) return [pinduoduoLogo]; if (value.includes("抖音") || value.includes("鎶栭煶")) return [douyinLogo]; if (normalized.includes("amazon")) return [amazonLogo]; if (normalized.includes("shopee")) return [shopeeLogo]; if (normalized.includes("lazada")) return [lazadaLogo]; if (normalized.includes("instagram")) return [instagramLogo]; if (value.includes("速卖通") || value.includes("閫熷崠閫")) return [aliexpressLogo]; if (normalized.includes("ebay")) return [ebayLogo]; if (normalized.includes("tiktok")) return [tiktokShopLogo]; return []; }; const renderPlatformLogo = (value: string) => { const sources = getPlatformLogoSources(value); return ( 1 ? " ecom-platform-logo-mark--duo" : ""}`} aria-hidden="true"> {sources.map((src) => ( ))} ); }; const marketLanguageOptions: Array<{ country: string; languages: string[] }> = [ { country: "中国", languages: ["中文"] }, { country: "美国", languages: ["英文"] }, { country: "加拿大", languages: ["英文", "法文"] }, { country: "英国", languages: ["英文"] }, { country: "德国", languages: ["德文"] }, { country: "法国", languages: ["法文"] }, { country: "意大利", languages: ["意大利语"] }, { country: "西班牙", languages: ["西班牙语"] }, { country: "日本", languages: ["日文"] }, { country: "韩国", languages: ["韩文"] }, { country: "澳大利亚", languages: ["英文"] }, { country: "新加坡", languages: ["英文", "中文"] }, { country: "马来西亚", languages: ["马来语", "英文", "中文"] }, { country: "印尼", languages: ["印度尼西亚语", "英文"] }, { country: "越南", languages: ["越南语", "英文"] }, { country: "泰国", languages: ["泰语", "英文"] }, { country: "菲律宾", languages: ["菲律宾语(他加禄语)", "英文"] }, { country: "巴西", languages: ["葡萄牙语"] }, { country: "墨西哥", languages: ["西班牙语"] }, { country: "智利", languages: ["西班牙语"] }, { country: "哥伦比亚", languages: ["西班牙语"] }, { country: "阿联酋", languages: ["阿拉伯语", "英文"] }, { country: "沙特阿拉伯", languages: ["阿拉伯语", "英文"] }, { country: "俄罗斯", languages: ["俄语"] }, { country: "波兰", languages: ["波兰语"] }, ]; const marketOptions = marketLanguageOptions.map((option) => option.country); const languageOptions = Array.from(new Set(marketLanguageOptions.flatMap((option) => option.languages))); const languageAliases: Record = { "鑻辨枃": "英文", "涓枃": "中文", "鑻辫": "英文", "鏃ヨ": "日文", "鏃ユ枃": "日文", "寰疯": "德文", "寰锋枃": "德文", "娉曡": "法文", "娉曟枃": "法文", "闊╄": "韩文", "闊╂枃": "韩文", "瑗挎枃": "西班牙语", "瑗跨彮鐗欒": "西班牙语", "钁℃枃": "葡萄牙语", "钁¤悇鐗欒": "葡萄牙语", "鍗板凹璇?": "印度尼西亚语", "鍗板害灏艰タ浜氳": "印度尼西亚语", "鑿插緥瀹捐": "菲律宾语(他加禄语)", "鑿插緥瀹捐锛堜粬鍔犵璇級": "菲律宾语(他加禄语)", }; const defaultPlatformSpec = platformSpecOptions[0]!; const getPlatformSpec = (value: string) => platformSpecOptions.find((option) => option.label === value || option.aliases?.includes(value)) ?? defaultPlatformSpec; const legacyPlatformAliases: Record = { "娣樺疂/澶╃尗": "淘宝/天猫", "浜笢": "京东", "鎷煎澶?": "拼多多", "鎶栭煶鐢靛晢": "抖音电商", "浜氶┈閫?Amazon": "亚马逊 Amazon", "閫熷崠閫?": "速卖通", }; const normalizePlatform = (value: string) => getPlatformSpec(legacyPlatformAliases[value] ?? value).label; const domesticPlatformLabels = new Set(["淘宝/天猫", "京东", "拼多多", "抖音电商"]); const domesticPlatformLanguages = ["中文"]; const isDomesticPlatform = (platformValue: string) => domesticPlatformLabels.has(normalizePlatform(platformValue)); const getPlatformRatioGroup = (value: string, mode?: PlatformRatioModeKey): PlatformRatioGroup => { const platformSpec = getPlatformSpec(value); return (mode ? platformSpec.ratioGroups?.[mode] : null) ?? { ratios: platformSpec.ratios, defaultRatio: platformSpec.defaultRatio, }; }; const getPlatformRatioOptions = (value: string, mode?: PlatformRatioModeKey) => getPlatformRatioGroup(value, mode).ratios; const getPlatformDefaultRatio = (value: string, mode?: PlatformRatioModeKey) => getPlatformRatioGroup(value, mode).defaultRatio; const getUniqueRatioOptions = (ratios: string[]) => Array.from(new Set(ratios)); const normalizeRatioToken = (value: string) => value.replaceAll("锛?", ":").replaceAll(":", ":").replaceAll("脳", "×").trim(); const normalizeRatioForPlatform = (platformValue: string, ratioValue: string, mode?: PlatformRatioModeKey) => { const platformRatios = getPlatformRatioOptions(platformValue, mode); if (platformRatios.includes(ratioValue)) return ratioValue; const normalizedRatio = normalizeRatioToken(ratioValue); const matchedRatio = platformRatios.find((option) => normalizeRatioToken(option).includes(normalizedRatio)); return matchedRatio ?? getPlatformDefaultRatio(platformValue, mode); }; const quickSetRatioOptions = ["1:1", "3:4", "9:16", "16:9"]; const getQuickSetRatioValue = (value: string) => { const normalizedValue = normalizeRatioToken(value); if (quickSetRatioOptions.includes(normalizedValue)) return normalizedValue; const sizeMatch = normalizedValue.match(/(\d+)\s*[×xX]\s*(\d+)/u); if (sizeMatch) { const width = Number(sizeMatch[1]); const height = Number(sizeMatch[2]); if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) { const aspect = formatAspectRatio(width, height); if (quickSetRatioOptions.includes(aspect)) return aspect; } } const ratioMatch = normalizedValue.match(/(\d+)\s*[::]\s*(\d+)/u); if (ratioMatch) { const aspect = `${Number(ratioMatch[1])}:${Number(ratioMatch[2])}`; if (quickSetRatioOptions.includes(aspect)) return aspect; } return quickSetRatioOptions[0]!; }; const formatRatioDisplayValue = (value: string) => { const normalizedValue = normalizeRatioToken(value); const sizeMatch = normalizedValue.match(/(\d+)\s*[×xX]\s*(\d+)\s*px?/u); if (sizeMatch) { const width = Number(sizeMatch[1]); const height = Number(sizeMatch[2]); return `${width}×${height}px\u00a0\u00a0\u00a0${formatAspectRatio(width, height)}`; } return normalizedValue .replace("娣樺疂涓诲浘 / SKU 鍥?", "淘宝主图 / SKU 图 ") .replace("浜笢涓诲浘 / SKU 鍥?", "京东主图 / SKU 图 ") .replace("璇︽儏椤靛", "详情页宽") .replace("鐭棰?", "短视频") .replace("涓诲浘", "主图") .replace("鍟嗗搧涓诲浘", "商品主图") .replace("鍟嗗搧鍥?", "商品图"); }; /** Extract CSS aspect-ratio from a ratio string like "1000x1000px 1:1" -> "1 / 1" */ const parseRatioToAspectCss = (ratioStr: string): string => { const match = ratioStr.match(/(\d+)\D+(\d+)/u); if (!match) return "1 / 1"; return `${match[1]} / ${match[2]}`; }; /** Normalize ratio display string ("1000脳1000px 1锛?") to API format ("1:1") */ const normalizeRatioForApi = (ratioStr: string): string => { const match = ratioStr.match(/(\d+)\D+(\d+)/u); if (!match) return "1:1"; return `${match[1]}:${match[2]}`; }; const greatestCommonDivisor = (left: number, right: number): number => { let a = Math.abs(left); let b = Math.abs(right); while (b) { [a, b] = [b, a % b]; } return a || 1; }; const formatAspectRatio = (width: number, height: number) => { const divisor = greatestCommonDivisor(width, height); return `${Math.round(width / divisor)}:${Math.round(height / divisor)}`; }; const formatUploadedImageRatio = (image?: CloneImageItem) => { if (!image) return null; const format = image.format ? `\u00a0\u00a0\u00a0${image.format}` : ""; 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 defaultMarketLanguageOption = marketLanguageOptions[0]!; const normalizeMarket = (value: string) => marketLanguageOptions.some((option) => option.country === value) ? value : defaultMarketLanguageOption.country; const normalizeLanguage = (value: string) => languageAliases[value] ?? value; const uniqueLanguages = (languages: string[]) => Array.from(new Set(languages)); const appendEnglish = (languages: string[]) => Array.from(new Set([...languages, "英文"])); const getMarketLanguageOptions = (marketValue: string) => appendEnglish((marketLanguageOptions.find((option) => option.country === marketValue) ?? defaultMarketLanguageOption).languages); const getPlatformLanguageOptions = (platformValue: string, marketValue: string) => { const marketLanguages = getMarketLanguageOptions(marketValue); if (!isDomesticPlatform(platformValue)) return marketLanguages; const localLanguages = marketLanguages.filter((item) => item !== "英文"); return uniqueLanguages([...localLanguages, ...domesticPlatformLanguages, "英文"]); }; const getPlatformDefaultLanguage = (platformValue: string, marketValue: string) => isDomesticPlatform(platformValue) ? "中文" : (getPlatformLanguageOptions(platformValue, marketValue)[0] ?? languageOptions[0] ?? "英文"); const normalizeLanguageForPlatform = (platformValue: string, marketValue: string, languageValue: string) => { const normalizedLanguage = normalizeLanguage(languageValue); const platformLanguages = getPlatformLanguageOptions(platformValue, marketValue); return platformLanguages.includes(normalizedLanguage) ? normalizedLanguage : getPlatformDefaultLanguage(platformValue, marketValue); }; const productSetOutputOptions: Array<{ key: ProductSetOutputKey; label: string }> = [ { key: "set", label: "套图" }, { key: "detail", label: "详情图" }, { key: "model", label: "模特图" }, { key: "video", label: "短视频" }, ]; const cloneOutputOptions: Array<{ key: CloneOutputKey; label: string }> = [ ...productSetOutputOptions, { key: "hot", label: "爆款复刻" }, { key: "video-outfit", label: "视频换装" }, ]; const cloneSetCountOptions: Array<{ key: CloneSetCountKey; title: string; desc: string; }> = [ { key: "selling", title: "卖点图", desc: "展示商品核心卖点和细节特写" }, { key: "white", title: "白底图", desc: "白底主图,多角度呈现商品细节" }, { key: "scene", title: "场景图", desc: "展示商品生活使用场景和人物搭配" }, ]; const cloneSetCountKeys = cloneSetCountOptions.map((option) => option.key); const defaultCloneSetCounts: Record = { selling: 3, white: 1, scene: 3, }; const minCloneSetTotal = 1; const maxCloneSetTotal = 16; const maxCloneProductImages = 7; const maxCloneReferenceImages = 20; const cloneVideoDurationMin = 5; const cloneVideoDurationMax = 45; const defaultEcommercePlatform = "淘宝/天猫"; const defaultProductSetOutput: ProductSetOutputKey = "set"; const defaultCloneOutput: CloneOutputKey = "set"; const cloneLatestSettingStorageKey = "omniai.clone-ai.latest-setting"; const ecommerceHistoryStorageKey = "omniai.ecommerce.history.records"; const cloneVideoQualityOptions: Array<{ key: CloneVideoQualityKey; label: string; desc: string }> = [ { key: "standard", label: "标准", desc: "快速出片" }, { key: "high", label: "高清", desc: "推荐" }, { key: "ultra", label: "超清", desc: "细节增强" }, ]; const cloneReplicateLevelOptions: Array<{ key: CloneReplicateLevelKey; title: string; desc: string }> = [ { key: "style", title: "参考风格", desc: "参考整体风格和结构,自动调整色彩和重构场景。" }, { key: "high", title: "高度复刻", desc: "参考视觉结构替换产品和文案,保留主要场景细节。" }, ]; const tryOnRatioOptions = ["3:4", "1:1", "9:16"]; const tryOnScenes = ["纯色棚拍", "都市街头", "街角咖啡", "自然草坪", "度假海滩", "温馨居家", "艺术展馆"]; const normalizeCloneModelSceneSelection = (scenes: string[] | null | undefined) => { const validScenes = (scenes ?? []).filter((scene) => typeof scene === "string" && scene.trim()); const latestScene = validScenes[validScenes.length - 1]; return latestScene ? [latestScene] : []; }; const tryOnModelOptions = { gender: ["女", "男"], age: ["青年", "少年", "中年"], ethnicity: ["欧美白人", "亚洲人", "拉美裔", "非洲裔"], body: ["标准", "高挑", "微胖", "运动"], }; 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 }, { id: "model", label: "03 妯$壒鍦烘櫙鍥?", src: productSetAssets.model }, { id: "detail", label: "04 缁嗚妭璇存槑", src: productSetAssets.detail }, { id: "selling", label: "05 鍗栫偣璇﹁В", src: productSetAssets.selling }, ]; const tryOnAssets = ossAssets.ecommerce.tryOn; const tryOnCards = [ { title: "澶氫欢娣锋惌鑷姩铻嶅悎", tone: "red", inputs: [tryOnAssets.dressA, tryOnAssets.dressB, tryOnAssets.modelWoman], results: [tryOnAssets.tryA, tryOnAssets.tryB], }, { title: "涓€浠朵篃鑳藉嚭澶х墖", tone: "brown", inputs: [tryOnAssets.jacket, tryOnAssets.modelMan], results: [tryOnAssets.jacketResultA, tryOnAssets.jacketResultB], }, { title: "闉嬪附楗板搧瀹岀編閫傞厤", tone: "gold", inputs: [tryOnAssets.hat, tryOnAssets.modelAsian], results: [tryOnAssets.hatResultA, tryOnAssets.hatResultB], }, ]; const detailTypeOptions = ["普通A+", "品牌A+", "标准详情页", "移动端长图"]; const detailModules = [ { id: "hero", title: "首页焦点图", desc: "集中呈现核心利益点" }, { id: "selling", title: "卖点强化图", desc: "放大产品优势" }, { id: "usage", title: "使用情境图", desc: "还原实际使用画面" }, { id: "angle", title: "外观角度图", desc: "展示不同视角造型" }, { id: "scene", title: "氛围场景图", desc: "营造产品应用环境" }, { id: "detail", title: "细节特写图", desc: "突出材质和做工" }, { id: "story", title: "品牌理念图", desc: "表达品牌主张" }, { id: "size", title: "规格尺寸图", desc: "说明尺寸容量尺码" }, { id: "compare", title: "效果对照图", desc: "呈现前后差异" }, { id: "spec", title: "参数信息表", desc: "整理商品关键数据" }, { id: "craft", title: "工艺流程图", desc: "说明制作与处理步骤" }, { id: "gift", title: "清单配件图", desc: "展示包装内全部内容" }, { id: "series", title: "SKU组合图", desc: "呈现颜色款式组合" }, { id: "ingredient", title: "成分材质图", desc: "说明配方或材料构成" }, { id: "service", title: "保障说明图", desc: "传达质保退换承诺" }, { id: "tips", title: "使用提示图", desc: "提醒操作与保养要点" }, ]; const defaultDetailModuleIds: string[] = []; const defaultCloneDetailModuleIds = ["hero", "selling", "usage", "angle", "scene", "detail"]; const maxDetailModuleSelection = 6; const cloneDetailModules = detailModules; 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]; function getImageFileFormat(file: File) { const mimeFormat = file.type.split("/")[1]?.replace("jpeg", "jpg").toUpperCase(); if (mimeFormat) return mimeFormat; return file.name.split(".").pop()?.toUpperCase() ?? ""; } function readImageDimensions(src: string): Promise<{ width: number; height: number }> { return new Promise((resolve, reject) => { const image = new Image(); image.onload = () => resolve({ width: image.naturalWidth, height: image.naturalHeight }); image.onerror = reject; image.src = src; }); } 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 src = localPreviewUrl; let ossKey: string | undefined; let shouldRevokeLocalPreview = false; let dimensions: { width?: number; height?: number } = {}; try { dimensions = await readImageDimensions(localPreviewUrl); } catch { dimensions = {}; } const mimeType = normalizeEcommerceImageMime(file.type); try { const uploadBlob = file.type === mimeType ? file : new Blob([file], { type: mimeType }); const uploaded = await aiGenerationClient.uploadAssetBinary(uploadBlob, { name: file.name, mimeType, scope: "ecommerce-product", }); src = uploaded.url; ossKey = uploaded.ossKey; shouldRevokeLocalPreview = true; } catch { src = localPreviewUrl; } finally { if (shouldRevokeLocalPreview) URL.revokeObjectURL(localPreviewUrl); } return { id: `${prefix}-${stamp}-${index}`, src, 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 { const candidate = item as Partial; return ( typeof candidate.id === "string" && typeof candidate.name === "string" && typeof candidate.savedAt === "string" && typeof candidate.output === "string" && typeof candidate.platform === "string" && typeof candidate.market === "string" && typeof candidate.language === "string" && typeof candidate.ratio === "string" && typeof candidate.videoDurationSeconds === "number" ); } function readCloneLatestSetting() { if (typeof window === "undefined") return null; try { const rawValue = window.localStorage.getItem(cloneLatestSettingStorageKey); if (rawValue) { const parsedValue: unknown = JSON.parse(rawValue); if (isCloneSavedSetting(parsedValue)) return parsedValue; } } catch { return null; } return null; } function writeCloneLatestSetting(setting: CloneSavedSetting) { if (typeof window === "undefined") return; window.localStorage.setItem(cloneLatestSettingStorageKey, JSON.stringify(setting)); } function isCloneImageItem(item: unknown): item is CloneImageItem { const candidate = item as Partial; return typeof candidate.id === "string" && typeof candidate.src === "string" && typeof candidate.name === "string"; } function isCloneResult(item: unknown): item is CloneResult { const candidate = item as Partial; return typeof candidate.id === "string" && typeof candidate.src === "string" && typeof candidate.label === "string"; } function isEcommerceHistoryRecord(item: unknown): item is EcommerceHistoryRecord { const candidate = item as Partial; return ( typeof candidate.id === "string" && typeof candidate.title === "string" && typeof candidate.createdAt === "number" && typeof candidate.output === "string" && typeof candidate.platform === "string" && typeof candidate.market === "string" && typeof candidate.language === "string" && typeof candidate.ratio === "string" && typeof candidate.requirement === "string" && Array.isArray(candidate.productImages) && candidate.productImages.every(isCloneImageItem) && Array.isArray(candidate.results) && candidate.results.every(isCloneResult) ); } function removeFilePayloadFromImages(images: CloneImageItem[]): CloneImageItem[] { return images.map(({ file: _file, ...image }) => image); } function normalizeEcommerceHistoryRecord(record: EcommerceHistoryRecord): EcommerceHistoryRecord { return { ...record, productImages: removeFilePayloadFromImages(record.productImages), referenceImages: removeFilePayloadFromImages(record.referenceImages ?? []), results: record.results ?? [], setResultImages: record.setResultImages ?? [], setCounts: record.setCounts ?? defaultCloneSetCounts, detailModules: record.detailModules ?? defaultCloneDetailModuleIds, modelScenes: record.modelScenes ?? [], replicateLevel: record.replicateLevel ?? "high", }; } function readEcommerceHistoryRecords() { if (typeof window === "undefined") return []; try { const rawValue = window.localStorage.getItem(ecommerceHistoryStorageKey); if (!rawValue) return []; const parsedValue: unknown = JSON.parse(rawValue); if (!Array.isArray(parsedValue)) return []; return parsedValue .filter(isEcommerceHistoryRecord) .map(normalizeEcommerceHistoryRecord) .sort((a, b) => b.createdAt - a.createdAt) .slice(0, 30); } catch { return []; } } function writeEcommerceHistoryRecords(records: EcommerceHistoryRecord[]) { if (typeof window === "undefined") return; window.localStorage.setItem(ecommerceHistoryStorageKey, JSON.stringify(records.map(normalizeEcommerceHistoryRecord).slice(0, 30))); } function clampCloneVideoDuration(value: number) { return Math.min(cloneVideoDurationMax, Math.max(cloneVideoDurationMin, Math.round(value))); } function ProductClonePage(_props: ProductClonePageProps = {}) { const setInputRef = useRef(null); const productInputRef = useRef(null); const quickProductInputRef = useRef(null); const cloneReferenceInputRef = useRef(null); const smartCutoutInputRef = useRef(null); const imageWorkbenchInputRef = useRef(null); const watermarkInputRef = useRef(null); const watermarkProcessTimeoutRef = useRef(null); const smartCutoutTransitionTimeoutRef = useRef(null); const smartCutoutPendingUrlsRef = useRef([]); const smartCutoutPaletteRef = useRef(null); const smartCutoutToolsRef = useRef(null); const composerMenuCloseTimeoutRef = useRef(null); const requirementTextareaRef = useRef(null); const commandComposerWrapRef = useRef(null); const garmentInputRef = useRef(null); const detailInputRef = useRef(null); const countHoldTimeoutRef = useRef(null); const countHoldIntervalRef = useRef(null); const isAuthenticated = Boolean((_props as Record).isAuthenticated); const requestLogin = () => { const handler = (_props as Record).onRequireLogin; if (typeof handler === "function") handler(); }; const imageGen = useGenerationTasks({ sourceView: "ecommerce" }); const appUsage = useAppStore((s) => s.usage); const latestCloneSettingRef = useRef(null); const skipInitialCloneAutoSaveRef = useRef(true); const skipNextCloneAutoSaveRef = useRef(false); const [activeTool, setActiveTool] = useState("clone"); useEffect(() => { setPreviewZoom(1); setIsCommandComposerCompact(false); }, [activeTool]); const [setImages, setSetImages] = useState([]); const [productSetPlatform, setProductSetPlatform] = useState(defaultEcommercePlatform); const [productSetMarket, setProductSetMarket] = useState(marketOptions[0]); const [productSetLanguage, setProductSetLanguage] = useState(getPlatformDefaultLanguage(defaultEcommercePlatform, marketOptions[0])); const [productSetRatio, setProductSetRatio] = useState(getPlatformDefaultRatio(defaultEcommercePlatform, defaultProductSetOutput)); const [productSetRequirement, setProductSetRequirement] = useState(""); const [productSetOutput, setProductSetOutput] = useState(defaultProductSetOutput); const [productSetStatus, setProductSetStatus] = useState("idle"); const [productSetResultImages, setProductSetResultImages] = useState([]); const [isSetUploadDragging, setIsSetUploadDragging] = useState(false); const [selectedProductSetPreview, setSelectedProductSetPreview] = useState<{ src: string; label: string } | null>(null); const [showHostingModal, setShowHostingModal] = useState(false); const [productImages, setProductImages] = useState([]); const [activeQuickTool, setActiveQuickTool] = useState<"cutout" | "set" | "detail" | "watermark" | "image-edit" | "hot-video" | null>(null); const [smartCutoutImage, setSmartCutoutImage] = useState(null); const [smartCutoutBatchImages, setSmartCutoutBatchImages] = useState([]); const [smartCutoutBackgroundColor, setSmartCutoutBackgroundColor] = useState("#ffffff"); const [smartCutoutBackgroundAlpha, setSmartCutoutBackgroundAlpha] = useState(100); const [smartCutoutHexDraft, setSmartCutoutHexDraft] = useState("#ffffff"); const [isSmartCutoutPaletteOpen, setIsSmartCutoutPaletteOpen] = useState(false); const [smartCutoutSizeKey, setSmartCutoutSizeKey] = useState("original"); const [isSmartCutoutDragging, setIsSmartCutoutDragging] = useState(false); const [isSmartCutoutComparing, setIsSmartCutoutComparing] = useState(false); const [isSmartCutoutTransitioning, setIsSmartCutoutTransitioning] = useState(false); const [smartCutoutTransitionMessage, setSmartCutoutTransitionMessage] = useState({ title: "正在切换页面", subtitle: "请稍候", }); const [watermarkImage, setWatermarkImage] = useState<{ src: string; name: string; format: string } | null>(null); const [watermarkStatus, setWatermarkStatus] = useState<"idle" | "processing" | "done">("idle"); const [isWatermarkDragging, setIsWatermarkDragging] = useState(false); const [imageWorkbenchImage, setImageWorkbenchImage] = useState<{ src: string; name: string; format: string } | null>(null); const [imageWorkbenchPrompt, setImageWorkbenchPrompt] = useState(""); const [imageWorkbenchBrushSize, setImageWorkbenchBrushSize] = useState(50); const [imageWorkbenchRatio, setImageWorkbenchRatio] = useState("1:1"); const [imageWorkbenchStatus, setImageWorkbenchStatus] = useState<"idle" | "processing" | "done">("idle"); const [isImageWorkbenchDragging, setIsImageWorkbenchDragging] = useState(false); const [imageWorkbenchMaskStrokes, setImageWorkbenchMaskStrokes] = useState }>>([]); const [imageWorkbenchBrushCursor, setImageWorkbenchBrushCursor] = useState<{ x: number; y: number } | null>(null); const [isProductUploadDragging, setIsProductUploadDragging] = useState(false); const [cloneOutput, setCloneOutput] = useState(defaultCloneOutput); const [videoHistoryVisible, setVideoHistoryVisible] = useState(false); const [videoPlanTrigger, setVideoPlanTrigger] = useState(0); const [openCloneBasicSelect, setOpenCloneBasicSelect] = useState(null); const [openQuickSetSelect, setOpenQuickSetSelect] = useState(null); const [visibleQuickSetSelect, setVisibleQuickSetSelect] = useState(null); const [isQuickSetSelectClosing, setIsQuickSetSelectClosing] = useState(false); const [composerMenu, setComposerMenu] = useState(null); const [visibleComposerMenu, setVisibleComposerMenu] = useState(null); const [isComposerMenuClosing, setIsComposerMenuClosing] = useState(false); const [composerPopoverLeft, setComposerPopoverLeft] = useState(0); const [isCommandHistoryCollapsed, setIsCommandHistoryCollapsed] = useState(() => (typeof window !== "undefined" ? window.innerWidth <= 1180 : false)); const [isQuickPanelCollapsed, setIsQuickPanelCollapsed] = useState(false); const [openCloneModelSelect, setOpenCloneModelSelect] = useState(null); const [cloneModelSelectDropUp, setCloneModelSelectDropUp] = useState(false); const [cloneReferenceMode, setCloneReferenceMode] = useState("upload"); const [cloneReferenceImages, setCloneReferenceImages] = useState([]); const [cloneReplicateLevel, setCloneReplicateLevel] = useState("high"); const [cloneSetCounts, setCloneSetCounts] = useState(defaultCloneSetCounts); const [selectedCloneDetailModules, setSelectedCloneDetailModules] = useState(defaultCloneDetailModuleIds); const [cloneModelPanelTab, setCloneModelPanelTab] = useState("scene"); const [selectedCloneModelScenes, setSelectedCloneModelScenes] = useState([]); const [cloneModelCustomScene, setCloneModelCustomScene] = useState(""); const [cloneModelGender, setCloneModelGender] = useState(tryOnModelOptions.gender[0]); const [cloneModelAge, setCloneModelAge] = useState(tryOnModelOptions.age[0]); const [cloneModelEthnicity, setCloneModelEthnicity] = useState(tryOnModelOptions.ethnicity[0]); const [cloneModelBody, setCloneModelBody] = useState(tryOnModelOptions.body[0]); const [cloneModelAppearance, setCloneModelAppearance] = useState(""); 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 videoOutfitPreviewUrl = useMemo(() => (videoOutfitVideoFile ? URL.createObjectURL(videoOutfitVideoFile) : ""), [videoOutfitVideoFile]); const [isCloneSettingsCollapsed, setIsCloneSettingsCollapsed] = useState(false); const [previewZoom, setPreviewZoom] = useState(1); const quickSetSelectTimerRef = useRef(null); const openQuickSetSelectRef = useRef(null); const visibleQuickSetSelectRef = useRef(null); const [previewOffset, setPreviewOffset] = useState({ x: 0, y: 0 }); const previewSurfaceRef = useRef(null); const previewZoomRef = useRef(previewZoom); const previewOffsetRef = useRef(previewOffset); const imageWorkbenchMaskPaintingRef = useRef(false); const imageWorkbenchActiveStrokeIdRef = useRef(null); const imageWorkbenchMaskCanvasRef = useRef(null); const imageWorkbenchLastMaskPointRef = useRef<{ x: number; y: number } | null>(null); const previewPanRef = useRef<{ active: boolean; startX: number; startY: number; offsetX: number; offsetY: number }>({ active: false, startX: 0, startY: 0, offsetX: 0, offsetY: 0, }); const [isCommandComposerCompact, setIsCommandComposerCompact] = useState(false); const typewriterText = useTypewriter("万物皆可AI,广告素材一键生成"); useEffect(() => { return () => { if (videoOutfitPreviewUrl) URL.revokeObjectURL(videoOutfitPreviewUrl); }; }, [videoOutfitPreviewUrl]); useEffect(() => { previewZoomRef.current = previewZoom; }, [previewZoom]); useEffect(() => { previewOffsetRef.current = previewOffset; }, [previewOffset]); useEffect(() => { if (typeof window === "undefined") return undefined; const syncHistoryPanel = () => { setIsCommandHistoryCollapsed(window.innerWidth <= 1180); }; syncHistoryPanel(); window.addEventListener("resize", syncHistoryPanel); return () => window.removeEventListener("resize", syncHistoryPanel); }, []); const previewTransformStyle = useMemo( () => ({ transform: `translate3d(${previewOffset.x}px, ${previewOffset.y}px, 0) scale(${previewZoom})`, }), [previewOffset.x, previewOffset.y, previewZoom], ); useEffect(() => { const container = previewSurfaceRef.current; if (!container) return undefined; const handleWheel = (event: WheelEvent) => { const target = event.target as HTMLElement | null; if (target?.closest(".ecom-command-composer-wrap, .clone-ai-preview-header")) return; event.preventDefault(); const currentZoom = previewZoomRef.current; const rect = container.getBoundingClientRect(); const cursorX = event.clientX - rect.left; const cursorY = event.clientY - rect.top; const zoomDelta = event.deltaY < 0 ? 1.08 : 0.92; const nextZoom = Math.min(2, Math.max(0.25, currentZoom * zoomDelta)); if (nextZoom === currentZoom) return; const currentOffset = previewOffsetRef.current; const contentX = (cursorX - currentOffset.x) / currentZoom; const contentY = (cursorY - currentOffset.y) / currentZoom; const nextOffset = { x: cursorX - contentX * nextZoom, y: cursorY - contentY * nextZoom, }; previewZoomRef.current = nextZoom; previewOffsetRef.current = nextOffset; setPreviewZoom(nextZoom); setPreviewOffset(nextOffset); }; container.addEventListener("wheel", handleWheel, { passive: false, capture: true }); return () => container.removeEventListener("wheel", handleWheel, { capture: true }); }, [activeTool, cloneOutput]); useEffect(() => { const container = previewSurfaceRef.current; if (!container) return undefined; const listenerOptions = { capture: true }; const handleMouseDown = (event: MouseEvent) => { if (event.button !== 1) return; const target = event.target as HTMLElement | null; if (target?.closest(".ecom-command-composer-wrap, .clone-ai-preview-header, input, textarea, select, a")) return; event.preventDefault(); const currentOffset = previewOffsetRef.current; previewPanRef.current = { active: true, startX: event.clientX, startY: event.clientY, offsetX: currentOffset.x, offsetY: currentOffset.y, }; container.classList.add("is-middle-panning"); }; const handleMouseMove = (event: MouseEvent) => { const pan = previewPanRef.current; if (!pan.active) return; event.preventDefault(); const nextOffset = { x: pan.offsetX + event.clientX - pan.startX, y: pan.offsetY + event.clientY - pan.startY, }; previewOffsetRef.current = nextOffset; setPreviewOffset(nextOffset); }; const stopMousePan = () => { const pan = previewPanRef.current; if (!pan.active) return; previewPanRef.current = { ...pan, active: false }; container.classList.remove("is-middle-panning"); }; const preventAuxClick = (event: MouseEvent) => { if (event.button === 1) event.preventDefault(); }; container.addEventListener("mousedown", handleMouseDown, listenerOptions); container.addEventListener("auxclick", preventAuxClick, listenerOptions); window.addEventListener("mousemove", handleMouseMove); window.addEventListener("mouseup", stopMousePan); window.addEventListener("blur", stopMousePan); return () => { container.removeEventListener("mousedown", handleMouseDown, listenerOptions); container.removeEventListener("auxclick", preventAuxClick, listenerOptions); window.removeEventListener("mousemove", handleMouseMove); window.removeEventListener("mouseup", stopMousePan); window.removeEventListener("blur", stopMousePan); }; }, [activeTool, cloneOutput]); const bindPreviewSurface = (node: HTMLElement | null) => { previewSurfaceRef.current = node; }; const getPreviewSurfaceProps = () => ({ ref: bindPreviewSurface, onMouseDown: (event: ReactMouseEvent) => { if (event.button === 1) event.preventDefault(); }, onAuxClick: (event: ReactMouseEvent) => { if (event.button === 1) event.preventDefault(); }, }); const handlePreviewWheel = (event: React.WheelEvent) => { if (!event.currentTarget) return; const container = event.currentTarget as HTMLElement; const rect = container.getBoundingClientRect(); const cursorX = event.clientX - rect.left; const cursorY = event.clientY - rect.top; const zoomDelta = event.deltaY < 0 ? 1.08 : 0.92; const nextZoom = Math.min(2, Math.max(0.25, previewZoom * zoomDelta)); if (nextZoom === previewZoom) return; const contentX = (cursorX + container.scrollLeft) / previewZoom; const contentY = (cursorY + container.scrollTop) / previewZoom; setPreviewZoom(nextZoom); requestAnimationFrame(() => { container.scrollLeft = contentX * nextZoom - cursorX; container.scrollTop = contentY * nextZoom - cursorY; }); }; const handleQuickPreviewWheel = (event: React.WheelEvent) => { event.preventDefault(); event.stopPropagation(); const zoomDelta = event.deltaY < 0 ? 1.08 : 0.92; setPreviewZoom((value) => Math.min(2, Math.max(0.25, value * zoomDelta))); }; const handleQuickPanelWheel = (event: React.WheelEvent) => { const panel = event.currentTarget; if (panel.scrollHeight <= panel.clientHeight) return; event.stopPropagation(); panel.scrollTop += event.deltaY; }; const [requirement, setRequirement] = useState(""); const [requirementImageMentionQuery, setRequirementImageMentionQuery] = useState(null); const [cloneSettingName, setCloneSettingName] = useState("鏂板缓鍒涗綔"); const [platform, setPlatform] = useState(defaultEcommercePlatform); const [market, setMarket] = useState(marketOptions[0]); const [language, setLanguage] = useState(getPlatformDefaultLanguage(defaultEcommercePlatform, marketOptions[0])); const [ratio, setRatio] = useState(getPlatformDefaultRatio(defaultEcommercePlatform, defaultCloneOutput)); const [status, setStatus] = useState("idle"); const [results, setResults] = useState([]); const [ecommerceHistoryRecords, setEcommerceHistoryRecords] = useState(() => readEcommerceHistoryRecords()); const [activeHistoryRecordId, setActiveHistoryRecordId] = useState(null); const [historyRefreshTick, setHistoryRefreshTick] = useState(0); const [isHistoryRefreshing, setIsHistoryRefreshing] = useState(false); const [historyRefreshMessage, setHistoryRefreshMessage] = useState(""); const [historyRefreshStamp, setHistoryRefreshStamp] = useState(0); const historyRefreshLockRef = useRef(false); const lastSavedHistorySignatureRef = useRef(""); 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]); const [modelAge, setModelAge] = useState(tryOnModelOptions.age[0]); const [modelEthnicity, setModelEthnicity] = useState(tryOnModelOptions.ethnicity[0]); const [modelBody, setModelBody] = useState(tryOnModelOptions.body[0]); const [appearance, setAppearance] = useState(""); const [selectedScenes, setSelectedScenes] = useState([]); useEffect(() => { if (status === "done") { setIsCommandComposerCompact(true); } else if (status === "generating" || status === "idle") { setIsCommandComposerCompact(false); } }, [status]); useEffect(() => { writeEcommerceHistoryRecords(ecommerceHistoryRecords); }, [ecommerceHistoryRecords]); const [customScene, setCustomScene] = useState(""); const [smartScene, setSmartScene] = useState(false); const [tryOnRatio, setTryOnRatio] = useState(tryOnRatioOptions[0]); const [tryOnStatus, setTryOnStatus] = useState("idle"); const [tryOnResultImages, setTryOnResultImages] = useState([]); const [detailProductImages, setDetailProductImages] = useState([]); const [detailPlatform, setDetailPlatform] = useState(platformOptions[0]); const [detailMarket, setDetailMarket] = useState(marketOptions[0]); const [detailLanguage, setDetailLanguage] = useState(getPlatformDefaultLanguage(platformOptions[0], marketOptions[0])); const [detailRatio, setDetailRatio] = useState(getQuickSetRatioValue(getPlatformDefaultRatio(platformOptions[0], "detail"))); const [detailType, setDetailType] = useState(detailTypeOptions[0]); const [detailRequirement, setDetailRequirement] = useState(""); const [selectedDetailModules, setSelectedDetailModules] = useState(defaultDetailModuleIds); const [detailStatus, setDetailStatus] = useState("idle"); const [detailResultUrl, setDetailResultUrl] = useState(null); const productSetRatioOptions = useMemo( () => getPlatformRatioOptions(productSetPlatform, productSetOutput), [productSetOutput, productSetPlatform], ); const hotUploadedRatioOption = useMemo( () => cloneOutput === "hot" ? formatUploadedImageRatio(cloneReferenceImages[0]) : null, [cloneOutput, cloneReferenceImages], ); const baseCloneRatioOptions = useMemo( () => getPlatformRatioOptions(platform, cloneOutput), [cloneOutput, platform], ); const cloneRatioOptions = useMemo( () => hotUploadedRatioOption ? getUniqueRatioOptions([...baseCloneRatioOptions, hotUploadedRatioOption]) : baseCloneRatioOptions, [baseCloneRatioOptions, hotUploadedRatioOption], ); const productSetLanguageOptions = useMemo( () => getPlatformLanguageOptions(productSetPlatform, productSetMarket), [productSetMarket, productSetPlatform], ); const cloneLanguageOptions = useMemo( () => getPlatformLanguageOptions(platform, market), [market, platform], ); const detailLanguageOptions = useMemo( () => getPlatformLanguageOptions(detailPlatform, detailMarket), [detailMarket, detailPlatform], ); const ecommerceMentionImages: MentionImageOption[] = [ ...productImages.map((image, index) => ({ ...image, label: `商品图 ${index + 1}` })), ...cloneReferenceImages.map((image, index) => ({ ...image, label: `参考图 ${index + 1}` })), ]; const ecommerceVideoImageDataUrls = useMemo( () => productImages.map((img) => img.src), [productImages], ); const ecommerceVideoImageFiles = useMemo( () => productImages.map((img) => img.file), [productImages], ); const selectedProductSetOutput = productSetOutputOptions.find((option) => option.key === productSetOutput) ?? productSetOutputOptions[0]!; const selectedCloneOutput = cloneOutputOptions.find((option) => option.key === cloneOutput) ?? cloneOutputOptions[1]!; const cloneRequirementPlaceholder = cloneOutput === "model" ? "建议包含以下信息:产品名称、核心卖点、期望场景、模特外貌描述(如小麦色皮肤、齐刘海、眼角有泪痣)、具体参数" : "建议包含以下信息:产品名称、核心卖点、期望场景、具体参数"; const productSetPreviewReady = productSetStatus === "done"; const cloneSetTotal = useMemo( () => Object.values(cloneSetCounts).reduce((sum, value) => sum + value, 0), [cloneSetCounts], ); const canGenerateSet = setImages.length > 0 && productSetStatus !== "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: CSSProperties = useMemo( () => ({ "--clone-video-duration-progress": `${cloneVideoDurationProgress}%`, }) as CSSProperties, [cloneVideoDurationProgress], ); 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); }; const insertRequirementImageMention = (image: MentionImageOption) => { const textarea = requirementTextareaRef.current; const cursor = textarea?.selectionStart ?? requirement.length; const next = insertImageMentionValue(requirement, cursor, image.name, 500); setRequirement(next.value); setRequirementImageMentionQuery(null); window.requestAnimationFrame(() => { requirementTextareaRef.current?.focus(); requirementTextareaRef.current?.setSelectionRange(next.selectionStart, next.selectionStart); }); }; 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 : "鍟嗗搧鍥句笂浼犲け璐?"); } }; const handleSetUpload = (event: ChangeEvent) => { const files = event.target.files; if (!files?.length) return; void addSetImages(Array.from(files)); event.target.value = ""; }; const handleSetDrop = (event: DragEvent) => { event.preventDefault(); setIsSetUploadDragging(false); const files = Array.from(event.dataTransfer.files); if (files.length) void addSetImages(files); }; const revokeSmartCutoutItem = (item: SmartCutoutImageItem | null) => { if (!item) return; URL.revokeObjectURL(item.src); if (item.originalSrc && item.originalSrc !== item.src) URL.revokeObjectURL(item.originalSrc); }; const revokeSmartCutoutItems = (items: SmartCutoutImageItem[]) => { items.forEach(revokeSmartCutoutItem); }; const clearSmartCutoutTransition = () => { if (smartCutoutTransitionTimeoutRef.current !== null) { window.clearTimeout(smartCutoutTransitionTimeoutRef.current); smartCutoutTransitionTimeoutRef.current = null; } if (smartCutoutPendingUrlsRef.current.length) { smartCutoutPendingUrlsRef.current.forEach((url) => URL.revokeObjectURL(url)); smartCutoutPendingUrlsRef.current = []; } setIsSmartCutoutTransitioning(false); }; const runSmartCutoutPageTransition = (message: { title: string; subtitle: string }, action: () => void, delay = 460) => { clearSmartCutoutTransition(); setSmartCutoutTransitionMessage(message); setIsSmartCutoutTransitioning(true); smartCutoutTransitionTimeoutRef.current = window.setTimeout(() => { smartCutoutTransitionTimeoutRef.current = null; action(); setIsSmartCutoutTransitioning(false); }, delay); }; const openSmartCutoutUpload = () => { clearSmartCutoutTransition(); setSmartCutoutTransitionMessage({ title: "正在进入智能抠图", subtitle: "为你打开图片处理工具", }); setActiveQuickTool("cutout"); setSmartCutoutBatchImages((current) => { revokeSmartCutoutItems(current); return []; }); setSmartCutoutImage((current) => { revokeSmartCutoutItem(current); return null; }); setIsSmartCutoutComparing(false); setComposerMenu(null); }; const openWatermarkRemovalPage = () => { clearSmartCutoutTransition(); setActiveQuickTool("watermark"); setComposerMenu(null); setIsCloneSettingsCollapsed(false); }; const closeWatermarkRemovalPage = () => { if (watermarkProcessTimeoutRef.current !== null) { window.clearTimeout(watermarkProcessTimeoutRef.current); watermarkProcessTimeoutRef.current = null; } setActiveQuickTool(null); setWatermarkStatus("idle"); setWatermarkImage((current) => { if (current?.src) URL.revokeObjectURL(current.src); return null; }); }; const addWatermarkImage = (file: File) => { const nextImage = { src: URL.createObjectURL(file), name: file.name, format: getImageFileFormat(file) || "PNG / JPG / WebP", }; setWatermarkImage((current) => { if (current?.src) URL.revokeObjectURL(current.src); return nextImage; }); setWatermarkStatus("idle"); setActiveQuickTool("watermark"); }; const removeWatermarkImage = () => { if (watermarkProcessTimeoutRef.current !== null) { window.clearTimeout(watermarkProcessTimeoutRef.current); watermarkProcessTimeoutRef.current = null; } setWatermarkStatus("idle"); setWatermarkImage((current) => { if (current?.src) URL.revokeObjectURL(current.src); return null; }); }; const handleWatermarkUpload = (event: ChangeEvent) => { const file = event.target.files?.[0]; if (!file) return; addWatermarkImage(file); event.target.value = ""; }; const handleWatermarkDrop = (event: DragEvent) => { event.preventDefault(); setIsWatermarkDragging(false); const file = Array.from(event.dataTransfer.files).find((item) => item.type.startsWith("image/")); if (file) addWatermarkImage(file); }; const handleWatermarkGenerate = () => { if (!watermarkImage || watermarkStatus === "processing") return; if (watermarkProcessTimeoutRef.current !== null) window.clearTimeout(watermarkProcessTimeoutRef.current); setWatermarkStatus("processing"); watermarkProcessTimeoutRef.current = window.setTimeout(() => { watermarkProcessTimeoutRef.current = null; setWatermarkStatus("done"); toast.success("去水印处理完成"); }, 900); }; const handleWatermarkDownload = () => { if (!watermarkImage || watermarkStatus !== "done") { toast.info("请先完成去水印"); return; } const link = document.createElement("a"); const safeName = (watermarkImage.name || "watermark-result").replace(/\.[^.]+$/, "").replace(/[\\/:*?"<>|]+/g, "-"); link.href = watermarkImage.src; link.download = `${safeName || "watermark-result"}-去水印.png`; document.body.appendChild(link); link.click(); link.remove(); }; const openImageWorkbenchPage = () => { clearSmartCutoutTransition(); setActiveQuickTool("image-edit"); setComposerMenu(null); setIsCloneSettingsCollapsed(false); setImageWorkbenchStatus("idle"); }; const closeImageWorkbenchPage = () => { setActiveQuickTool(null); setImageWorkbenchStatus("idle"); setImageWorkbenchPrompt(""); setImageWorkbenchMaskStrokes([]); setImageWorkbenchBrushCursor(null); clearImageWorkbenchMaskCanvas(); imageWorkbenchMaskPaintingRef.current = false; imageWorkbenchActiveStrokeIdRef.current = null; setImageWorkbenchImage((current) => { if (current?.src) URL.revokeObjectURL(current.src); return null; }); }; const addImageWorkbenchImage = (file: File) => { if (!file.type.startsWith("image/")) { toast.error("请上传图片文件"); return; } const nextImage = { src: URL.createObjectURL(file), name: file.name, format: getImageFileFormat(file) || "PNG / JPG / WebP", }; setImageWorkbenchImage((current) => { if (current?.src) URL.revokeObjectURL(current.src); return nextImage; }); setImageWorkbenchStatus("idle"); setImageWorkbenchMaskStrokes([]); setImageWorkbenchBrushCursor(null); clearImageWorkbenchMaskCanvas(); imageWorkbenchActiveStrokeIdRef.current = null; setActiveQuickTool("image-edit"); }; const removeImageWorkbenchImage = () => { setImageWorkbenchStatus("idle"); setImageWorkbenchMaskStrokes([]); setImageWorkbenchBrushCursor(null); clearImageWorkbenchMaskCanvas(); imageWorkbenchMaskPaintingRef.current = false; imageWorkbenchActiveStrokeIdRef.current = null; setImageWorkbenchImage((current) => { if (current?.src) URL.revokeObjectURL(current.src); return null; }); }; const handleImageWorkbenchUpload = (event: ChangeEvent) => { const file = event.target.files?.[0]; if (!file) return; addImageWorkbenchImage(file); event.target.value = ""; }; const handleImageWorkbenchDrop = (event: DragEvent) => { event.preventDefault(); setIsImageWorkbenchDragging(false); const file = Array.from(event.dataTransfer.files).find((item) => item.type.startsWith("image/")); if (file) addImageWorkbenchImage(file); }; const handleImageWorkbenchGenerate = () => { if (!imageWorkbenchImage) { toast.info("请先上传图片"); return; } setImageWorkbenchStatus("processing"); window.setTimeout(() => { setImageWorkbenchStatus("done"); toast.success("局部重绘已完成"); }, 900); }; const syncImageWorkbenchMaskCanvas = () => { const canvas = imageWorkbenchMaskCanvasRef.current; if (!canvas) return null; const rect = canvas.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; const width = Math.max(1, Math.round(rect.width * dpr)); const height = Math.max(1, Math.round(rect.height * dpr)); if (canvas.width !== width || canvas.height !== height) { canvas.width = width; canvas.height = height; } const context = canvas.getContext("2d"); if (!context) return null; context.setTransform(dpr, 0, 0, dpr, 0, 0); context.lineCap = "round"; context.lineJoin = "round"; context.strokeStyle = "rgba(30, 189, 219, 0.52)"; context.fillStyle = "rgba(30, 189, 219, 0.52)"; context.lineWidth = imageWorkbenchBrushSize; return { canvas, context, rect }; }; const clearImageWorkbenchMaskCanvas = () => { const canvas = imageWorkbenchMaskCanvasRef.current; if (!canvas) return; const context = canvas.getContext("2d"); if (!context) return; context.setTransform(1, 0, 0, 1, 0, 0); context.clearRect(0, 0, canvas.width, canvas.height); }; const getImageWorkbenchMaskPoint = (event: ReactPointerEvent) => { const canvas = imageWorkbenchMaskCanvasRef.current; const rect = (canvas ?? event.currentTarget).getBoundingClientRect(); const x = clampNumber(event.clientX - rect.left, 0, rect.width); const y = clampNumber(event.clientY - rect.top, 0, rect.height); return { x, y }; }; const appendImageWorkbenchMaskPoint = (event: ReactPointerEvent) => { const point = getImageWorkbenchMaskPoint(event); const rect = imageWorkbenchMaskCanvasRef.current?.getBoundingClientRect(); setImageWorkbenchBrushCursor(rect ? { x: (point.x / Math.max(rect.width, 1)) * 100, y: (point.y / Math.max(rect.height, 1)) * 100 } : null); setImageWorkbenchStatus((current) => (current === "done" ? "idle" : current)); const activeStrokeId = imageWorkbenchActiveStrokeIdRef.current; if (!activeStrokeId) return; const lastPoint = imageWorkbenchLastMaskPointRef.current; if (lastPoint && Math.hypot(point.x - lastPoint.x, point.y - lastPoint.y) < 1.5) return; const synced = syncImageWorkbenchMaskCanvas(); if (!synced) return; synced.context.beginPath(); synced.context.moveTo(lastPoint?.x ?? point.x, lastPoint?.y ?? point.y); synced.context.lineTo(point.x, point.y); synced.context.stroke(); imageWorkbenchLastMaskPointRef.current = point; }; const handleImageWorkbenchMaskPointerDown = (event: ReactPointerEvent) => { if (!imageWorkbenchImage || event.button !== 0) return; event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); syncImageWorkbenchMaskCanvas(); const point = getImageWorkbenchMaskPoint(event); const rect = imageWorkbenchMaskCanvasRef.current?.getBoundingClientRect(); const strokeId = `mask-${Date.now()}`; imageWorkbenchMaskPaintingRef.current = true; imageWorkbenchActiveStrokeIdRef.current = strokeId; imageWorkbenchLastMaskPointRef.current = point; setImageWorkbenchBrushCursor(rect ? { x: (point.x / Math.max(rect.width, 1)) * 100, y: (point.y / Math.max(rect.height, 1)) * 100 } : null); setImageWorkbenchStatus((current) => (current === "done" ? "idle" : current)); setImageWorkbenchMaskStrokes((current) => [...current, { id: strokeId, size: imageWorkbenchBrushSize, points: [] }]); const synced = syncImageWorkbenchMaskCanvas(); if (synced) { synced.context.beginPath(); synced.context.arc(point.x, point.y, imageWorkbenchBrushSize / 2, 0, Math.PI * 2); synced.context.fill(); } }; const handleImageWorkbenchMaskPointerMove = (event: ReactPointerEvent) => { if (!imageWorkbenchImage) return; const point = getImageWorkbenchMaskPoint(event); const rect = imageWorkbenchMaskCanvasRef.current?.getBoundingClientRect(); setImageWorkbenchBrushCursor(rect ? { x: (point.x / Math.max(rect.width, 1)) * 100, y: (point.y / Math.max(rect.height, 1)) * 100 } : null); if (!imageWorkbenchMaskPaintingRef.current) return; appendImageWorkbenchMaskPoint(event); }; const stopImageWorkbenchMaskPainting = () => { imageWorkbenchMaskPaintingRef.current = false; imageWorkbenchActiveStrokeIdRef.current = null; imageWorkbenchLastMaskPointRef.current = null; }; const clearQuickSetSelectTimer = () => { if (quickSetSelectTimerRef.current) { window.clearTimeout(quickSetSelectTimerRef.current); quickSetSelectTimerRef.current = null; } }; const resetQuickSetSelectState = () => { clearQuickSetSelectTimer(); openQuickSetSelectRef.current = null; visibleQuickSetSelectRef.current = null; setOpenQuickSetSelect(null); setVisibleQuickSetSelect(null); setIsQuickSetSelectClosing(false); }; const closeQuickSetSelect = () => { if (!visibleQuickSetSelectRef.current) { openQuickSetSelectRef.current = null; setOpenQuickSetSelect(null); return; } clearQuickSetSelectTimer(); openQuickSetSelectRef.current = null; setOpenQuickSetSelect(null); setIsQuickSetSelectClosing(true); quickSetSelectTimerRef.current = window.setTimeout(() => { visibleQuickSetSelectRef.current = null; setVisibleQuickSetSelect(null); setIsQuickSetSelectClosing(false); quickSetSelectTimerRef.current = null; }, 420); }; const showQuickSetSelect = (key: CloneBasicSelectKey) => { clearQuickSetSelectTimer(); if (visibleQuickSetSelectRef.current && visibleQuickSetSelectRef.current !== key && openQuickSetSelectRef.current) { openQuickSetSelectRef.current = null; setOpenQuickSetSelect(null); setIsQuickSetSelectClosing(true); quickSetSelectTimerRef.current = window.setTimeout(() => { visibleQuickSetSelectRef.current = key; openQuickSetSelectRef.current = key; setVisibleQuickSetSelect(key); setOpenQuickSetSelect(key); setIsQuickSetSelectClosing(false); quickSetSelectTimerRef.current = null; }, 180); return; } visibleQuickSetSelectRef.current = key; openQuickSetSelectRef.current = key; setVisibleQuickSetSelect(key); setOpenQuickSetSelect(key); setIsQuickSetSelectClosing(false); }; const toggleQuickSetSelect = (key: CloneBasicSelectKey) => { if (openQuickSetSelectRef.current === key) { closeQuickSetSelect(); return; } showQuickSetSelect(key); }; const openQuickProductSetPage = () => { clearSmartCutoutTransition(); setActiveQuickTool("set"); setComposerMenu(null); setIsCloneSettingsCollapsed(false); setIsQuickPanelCollapsed(false); setPreviewZoom(1); resetQuickSetSelectState(); }; const openQuickDetailPage = () => { clearSmartCutoutTransition(); setActiveQuickTool("detail"); setComposerMenu(null); setIsCloneSettingsCollapsed(false); setIsQuickPanelCollapsed(false); setPreviewZoom(1); resetQuickSetSelectState(); if (!selectedDetailModules.length) setSelectedDetailModules(defaultCloneDetailModuleIds); }; const openHotVideoPage = () => { clearSmartCutoutTransition(); setActiveQuickTool("hot-video"); setComposerMenu(null); setIsCloneSettingsCollapsed(true); setIsCommandHistoryCollapsed(true); }; const closeHotVideoPage = () => { setActiveQuickTool(null); setComposerMenu(null); setIsCloneSettingsCollapsed(false); setIsCommandHistoryCollapsed(false); }; const closeSmartCutoutTool = () => { runSmartCutoutPageTransition( { title: "正在返回首页", subtitle: "回到电商智能体", }, () => { setSmartCutoutBatchImages((current) => { revokeSmartCutoutItems(current); return []; }); setSmartCutoutImage((current) => { revokeSmartCutoutItem(current); return null; }); setIsSmartCutoutComparing(false); setActiveQuickTool(null); setComposerMenu(null); }, ); }; const goSmartCutoutPrevious = () => { if (!smartCutoutImage) { closeSmartCutoutTool(); return; } runSmartCutoutPageTransition( { title: "正在返回上一页", subtitle: "回到图片上传页", }, () => { setSmartCutoutBatchImages((current) => { revokeSmartCutoutItems(current); return []; }); setSmartCutoutImage((current) => { revokeSmartCutoutItem(current); return null; }); setIsSmartCutoutComparing(false); }, ); }; const addSmartCutoutImage = (files: File[]) => { const imageFiles = files.filter((file) => file.type.startsWith("image/")); if (!imageFiles.length) { toast.error("请上传图片文件"); return; } clearSmartCutoutTransition(); setSmartCutoutBatchImages((current) => { revokeSmartCutoutItems(current); return []; }); setSmartCutoutImage((current) => { revokeSmartCutoutItem(current); return null; }); setIsSmartCutoutComparing(false); const nextImages = imageFiles.map((file) => { const originalSrc = URL.createObjectURL(file); return { src: originalSrc, originalSrc, name: file.name }; }); smartCutoutPendingUrlsRef.current = nextImages.map((item) => item.src); setActiveQuickTool("cutout"); setSmartCutoutSizeKey("original"); setSmartCutoutTransitionMessage({ title: imageFiles.length > 1 ? "正在批量抠图" : "正在智能抠图", subtitle: imageFiles.length > 1 ? `正在处理 ${imageFiles.length} 张图片` : "即将进入图片编辑室", }); setIsSmartCutoutTransitioning(true); smartCutoutTransitionTimeoutRef.current = window.setTimeout(() => { smartCutoutTransitionTimeoutRef.current = null; smartCutoutPendingUrlsRef.current = []; setSmartCutoutBatchImages(nextImages); setSmartCutoutImage(nextImages[0]); setIsSmartCutoutTransitioning(false); }, 620); }; const handleSmartCutoutUpload = (event: ChangeEvent) => { const files = event.target.files; if (!files?.length) return; addSmartCutoutImage(Array.from(files)); event.target.value = ""; }; const handleSmartCutoutDrop = (event: DragEvent) => { event.preventDefault(); setIsSmartCutoutDragging(false); const files = Array.from(event.dataTransfer.files); if (files.length) addSmartCutoutImage(files); }; const smartCutoutBackgroundValue = useMemo(() => { const rgb = hexToRgb(smartCutoutBackgroundColor) ?? { r: 255, g: 255, b: 255 }; if (smartCutoutBackgroundAlpha >= 100) return smartCutoutBackgroundColor; return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${Math.round(smartCutoutBackgroundAlpha) / 100})`; }, [smartCutoutBackgroundAlpha, smartCutoutBackgroundColor]); const smartCutoutColorHsv = useMemo(() => hexToHsv(smartCutoutBackgroundColor), [smartCutoutBackgroundColor]); const selectedSmartCutoutSize = useMemo( () => smartCutoutSizeOptions.find((option) => option.key === smartCutoutSizeKey) ?? smartCutoutSizeOptions[0], [smartCutoutSizeKey], ); const previewSmartCutoutSize = isSmartCutoutComparing ? smartCutoutSizeOptions[0] : selectedSmartCutoutSize; const previewSmartCutoutSizeKey = isSmartCutoutComparing ? "original" : smartCutoutSizeKey; const previewSmartCutoutImageSrc = isSmartCutoutComparing ? smartCutoutImage?.originalSrc ?? smartCutoutImage?.src : smartCutoutImage?.src; const smartCutoutFrameStyle = useMemo( () => ({ "--smart-cutout-bg": smartCutoutBackgroundValue, "--smart-cutout-frame-width": previewSmartCutoutSize.frameWidth, "--smart-cutout-frame-aspect": previewSmartCutoutSize.frameAspect, "--smart-cutout-image-max-width": previewSmartCutoutSize.imageMaxWidth, "--smart-cutout-image-max-height": previewSmartCutoutSize.imageMaxHeight, } as CSSProperties), [previewSmartCutoutSize, smartCutoutBackgroundValue], ); const showSmartCutoutOriginalCompare = (event: ReactPointerEvent) => { event.currentTarget.setPointerCapture(event.pointerId); setIsSmartCutoutComparing(true); }; const hideSmartCutoutOriginalCompare = () => { setIsSmartCutoutComparing(false); }; const applySmartCutoutHsv = (h: number, s: number, v: number) => { const rgb = hsvToRgb(h, s, v); setSmartCutoutBackgroundColor(rgbToHex(rgb.r, rgb.g, rgb.b)); }; const updateSmartCutoutColorFromPoint = (element: HTMLElement, clientX: number, clientY: number) => { const rect = element.getBoundingClientRect(); const saturation = clampNumber(((clientX - rect.left) / rect.width) * 100, 0, 100); const value = clampNumber(100 - ((clientY - rect.top) / rect.height) * 100, 0, 100); applySmartCutoutHsv(smartCutoutColorHsv.h, saturation, value); }; const handleSmartCutoutColorPlanePointer = (event: ReactPointerEvent) => { event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); updateSmartCutoutColorFromPoint(event.currentTarget, event.clientX, event.clientY); }; const handleSmartCutoutColorPlaneMove = (event: ReactPointerEvent) => { if (event.buttons !== 1) return; updateSmartCutoutColorFromPoint(event.currentTarget, event.clientX, event.clientY); }; const handleSmartCutoutHexChange = (value: string) => { const nextValue = value.startsWith("#") ? value : `#${value}`; if (!/^#[0-9a-fA-F]{0,6}$/.test(nextValue)) return; setSmartCutoutHexDraft(nextValue); const normalized = normalizeHexColor(nextValue); if (normalized) setSmartCutoutBackgroundColor(normalized); }; const scrollSmartCutoutTools = (direction: -1 | 1) => { smartCutoutToolsRef.current?.scrollBy({ left: direction * 340, behavior: "smooth", }); }; const handleSmartCutoutDownload = async () => { if (!smartCutoutImage) { toast.error("请先上传图片"); return; } try { const image = new Image(); image.decoding = "async"; const imageLoaded = new Promise((resolve, reject) => { image.onload = () => resolve(); image.onerror = () => reject(new Error("图片加载失败")); }); image.src = smartCutoutImage.src; await imageLoaded; const aspect = parseSmartCutoutAspect(selectedSmartCutoutSize.frameAspect); const naturalWidth = Math.max(1, image.naturalWidth || image.width || 1200); const naturalHeight = Math.max(1, image.naturalHeight || image.height || 900); const outputWidth = "outputWidth" in selectedSmartCutoutSize ? selectedSmartCutoutSize.outputWidth : undefined; const outputHeight = "outputHeight" in selectedSmartCutoutSize ? selectedSmartCutoutSize.outputHeight : undefined; let canvasWidth = naturalWidth; let canvasHeight = naturalHeight; if (outputWidth && outputHeight) { canvasWidth = outputWidth; canvasHeight = outputHeight; } else if (aspect) { const longSide = 1600; if (aspect >= 1) { canvasWidth = longSide; canvasHeight = Math.round(longSide / aspect); } else { canvasHeight = longSide; canvasWidth = Math.round(longSide * aspect); } } else { const maxSide = 1600; const scale = Math.min(1, maxSide / Math.max(naturalWidth, naturalHeight)); canvasWidth = Math.max(1, Math.round(naturalWidth * scale)); canvasHeight = Math.max(1, Math.round(naturalHeight * scale)); } const canvas = document.createElement("canvas"); canvas.width = canvasWidth; canvas.height = canvasHeight; const context = canvas.getContext("2d"); if (!context) throw new Error("无法生成图片"); context.clearRect(0, 0, canvasWidth, canvasHeight); context.fillStyle = smartCutoutBackgroundValue; context.fillRect(0, 0, canvasWidth, canvasHeight); const maxWidthRatio = parseSmartCutoutPercent(selectedSmartCutoutSize.imageMaxWidth, 0.82); const maxHeightRatio = parseSmartCutoutPercent(selectedSmartCutoutSize.imageMaxHeight, 0.82); const fitScale = Math.min((canvasWidth * maxWidthRatio) / naturalWidth, (canvasHeight * maxHeightRatio) / naturalHeight, 1); const drawWidth = Math.round(naturalWidth * fitScale); const drawHeight = Math.round(naturalHeight * fitScale); const drawX = Math.round((canvasWidth - drawWidth) / 2); const drawY = Math.round((canvasHeight - drawHeight) / 2); context.drawImage(image, drawX, drawY, drawWidth, drawHeight); const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png")); if (!blob) throw new Error("图片导出失败"); const objectUrl = URL.createObjectURL(blob); const link = document.createElement("a"); const safeName = (smartCutoutImage.name || "smart-cutout").replace(/\.[^.]+$/, "").replace(/[\\/:*?"<>|]+/g, "-"); link.href = objectUrl; link.download = `${safeName || "smart-cutout"}-${selectedSmartCutoutSize.label}.png`; document.body.appendChild(link); link.click(); link.remove(); window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000); toast.success("已下载图片"); } catch (error) { toast.error(error instanceof Error ? error.message : "下载图片失败"); } }; useEffect(() => { setSmartCutoutHexDraft(smartCutoutBackgroundColor); }, [smartCutoutBackgroundColor]); useEffect(() => { if (!isSmartCutoutPaletteOpen) return undefined; const handlePointerDown = (event: PointerEvent) => { if (!smartCutoutPaletteRef.current?.contains(event.target as Node)) { setIsSmartCutoutPaletteOpen(false); } }; document.addEventListener("pointerdown", handlePointerDown); return () => document.removeEventListener("pointerdown", handlePointerDown); }, [isSmartCutoutPaletteOpen]); const removeSetImage = (imageId: string) => { setSetImages((current) => { const next = current.filter((item) => item.id !== imageId); if (next.length === 0) setProductSetStatus("idle"); return next; }); }; const addProductImages = async (files: File[]) => { const imageFiles = notifyRejectedImages(files); 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 : "鍟嗗搧鍥句笂浼犲け璐?"); } }; const handleProductUpload = (event: ChangeEvent) => { const files = event.target.files; if (!files?.length) return; void addProductImages(Array.from(files)); event.target.value = ""; }; const addComposerAssets = (files: File[]) => { const imageFiles = files.filter((file) => file.type.startsWith("image/")); const videoFiles = files.filter((file) => file.type.startsWith("video/")); const unsupportedCount = files.length - imageFiles.length - videoFiles.length; if (imageFiles.length) void addProductImages(imageFiles); if (videoFiles[0]) { setVideoOutfitVideoFile(videoFiles[0]); setStatus("ready"); setResults([]); } if (unsupportedCount > 0) toast.info("浠呮敮鎸佷笂浼犲浘鐗囨垨瑙嗛鏂囦欢"); }; const handleComposerAssetUpload = (event: ChangeEvent) => { const files = event.target.files; if (!files?.length) return; addComposerAssets(Array.from(files)); event.target.value = ""; }; const handleProductDrop = (event: DragEvent) => { event.preventDefault(); setIsProductUploadDragging(false); const files = Array.from(event.dataTransfer.files); if (files.length) void addProductImages(files); }; const handleQuickProductDrop = (event: DragEvent) => { event.preventDefault(); const files = Array.from(event.dataTransfer.files); if (files.length) void addProductImages(files); }; const removeProductImage = (imageId: string) => { setProductImages((current) => { const next = current.filter((item) => item.id !== imageId); if (next.length === 0) { setStatus("idle"); setResults([]); } return next; }); }; const hydrateCloneReferenceImageMeta = (items: CloneImageItem[]) => { items.forEach((item) => { readImageDimensions(item.src) .then(({ width, height }) => { setCloneReferenceImages((current) => current.map((currentItem) => (currentItem.id === item.id ? { ...currentItem, width, height } : currentItem)), ); }) .catch(() => undefined); }); }; const addCloneReferenceImages = async (files: File[]) => { const imageFiles = notifyRejectedImages(files); 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 handleCloneReferenceUpload = (event: ChangeEvent) => { const files = event.target.files; if (!files?.length) return; void addCloneReferenceImages(Array.from(files)); event.target.value = ""; }; const [isCloneReferenceDragging, setIsCloneReferenceDragging] = useState(false); const handleCloneReferenceDragOver = (event: DragEvent) => { event.preventDefault(); event.stopPropagation(); if (event.dataTransfer.types.includes("Files")) { setIsCloneReferenceDragging(true); } }; const handleCloneReferenceDragLeave = (event: DragEvent) => { event.preventDefault(); event.stopPropagation(); if (event.currentTarget === event.target || !event.currentTarget.contains(event.relatedTarget as Node)) { setIsCloneReferenceDragging(false); } }; const handleCloneReferenceDrop = (event: DragEvent) => { event.preventDefault(); event.stopPropagation(); setIsCloneReferenceDragging(false); const files = Array.from(event.dataTransfer.files); if (files.length) addCloneReferenceImages(files); }; const updateCloneSetCount = (key: CloneSetCountKey, delta: -1 | 1) => { setCloneSetCounts((current) => { const total = Object.values(current).reduce((sum, value) => sum + value, 0); const nextValue = current[key] + delta; if (delta < 0 && (current[key] <= 0 || total <= minCloneSetTotal)) return current; if (delta > 0 && total >= maxCloneSetTotal) return current; return { ...current, [key]: Math.max(0, Math.min(maxCloneSetTotal, nextValue)) }; }); }; const clearCloneSetCountHold = () => { window.removeEventListener("pointerup", clearCloneSetCountHold); window.removeEventListener("pointercancel", clearCloneSetCountHold); if (countHoldTimeoutRef.current !== null) { window.clearTimeout(countHoldTimeoutRef.current); countHoldTimeoutRef.current = null; } if (countHoldIntervalRef.current !== null) { window.clearInterval(countHoldIntervalRef.current); countHoldIntervalRef.current = null; } }; const startCloneSetCountHold = (key: CloneSetCountKey, delta: -1 | 1, disabled: boolean) => { if (disabled) return; clearCloneSetCountHold(); updateCloneSetCount(key, delta); window.addEventListener("pointerup", clearCloneSetCountHold, { once: true }); window.addEventListener("pointercancel", clearCloneSetCountHold, { once: true }); countHoldTimeoutRef.current = window.setTimeout(() => { countHoldIntervalRef.current = window.setInterval(() => updateCloneSetCount(key, delta), 110); }, 320); }; const toggleCloneDetailModule = (moduleId: string) => { setSelectedCloneDetailModules((current) => { if (current.includes(moduleId)) return current.filter((item) => item !== moduleId); if (current.length >= maxDetailModuleSelection) { toast.info(`最多选择 ${maxDetailModuleSelection} 个模块`); return current; } return [...current, moduleId]; }); }; const toggleCloneModelScene = (scene: string) => { setSelectedCloneModelScenes((current) => (current[0] === scene ? [] : [scene])); }; const handleProductSetPlatformChange = (nextPlatform: string) => { const normalizedPlatform = normalizePlatform(nextPlatform); setProductSetPlatform(normalizedPlatform); setProductSetRatio((current) => normalizeRatioForPlatform(normalizedPlatform, current, productSetOutput)); setProductSetLanguage(getPlatformDefaultLanguage(normalizedPlatform, productSetMarket)); }; const handleProductSetOutputChange = (nextOutput: ProductSetOutputKey) => { setProductSetOutput(nextOutput); setProductSetRatio((current) => normalizeRatioForPlatform(productSetPlatform, current, nextOutput)); }; const handleProductSetMarketChange = (nextMarket: string) => { const normalizedMarket = normalizeMarket(nextMarket); setProductSetMarket(normalizedMarket); setProductSetLanguage(getPlatformDefaultLanguage(productSetPlatform, normalizedMarket)); }; const handleClonePlatformChange = (nextPlatform: string) => { const normalizedPlatform = normalizePlatform(nextPlatform); setPlatform(normalizedPlatform); setRatio((current) => cloneOutput === "hot" && current.startsWith("涓婁紶鍥剧墖") && hotUploadedRatioOption ? hotUploadedRatioOption : normalizeRatioForPlatform(normalizedPlatform, current, cloneOutput), ); setLanguage(getPlatformDefaultLanguage(normalizedPlatform, market)); }; const handleCloneOutputChange = (nextOutput: CloneOutputKey) => { setCloneOutput(nextOutput); setRatio((current) => nextOutput === "hot" && current.startsWith("涓婁紶鍥剧墖") && hotUploadedRatioOption ? hotUploadedRatioOption : normalizeRatioForPlatform(platform, current, nextOutput), ); }; const handleCloneMarketChange = (nextMarket: string) => { const normalizedMarket = normalizeMarket(nextMarket); setMarket(normalizedMarket); setLanguage(getPlatformDefaultLanguage(platform, normalizedMarket)); }; const handleDetailPlatformChange = (nextPlatform: string) => { const normalizedPlatform = normalizePlatform(nextPlatform); setDetailPlatform(normalizedPlatform); setDetailLanguage(getPlatformDefaultLanguage(normalizedPlatform, detailMarket)); setDetailRatio((current) => getQuickSetRatioValue(current)); }; const handleDetailMarketChange = (nextMarket: string) => { const normalizedMarket = normalizeMarket(nextMarket); setDetailMarket(normalizedMarket); setDetailLanguage(getPlatformDefaultLanguage(detailPlatform, normalizedMarket)); }; const createCloneSettingSnapshot = (name: string, id = `clone-setting-${Date.now()}`): CloneSavedSetting => ({ id, name, savedAt: new Date().toISOString(), output: cloneOutput, platform, market, language, ratio, setCounts: { ...cloneSetCounts }, detailModules: [...selectedCloneDetailModules], modelPanelTab: cloneModelPanelTab, modelScenes: [...selectedCloneModelScenes], modelCustomScene: cloneModelCustomScene, modelGender: cloneModelGender, modelAge: cloneModelAge, modelEthnicity: cloneModelEthnicity, modelBody: cloneModelBody, modelAppearance: cloneModelAppearance, videoQuality: cloneVideoQuality, videoDurationSeconds: cloneVideoDuration, videoSmart: cloneVideoSmart, referenceMode: cloneReferenceMode, replicateLevel: cloneReplicateLevel, requirement, }); const latestCloneSettingSnapshot = useMemo( () => createCloneSettingSnapshot(cloneSettingName, "clone-setting-latest"), [ cloneOutput, platform, market, language, ratio, cloneSetCounts, selectedCloneDetailModules, cloneModelPanelTab, selectedCloneModelScenes, cloneModelCustomScene, cloneModelGender, cloneModelAge, cloneModelEthnicity, cloneModelBody, cloneModelAppearance, cloneVideoQuality, cloneVideoDuration, cloneVideoSmart, cloneReferenceMode, cloneReplicateLevel, requirement, cloneSettingName, ], ); const persistLatestCloneSetting = () => { const snapshot = createCloneSettingSnapshot(cloneSettingName, "clone-setting-latest"); latestCloneSettingRef.current = snapshot; writeCloneLatestSetting(snapshot); return snapshot; }; const applyCloneSavedSetting = (setting: CloneSavedSetting) => { const nextCounts = { selling: Number.isFinite(setting.setCounts?.selling) ? setting.setCounts.selling : defaultCloneSetCounts.selling, white: Number.isFinite(setting.setCounts?.white) ? setting.setCounts.white : defaultCloneSetCounts.white, scene: Number.isFinite(setting.setCounts?.scene) ? setting.setCounts.scene : defaultCloneSetCounts.scene, }; const nextPlatform = normalizePlatform(setting.platform); const nextMarket = normalizeMarket(setting.market); const nextOutput = cloneOutputOptions.some((option) => option.key === setting.output) ? setting.output : defaultCloneOutput; setCloneOutput(nextOutput); setPlatform(nextPlatform); setMarket(nextMarket); setLanguage(normalizeLanguageForPlatform(nextPlatform, nextMarket, setting.language)); setRatio(normalizeRatioForPlatform(nextPlatform, setting.ratio, nextOutput)); setCloneSetCounts(nextCounts); setSelectedCloneDetailModules((setting.detailModules?.length ? setting.detailModules : defaultCloneDetailModuleIds).slice(0, maxDetailModuleSelection)); setCloneModelPanelTab(setting.modelPanelTab === "model" ? "model" : "scene"); setSelectedCloneModelScenes(normalizeCloneModelSceneSelection(setting.modelScenes)); setCloneModelCustomScene(setting.modelCustomScene ?? ""); setCloneModelGender(tryOnModelOptions.gender.includes(setting.modelGender) ? setting.modelGender : tryOnModelOptions.gender[0]); setCloneModelAge(tryOnModelOptions.age.includes(setting.modelAge) ? setting.modelAge : tryOnModelOptions.age[0]); setCloneModelEthnicity( tryOnModelOptions.ethnicity.includes(setting.modelEthnicity) ? setting.modelEthnicity : tryOnModelOptions.ethnicity[0], ); setCloneModelBody(tryOnModelOptions.body.includes(setting.modelBody) ? setting.modelBody : tryOnModelOptions.body[0]); setCloneModelAppearance(setting.modelAppearance ?? ""); setCloneVideoQuality( cloneVideoQualityOptions.some((option) => option.key === setting.videoQuality) ? setting.videoQuality : "high", ); setCloneVideoDuration(clampCloneVideoDuration(setting.videoDurationSeconds)); setCloneVideoSmart(Boolean(setting.videoSmart)); setCloneReferenceMode(setting.referenceMode === "link" ? "link" : "upload"); setCloneReplicateLevel(setting.replicateLevel === "style" ? "style" : "high"); setRequirement((setting.requirement ?? "").slice(0, 500)); setCloneSettingName(setting.name); latestCloneSettingRef.current = setting; writeCloneLatestSetting(setting); }; useEffect(() => { latestCloneSettingRef.current = latestCloneSettingSnapshot; }, [latestCloneSettingSnapshot]); useEffect(() => { window.localStorage.removeItem(cloneLatestSettingStorageKey); }, []); useEffect(() => { setProductSetRatio((current) => normalizeRatioForPlatform(productSetPlatform, current, productSetOutput)); }, [productSetOutput, productSetPlatform]); useEffect(() => { if (activeQuickTool === "set") { setRatio((current) => getQuickSetRatioValue(current)); return; } setRatio((current) => { const platformRatios = getPlatformRatioOptions(platform, cloneOutput); const availableRatios = hotUploadedRatioOption ? getUniqueRatioOptions([...platformRatios, hotUploadedRatioOption]) : platformRatios; if (current.startsWith("涓婁紶鍥剧墖") && hotUploadedRatioOption) return hotUploadedRatioOption; if (availableRatios.includes(current)) return current; const normalizedRatio = normalizeRatioToken(current); const matchedRatio = availableRatios.find((option) => normalizeRatioToken(option).includes(normalizedRatio)); return matchedRatio ?? getPlatformDefaultRatio(platform, cloneOutput); }); }, [activeQuickTool, cloneOutput, hotUploadedRatioOption, platform]); useEffect(() => { if (skipInitialCloneAutoSaveRef.current) { skipInitialCloneAutoSaveRef.current = false; return undefined; } if (skipNextCloneAutoSaveRef.current) { skipNextCloneAutoSaveRef.current = false; return undefined; } const timeoutId = window.setTimeout(() => { persistLatestCloneSetting(); }, 300); return () => window.clearTimeout(timeoutId); }, [ activeTool, cloneOutput, platform, market, language, ratio, cloneSetCounts, selectedCloneDetailModules, cloneModelPanelTab, selectedCloneModelScenes, cloneModelCustomScene, cloneModelGender, cloneModelAge, cloneModelEthnicity, cloneModelBody, cloneModelAppearance, cloneVideoQuality, cloneVideoDuration, cloneVideoSmart, cloneReferenceMode, cloneReplicateLevel, requirement, cloneSettingName, ]); useEffect(() => { const persistSnapshot = () => { if (latestCloneSettingRef.current) writeCloneLatestSetting(latestCloneSettingRef.current); }; const handleVisibilityChange = () => { if (document.visibilityState === "hidden") persistSnapshot(); }; window.addEventListener("pagehide", persistSnapshot); document.addEventListener("visibilitychange", handleVisibilityChange); return () => { persistSnapshot(); window.removeEventListener("pagehide", persistSnapshot); document.removeEventListener("visibilitychange", handleVisibilityChange); }; }, []); useEffect(() => clearCloneSetCountHold, []); useEffect(() => { if (!openCloneBasicSelect) return undefined; const handlePointerDown = (event: PointerEvent) => { const target = event.target; if (!(target instanceof Element) || target.closest("[data-clone-basic-select]")) return; setOpenCloneBasicSelect(null); }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") setOpenCloneBasicSelect(null); }; document.addEventListener("pointerdown", handlePointerDown); document.addEventListener("keydown", handleKeyDown); return () => { document.removeEventListener("pointerdown", handlePointerDown); document.removeEventListener("keydown", handleKeyDown); }; }, [openCloneBasicSelect]); useEffect(() => { if (!composerMenu && !(status === "done" && !isCommandComposerCompact)) return undefined; const handlePointerDown = (event: PointerEvent) => { const target = event.target; if (!(target instanceof Node)) return; const composer = commandComposerWrapRef.current; if (composer?.contains(target)) return; if (composerMenu) setComposerMenu(null); if (status === "done" && !isCommandComposerCompact) setIsCommandComposerCompact(true); }; document.addEventListener("pointerdown", handlePointerDown); return () => document.removeEventListener("pointerdown", handlePointerDown); }, [composerMenu, isCommandComposerCompact, status]); useEffect(() => { if (composerMenuCloseTimeoutRef.current !== null) { window.clearTimeout(composerMenuCloseTimeoutRef.current); composerMenuCloseTimeoutRef.current = null; } if (composerMenu) { setVisibleComposerMenu(composerMenu); setIsComposerMenuClosing(false); return; } if (!visibleComposerMenu) return; setIsComposerMenuClosing(true); composerMenuCloseTimeoutRef.current = window.setTimeout(() => { composerMenuCloseTimeoutRef.current = null; setVisibleComposerMenu(null); setIsComposerMenuClosing(false); }, 220); }, [composerMenu, visibleComposerMenu]); useEffect( () => () => { if (composerMenuCloseTimeoutRef.current !== null) { window.clearTimeout(composerMenuCloseTimeoutRef.current); } }, [], ); useEffect(() => { if (!openCloneModelSelect) return undefined; const handlePointerDown = (event: PointerEvent) => { const target = event.target; if (!(target instanceof Element) || target.closest("[data-clone-model-select]")) return; setOpenCloneModelSelect(null); setCloneModelSelectDropUp(false); }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { setOpenCloneModelSelect(null); setCloneModelSelectDropUp(false); } }; document.addEventListener("pointerdown", handlePointerDown); document.addEventListener("keydown", handleKeyDown); return () => { document.removeEventListener("pointerdown", handlePointerDown); document.removeEventListener("keydown", handleKeyDown); }; }, [openCloneModelSelect]); 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 : "鏈嶉グ鍥句笂浼犲け璐?"); } })(); event.target.value = ""; }; const handleDetailUpload = (event: ChangeEvent) => { const files = event.target.files; if (!files?.length) return; void addDetailImages(Array.from(files)); event.target.value = ""; }; const handleDetailDrop = (event: DragEvent) => { event.preventDefault(); const files = Array.from(event.dataTransfer.files); if (files.length) void addDetailImages(files); }; const removeDetailImage = (imageId: string) => { setDetailProductImages((current) => { const next = current.filter((item) => item.id !== imageId); if (next.length === 0) { setDetailStatus("idle"); setDetailResultUrl(null); } return next; }); }; 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 addDetailImages = async (files: File[]) => { const uploadedFiles = notifyRejectedImages(files); if (!uploadedFiles.length) return; try { const nextImages = await createUploadedImageItems(uploadedFiles, 3 - detailProductImages.length, "detail"); setDetailProductImages((current) => { if (current.length >= 3) return current; return nextImages.length ? [...current, ...nextImages].slice(0, 3) : current; }); setDetailStatus("ready"); setDetailResultUrl(null); } catch (err) { toast.error(err instanceof Error ? err.message : "璇︽儏鍥句笂浼犲け璐?"); } }; const uploadCloneImages = async (images: CloneImageItem[]): Promise => { 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" }); urls.push(url); } catch { // skip images that fail to upload } } return urls; }; const IMAGE_MODEL = "gpt-image-2"; const setCountLabels: Record = { selling: { label: "鍗栫偣鍥?", promptDesc: "selling-point infographic image highlighting core product advantages and detail close-ups" }, white: { label: "鐧藉簳鍥?", promptDesc: "clean white-background product photo showing the item from its best angle, studio lighting, no props" }, 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.`); } parts.push(`Platform: ${pPlatform}. Aspect ratio: ${pRatio}. Language/copy: ${pLanguage}. Market: ${pMarket}.`); parts.push("Must comply with platform image guidelines 鈥?proper margins, no watermark, professional quality."); return parts.join(" "); }; 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."); parts.push(`Replicate the visual style, color palette, and layout feel of the source product image, then adapt it for ${pPlatform} marketplace standards.`); parts.push(`Platform: ${pPlatform}. Aspect ratio: ${pRatio}. Language/copy: ${pLanguage}. Market: ${pMarket}.`); parts.push("The result must look professional and optimized for high click-through rate and conversion on the specified platform."); } if (userText.trim()) { parts.push(`Additional user requirements: ${userText.trim()}`); } return parts.join(" "); }; const generateSetImages = async ( images: CloneImageItem[], counts: Record, userText: string, pPlatform: string, pRatio: string, pLanguage: string, pMarket: string, setStatusFn: (status: "generating" | "done" | "idle" | "failed") => void, setResultFn: (urls: string[]) => void, ): Promise => { setStatusFn("generating"); try { const referenceUrls = await uploadCloneImages(images); if (!referenceUrls.length) { setStatusFn("idle"); return; } if (imageAbortRef.current.current) { setStatusFn("idle"); return; } const generatedUrls: string[] = []; const stamp = Date.now(); for (const countKey of cloneSetCountKeys) { if (imageAbortRef.current.current) break; const count = counts[countKey]; for (let i = 0; i < count; i++) { if (imageAbortRef.current.current) break; const subPrompt = buildSetSubPrompt(countKey, i, count, pPlatform, pRatio, pLanguage, pMarket); const fullPrompt = userText.trim() ? `${subPrompt} Additional user requirements: ${userText.trim()}` : subPrompt; const { taskId } = await aiGenerationClient.createImageTask({ model: IMAGE_MODEL, prompt: fullPrompt, ratio: normalizeRatioForApi(pRatio), quality: pRatio.includes("720") ? "720P" : "1080P", 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; 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 }); } else { generatedUrls.push(""); imageGen.updateTask(storeId, { status: "failed", error: "鐢熸垚鏈繑鍥炵粨鏋?" }); } } } if (imageAbortRef.current.current) { setStatusFn("idle"); return; } setResultFn(generatedUrls); setStatusFn(generatedUrls.some(Boolean) ? "done" : "failed"); } 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"); } }; const generateEcommerceImage = async ( outputKey: CloneOutputKey, images: CloneImageItem[], userText: string, pPlatform: string, pRatio: string, pLanguage: string, pMarket: string, tryOnOptions?: EcommerceImagePromptOptions, statusFn?: (status: "generating" | "done" | "idle" | "failed") => void, resultFn?: (results: CloneResult[]) => void, ): Promise => { statusFn?.("generating"); try { const referenceUrls = await uploadCloneImages(images); if (!referenceUrls.length) { statusFn?.("idle"); return; } if (imageAbortRef.current.current) { statusFn?.("idle"); return; } const prompt = buildEcommerceImagePrompt(outputKey, userText, pPlatform, pRatio, pLanguage, pMarket, tryOnOptions); const stamp = Date.now(); const { taskId } = await aiGenerationClient.createImageTask({ model: IMAGE_MODEL, prompt, ratio: normalizeRatioForApi(pRatio), quality: pRatio.includes("720") ? "720P" : "1080P", 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; } 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 }); } else { statusFn?.("idle"); imageGen.updateTask(storeId, { status: "failed", error: "鐢熸垚鏈繑鍥炵粨鏋?" }); } } 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); } statusFn?.("failed"); } }; 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 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 handleGenerate = () => { if (!canGenerate) return; if ((appUsage?.balanceCents ?? 0) <= 0) { toast.error("绉垎涓嶈冻锛岃鍏呭€煎悗缁х画"); return; } if (cloneOutput === "set" && cloneSetTotal > 5) { if (!window.confirm("将生成 " + String(cloneSetTotal) + " 张图片,可能消耗较多积分,是否继续?")) return; } imageAbortRef.current = { current: false }; lastFailedActionRef.current = null; if (cloneOutput === "video-outfit") { void handleVideoOutfitGenerate(); } else if (cloneOutput === "set") { void generateSetImages( productImages, cloneSetCounts, requirement, platform, ratio, language, market, (s) => setStatus(s as ProductCloneStatus), (urls) => setProductSetResultImages(urls), ); } else { const clonePromptOptions: EcommerceImagePromptOptions | undefined = cloneOutput === "model" ? { gender: cloneModelGender, age: cloneModelAge, ethnicity: cloneModelEthnicity, body: cloneModelBody, 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, ); 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) => { const urls: string[] = []; for (const item of res) { if (item.src) urls.push(item.src); } setTryOnResultImages(urls); }, ); lastFailedActionRef.current = () => handleTryOnGenerate(); }; const toggleScene = (scene: string) => { setSelectedScenes((current) => current.includes(scene) ? current.filter((item) => item !== scene) : [...current, scene], ); }; const toggleDetailModule = (moduleId: string) => { setSelectedDetailModules((current) => { if (current.includes(moduleId)) return current.filter((item) => item !== moduleId); if (current.length >= maxDetailModuleSelection) { toast.info(`最多选择 ${maxDetailModuleSelection} 个模块`); return current; } return [...current, moduleId]; }); }; 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 }) => { setSelectedProductSetPreview(card); }; const handleDetailAiWrite = () => { setDetailRequirement( "1.浜у搧鍚嶇О锛氭棤绾块檷鍣摑鐗欒€虫満\n2.鏍稿績鍗栫偣锛氫富鍔ㄩ檷鍣€?4H缁埅銆佷綆寤惰繜杩炴帴銆佽垝閫備僵鎴碶n3.閫傜敤浜虹兢锛氶€氬嫟銆佸姙鍏€佽繍鍔ㄥ拰鏃呰鐢ㄦ埛\n4.鏈熸湜鍦烘櫙锛氬湴閾侀€氬嫟銆佸眳瀹跺姙鍏€佹埛澶栬繍鍔╘n5.鍏蜂綋鍙傛暟锛氳摑鐗?.3銆両PX4闃叉按銆佸揩鍏?0鍒嗛挓浣跨敤2灏忔椂", ); }; const handleDetailGenerate = () => { if (!canGenerateDetail) return; imageAbortRef.current = { current: false }; lastFailedActionRef.current = null; void generateEcommerceImage( "detail", detailProductImages, detailRequirement, detailPlatform, detailRatio, detailLanguage, detailMarket, { detailModules: selectedDetailModules }, (s: string) => setDetailStatus(s as DetailStatus), (res) => setDetailResultUrl(res[0]?.src ?? null), ); }; const resetTask = () => { setSetImages([]); setProductSetRequirement(""); setProductSetPlatform(defaultEcommercePlatform); setProductSetLanguage(getPlatformDefaultLanguage(defaultEcommercePlatform, productSetMarket)); setProductSetOutput(defaultProductSetOutput); setProductSetRatio(getPlatformDefaultRatio(defaultEcommercePlatform, defaultProductSetOutput)); setProductSetStatus("idle"); setProductSetResultImages([]); setIsSetUploadDragging(false); setSelectedProductSetPreview(null); setShowHostingModal(false); setProductImages([]); setIsProductUploadDragging(false); setCloneOutput(defaultCloneOutput); setPlatform(defaultEcommercePlatform); setLanguage(getPlatformDefaultLanguage(defaultEcommercePlatform, market)); setRatio(getPlatformDefaultRatio(defaultEcommercePlatform, defaultCloneOutput)); setCloneSetCounts(defaultCloneSetCounts); setSelectedCloneDetailModules(defaultCloneDetailModuleIds); setCloneModelPanelTab("scene"); setSelectedCloneModelScenes([]); setCloneModelCustomScene(""); setCloneModelGender(tryOnModelOptions.gender[0]); setCloneModelAge(tryOnModelOptions.age[0]); setCloneModelEthnicity(tryOnModelOptions.ethnicity[0]); setCloneModelBody(tryOnModelOptions.body[0]); setCloneModelAppearance(""); setCloneVideoQuality("high"); setCloneVideoDuration(10); setCloneVideoSmart(true); setCloneReferenceMode("upload"); setCloneReferenceImages([]); setCloneReplicateLevel("high"); setRequirement(""); setCloneSettingName("鏂板缓鍒涗綔"); setResults([]); setStatus("idle"); setGarmentImages([]); setAppearance(""); setSelectedScenes([]); setCustomScene(""); setSmartScene(false); setTryOnRatio(tryOnRatioOptions[0]); setTryOnStatus("idle"); setTryOnResultImages([]); setDetailProductImages([]); setDetailRequirement(""); setSelectedDetailModules(defaultDetailModuleIds); setDetailStatus("idle"); }; const activeToolMeta = sideTools.find((tool) => tool.key === activeTool); const isSetTool = activeTool === "set"; const isDetail = activeTool === "detail"; const isTryOn = activeTool === "wear"; const isCloneTool = activeTool === "clone"; const isSmartCutoutTool = isCloneTool && activeQuickTool === "cutout"; const isQuickSetTool = isCloneTool && activeQuickTool === "set"; const isQuickDetailTool = isCloneTool && activeQuickTool === "detail"; const isWatermarkTool = isCloneTool && activeQuickTool === "watermark"; const isImageEditTool = isCloneTool && activeQuickTool === "image-edit"; const isHotVideoTool = isCloneTool && activeQuickTool === "hot-video"; const pageLabel = isSetTool ? "鍟嗗搧濂楀浘" : isDetail ? "A+/璇︽儏椤?" : isTryOn ? "AI鏈嶉グ绌挎埓" : activeToolMeta?.label || "鍟嗗搧宸ュ叿"; const setPrimaryLabel = setImages.length === 0 ? "璇峰厛涓婁紶鍟嗗搧鍘熷浘" : productSetStatus === "generating" ? "鐢熸垚涓?.." : "鐢熸垚" + selectedProductSetOutput.label; const tryOnPrimaryLabel = garmentImages.length === 0 ? "璇峰厛涓婁紶鏈嶈鍥剧墖" : tryOnStatus === "generating" ? "鐢熸垚涓?.." : "鐢熸垚鏈嶉グ绌挎埓鍥?"; const detailPrimaryLabel = detailProductImages.length === 0 ? "璇蜂笂浼犱骇鍝佸浘" : detailStatus === "generating" ? "鐢熸垚涓?.." : "鐢熸垚A+璇︽儏椤?"; const clonePrimaryLabel = productImages.length === 0 ? "璇峰厛涓婁紶鍟嗗搧鍘熷浘" : status === "generating" ? "鐢熸垚涓?.." : "鐢熸垚" + selectedCloneOutput.label; const setPreviewCards: CloneResult[] = []; let setIndex = 0; for (const countKey of cloneSetCountKeys) { const count = cloneSetCounts[countKey]; const info = setCountLabels[countKey]; for (let i = 0; i < count; i++) { setPreviewCards.push({ id: String(countKey) + "-" + String(i), src: productSetResultImages[setIndex] || productSetPreviewCards[setIndex % productSetPreviewCards.length]?.src || "", label: info.label + (count > 1 ? " " + String(i + 1) : ""), }); setIndex++; } } const clonePreviewCards: CloneResult[] = []; let cloneIndex = 0; for (const countKey of cloneSetCountKeys) { const count = cloneSetCounts[countKey]; const info = setCountLabels[countKey]; for (let i = 0; i < count; i++) { clonePreviewCards.push({ id: String(countKey) + "-" + String(i), src: productSetResultImages[cloneIndex] || productSetPreviewCards[cloneIndex % productSetPreviewCards.length]?.src || "", label: info.label + (count > 1 ? " " + String(i + 1) : ""), }); cloneIndex++; } } const getCurrentHistoryResults = () => cloneOutput === "set" ? productSetResultImages .filter(Boolean) .map((src, index) => ({ id: "history-set-" + String(index), src, label: clonePreviewCards[index]?.label || "濂楀浘 " + String(index + 1) })) : results.filter((item) => item.src); const buildHistorySignature = (output: CloneOutputKey, prompt: string, historyResults: CloneResult[], sourceImages: CloneImageItem[]) => [ output, prompt.trim(), historyResults.map((item) => item.src).join("|"), sourceImages.map((item) => item.src).join("|"), ].join("::"); const formatHistoryTime = (timestamp: number) => { const diff = Math.max(0, Date.now() - timestamp); const minute = 60 * 1000; const hour = 60 * minute; const day = 24 * hour; if (diff < minute) return "鍒氬垰"; if (diff < hour) return String(Math.floor(diff / minute)) + " 分钟前"; if (diff < day) return String(Math.floor(diff / hour)) + " 小时前"; return String(Math.floor(diff / day)) + " 天前"; }; const saveCurrentEcommerceHistory = () => { const historyResults = getCurrentHistoryResults(); if (!historyResults.length) return null; const signature = buildHistorySignature(cloneOutput, requirement, historyResults, productImages); if (lastSavedHistorySignatureRef.current === signature && activeHistoryRecordId) return activeHistoryRecordId; const createdAt = Date.now(); const outputLabel = cloneOutputOptions.find((option) => option.key === cloneOutput)?.label || "鐢熸垚璁板綍"; const title = requirement.trim() || outputLabel + " " + new Date(createdAt).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" }); const record: EcommerceHistoryRecord = { id: crypto.randomUUID(), title, createdAt, output: cloneOutput, platform, market, language, ratio, requirement, productImages, results: historyResults, setResultImages: cloneOutput === "set" ? historyResults.map((item) => item.src) : [], setCounts: cloneSetCounts, detailModules: selectedCloneDetailModules, modelScenes: selectedCloneModelScenes, referenceImages: cloneReferenceImages, replicateLevel: cloneReplicateLevel, }; lastSavedHistorySignatureRef.current = signature; setEcommerceHistoryRecords((current) => { const nextRecords = [record, ...current.filter((item) => item.id !== record.id)].slice(0, 30); writeEcommerceHistoryRecords(nextRecords); return nextRecords; }); setActiveHistoryRecordId(record.id); return record.id; }; const openEcommerceHistoryRecord = (record: EcommerceHistoryRecord) => { setActiveTool("clone"); setCloneOutput(record.output); setPlatform(record.platform); setMarket(record.market); setLanguage(record.language); setRatio(record.ratio); setRequirement(record.requirement); setProductImages(record.productImages); setCloneSetCounts(record.setCounts); setSelectedCloneDetailModules(record.detailModules.slice(0, maxDetailModuleSelection)); setSelectedCloneModelScenes(record.modelScenes); setCloneReferenceImages(record.referenceImages); setCloneReplicateLevel(record.replicateLevel); setProductSetResultImages(record.setResultImages); setResults(record.output === "set" ? [] : record.results); setStatus("done"); setPreviewZoom(1); setComposerMenu(null); setActiveHistoryRecordId(record.id); lastSavedHistorySignatureRef.current = buildHistorySignature(record.output, record.requirement, record.results, record.productImages); setIsCommandComposerCompact(true); }; const handleNewEcommerceConversation = () => { saveCurrentEcommerceHistory(); resetTask(); setPreviewZoom(1); setPreviewOffset({ x: 0, y: 0 }); setComposerMenu(null); setIsCommandComposerCompact(false); setActiveHistoryRecordId(null); lastSavedHistorySignatureRef.current = ""; }; const refreshEcommerceHistory = () => { if (historyRefreshLockRef.current) return; historyRefreshLockRef.current = true; setIsHistoryRefreshing(true); setHistoryRefreshMessage("鍒锋柊涓?.."); setHistoryRefreshStamp(Date.now()); window.setTimeout(() => { const storedRecords = readEcommerceHistoryRecords(); const mergedRecords = [...ecommerceHistoryRecords, ...storedRecords] .reduce((records, record) => { if (!records.some((item) => item.id === record.id)) records.push(record); return records; }, []) .sort((a, b) => b.createdAt - a.createdAt) .slice(0, 30); writeEcommerceHistoryRecords(mergedRecords); setHistoryRefreshTick((tick) => tick + 1); setEcommerceHistoryRecords(mergedRecords); setHistoryRefreshMessage(mergedRecords.length ? "已刷新 " + String(mergedRecords.length) + " 条记录" : "暂无可刷新记录"); setHistoryRefreshStamp(Date.now()); setIsHistoryRefreshing(false); historyRefreshLockRef.current = false; }, 180); window.setTimeout(() => setHistoryRefreshMessage(""), 3000); }; useEffect(() => { if (status === "done") saveCurrentEcommerceHistory(); }, [status, results, productSetResultImages]); const detailSourcePreviewImages = detailProductImages.length ? detailProductImages.reduce((urls, item) => { urls.push(item.src); return urls; }, []) : detailProductSamples; const cloneBasicSelects: Array<{ key: CloneBasicSelectKey; label: string; value: string; options: string[]; onChange: (value: string) => void; }> = [ { key: "platform", label: "平台", value: platform, options: platformOptions, onChange: handleClonePlatformChange }, { key: "market", label: "国家", value: market, options: marketOptions, onChange: handleCloneMarketChange }, { key: "language", label: "语种", value: language, options: cloneLanguageOptions, onChange: setLanguage }, { key: "ratio", label: "尺寸/比例", value: ratio, options: cloneRatioOptions, onChange: setRatio }, ]; const handleQuickSetPlatformChange = (nextPlatform: string) => { const normalizedPlatform = normalizePlatform(nextPlatform); setPlatform(normalizedPlatform); setLanguage(getPlatformDefaultLanguage(normalizedPlatform, market)); }; const quickSetBasicSelects: Array<{ key: CloneBasicSelectKey; label: string; value: string; options: string[]; onChange: (value: string) => void; }> = [ { key: "platform", label: "平台", value: platform, options: platformOptions, onChange: handleQuickSetPlatformChange }, { key: "market", label: "国家", value: market, options: marketOptions, onChange: handleCloneMarketChange }, { key: "language", label: "语种", value: language, options: cloneLanguageOptions, onChange: setLanguage }, { key: "ratio", label: "尺寸/比例", value: getQuickSetRatioValue(ratio), options: quickSetRatioOptions, onChange: setRatio }, ]; const quickDetailBasicSelects: Array<{ key: CloneBasicSelectKey; label: string; value: string; options: string[]; onChange: (value: string) => void; }> = [ { key: "platform", label: "平台", value: detailPlatform, options: platformOptions, onChange: handleDetailPlatformChange }, { key: "market", label: "国家", value: detailMarket, options: marketOptions, onChange: handleDetailMarketChange }, { key: "language", label: "语种", value: detailLanguage, options: detailLanguageOptions, onChange: setDetailLanguage }, { key: "ratio", label: "尺寸/比例", value: getQuickSetRatioValue(detailRatio), options: quickSetRatioOptions, onChange: setDetailRatio }, ]; const cloneModelSelects: Array<{ key: CloneModelSelectKey; label: string; value: string; options: string[]; onChange: (value: string) => void; }> = [ { key: "gender", label: "性别", value: cloneModelGender, options: tryOnModelOptions.gender, onChange: setCloneModelGender }, { key: "age", label: "年龄", value: cloneModelAge, options: tryOnModelOptions.age, onChange: setCloneModelAge }, { key: "ethnicity", label: "人种", value: cloneModelEthnicity, options: tryOnModelOptions.ethnicity, onChange: setCloneModelEthnicity, }, { key: "body", label: "体型", value: cloneModelBody, options: tryOnModelOptions.body, onChange: setCloneModelBody }, ]; const setPanel = ( ); const clonePanel = ( { setVideoOutfitVideoFile(video); setVideoOutfitRefFile(ref); }} onStartVideoPlan={() => setVideoPlanTrigger((n) => n + 1)} /> ); const detailPanel = ( ); const tryOnPanel = ( ); const placeholderPanel = ( <>
{activeToolMeta?.icon}

{activeToolMeta?.label}

该工具页面正在接入,当前可使用电商 AI 作图、商品套图、A+ 详情与服饰穿搭。

); const setPreview = (

预览

上传商品图,AI 即刻生成 符合多电商平台规范 的高转化率商品套图。

{productSetPreviewReady ? (
) : (
{productSetStatus === "generating" ? : } {productSetStatus === "generating" ? "正在生成" : "等待生成"} {productSetStatus === "generating" ? : null} {productSetStatus === "generating" ? "AI 正在整理主图、场景、细节与卖点图。" : "上传商品原图并填写信息后,AI 将为您生成专业的电商商品图。"}
)} {productSetStatus === "done" ?

已生成{selectedProductSetOutput.label}预览

: null}
淇℃伅璇︽儏 {productSetRequirement.length}/500