import { AppstoreOutlined, ClearOutlined, CloudUploadOutlined, CloseOutlined, DeleteOutlined, DownloadOutlined, EditOutlined, FireOutlined, FileImageOutlined, FolderOpenOutlined, FrownOutlined, GlobalOutlined, LayoutOutlined, LoadingOutlined, MenuFoldOutlined, MenuUnfoldOutlined, PaperClipOutlined, PlusOutlined, QuestionCircleOutlined, ReloadOutlined, ScissorOutlined, SettingOutlined, SkinOutlined, TableOutlined, VideoCameraOutlined, } from "@ant-design/icons"; import { Fragment, 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 { createPortal } from "react-dom"; 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 { ecommerceOssScopes, saveUnifiedEcommerceGenerationRecord, deleteEcommerceGenerationRecord } from "./ecommerceGenerationPersistence"; import { downloadResultAsset } from "../workbench/workbenchDownload"; 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 ecommerceInspirationTabs = ["最近打开", "一键同款", "海报模板", "热门", "商品图", "模特穿戴"]; const ecommerceInspirationAssets = ossAssets.ecommerce.inspiration; const ecommerceInspirationRows = [ { title: "作品记录", desc: "沉淀最近生成的高转化素材,随时回看与复用。", variant: "team", cards: [ { title: "指定ASIN,优化listing", meta: "竞品拆解 · 卖点重排 · 图文建议", mediaUrl: ecommerceInspirationAssets.asinListing, mediaType: "image" }, { title: "TikTok美区爆品分析", meta: "脚本方向 · 人群洞察 · 素材策略", mediaUrl: ecommerceInspirationAssets.tiktokPreference, mediaType: "image" }, { title: "竞品分析 + 全套listing", meta: "关键词 · 主图结构 · 转化建议", mediaUrl: ecommerceInspirationAssets.competitorListing, mediaType: "image" }, { title: "世界杯属性快闪视频", meta: "热点追踪 · 模板复用 · 15秒短片", mediaUrl: ecommerceInspirationAssets.worldCupFlashVideo, mediaType: "video" }, ], }, { title: "电商套图", desc: "主图 / 详情图全套一次性生成。", variant: "listing", cards: [ { title: "科技礼盒主图", meta: "高反差质感 · 参数卖点", mediaUrl: ecommerceInspirationAssets.officeStyleSet, mediaType: "image" }, { title: "美妆节日套图", meta: "促销氛围 · 多规格展示", mediaUrl: ecommerceInspirationAssets.fathersDaySet, mediaType: "image" }, { title: "防晒产品场景", meta: "户外光感 · 功效表达", mediaUrl: ecommerceInspirationAssets.sprayScene, mediaType: "image" }, { title: "露营家具详情", meta: "场景组合 · 尺寸说明", mediaUrl: ecommerceInspirationAssets.campingCart, mediaType: "image" }, { title: "香氛A+页面", meta: "材质细节 · 品牌氛围", mediaUrl: ecommerceInspirationAssets.perfumeSet, mediaType: "image" }, { title: "童装listing组合", meta: "多角度 · 人群展示", mediaUrl: ecommerceInspirationAssets.cosmeticApplication, mediaType: "image" }, { title: "高考文具淘宝套图", meta: "文具套装 · 淘宝主图 · 卖点陈列", mediaUrl: ecommerceInspirationAssets.stationeryTaobaoSet, mediaType: "image" }, { title: "条纹单人沙发套图", meta: "家居场景 · 多角度展示 · 软装质感", mediaUrl: ecommerceInspirationAssets.stripedSingleSofaSet, mediaType: "image" }, { title: "棕色皮夹克照片集", meta: "服饰套图 · 质感细节 · 穿搭展示", mediaUrl: ecommerceInspirationAssets.brownLeatherJacketPhotoSet, mediaType: "image" }, { title: "防晒帽模特佩戴", meta: "真人试戴 · 户外防晒 · 穿戴效果", mediaUrl: ecommerceInspirationAssets.modelSunHatTryon, mediaType: "image" }, { title: "淘宝耳机商品图", meta: "数码主图 · 参数卖点 · 平台套图", mediaUrl: ecommerceInspirationAssets.taobaoEarphoneProduct, mediaType: "image" }, { title: "Etsy香薰蜡烛套图", meta: "香氛氛围 · 手作质感 · 跨境陈列", mediaUrl: ecommerceInspirationAssets.etsyScentedCandleSet, mediaType: "image" }, ], }, { title: "商品视频", desc: "口播模拟 / 商品展示视频 / 社媒短片。", variant: "video", cards: [ { title: "口播种草短片", meta: "手持展示 · 真实推荐", mediaUrl: ecommerceInspirationAssets.spokenReview, mediaType: "video" }, { title: "香水质感视频", meta: "光影旋转 · 高级静物", mediaUrl: ecommerceInspirationAssets.perfumeTexture, mediaType: "video" }, { title: "玩具互动短视频", meta: "生活场景 · 情绪表达", mediaUrl: ecommerceInspirationAssets.toyInteraction, mediaType: "video" }, { title: "器皿产品展示", meta: "极简背景 · 材质突出", mediaUrl: ecommerceInspirationAssets.vesselDisplay, mediaType: "video" }, { title: "饰品模特试戴", meta: "近景特写 · 搭配建议", mediaUrl: ecommerceInspirationAssets.jewelryModel, mediaType: "video" }, { title: "包袋生活方式", meta: "室内场景 · 组合展示", mediaUrl: ecommerceInspirationAssets.sofaLifestyle, mediaType: "video" }, { title: "口红TikTok带货", meta: "UGC口播 · 真实推荐 · 社媒转化", mediaUrl: ecommerceInspirationAssets.lipstickUgcTiktokVideo, mediaType: "video" }, { title: "小夜灯抖音开箱", meta: "开箱种草 · 暖光氛围 · 竖版短片", mediaUrl: ecommerceInspirationAssets.nightLightUnboxingDouyin, mediaType: "video" }, { title: "清洁剂痛点解决", meta: "问题演示 · 功效对比 · 抖音素材", mediaUrl: ecommerceInspirationAssets.cleanerPainpointDouyin, mediaType: "video" }, { title: "连衣裙穿搭视频", meta: "服饰上身 · 场景走动 · 穿搭展示", mediaUrl: ecommerceInspirationAssets.dressOutfitVideo, mediaType: "video" }, { title: "防晒霜TikTok种草", meta: "UGC测评 · 户外防晒 · 平台短片", mediaUrl: ecommerceInspirationAssets.sunscreenUgcTiktokVideo, mediaType: "video" }, { title: "世界杯属性快闪", meta: "热点短片 · 节奏快闪 · 活动素材", mediaUrl: ecommerceInspirationAssets.worldCupFlashVideo, mediaType: "video" }, ], }, ] as const; // 把灵感卡片的标题 + 卖点要点合成一段可直接填入指令栏的提示词。 const buildInspirationPrompt = (title: string, meta: string): string => { const points = meta .split(/[·、,,]/) .map((part) => part.trim()) .filter(Boolean); const base = title.trim(); return points.length ? `${base}。风格要点:${points.join("、")}。` : `${base}。`; }; 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 { 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"; type CommerceScenarioKey = "popular" | "poster" | "mainImage" | "scene" | "festival" | "model" | "background" | "retouch" | "salesVideo"; 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 CloneTemplateAsset = { id: string; title: string; prompt: string; mediaUrl: string; }; interface CommerceScenarioTemplate extends CloneTemplateAsset { scenario: Exclude; output: ProductSetOutputKey; desc: string; badge: string; } 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 CanvasNode { id: string; mode: string; sourceImage?: string; results: CloneResult[]; createdAt: number; x: number; y: number; } interface PreviewTouchPoint { id: number; x: number; y: number; } interface PreviewTouchGesture { mode: "none" | "pan" | "pinch"; points: PreviewTouchPoint[]; startOffset: { x: number; y: number }; startZoom: number; startDistance: number; startCenter: { x: number; y: number }; } 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; } type EcommerceHistoryStatus = "generating" | "done" | "failed"; interface EcommerceHistoryTurn { id: string; createdAt: number; status: EcommerceHistoryStatus; errorMessage?: string; 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 EcommerceHistoryRecord { id: string; title: string; createdAt: number; status?: EcommerceHistoryStatus; errorMessage?: string; 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; turns?: EcommerceHistoryTurn[]; } interface ProductSetPreviewSelection { src: string; label: string; nodeId?: string; cardId?: string; removable?: boolean; } interface EcommerceImagePromptOptions { gender?: string; age?: string; ethnicity?: string; body?: string; appearance?: string; scenes?: string[]; customScene?: string; smartScene?: boolean; detailModules?: string[]; } type PlatformRatioModeKey = ProductSetOutputKey | "hot"; 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:1", "800×800px\u00a0\u00a0\u00a01:1"], defaultRatio: "1000×1000px\u00a0\u00a0\u00a01:1", }, detail: { ratios: [ "750×1000px\u00a0\u00a0\u00a03:4", "790×1053px\u00a0\u00a0\u00a03:4", "750×1125px\u00a0\u00a0\u00a02:3", "790×1185px\u00a0\u00a0\u00a02:3", ], defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4", }, model: { ratios: ["750×1000px\u00a0\u00a0\u00a03:4"], defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4", }, video: { ratios: ["1080×1920px\u00a0\u00a0\u00a09:16", "1080×1440px\u00a0\u00a0\u00a03:4", "1080×1080px\u00a0\u00a0\u00a01:1"], defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16", }, hot: { ratios: ["800×800px\u00a0\u00a0\u00a01:1"], defaultRatio: "800×800px\u00a0\u00a0\u00a01:1", }, }, specs: ["主图 / SKU 图 800×800px,≤3MB", "详情页宽 750px 或 790px,单张高度≤1546px"], tip: "建议主图 200-400KB JPG,超过 500KB 会影响加载速度。", }, { label: "京东", ratios: ["京东主图 / SKU 图 800×800px", "详情页宽 750px", "首图主体占比 ≥80%"], defaultRatio: "京东主图 / SKU 图 800×800px", ratioGroups: { set: { ratios: ["1000×1000px\u00a0\u00a0\u00a01:1"], defaultRatio: "1000×1000px\u00a0\u00a0\u00a01:1", }, detail: { ratios: [ "750×1000px\u00a0\u00a0\u00a03:4", "990×1320px\u00a0\u00a0\u00a03:4", "750×1125px\u00a0\u00a0\u00a02:3", "990×1485px\u00a0\u00a0\u00a02:3", ], defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4", }, model: { ratios: ["750×1125px\u00a0\u00a0\u00a02:3", "990×1485px\u00a0\u00a0\u00a02:3"], defaultRatio: "750×1125px\u00a0\u00a0\u00a02:3", }, video: { ratios: ["1080×1920px\u00a0\u00a0\u00a09:16", "1920×1080px\u00a0\u00a0\u00a016:9"], defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16", }, hot: { ratios: ["800×800px\u00a0\u00a0\u00a01:1"], defaultRatio: "800×800px\u00a0\u00a0\u00a01:1", }, }, specs: ["主图 / SKU 图 800×800px,白底,≤3MB", "详情页宽 750px,首图主体占比 ≥80%"], }, { label: "拼多多", ratios: ["主图 750×352px", "主图 800×800px", "详情页宽 750px"], defaultRatio: "主图 750×352px", ratioGroups: { set: { ratios: ["800×800px\u00a0\u00a0\u00a01:1", "750×1000px\u00a0\u00a0\u00a03:4"], defaultRatio: "800×800px\u00a0\u00a0\u00a01:1", }, detail: { ratios: ["750×1000px\u00a0\u00a0\u00a03:4", "750×1125px\u00a0\u00a0\u00a02:3"], defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4", }, model: { ratios: ["750×1000px\u00a0\u00a0\u00a03:4"], defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4", }, video: { ratios: ["1080×1920px\u00a0\u00a0\u00a09:16"], defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16", }, hot: { ratios: ["800×800px\u00a0\u00a0\u00a01:1"], defaultRatio: "800×800px\u00a0\u00a0\u00a01:1", }, }, specs: ["主图 750×352px 或 800×800px,≤1MB", "详情页宽 750px,要求纯白底、无水印、无拼接"], }, { label: "抖音电商", ratios: ["短视频1080×1920px"], defaultRatio: "短视频1080×1920px", ratioGroups: { video: { ratios: ["1080×1920px\u00a0\u00a0\u00a09:16"], defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16", }, hot: { ratios: ["800×800px\u00a0\u00a0\u00a01:1"], defaultRatio: "800×800px\u00a0\u00a0\u00a01:1", }, }, specs: ["短视频 1080×1920px,9:16", "30s 内最佳"], }, { label: "亚马逊 Amazon", ratios: ["主图 ≥1600×1600px", "建议 2000×2000px+", "最小 500×500px"], defaultRatio: "主图 ≥1600×1600px", ratioGroups: { set: { ratios: ["1600×1600px\u00a0\u00a0\u00a01:1"], defaultRatio: "1600×1600px\u00a0\u00a0\u00a01:1", }, detail: { ratios: ["1600×1600px\u00a0\u00a0\u00a01:1", "1200×1800px\u00a0\u00a0\u00a02:3", "1200×1600px\u00a0\u00a0\u00a03:4"], defaultRatio: "1200×1800px\u00a0\u00a0\u00a02:3", }, model: { ratios: ["1200×1800px\u00a0\u00a0\u00a02:3"], defaultRatio: "1200×1800px\u00a0\u00a0\u00a02:3", }, video: { ratios: ["1920×1080px\u00a0\u00a0\u00a016:9"], defaultRatio: "1920×1080px\u00a0\u00a0\u00a016:9", }, hot: { ratios: ["1600×1600px\u00a0\u00a0\u00a01:1"], defaultRatio: "1600×1600px\u00a0\u00a0\u00a01:1", }, }, 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:1"], defaultRatio: "800×800px\u00a0\u00a0\u00a01:1", }, detail: { ratios: ["750×1000px\u00a0\u00a0\u00a03:4", "750×1125px\u00a0\u00a0\u00a02:3"], defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4", }, model: { ratios: ["750×1000px\u00a0\u00a0\u00a03:4"], defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4", }, video: { ratios: ["1080×1920px\u00a0\u00a0\u00a09:16"], defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16", }, hot: { ratios: ["800×800px\u00a0\u00a0\u00a01:1"], defaultRatio: "800×800px\u00a0\u00a0\u00a01:1", }, }, specs: ["商品主图推荐 1024×1024px,基础 800×800px", "≤2MB,白底或浅色底"], aliases: ["虾皮 Shopee/Lazada", "虾皮"], }, { label: "Lazada", ratios: ["商品主图 800×800px"], defaultRatio: "商品主图 800×800px", ratioGroups: { set: { ratios: ["800×800px\u00a0\u00a0\u00a01:1"], defaultRatio: "800×800px\u00a0\u00a0\u00a01:1", }, detail: { ratios: ["750×1000px\u00a0\u00a0\u00a03:4", "750×1125px\u00a0\u00a0\u00a02:3"], defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4", }, model: { ratios: ["750×1000px\u00a0\u00a0\u00a03:4"], defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4", }, video: { ratios: ["1080×1920px\u00a0\u00a0\u00a09:16"], defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16", }, hot: { ratios: ["800×800px\u00a0\u00a0\u00a01:1"], defaultRatio: "800×800px\u00a0\u00a0\u00a01:1", }, }, specs: ["商品主图 800×800px,1: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:1", "1080×1350px\u00a0\u00a0\u00a04:5"], defaultRatio: "1080×1080px\u00a0\u00a0\u00a01:1", }, detail: { ratios: ["1080×1350px\u00a0\u00a0\u00a04:5"], defaultRatio: "1080×1350px\u00a0\u00a0\u00a04:5", }, model: { ratios: ["1080×1350px\u00a0\u00a0\u00a04:5"], defaultRatio: "1080×1350px\u00a0\u00a0\u00a04:5", }, video: { ratios: ["1080×1920px\u00a0\u00a0\u00a09:16", "1080×1350px\u00a0\u00a0\u00a04:5"], defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16", }, }, specs: ["帖子 1080×1350px 或 1080×1080px", "Stories / Reels 封面 1080×1920px,头像 320×320px"], tip: "建议 ≤8MB JPG。", aliases: ["Instagram Reels"], }, { label: "速卖通", ratios: ["主图 800×800px", "主图 1000×1000px+"], defaultRatio: "主图 800×800px", ratioGroups: { set: { ratios: ["1000×1000px\u00a0\u00a0\u00a01:1"], defaultRatio: "1000×1000px\u00a0\u00a0\u00a01:1", }, detail: { ratios: ["750×1125px\u00a0\u00a0\u00a02:3", "750×1000px\u00a0\u00a0\u00a03:4"], defaultRatio: "750×1125px\u00a0\u00a0\u00a02:3", }, model: { ratios: ["750×1125px\u00a0\u00a0\u00a02:3"], defaultRatio: "750×1125px\u00a0\u00a0\u00a02:3", }, video: { ratios: ["1080×1920px\u00a0\u00a0\u00a09:16", "1920×1080px\u00a0\u00a0\u00a016:9"], defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16", }, hot: { ratios: ["800×800px\u00a0\u00a0\u00a01:1"], defaultRatio: "800×800px\u00a0\u00a0\u00a01:1", }, }, specs: ["主图建议 800×800px 或更高,1:1", "适合跨境电商主图、SKU 图和场景图"], }, { label: "eBay", ratios: ["商品图1:1", "白底多角度展示图 1:1"], defaultRatio: "商品图1:1", ratioGroups: { set: { ratios: ["1600×1600px\u00a0\u00a0\u00a01:1"], defaultRatio: "1600×1600px\u00a0\u00a0\u00a01:1", }, detail: { ratios: ["1000×1500px\u00a0\u00a0\u00a02:3", "1200×1600px\u00a0\u00a0\u00a03:4"], defaultRatio: "1000×1500px\u00a0\u00a0\u00a02:3", }, model: { ratios: ["1000×1500px\u00a0\u00a0\u00a02:3"], defaultRatio: "1000×1500px\u00a0\u00a0\u00a02:3", }, video: { ratios: ["1920×1080px\u00a0\u00a0\u00a016:9", "1080×1920px\u00a0\u00a0\u00a09:16"], defaultRatio: "1920×1080px\u00a0\u00a0\u00a016:9", }, hot: { ratios: ["1600×1600px\u00a0\u00a0\u00a01:1"], defaultRatio: "1600×1600px\u00a0\u00a0\u00a01:1", }, }, specs: ["商品图建议 1:1,主体清晰居中", "适合白底主图和多角度展示图"], }, { label: "TikTok Shop", ratios: ["商品主图 1:1", "短视频/ 竖版封面 9:16"], defaultRatio: "商品主图 1:1", ratioGroups: { set: { ratios: ["1280×1280px\u00a0\u00a0\u00a01:1"], defaultRatio: "1280×1280px\u00a0\u00a0\u00a01:1", }, detail: { ratios: ["1080×1350px\u00a0\u00a0\u00a04:5"], defaultRatio: "1080×1350px\u00a0\u00a0\u00a04:5", }, model: { ratios: ["1080×1350px\u00a0\u00a0\u00a04:5"], defaultRatio: "1080×1350px\u00a0\u00a0\u00a04:5", }, video: { ratios: ["1080×1920px\u00a0\u00a0\u00a09:16"], defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16", }, hot: { ratios: ["800×800px\u00a0\u00a0\u00a01:1"], defaultRatio: "800×800px\u00a0\u00a0\u00a01:1", }, }, specs: ["商品主图建议 1:1", "短视频竖版封面建议 9:16"], }, ]; const platformOptions = platformSpecOptions.map((option) => option.label); const getPlatformLogoText = (value: string) => { const normalized = value.toLowerCase(); if (value.includes("淘宝") || value.includes("天猫")) return "淘"; if (value.includes("京东")) return "京"; if (value.includes("拼多多") || value.includes("鎷煎澶")) return "拼"; if (value.includes("抖音")) return "抖"; if (normalized.includes("amazon")) return "a"; if (normalized.includes("shopee")) return "S"; if (normalized.includes("lazada")) return "L"; if (normalized.includes("instagram")) return "IG"; if (value.includes("速卖通") || value.includes("閫熷崠閫")) return "AE"; if (normalized.includes("ebay")) return "eB"; if (normalized.includes("tiktok")) return "♪"; return value.trim().slice(0, 1).toUpperCase() || "商"; }; const getPlatformLogoVariant = (value: string) => { const normalized = value.toLowerCase(); if (value.includes("淘宝") || value.includes("天猫")) return "taobao"; if (value.includes("京东")) return "jd"; if (value.includes("拼多多") || value.includes("鎷煎澶")) return "pdd"; if (value.includes("抖音")) return "douyin"; if (normalized.includes("amazon")) return "amazon"; if (normalized.includes("shopee")) return "shopee"; if (normalized.includes("lazada")) return "lazada"; if (normalized.includes("instagram")) return "instagram"; if (value.includes("速卖通") || value.includes("閫熷崠閫")) return "aliexpress"; if (normalized.includes("ebay")) return "ebay"; if (normalized.includes("tiktok")) return "tiktok"; return "default"; }; const getPlatformLogoMarks = (value: string) => { if (value.includes("淘宝") || value.includes("天猫")) return ["淘", "猫"]; return [getPlatformLogoText(value)]; }; const renderPlatformLogo = (value: string) => { const marks = getPlatformLogoMarks(value); const variant = getPlatformLogoVariant(value); return ( 1 ? " ecom-platform-logo-mark--duo" : ""}`} aria-hidden="true" > {marks.map((text) => ( 1 ? " ecom-platform-logo-mark__tile--wide" : ""}`}> {text} ))} ); }; 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("\u00a0", " ") .replaceAll("脳", "×") .replaceAll("*", "×") .replaceAll(":", ":") .replace(/锛\?/g, ":") .replace(/\s+/g, " ") .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", "4:3", "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("鍟嗗搧鍥?", "商品图") .replace(/\s+:/g, ":") .replace(/:\s+/g, ":"); }; const getRatioDisplayParts = (value: string) => { const display = formatRatioDisplayValue(value).replace(/\u00a0/g, " ").replace(/\s+/g, " ").trim(); const aspectMatch = display.match(/(\d+\s*[::]\s*\d+)(?!.*\d+\s*[::]\s*\d+)/u); const aspect = aspectMatch?.[1]?.replace(/\s+/g, "") ?? "自适应"; const size = aspectMatch ? display.replace(aspectMatch[0], "").trim() : display; return { size: size || "原图比例", aspect, }; }; /** 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]}`; }; const supportedImageApiRatios = ["1:1", "3:4", "4:3", "9:16", "16:9"] as const; type SupportedImageApiRatio = typeof supportedImageApiRatios[number]; const toSupportedImageApiRatio = (width: number, height: number): SupportedImageApiRatio => { if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return "1:1"; let bestRatio: SupportedImageApiRatio = "1:1"; let bestScore = Number.POSITIVE_INFINITY; const target = Math.log(width / height); for (const ratio of supportedImageApiRatios) { const [left, right] = ratio.split(":").map(Number); const score = Math.abs(target - Math.log(left / right)); if (score < bestScore) { bestRatio = ratio; bestScore = score; } } return bestRatio; }; /** Normalize ratio display string ("1000×1000px 1:1") to an image API aspect ratio ("1:1"). */ const normalizeRatioForApi = (ratioStr: string): string => { const normalizedValue = normalizeRatioToken(ratioStr); const explicitRatios = Array.from(normalizedValue.matchAll(/(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)/g)); const explicitRatio = explicitRatios.at(-1); if (explicitRatio) { return toSupportedImageApiRatio(Number(explicitRatio[1]), Number(explicitRatio[2])); } const sizeMatch = normalizedValue.match(/(\d+(?:\.\d+)?)\s*[×xX*]\s*(\d+(?:\.\d+)?)/u); if (!sizeMatch) return "1:1"; return toSupportedImageApiRatio(Number(sizeMatch[1]), Number(sizeMatch[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; desc: string; icon: ReactNode }> = [ { key: "set", label: "套图", desc: "主图/卖点/场景", icon: }, { key: "detail", label: "详情图", desc: "长图模块化生成", icon: }, { key: "model", label: "模特图", desc: "真人穿搭展示", icon: }, { key: "video", label: "短视频", desc: "分镜视频链路", icon: }, ]; const cloneOutputOptions: Array<{ key: ProductSetOutputKey; label: string; desc: string; icon: ReactNode }> = [ ...productSetOutputOptions, ]; const commerceScenarioOptions: Array<{ key: CommerceScenarioKey; label: string; desc: string; icon: ReactNode }> = [ { key: "popular", label: "热门", desc: "高频模板", icon: }, { key: "poster", label: "海报生成", desc: "活动视觉", icon: }, { key: "mainImage", label: "商品主图", desc: "主图转化", icon: }, { key: "scene", label: "场景图", desc: "生活氛围", icon: }, { key: "festival", label: "节日风格图", desc: "节点营销", icon: }, { key: "model", label: "模特图", desc: "真人展示", icon: }, { key: "background", label: "更换背景", desc: "背景重构", icon: }, { key: "retouch", label: "无痕改图", desc: "精修优化", icon: }, { key: "salesVideo", label: "带货视频", desc: "短视频脚本", icon: }, ]; const commerceScenarioOutputMap: Record, ProductSetOutputKey> = { poster: "set", mainImage: "set", scene: "set", festival: "set", model: "model", background: "set", retouch: "set", salesVideo: "video", }; const commerceScenarioTemplates: CommerceScenarioTemplate[] = [ { id: "poster-campaign-clean", scenario: "poster", output: "set", title: "新品活动海报", desc: "适合首发、上新、促销专题的主视觉", badge: "高频推荐", prompt: "帮我生成一张电商新品活动海报,突出产品主体、核心卖点和促销氛围,画面干净高级,适合店铺首页和广告投放。", mediaUrl: ossAssets.ecommerce.detail.longPage, }, { id: "poster-social-drop", scenario: "poster", output: "set", title: "社媒种草海报", desc: "更适合小红书、朋友圈、站外广告", badge: "热门模板", prompt: "生成一张社媒种草风格商品海报,突出产品质感、生活方式和一句清晰卖点,画面轻盈、有品牌感。", mediaUrl: ossAssets.ecommerce.inspiration.officeStyleSet, }, { id: "main-clean-product", scenario: "mainImage", output: "set", title: "高转化商品主图", desc: "白底/浅场景,主体清楚,卖点明确", badge: "高频推荐", prompt: "生成一张高转化商品主图,产品主体居中清晰,背景简洁,突出核心卖点和材质细节,适合电商搜索列表展示。", mediaUrl: ossAssets.ecommerce.productSet.main, }, { id: "main-selling-point", scenario: "mainImage", output: "set", title: "卖点强化主图", desc: "适合列表点击率优化", badge: "点击率优先", prompt: "生成一张卖点强化商品主图,保留产品真实质感,加入清晰卖点表达和轻量信息层级,适合提升列表点击率。", mediaUrl: ossAssets.ecommerce.productSet.selling, }, { id: "scene-lifestyle", scenario: "scene", output: "set", title: "生活方式场景图", desc: "把商品放进真实使用环境", badge: "高频推荐", prompt: "生成生活方式商品场景图,把产品自然放入真实使用环境,突出使用感、氛围和购买理由,画面真实且商业化。", mediaUrl: ossAssets.ecommerce.productSet.scene, }, { id: "scene-premium", scenario: "scene", output: "set", title: "高级质感场景", desc: "适合品牌调性和详情页氛围图", badge: "品牌感", prompt: "生成高级质感商品场景图,背景克制、光影柔和,突出产品材质、轮廓和品牌调性,适合详情页和广告素材。", mediaUrl: ossAssets.ecommerce.detail.gridA, }, { id: "festival-seasonal", scenario: "festival", output: "set", title: "节日营销图", desc: "适合大促、节庆、节点活动", badge: "节点营销", prompt: "生成节日营销风格商品图,结合节日氛围和促销视觉,但保持产品主体清晰、信息不过载,适合电商活动投放。", mediaUrl: ossAssets.ecommerce.detail.gridB, }, { id: "festival-gift", scenario: "festival", output: "set", title: "礼赠氛围图", desc: "适合礼盒、礼品、节日送礼场景", badge: "热门模板", prompt: "生成礼赠氛围商品图,突出节日送礼感、包装质感和温暖情绪,画面高级克制,适合活动页与社媒投放。", mediaUrl: ossAssets.ecommerce.detail.gridC, }, { id: "model-natural-fit", scenario: "model", output: "model", title: "自然穿搭模特图", desc: "突出上身效果、版型和真实穿着", badge: "高频推荐", prompt: "生成自然穿搭模特图,突出服饰上身效果、版型和整体气质,模特姿态自然,适合服饰电商详情与主图展示。", mediaUrl: ossAssets.ecommerce.tryOn.dressA, }, { id: "model-street", scenario: "model", output: "model", title: "街拍模特场景", desc: "更适合年轻化、生活方式品牌", badge: "风格推荐", prompt: "生成街拍风格模特图,模特自然展示商品,背景有生活气息,突出穿搭氛围、比例和品牌调性。", mediaUrl: ossAssets.ecommerce.tryOn.modelWoman, }, { id: "background-clean", scenario: "background", output: "set", title: "商品换浅色背景", desc: "保留主体,重构干净商业背景", badge: "高频推荐", prompt: "为商品更换干净浅色商业背景,保留产品主体、边缘和材质细节,整体画面适合电商主图和广告素材。", mediaUrl: ossAssets.ecommerce.productSet.detail, }, { id: "background-scene", scenario: "background", output: "set", title: "商品换场景背景", desc: "从普通拍摄变成真实使用场景", badge: "场景增强", prompt: "为商品更换真实使用场景背景,保持主体比例和边缘自然,增强生活化氛围和商业转化感。", mediaUrl: ossAssets.ecommerce.productSet.scene, }, { id: "retouch-clean", scenario: "retouch", output: "set", title: "白底精修图", desc: "修正瑕疵、增强质感和边缘细节", badge: "高频推荐", prompt: "对商品图进行无痕精修,清理瑕疵、优化光影和边缘细节,保持商品真实结构,输出干净高级的电商图。", mediaUrl: ossAssets.ecommerce.productSet.main, }, { id: "retouch-premium", scenario: "retouch", output: "set", title: "质感增强图", desc: "强化材质、反光和商品高级感", badge: "精修模板", prompt: "对商品图进行质感增强,强化材质、光泽、纹理和立体感,画面自然不过度修饰,适合商业广告素材。", mediaUrl: ossAssets.ecommerce.productSet.selling, }, { id: "sales-video-hook", scenario: "salesVideo", output: "video", title: "带货视频开场", desc: "第一秒抓住注意力,快速进入卖点", badge: "高频推荐", prompt: "生成电商带货短视频脚本和分镜,第一秒突出产品和痛点,随后展示核心卖点、使用场景和行动引导。", mediaUrl: ossAssets.ecommerce.inspiration.tiktokPreference, }, { id: "sales-video-demo", scenario: "salesVideo", output: "video", title: "使用演示视频", desc: "适合讲解型、种草型短视频", badge: "转化优先", prompt: "生成商品使用演示短视频分镜,围绕使用过程、关键卖点和效果对比展开,节奏清晰,适合带货转化。", mediaUrl: ossAssets.ecommerce.inspiration.asinListing, }, ]; const popularCommerceScenarioTemplates = commerceScenarioOptions .filter((option): option is { key: Exclude; label: string; desc: string; icon: ReactNode } => option.key !== "popular") .map((option) => commerceScenarioTemplates.find((template) => template.scenario === option.key)) .filter((template): template is CommerceScenarioTemplate => Boolean(template)); 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 = 20; 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 getRemoteImageFormat(mimeType: string, imageUrl: string) { const mimeFormat = mimeType.split("/")[1]?.replace("jpeg", "jpg").toUpperCase(); if (mimeFormat) return mimeFormat; return imageUrl.split("?")[0].split(".").pop()?.toUpperCase() ?? "IMAGE"; } function getRemoteImageName(imageUrl: string, fallback: string) { try { const parsed = new URL(imageUrl); const filename = decodeURIComponent(parsed.pathname.split("/").filter(Boolean).pop() || ""); return filename || fallback; } catch { return fallback; } } 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: ecommerceOssScopes.productSource, }); 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(({ id, src, name, width, height, format, mimeType, ossKey }) => ({ id, src, name, width, height, format, mimeType, ossKey, })); } function getTurnResults(turn: EcommerceHistoryTurn): CloneResult[] { if (turn.results?.length) return turn.results.filter((item) => item.src); if (turn.output !== "set") return []; return (turn.setResultImages ?? []) .filter(Boolean) .map((src, index) => ({ id: `${turn.id}-set-${index}`, src, label: `套图 ${index + 1}` })); } function buildHistoryTurnFromRecord(record: EcommerceHistoryRecord): EcommerceHistoryTurn { return { id: `${record.id}-turn-initial`, createdAt: record.createdAt, status: record.status ?? "done", errorMessage: record.status === "failed" ? record.errorMessage : undefined, output: record.output, platform: record.platform, market: record.market, language: record.language, ratio: record.ratio, requirement: record.requirement, productImages: record.productImages ?? [], results: record.results ?? [], setResultImages: record.setResultImages ?? [], setCounts: record.setCounts ?? defaultCloneSetCounts, detailModules: record.detailModules ?? defaultCloneDetailModuleIds, modelScenes: record.modelScenes ?? [], referenceImages: record.referenceImages ?? [], replicateLevel: record.replicateLevel ?? "high", }; } function normalizeEcommerceHistoryTurn(turn: EcommerceHistoryTurn, fallback: EcommerceHistoryRecord, index: number): EcommerceHistoryTurn { const status = turn.status ?? fallback.status ?? "done"; return { id: typeof turn.id === "string" && turn.id ? turn.id : `${fallback.id}-turn-${index + 1}`, createdAt: typeof turn.createdAt === "number" ? turn.createdAt : fallback.createdAt, status, errorMessage: status === "failed" ? turn.errorMessage ?? fallback.errorMessage : undefined, output: turn.output ?? fallback.output, platform: turn.platform ?? fallback.platform, market: turn.market ?? fallback.market, language: turn.language ?? fallback.language, ratio: turn.ratio ?? fallback.ratio, requirement: turn.requirement ?? fallback.requirement, productImages: removeFilePayloadFromImages(Array.isArray(turn.productImages) ? turn.productImages : fallback.productImages), results: Array.isArray(turn.results) ? turn.results.filter(isCloneResult) : [], setResultImages: Array.isArray(turn.setResultImages) ? turn.setResultImages.filter(Boolean) : [], setCounts: turn.setCounts ?? fallback.setCounts ?? defaultCloneSetCounts, detailModules: turn.detailModules ?? fallback.detailModules ?? defaultCloneDetailModuleIds, modelScenes: turn.modelScenes ?? fallback.modelScenes ?? [], referenceImages: removeFilePayloadFromImages(Array.isArray(turn.referenceImages) ? turn.referenceImages : fallback.referenceImages ?? []), replicateLevel: turn.replicateLevel ?? fallback.replicateLevel ?? "high", }; } function normalizeEcommerceHistoryRecord(record: EcommerceHistoryRecord): EcommerceHistoryRecord { const status = record.status ?? "done"; const baseRecord = { ...record, status, errorMessage: status === "failed" ? record.errorMessage : undefined, 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", }; const rawTurns = Array.isArray(record.turns) && record.turns.length ? record.turns : [buildHistoryTurnFromRecord(baseRecord)]; const turns = rawTurns.map((turn, index) => normalizeEcommerceHistoryTurn(turn, baseRecord, index)); return { ...baseRecord, turns, }; } 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 cloneReferenceInputRef = useRef(null); const smartCutoutInputRef = useRef(null); const imageWorkbenchInputRef = useRef(null); const imageWorkbenchUrlInputRef = useRef(null); const imageWorkbenchProgressRef = useRef(null); const watermarkInputRef = useRef(null); const watermarkUrlInputRef = useRef(null); const watermarkProcessTimeoutRef = useRef(null); const translateInputRef = useRef(null); const translateUrlInputRef = useRef(null); const translateProcessTimeoutRef = useRef(null); const smartCutoutTransitionTimeoutRef = useRef(null); const smartCutoutPendingUrlsRef = useRef([]); const smartCutoutPaletteRef = useRef(null); 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 detailProgressRef = useRef(null); const hotProgressRef = useRef(null); const hotMaterialInputRef = 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"); // 套图/图像生成的真实进度(0-100):多张串行生成时按"已完成张数 + 当前张子进度"推进, // 替代进度条原先写死 50 导致卡在 75% 的假进度。 const [generationProgress, setGenerationProgress] = useState(0); const [productSetResultImages, setProductSetResultImages] = useState([]); const [isSetUploadDragging, setIsSetUploadDragging] = useState(false); const [selectedProductSetPreview, setSelectedProductSetPreview] = useState(null); const [showHostingModal, setShowHostingModal] = useState(false); const [productImages, setProductImages] = useState([]); const [activeQuickTool, setActiveQuickTool] = useState<"cutout" | "detail" | "watermark" | "image-edit" | "translate" | "hot" | 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" | "failed">("idle"); const [isWatermarkDragging, setIsWatermarkDragging] = useState(false); const [watermarkResultUrl, setWatermarkResultUrl] = useState(null); const [watermarkProgress, setWatermarkProgress] = useState(0); const [translateImage, setTranslateImage] = useState<{ src: string; name: string; format: string } | null>(null); const [translateStatus, setTranslateStatus] = useState<"idle" | "processing" | "done" | "failed">("idle"); const [isTranslateDragging, setIsTranslateDragging] = useState(false); const [translateLanguage, setTranslateLanguage] = useState("zh"); const [translateResultUrl, setTranslateResultUrl] = useState(null); 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" | "failed">("idle"); const [isImageWorkbenchDragging, setIsImageWorkbenchDragging] = useState(false); const [imageWorkbenchMaskStrokes, setImageWorkbenchMaskStrokes] = useState }>>([]); const [imageWorkbenchBrushCursor, setImageWorkbenchBrushCursor] = useState<{ x: number; y: number } | null>(null); const [imageWorkbenchResultUrl, setImageWorkbenchResultUrl] = useState(null); const [imageWorkbenchProgress, setImageWorkbenchProgress] = useState(0); const [isProductUploadDragging, setIsProductUploadDragging] = useState(false); const [activeCommerceScenario, setActiveCommerceScenario] = useState("popular"); const [cloneOutput, setCloneOutput] = useState(defaultCloneOutput); const [isCloneTemplateStripVisible, setIsCloneTemplateStripVisible] = useState(true); const [videoHistoryVisible, setVideoHistoryVisible] = useState(false); const [isVideoWorkspaceVisible, setIsVideoWorkspaceVisible] = 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 [composerPopoverTop, setComposerPopoverTop] = useState(0); const [isCommandHistoryCollapsed, setIsCommandHistoryCollapsed] = useState(true); const [inspirationPreview, setInspirationPreview] = useState<{ mediaUrl: string; mediaType: "image" | "video"; prompt: string } | null>(null); 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 [isCloneSettingsCollapsed, setIsCloneSettingsCollapsed] = useState(false); const [isCloneConversationCollapsed, setIsCloneConversationCollapsed] = 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 previewTouchGestureRef = useRef({ mode: "none", points: [], startOffset: { x: 0, y: 0 }, startZoom: 1, startDistance: 0, startCenter: { x: 0, y: 0 }, }); const nodeDragRef = useRef<{ active: boolean; nodeId: string; startX: number; startY: number; originX: number; originY: number }>({ active: false, nodeId: "", startX: 0, startY: 0, originX: 0, originY: 0, }); const [isCommandComposerCompact, setIsCommandComposerCompact] = useState(false); const typewriterText = useTypewriter("万物皆可AI,广告素材一键生成"); useEffect(() => { previewZoomRef.current = previewZoom; }, [previewZoom]); useEffect(() => { previewOffsetRef.current = previewOffset; }, [previewOffset]); useEffect(() => { if (typeof window === "undefined") return undefined; // aside 默认收起,用户手动控制展开/收起 return undefined; }, []); useEffect(() => { if (!inspirationPreview) return undefined; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") setInspirationPreview(null); }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [inspirationPreview]); const previewTransformStyle = useMemo( () => ({ transform: `translate3d(${previewOffset.x}px, ${previewOffset.y}px, 0) scale(${previewZoom})`, }), [previewOffset.x, previewOffset.y, previewZoom], ); const updatePreviewTransform = (nextZoom: number, nextOffset: { x: number; y: number }) => { previewZoomRef.current = nextZoom; previewOffsetRef.current = nextOffset; setPreviewZoom(nextZoom); setPreviewOffset(nextOffset); }; const getPreviewGestureDistance = (points: PreviewTouchPoint[]) => { if (points.length < 2) return 0; return Math.hypot(points[0]!.x - points[1]!.x, points[0]!.y - points[1]!.y); }; const getPreviewGestureCenter = (points: PreviewTouchPoint[]) => { if (points.length < 2) return points[0] ? { x: points[0].x, y: points[0].y } : { x: 0, y: 0 }; return { x: (points[0]!.x + points[1]!.x) / 2, y: (points[0]!.y + points[1]!.y) / 2, }; }; const isPreviewTouchInteractiveTarget = (target: HTMLElement | null) => Boolean(target?.closest(".ecom-command-composer-wrap, .clone-ai-preview-header, .clone-ai-source-corner-action, input, textarea, select, a, button")); const startPreviewTouchGesture = (event: ReactPointerEvent) => { if (event.pointerType === "mouse" || isPreviewTouchInteractiveTarget(event.target as HTMLElement | null)) return; event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); const points = [ ...previewTouchGestureRef.current.points.filter((point) => point.id !== event.pointerId), { id: event.pointerId, x: event.clientX, y: event.clientY }, ].slice(-2); const mode = points.length >= 2 ? "pinch" : "pan"; previewTouchGestureRef.current = { mode, points, startOffset: previewOffsetRef.current, startZoom: previewZoomRef.current, startDistance: getPreviewGestureDistance(points), startCenter: getPreviewGestureCenter(points), }; event.currentTarget.classList.add("is-touch-panning"); }; const movePreviewTouchGesture = (event: ReactPointerEvent) => { const gesture = previewTouchGestureRef.current; if (gesture.mode === "none" || event.pointerType === "mouse") return; event.preventDefault(); const points = gesture.points.map((point) => point.id === event.pointerId ? { ...point, x: event.clientX, y: event.clientY } : point); if (!points.some((point) => point.id === event.pointerId)) return; if (gesture.mode === "pinch" && points.length >= 2 && gesture.startDistance > 0) { const rect = event.currentTarget.getBoundingClientRect(); const center = getPreviewGestureCenter(points); const zoomRatio = getPreviewGestureDistance(points) / gesture.startDistance; const nextZoom = Math.min(2, Math.max(0.25, gesture.startZoom * zoomRatio)); const startCenterX = gesture.startCenter.x - rect.left; const startCenterY = gesture.startCenter.y - rect.top; const currentCenterX = center.x - rect.left; const currentCenterY = center.y - rect.top; const contentX = (startCenterX - gesture.startOffset.x) / gesture.startZoom; const contentY = (startCenterY - gesture.startOffset.y) / gesture.startZoom; updatePreviewTransform(nextZoom, { x: currentCenterX - contentX * nextZoom, y: currentCenterY - contentY * nextZoom, }); } else { const point = points[0]!; const startPoint = gesture.points[0]!; updatePreviewTransform(gesture.startZoom, { x: gesture.startOffset.x + point.x - startPoint.x, y: gesture.startOffset.y + point.y - startPoint.y, }); } previewTouchGestureRef.current = { ...gesture, points }; }; const stopPreviewTouchGesture = (event: ReactPointerEvent) => { const gesture = previewTouchGestureRef.current; if (event.pointerType === "mouse" || gesture.mode === "none") return; const points = gesture.points.filter((point) => point.id !== event.pointerId); if (points.length) { previewTouchGestureRef.current = { mode: "pan", points, startOffset: previewOffsetRef.current, startZoom: previewZoomRef.current, startDistance: 0, startCenter: getPreviewGestureCenter(points), }; } else { previewTouchGestureRef.current = { mode: "none", points: [], startOffset: previewOffsetRef.current, startZoom: previewZoomRef.current, startDistance: 0, startCenter: { x: 0, y: 0 }, }; event.currentTarget.classList.remove("is-touch-panning"); } try { event.currentTarget.releasePointerCapture(event.pointerId); } catch { // Pointer capture can already be released by the browser after cancel. } }; useEffect(() => { const container = previewSurfaceRef.current; if (!container) return undefined; const handleWheel = (event: WheelEvent) => { const target = event.target as HTMLElement | null; if (target?.closest(".ecom-inspiration-preview")) { event.preventDefault(); return; } 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 !== 0 && event.button !== 1) return; const target = event.target as HTMLElement | null; if (target?.closest(".ecom-command-composer-wrap, .clone-ai-preview-header, .clone-ai-preview-showcase, .clone-ai-main-result, .clone-ai-result-grid, .clone-ai-node-drag-handle, input, textarea, select, a, button, img")) 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(); }, onPointerDown: startPreviewTouchGesture, onPointerMove: movePreviewTouchGesture, onPointerUp: stopPreviewTouchGesture, onPointerCancel: stopPreviewTouchGesture, }); const startCanvasNodeDrag = (event: ReactPointerEvent, node: CanvasNode) => { if (event.button !== 0 || event.pointerType === "mouse") return; if ((event.target as HTMLElement | null)?.closest("button, a, input, textarea, select")) return; event.preventDefault(); event.stopPropagation(); event.currentTarget.setPointerCapture(event.pointerId); nodeDragRef.current = { active: true, nodeId: node.id, startX: event.clientX, startY: event.clientY, originX: node.x, originY: node.y }; }; const moveCanvasNodeDrag = (event: ReactPointerEvent, nodeId: string) => { const drag = nodeDragRef.current; if (!drag.active || drag.nodeId !== nodeId || event.pointerType === "mouse") return; event.preventDefault(); event.stopPropagation(); const zoom = previewZoomRef.current; const dx = (event.clientX - drag.startX) / zoom; const dy = (event.clientY - drag.startY) / zoom; setCanvasNodes((prev) => prev.map((node) => node.id === nodeId ? { ...node, x: drag.originX + dx, y: drag.originY + dy } : node)); }; const stopCanvasNodeDrag = (event: ReactPointerEvent, nodeId: string) => { if (nodeDragRef.current.nodeId !== nodeId || event.pointerType === "mouse") return; nodeDragRef.current = { ...nodeDragRef.current, active: false }; event.stopPropagation(); try { event.currentTarget.releasePointerCapture(event.pointerId); } catch { // Pointer capture can already be released by the browser after cancel. } }; 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 [canvasNodes, setCanvasNodes] = 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 activeHistoryTurnIdRef = useRef(null); 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 [detailProgress, setDetailProgress] = useState(0); const [hotRequirement, setHotRequirement] = useState(""); const [isHotMaterialDragging, setIsHotMaterialDragging] = useState(false); const [hotMaterialHoverZoom, setHotMaterialHoverZoom] = useState<{ src: string; x: number; y: number; placement: "above" | "below" } | null>(null); const [hotPlatform, setHotPlatform] = useState(platformOptions[0]); const [hotMarket, setHotMarket] = useState(marketOptions[0]); const [hotLanguage, setHotLanguage] = useState(getPlatformDefaultLanguage(platformOptions[0], marketOptions[0])); const [hotRatio, setHotRatio] = useState(getQuickSetRatioValue(getPlatformDefaultRatio(platformOptions[0], "detail"))); const [hotStatus, setHotStatus] = useState("idle"); const [hotResultUrl, setHotResultUrl] = useState(null); const [hotProgress, setHotProgress] = useState(0); const productSetRatioOptions = useMemo( () => getPlatformRatioOptions(productSetPlatform, productSetOutput), [productSetOutput, productSetPlatform], ); const baseCloneRatioOptions = useMemo( () => getPlatformRatioOptions(platform, cloneOutput), [cloneOutput, platform], ); const cloneRatioOptions = baseCloneRatioOptions; 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 hotLanguageOptions = useMemo( () => getPlatformLanguageOptions(hotPlatform, hotMarket), [hotMarket, hotPlatform], ); 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 activeCommerceScenarioTemplates = activeCommerceScenario === "popular" ? popularCommerceScenarioTemplates : commerceScenarioTemplates.filter((template) => template.scenario === activeCommerceScenario); 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 = productImages.length > 0 && status !== "generating"; const canGenerateTryOn = garmentImages.length > 0 && tryOnStatus !== "generating" && tryOnStatus !== "modeling"; const canGenerateDetail = detailProductImages.length > 0 && detailStatus !== "generating"; const canGenerateHot = cloneReferenceImages.length > 0 && hotStatus !== "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 (activeHistoryRecordId) { const turnId = activeHistoryTurnIdRef.current; if (turnId) { updateLocalEcommerceHistoryTurn(activeHistoryRecordId, turnId, (turn) => ({ ...turn, status: "failed", errorMessage: "已取消生成", })); } else { updateLocalEcommerceHistoryRecord(activeHistoryRecordId, (record) => ({ ...record, status: "failed", errorMessage: "已取消生成", })); } } } 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(); setComposerMenu(null); toast.info("功能正在优化中"); }; const openWatermarkRemovalPage = () => { clearSmartCutoutTransition(); setActiveQuickTool("watermark"); setComposerMenu(null); setIsCloneSettingsCollapsed(false); }; const loadRemoteImageFromInput = async (input: HTMLInputElement | null, fallbackName: string) => { const rawValue = input?.value.trim() ?? ""; if (!rawValue) { toast.info("请先粘贴图片 URL"); return null; } let imageUrl: string; try { const parsed = new URL(rawValue, window.location.href); if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { throw new Error("仅支持 http 或 https 图片链接"); } imageUrl = parsed.toString(); } catch (error) { toast.error(error instanceof Error ? error.message : "图片 URL 不正确"); return null; } try { const response = await fetch(imageUrl); if (!response.ok) throw new Error(`图片读取失败(${response.status})`); const blob = await response.blob(); if (!blob.type.startsWith("image/")) throw new Error("链接内容不是图片"); const src = URL.createObjectURL(blob); try { await readImageDimensions(src); } catch { URL.revokeObjectURL(src); throw new Error("图片无法预览,请换一个链接"); } if (input) input.value = ""; return { src, name: getRemoteImageName(imageUrl, fallbackName), format: getRemoteImageFormat(blob.type, imageUrl), }; } catch (error) { toast.error(error instanceof Error ? error.message : "图片导入失败"); return null; } }; const closeWatermarkRemovalPage = () => { stopWatermarkProgress(); setActiveQuickTool(null); setWatermarkStatus("idle"); setWatermarkResultUrl(null); setWatermarkProgress(0); 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"); setWatermarkResultUrl(null); setWatermarkProgress(0); setActiveQuickTool("watermark"); }; const removeWatermarkImage = () => { stopWatermarkProgress(); setWatermarkStatus("idle"); setWatermarkResultUrl(null); setWatermarkProgress(0); 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 handleWatermarkUrlImport = async () => { const nextImage = await loadRemoteImageFromInput(watermarkUrlInputRef.current, "watermark-source"); if (!nextImage) return; setWatermarkImage((current) => { if (current?.src) URL.revokeObjectURL(current.src); return nextImage; }); setWatermarkStatus("idle"); setActiveQuickTool("watermark"); toast.success("图片已导入"); }; const stopWatermarkProgress = () => { if (watermarkProcessTimeoutRef.current !== null) { window.clearInterval(watermarkProcessTimeoutRef.current); watermarkProcessTimeoutRef.current = null; } }; const startWatermarkProgress = () => { stopWatermarkProgress(); setWatermarkProgress(0); watermarkProcessTimeoutRef.current = window.setInterval(() => { setWatermarkProgress((prev) => { if (prev >= 90) { stopWatermarkProgress(); return 90; } return prev + (90 - prev) * 0.06; }); }, 500); }; const handleWatermarkGenerate = async () => { if (!watermarkImage || watermarkStatus === "processing") return; setWatermarkStatus("processing"); setWatermarkResultUrl(null); startWatermarkProgress(); try { const sourceBlob = await fetch(watermarkImage.src).then((res) => res.blob()); const sourceMime = normalizeEcommerceImageMime(sourceBlob.type || "image/png"); const { url: imageUrl } = await aiGenerationClient.uploadAssetBinary(sourceBlob, { name: `watermark-source-${Date.now()}.png`, mimeType: sourceMime, scope: ecommerceOssScopes.productSource, }); const { taskId } = await aiGenerationClient.createImageEditTask({ imageUrl, function: "watermark-remove", }); const resultUrl = await waitForTask(taskId, { abortRef: { current: false }, onProgress: () => {}, }); if (resultUrl) { const persistedUrl = await persistGeneratedImageUrl(resultUrl, ecommerceOssScopes.cloneResult("watermark"), "ecommerce-watermark"); setWatermarkResultUrl(persistedUrl); setWatermarkStatus("done"); stopWatermarkProgress(); setWatermarkProgress(100); toast.success("去水印处理完成"); void saveUnifiedEcommerceGenerationRecord({ clientRecordId: crypto.randomUUID(), title: `去水印 ${watermarkImage.name || ""}`.trim(), mode: "watermark", taskIds: [taskId], sourceImages: [{ url: imageUrl, label: watermarkImage.name || "watermark-source" }], results: [{ url: persistedUrl, label: "去水印结果", mediaType: "image", taskId }], createdAt: new Date().toISOString(), }); } else { setWatermarkStatus("failed"); stopWatermarkProgress(); setWatermarkProgress(0); toast.error("去水印未返回结果"); } } catch (err) { setWatermarkStatus("failed"); stopWatermarkProgress(); setWatermarkProgress(0); if (err instanceof ServerRequestError && err.status === 402) { toast.error("余额不足,请充值后继续"); } else { toast.error(err instanceof Error ? err.message : "去水印失败"); } } }; const handleWatermarkDownload = () => { if (!watermarkResultUrl || watermarkStatus !== "done") { toast.info("请先完成去水印"); return; } const link = document.createElement("a"); const safeName = (watermarkImage?.name || "watermark-result").replace(/\.[^.]+$/, "").replace(/[\\/:*?"<>|]+/g, "-"); link.href = watermarkResultUrl; link.download = `${safeName || "watermark-result"}-去水印.png`; document.body.appendChild(link); link.click(); link.remove(); }; const openImageTranslatePage = () => { clearSmartCutoutTransition(); setComposerMenu(null); toast.info("功能正在优化中"); }; const closeImageTranslatePage = () => { if (translateProcessTimeoutRef.current !== null) { window.clearTimeout(translateProcessTimeoutRef.current); translateProcessTimeoutRef.current = null; } setActiveQuickTool(null); setTranslateStatus("idle"); setTranslateResultUrl(null); setTranslateImage((current) => { if (current?.src) URL.revokeObjectURL(current.src); return null; }); }; const addTranslateImage = (file: File) => { const nextImage = { src: URL.createObjectURL(file), name: file.name, format: getImageFileFormat(file) || "PNG / JPG / WebP", }; setTranslateImage((current) => { if (current?.src) URL.revokeObjectURL(current.src); return nextImage; }); setTranslateStatus("idle"); setTranslateResultUrl(null); setActiveQuickTool("translate"); }; const removeTranslateImage = () => { if (translateProcessTimeoutRef.current !== null) { window.clearTimeout(translateProcessTimeoutRef.current); translateProcessTimeoutRef.current = null; } setTranslateStatus("idle"); setTranslateResultUrl(null); setTranslateImage((current) => { if (current?.src) URL.revokeObjectURL(current.src); return null; }); }; const handleTranslateUpload = (event: ChangeEvent) => { const file = event.target.files?.[0]; if (!file) return; addTranslateImage(file); event.target.value = ""; }; const handleTranslateDrop = (event: DragEvent) => { event.preventDefault(); setIsTranslateDragging(false); const file = Array.from(event.dataTransfer.files).find((item) => item.type.startsWith("image/")); if (file) addTranslateImage(file); }; const handleTranslateUrlImport = async () => { const nextImage = await loadRemoteImageFromInput(translateUrlInputRef.current, "translate-source"); if (!nextImage) return; setTranslateImage((current) => { if (current?.src) URL.revokeObjectURL(current.src); return nextImage; }); setTranslateStatus("idle"); setTranslateResultUrl(null); toast.success("图片已导入"); }; const handleTranslateGenerate = async () => { if (!translateImage || translateStatus === "processing") return; const targetLabel = translateLanguageOptions.find((option) => option.value === translateLanguage)?.label || "中文"; setTranslateStatus("processing"); setTranslateResultUrl(null); try { const sourceBlob = await fetch(translateImage.src).then((res) => res.blob()); const sourceMime = normalizeEcommerceImageMime(sourceBlob.type || "image/png"); const { url: imageUrl } = await aiGenerationClient.uploadAssetBinary(sourceBlob, { name: `translate-source-${Date.now()}.png`, mimeType: sourceMime, scope: ecommerceOssScopes.productSource, }); const prompt = `将图片中的所有文字翻译成${targetLabel},保持原有的排版、字体风格、位置和整体设计不变,只替换文字内容。`; const { taskId } = await aiGenerationClient.createImageEditTask({ imageUrl, function: "description_edit", prompt, }); const resultUrl = await waitForTask(taskId, { kind: "image", abortRef: { current: false }, onProgress: () => {}, }); if (resultUrl) { const persistedUrl = await persistGeneratedImageUrl(resultUrl, ecommerceOssScopes.cloneResult("translate"), "ecommerce-translate"); setTranslateResultUrl(persistedUrl); setTranslateStatus("done"); toast.success("图片翻译完成"); void saveUnifiedEcommerceGenerationRecord({ clientRecordId: crypto.randomUUID(), title: `图片翻译(${targetLabel}) ${translateImage.name || ""}`.trim(), mode: "translate", prompt, taskIds: [taskId], sourceImages: [{ url: imageUrl, label: translateImage.name || "translate-source" }], results: [{ url: persistedUrl, label: "翻译结果", mediaType: "image", taskId }], config: { targetLanguage: translateLanguage }, createdAt: new Date().toISOString(), }); } else { setTranslateStatus("failed"); toast.error("翻译未返回结果"); } } catch (err) { setTranslateStatus("failed"); if (err instanceof ServerRequestError && err.status === 402) { toast.error("余额不足,请充值后继续"); } else { toast.error(err instanceof Error ? err.message : "图片翻译失败"); } } }; const handleTranslateDownload = () => { if (!translateResultUrl || translateStatus !== "done") { toast.info("请先完成图片翻译"); return; } const link = document.createElement("a"); const safeName = (translateImage?.name || "translate-result").replace(/\.[^.]+$/, "").replace(/[\\/:*?"<>|]+/g, "-"); link.href = translateResultUrl; link.download = `${safeName || "translate-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"); setImageWorkbenchResultUrl(null); 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"); setImageWorkbenchResultUrl(null); setImageWorkbenchMaskStrokes([]); setImageWorkbenchBrushCursor(null); clearImageWorkbenchMaskCanvas(); imageWorkbenchActiveStrokeIdRef.current = null; setActiveQuickTool("image-edit"); }; const removeImageWorkbenchImage = () => { setImageWorkbenchStatus("idle"); setImageWorkbenchResultUrl(null); 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 handleImageWorkbenchUrlImport = async () => { const nextImage = await loadRemoteImageFromInput(imageWorkbenchUrlInputRef.current, "image-workbench-source"); if (!nextImage) return; setImageWorkbenchImage((current) => { if (current?.src) URL.revokeObjectURL(current.src); return nextImage; }); setImageWorkbenchStatus("idle"); setImageWorkbenchMaskStrokes([]); setImageWorkbenchBrushCursor(null); clearImageWorkbenchMaskCanvas(); imageWorkbenchActiveStrokeIdRef.current = null; setActiveQuickTool("image-edit"); toast.success("图片已导入"); }; const stopWorkbenchProgress = () => { if (imageWorkbenchProgressRef.current !== null) { window.clearInterval(imageWorkbenchProgressRef.current); imageWorkbenchProgressRef.current = null; } }; const startWorkbenchProgress = () => { stopWorkbenchProgress(); setImageWorkbenchProgress(0); imageWorkbenchProgressRef.current = window.setInterval(() => { setImageWorkbenchProgress((prev) => { if (prev >= 90) { stopWorkbenchProgress(); return 90; } return prev + (90 - prev) * 0.06; }); }, 500); }; const exportWorkbenchMask = (): string | null => { const canvas = imageWorkbenchMaskCanvasRef.current; if (!canvas) return null; const ctx = canvas.getContext("2d"); if (!ctx) return null; const w = canvas.width; const h = canvas.height; const maskCanvas = document.createElement("canvas"); maskCanvas.width = w; maskCanvas.height = h; const maskCtx = maskCanvas.getContext("2d")!; maskCtx.fillStyle = "#000000"; maskCtx.fillRect(0, 0, w, h); const imgData = ctx.getImageData(0, 0, w, h); const maskData = maskCtx.getImageData(0, 0, w, h); for (let i = 3; i < imgData.data.length; i += 4) { if (imgData.data[i] > 0) { const pi = i - 3; maskData.data[pi] = 255; maskData.data[pi + 1] = 255; maskData.data[pi + 2] = 255; maskData.data[pi + 3] = 255; } } maskCtx.putImageData(maskData, 0, 0); return maskCanvas.toDataURL("image/png"); }; const handleImageWorkbenchGenerate = async () => { if (!imageWorkbenchImage) { toast.info("请先上传图片"); return; } setImageWorkbenchStatus("processing"); setImageWorkbenchResultUrl(null); startWorkbenchProgress(); try { const sourceBlob = await fetch(imageWorkbenchImage.src).then((res) => res.blob()); const sourceMime = normalizeEcommerceImageMime(sourceBlob.type || "image/png"); const { url: imageUrl } = await aiGenerationClient.uploadAssetBinary(sourceBlob, { name: `inpaint-source-${Date.now()}.png`, mimeType: sourceMime, scope: ecommerceOssScopes.productSource, }); let maskUrl: string | undefined; if (imageWorkbenchMaskStrokes.length > 0) { const maskDataUrl = exportWorkbenchMask(); if (maskDataUrl) { const { url } = await aiGenerationClient.uploadAsset({ dataUrl: maskDataUrl, name: `inpaint-mask-${Date.now()}.png`, mimeType: "image/png", scope: ecommerceOssScopes.productSource, }); maskUrl = url; } } const { taskId } = await aiGenerationClient.createImageEditTask({ imageUrl, function: "inpaint", prompt: imageWorkbenchPrompt || undefined, maskUrl, ratio: imageWorkbenchRatio, }); const resultUrl = await waitForTask(taskId, { abortRef: { current: false }, onProgress: () => {}, }); if (resultUrl) { const persistedUrl = await persistGeneratedImageUrl(resultUrl, ecommerceOssScopes.cloneResult("inpaint"), "ecommerce-inpaint"); setImageWorkbenchResultUrl(persistedUrl); setImageWorkbenchStatus("done"); stopWorkbenchProgress(); setImageWorkbenchProgress(100); toast.success("局部重绘已完成"); void saveUnifiedEcommerceGenerationRecord({ clientRecordId: crypto.randomUUID(), title: imageWorkbenchPrompt.trim() || `图片修改 ${imageWorkbenchImage.name || ""}`.trim(), mode: "inpaint", prompt: imageWorkbenchPrompt || undefined, taskIds: [taskId], sourceImages: [{ url: imageUrl, label: imageWorkbenchImage.name || "inpaint-source" }], results: [{ url: persistedUrl, label: "局部重绘结果", mediaType: "image", taskId }], config: { ratio: imageWorkbenchRatio }, createdAt: new Date().toISOString(), }); } else { setImageWorkbenchStatus("failed"); stopWorkbenchProgress(); setImageWorkbenchProgress(0); toast.error("重绘未返回结果"); } } catch (err) { setImageWorkbenchStatus("failed"); stopWorkbenchProgress(); setImageWorkbenchProgress(0); if (err instanceof ServerRequestError && err.status === 402) { toast.error("余额不足,请充值后继续"); } else { toast.error(err instanceof Error ? err.message : "重绘失败"); } } }; const syncImageWorkbenchMaskCanvas = () => { 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 openQuickDetailPage = () => { clearSmartCutoutTransition(); setActiveQuickTool("detail"); setComposerMenu(null); setIsCloneSettingsCollapsed(false); setIsQuickPanelCollapsed(false); setPreviewZoom(1); resetQuickSetSelectState(); if (!selectedDetailModules.length) setSelectedDetailModules(defaultCloneDetailModuleIds); }; const openHotClonePage = () => { clearSmartCutoutTransition(); setActiveQuickTool("hot"); setComposerMenu(null); setIsCloneSettingsCollapsed(false); setIsQuickPanelCollapsed(false); setPreviewZoom(1); resetQuickSetSelectState(); }; 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 unsupportedCount = files.length - imageFiles.length; if (imageFiles.length) void addProductImages(imageFiles); 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 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 removeCloneReferenceImage = (imageId: string) => { setCloneReferenceImages((current) => { const next = current.filter((item) => item.id !== imageId); if (next.length === 0) { setHotStatus("idle"); setHotResultUrl(null); } return next; }); }; 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) => normalizeRatioForPlatform(normalizedPlatform, current, cloneOutput), ); setLanguage(getPlatformDefaultLanguage(normalizedPlatform, market)); }; const handleCloneOutputChange = (nextOutput: CloneOutputKey) => { setCloneOutput(nextOutput); setIsCloneTemplateStripVisible(true); if (nextOutput !== "video") setIsVideoWorkspaceVisible(false); setRatio((current) => normalizeRatioForPlatform(platform, current, nextOutput), ); }; const handleCommerceScenarioClick = (nextScenario: CommerceScenarioKey) => { if (nextScenario === activeCommerceScenario) { setIsCloneTemplateStripVisible((visible) => !visible); setComposerMenu(null); return; } setActiveCommerceScenario(nextScenario); setIsCloneTemplateStripVisible(true); setComposerMenu(null); if (nextScenario === "popular") return; const mappedOutput = commerceScenarioOutputMap[nextScenario]; if (mappedOutput !== cloneOutput) handleCloneOutputChange(mappedOutput); }; 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(() => { setRatio((current) => { const platformRatios = getPlatformRatioOptions(platform, cloneOutput); if (platformRatios.includes(current)) return current; const normalizedRatio = normalizeRatioToken(current); const matchedRatio = platformRatios.find((option) => normalizeRatioToken(option).includes(normalizedRatio)); return matchedRatio ?? getPlatformDefaultRatio(platform, cloneOutput); }); }, [cloneOutput, 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 && composerMenu !== "settings") 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: ecommerceOssScopes.productSource }); urls.push(url); } catch { // skip images that fail to upload } } return urls; }; 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(); const totalCount = Math.max(1, cloneSetCountKeys.reduce((sum, key) => sum + counts[key], 0)); let completedCount = 0; setGenerationProgress(0); 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({ 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, { kind: "image", abortRef: imageAbortRef.current, onProgress: (event) => { // 整体进度 = (已完成张数 + 当前张子进度) / 总张数。 const sub = Math.max(0, Math.min(100, Number(event.progress) || 0)); const overall = ((completedCount + sub / 100) / totalCount) * 100; setGenerationProgress(Math.round(Math.min(99, overall))); }, }); } finally { untrackEcommerceTask(taskId); } if (imageAbortRef.current.current) break; if (resultUrl) { const persistedUrl = await persistGeneratedImageUrl(resultUrl, ecommerceOssScopes.cloneResult("set"), `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: "生成未返回结果" }); } completedCount += 1; setGenerationProgress(Math.round(Math.min(99, (completedCount / totalCount) * 100))); } } 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(); setGenerationProgress(0); const { taskId } = await aiGenerationClient.createImageTask({ 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, { kind: "image", abortRef: imageAbortRef.current, onProgress: (event) => { const sub = Math.max(0, Math.min(100, Number(event.progress) || 0)); setGenerationProgress(Math.round(Math.min(99, sub))); }, }); } finally { untrackEcommerceTask(taskId); } if (imageAbortRef.current.current) { statusFn?.("idle"); return; } if (resultUrl) { const persistedUrl = await persistGeneratedImageUrl(resultUrl, ecommerceOssScopes.cloneResult(outputKey), `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 handleGenerate = () => { if (!canGenerate) return; if ((appUsage?.balanceCents ?? 0) <= 0) { toast.error("积分不足,请充值后继续"); return; } if (cloneOutput === "set" && cloneSetTotal > 5) { if (!window.confirm("将生成 " + String(cloneSetTotal) + " 张图片,可能消耗较多积分,是否继续?")) return; } setComposerMenu(null); setIsCommandComposerCompact(true); imageAbortRef.current = { current: false }; lastFailedActionRef.current = null; setGenerationProgress(0); setResults([]); setProductSetResultImages([]); const pendingGeneration = beginEcommerceHistoryTurn(); const pendingRecordId = pendingGeneration.record.id; const pendingTurnId = pendingGeneration.turn.id; setPreviewZoom(1); setPreviewOffset({ x: 0, y: 0 }); previewOffsetRef.current = { x: 0, y: 0 }; if (cloneOutput === "set") { void generateSetImages( productImages, cloneSetCounts, requirement, platform, ratio, language, market, (s) => { setStatus(s as ProductCloneStatus); if (s === "generating") { updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({ ...turn, status: "generating", errorMessage: undefined })); } else if (s === "failed") { updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({ ...turn, status: "failed", errorMessage: "生成失败,请检查网络或参数后重试。" })); } }, (urls) => { setProductSetResultImages(urls); const validUrls = urls.filter(Boolean); const resultCards = validUrls.map((src, i) => ({ id: `set-${Date.now()}-${i}`, src, label: `套图 ${i + 1}` })); updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({ ...turn, status: validUrls.length ? "done" : "failed", errorMessage: validUrls.length ? undefined : "生成未返回结果", setResultImages: validUrls, results: resultCards, })); if (validUrls.length) { upsertCanvasNode({ id: pendingTurnId, mode: "set", sourceImage: productImages[0]?.src, results: resultCards, createdAt: Date.now(), }); } }, ); lastFailedActionRef.current = () => handleGenerate(); } 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); if (s === "generating") { updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({ ...turn, status: "generating", errorMessage: undefined })); } else if (s === "failed") { updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({ ...turn, status: "failed", errorMessage: "生成失败,请检查网络或参数后重试。" })); } }, (newResults: CloneResult[]) => { const validResults = newResults.filter((item) => item.src); setResults(validResults); updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({ ...turn, status: validResults.length ? "done" : "failed", errorMessage: validResults.length ? undefined : newResults[0]?.label || "生成未返回结果", results: validResults, setResultImages: [], })); if (validResults.length && validResults[0].src) { upsertCanvasNode({ id: pendingTurnId, mode: cloneOutput, sourceImage: productImages[0]?.src, results: validResults, createdAt: Date.now(), }); } }, ); 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: { id?: string; src: string; label: string }, options?: { nodeId?: string; removable?: boolean }) => { setSelectedProductSetPreview({ src: card.src, label: card.label, cardId: card.id, nodeId: options?.nodeId, removable: Boolean(options?.removable && options.nodeId && card.id), }); }; const handleDownloadCanvasResult = async (card: { src: string; label: string }) => { try { await downloadResultAsset(card.src, card.label || "generated-image", false); toast.success("已开始下载图片"); } catch (error) { toast.error(error instanceof Error ? error.message : "下载图片失败"); } }; const removeCanvasResult = (nodeId: string, cardId: string) => { setCanvasNodes((current) => current .map((node) => (node.id === nodeId ? { ...node, results: node.results.filter((card) => card.id !== cardId) } : node)) .filter((node) => node.sourceImage || node.results.length > 0), ); setResults((current) => current.filter((card) => card.id !== cardId)); toast.success("已从当前视图移除"); }; const upsertCanvasNode = (node: Omit) => { setCanvasNodes((current) => { const existingIndex = current.findIndex((item) => item.id === node.id); if (existingIndex >= 0) { return current.map((item) => (item.id === node.id ? { ...item, ...node } : item)); } return [ ...current, { ...node, x: current.length * 420, y: current.length % 2 === 0 ? 0 : 160, }, ]; }); }; const removeSelectedProductSetPreview = (preview: ProductSetPreviewSelection) => { if (!preview.nodeId || !preview.cardId) return; removeCanvasResult(preview.nodeId, preview.cardId); setSelectedProductSetPreview(null); }; const handleDetailAiWrite = () => { setDetailRequirement( "1.产品名称:无线降噪蓝牙耳机\n2.核心卖点:主动降噪、24H续航、低延迟连接、舒适佩戴\n3.适用人群:通勤、办公、运动和旅行用户\n4.期望场景:地铁通勤、居家办公、户外运动\n5.具体参数:蓝牙5.3、IPX4防水、快充10分钟使用2小时", ); }; const stopDetailProgress = () => { if (detailProgressRef.current !== null) { window.clearInterval(detailProgressRef.current); detailProgressRef.current = null; } }; const startDetailProgress = () => { stopDetailProgress(); setDetailProgress(0); detailProgressRef.current = window.setInterval(() => { setDetailProgress((prev) => { if (prev >= 90) { stopDetailProgress(); return 90; } return prev + (90 - prev) * 0.06; }); }, 500); }; const handleDetailGenerate = () => { if (!canGenerateDetail) return; imageAbortRef.current = { current: false }; lastFailedActionRef.current = null; startDetailProgress(); void generateEcommerceImage( "detail", detailProductImages, detailRequirement, detailPlatform, detailRatio, detailLanguage, detailMarket, { detailModules: selectedDetailModules }, (s: string) => { setDetailStatus(s as DetailStatus); if (s === "done") { stopDetailProgress(); setDetailProgress(100); } else if (s === "failed" || s === "idle") { stopDetailProgress(); setDetailProgress(0); } }, (res) => setDetailResultUrl(res[0]?.src ?? null), ); }; const handleHotPlatformChange = (nextPlatform: string) => { const normalizedPlatform = normalizePlatform(nextPlatform); setHotPlatform(normalizedPlatform); setHotLanguage(getPlatformDefaultLanguage(normalizedPlatform, hotMarket)); setHotRatio((current) => getQuickSetRatioValue(current)); }; const handleHotMarketChange = (nextMarket: string) => { const normalizedMarket = normalizeMarket(nextMarket); setHotMarket(normalizedMarket); setHotLanguage(getPlatformDefaultLanguage(hotPlatform, normalizedMarket)); }; const handleHotAiWrite = () => { setHotRequirement( "1.产品名称:便携式咖啡保温杯\n2.核心卖点:316不锈钢内胆、12小时长效保温、防漏便携、大容量\n3.参考风格:极简日系、暖光氛围、生活场景\n4.期望场景:办公桌面、户外通勤、运动健身\n5.具体参数:容量500ml、口径4.5cm、高度22cm", ); }; const stopHotProgress = () => { if (hotProgressRef.current !== null) { window.clearInterval(hotProgressRef.current); hotProgressRef.current = null; } }; const startHotProgress = () => { stopHotProgress(); setHotProgress(0); hotProgressRef.current = window.setInterval(() => { setHotProgress((prev) => { if (prev >= 90) { stopHotProgress(); return 90; } return prev + (90 - prev) * 0.06; }); }, 500); }; const handleHotGenerate = () => { if (!canGenerateHot) return; imageAbortRef.current = { current: false }; lastFailedActionRef.current = null; startHotProgress(); void generateEcommerceImage( "hot", cloneReferenceImages, hotRequirement, hotPlatform, hotRatio, hotLanguage, hotMarket, undefined, (s: string) => { setHotStatus(s as DetailStatus); if (s === "done") { stopHotProgress(); setHotProgress(100); } else if (s === "failed" || s === "idle") { stopHotProgress(); setHotProgress(0); } }, (res) => setHotResultUrl(res[0]?.src ?? null), ); }; const handleHotMaterialMouseEnter = (src: string, event: ReactMouseEvent) => { const rect = event.currentTarget.getBoundingClientRect(); const previewHalfWidth = 150; const previewHeight = 360; const gap = 12; const viewportWidth = window.innerWidth || document.documentElement.clientWidth; const viewportHeight = window.innerHeight || document.documentElement.clientHeight; const x = Math.min( Math.max(rect.left + rect.width / 2, previewHalfWidth + gap), Math.max(previewHalfWidth + gap, viewportWidth - previewHalfWidth - gap), ); const showAbove = rect.top > previewHeight + gap; const y = showAbove ? rect.top - gap : Math.min(rect.bottom + gap, viewportHeight - gap); setHotMaterialHoverZoom({ src, x, y, placement: showAbove ? "above" : "below" }); }; const handleHotMaterialMouseLeave = () => setHotMaterialHoverZoom(null); const renderHotMaterialThumbs = (items: CloneImageItem[], onRemove: (imageId: string) => void) => (
{items.map((item) => (
handleHotMaterialMouseEnter(item.src, e)} onMouseLeave={handleHotMaterialMouseLeave} > {item.name}
))}
); const closeHotClonePage = () => { stopHotProgress(); setActiveQuickTool(null); setHotStatus("idle"); setHotResultUrl(null); setHotProgress(0); setHotRequirement(""); setIsHotMaterialDragging(false); setHotMaterialHoverZoom(null); setComposerMenu(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 isQuickDetailTool = isCloneTool && activeQuickTool === "detail"; const isWatermarkTool = isCloneTool && activeQuickTool === "watermark"; const isImageEditTool = isCloneTool && activeQuickTool === "image-edit"; const isTranslateTool = isCloneTool && activeQuickTool === "translate"; const isHotCloneTool = isCloneTool && activeQuickTool === "hot"; 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 = cloneOutput === "video" ? !productImages.length && !requirement.trim() ? "填写需求或上传商品图" : !isAuthenticated ? "登录后生成短视频" : "生成短视频" : 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 getHistoryRecordResults = (record: EcommerceHistoryRecord) => { const turns = record.turns?.length ? record.turns : [buildHistoryTurnFromRecord(record)]; return turns.flatMap(getTurnResults); }; const persistEcommerceHistoryRecord = (record: EcommerceHistoryRecord, historyResults: CloneResult[]) => { void saveUnifiedEcommerceGenerationRecord({ clientRecordId: record.id, title: record.title, mode: record.output, prompt: record.requirement, sourceImages: record.productImages.map((image, index) => ({ url: image.src, ossKey: image.ossKey, label: image.name || `source-${index + 1}`, })), results: historyResults.map((item) => ({ url: item.src, label: item.label, mediaType: "image", })), config: { platform: record.platform, market: record.market, language: record.language, ratio: record.ratio, setCounts: record.setCounts, detailModules: record.detailModules, modelScenes: record.modelScenes, replicateLevel: record.replicateLevel, }, metadata: { localHistoryStorageKey: ecommerceHistoryStorageKey, referenceImageCount: record.referenceImages.length, turnCount: record.turns?.length ?? 1, latestTurnId: record.turns?.[record.turns.length - 1]?.id, }, createdAt: new Date(record.createdAt).toISOString(), }); }; 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 buildEcommerceHistoryTitle = (output: CloneOutputKey, prompt: string, createdAt: number) => { const outputLabel = cloneOutputOptions.find((option) => option.key === output)?.label || "生成记录"; return prompt.trim() || outputLabel + " " + new Date(createdAt).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" }); }; const updateLocalEcommerceHistoryRecord = (recordId: string, updater: (record: EcommerceHistoryRecord) => EcommerceHistoryRecord) => { setEcommerceHistoryRecords((current) => { const nextRecords = current.map((record) => (record.id === recordId ? normalizeEcommerceHistoryRecord(updater(record)) : record)); writeEcommerceHistoryRecords(nextRecords); return nextRecords; }); }; const buildCurrentEcommerceHistoryTurn = (turnId: string, createdAt: number, turnStatus: EcommerceHistoryStatus = "generating"): EcommerceHistoryTurn => ({ id: turnId, createdAt, status: turnStatus, output: cloneOutput, platform, market, language, ratio, requirement, productImages, results: [], setResultImages: [], setCounts: cloneSetCounts, detailModules: selectedCloneDetailModules, modelScenes: selectedCloneModelScenes, referenceImages: cloneReferenceImages, replicateLevel: cloneReplicateLevel, }); const syncRecordSummaryWithTurn = (record: EcommerceHistoryRecord, turn: EcommerceHistoryTurn): EcommerceHistoryRecord => ({ ...record, status: turn.status, errorMessage: turn.status === "failed" ? turn.errorMessage : undefined, output: turn.output, platform: turn.platform, market: turn.market, language: turn.language, ratio: turn.ratio, requirement: turn.requirement, productImages: turn.productImages, results: turn.results, setResultImages: turn.setResultImages, setCounts: turn.setCounts, detailModules: turn.detailModules, modelScenes: turn.modelScenes, referenceImages: turn.referenceImages, replicateLevel: turn.replicateLevel, }); const updateLocalEcommerceHistoryTurn = ( recordId: string, turnId: string, updater: (turn: EcommerceHistoryTurn) => EcommerceHistoryTurn, ) => { updateLocalEcommerceHistoryRecord(recordId, (record) => { const turns = record.turns?.length ? record.turns : [buildHistoryTurnFromRecord(record)]; let updatedTurn: EcommerceHistoryTurn | null = null; const nextTurns = turns.map((turn) => { if (turn.id !== turnId) return turn; updatedTurn = normalizeEcommerceHistoryTurn(updater(turn), record, turns.indexOf(turn)); return updatedTurn; }); return updatedTurn ? syncRecordSummaryWithTurn({ ...record, turns: nextTurns }, updatedTurn) : record; }); }; const beginEcommerceHistoryTurn = () => { const createdAt = Date.now(); const turn = buildCurrentEcommerceHistoryTurn(crypto.randomUUID(), createdAt); const existingRecord = activeHistoryRecordId ? ecommerceHistoryRecords.find((record) => record.id === activeHistoryRecordId) : null; const recordId = existingRecord?.id ?? crypto.randomUUID(); const baseRecord: EcommerceHistoryRecord = existingRecord ?? { id: recordId, title: buildEcommerceHistoryTitle(cloneOutput, requirement, createdAt), createdAt, status: turn.status, output: turn.output, platform: turn.platform, market: turn.market, language: turn.language, ratio: turn.ratio, requirement: turn.requirement, productImages: turn.productImages, results: turn.results, setResultImages: turn.setResultImages, setCounts: turn.setCounts, detailModules: turn.detailModules, modelScenes: turn.modelScenes, referenceImages: turn.referenceImages, replicateLevel: turn.replicateLevel, turns: [], }; const previousTurns = baseRecord.turns?.length ? baseRecord.turns : existingRecord ? [buildHistoryTurnFromRecord(baseRecord)] : []; const record = normalizeEcommerceHistoryRecord(syncRecordSummaryWithTurn({ ...baseRecord, turns: [...previousTurns, turn], }, turn)); setEcommerceHistoryRecords((current) => { const nextRecords = [record, ...current.filter((item) => item.id !== record.id)].slice(0, 30); writeEcommerceHistoryRecords(nextRecords); return nextRecords; }); setActiveHistoryRecordId(record.id); activeHistoryTurnIdRef.current = turn.id; return { record, turn }; }; const saveCurrentEcommerceHistory = () => { const activeRecord = activeHistoryRecordId ? ecommerceHistoryRecords.find((record) => record.id === activeHistoryRecordId) : null; const historyResults = activeRecord?.turns?.length ? getHistoryRecordResults(activeRecord) : getCurrentHistoryResults(); if (!historyResults.length) return null; const signature = activeRecord?.turns?.length ? buildHistorySignature(activeRecord.output, activeRecord.requirement, historyResults, activeRecord.productImages) : buildHistorySignature(cloneOutput, requirement, historyResults, productImages); if (lastSavedHistorySignatureRef.current === signature && activeHistoryRecordId) return activeHistoryRecordId; const createdAt = Date.now(); const record: EcommerceHistoryRecord = activeRecord?.turns?.length ? normalizeEcommerceHistoryRecord(activeRecord) : normalizeEcommerceHistoryRecord({ id: activeRecord?.id ?? crypto.randomUUID(), title: activeRecord?.title ?? buildEcommerceHistoryTitle(cloneOutput, requirement, createdAt), createdAt: activeRecord?.createdAt ?? createdAt, status: "done", errorMessage: undefined, 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; persistEcommerceHistoryRecord(record, historyResults); 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((record.status ?? "done") as ProductCloneStatus); setPreviewZoom(1); setComposerMenu(null); setActiveHistoryRecordId(record.id); activeHistoryTurnIdRef.current = record.status === "generating" ? record.turns?.find((turn) => turn.status === "generating")?.id ?? null : null; const recordResults = getHistoryRecordResults(record); lastSavedHistorySignatureRef.current = buildHistorySignature(record.output, record.requirement, recordResults, record.productImages); setIsCommandComposerCompact(true); const turns = record.turns?.length ? record.turns : [buildHistoryTurnFromRecord(record)]; const nodes = turns.reduce((items, turn) => { const turnResults = getTurnResults(turn); if (!turnResults.length) return items; const index = items.length; items.push({ id: turn.id, mode: turn.output, sourceImage: turn.productImages[0]?.src, results: turnResults, createdAt: turn.createdAt, x: index * 420, y: index % 2 === 0 ? 0 : 160, }); return items; }, []); setCanvasNodes(nodes); setPreviewOffset({ x: 0, y: 0 }); previewOffsetRef.current = { x: 0, y: 0 }; }; const handleNewEcommerceConversation = () => { saveCurrentEcommerceHistory(); resetTask(); setCanvasNodes([]); setPreviewZoom(1); setPreviewOffset({ x: 0, y: 0 }); setComposerMenu(null); setIsCommandComposerCompact(false); setActiveHistoryRecordId(null); activeHistoryTurnIdRef.current = 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); }; const deleteHistoryRecord = (recordId: string, event: ReactMouseEvent) => { event.stopPropagation(); const record = ecommerceHistoryRecords.find((r) => r.id === recordId); if (!record) return; const next = ecommerceHistoryRecords.filter((r) => r.id !== recordId); setEcommerceHistoryRecords(next); writeEcommerceHistoryRecords(next); if (activeHistoryRecordId === recordId) { setActiveHistoryRecordId(null); activeHistoryTurnIdRef.current = null; } deleteEcommerceGenerationRecord(recordId).catch(() => {}); }; 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 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 quickHotBasicSelects: Array<{ key: CloneBasicSelectKey; label: string; value: string; options: string[]; onChange: (value: string) => void; }> = [ { key: "platform", label: "平台", value: hotPlatform, options: platformOptions, onChange: handleHotPlatformChange }, { key: "market", label: "国家", value: hotMarket, options: marketOptions, onChange: handleHotMarketChange }, { key: "language", label: "语种", value: hotLanguage, options: hotLanguageOptions, onChange: setHotLanguage }, { key: "ratio", label: "尺寸/比例", value: getQuickSetRatioValue(hotRatio), options: quickSetRatioOptions, onChange: setHotRatio }, ]; 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 = ( ); 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