diff --git a/src/components/Topbar.tsx b/src/components/Topbar.tsx index 1eee5ad..a02ae72 100644 --- a/src/components/Topbar.tsx +++ b/src/components/Topbar.tsx @@ -138,9 +138,10 @@ export function Topbar({ type="button" className="ecommerce-profile-popover__backdrop" aria-label="关闭账户信息" + style={{ pointerEvents: "auto" }} onClick={() => onProfileMenuOpenChange(false)} /> -
+
diff --git a/src/features/ecommerce/EcommercePage.tsx b/src/features/ecommerce/EcommercePage.tsx index 7659523..680b768 100644 --- a/src/features/ecommerce/EcommercePage.tsx +++ b/src/features/ecommerce/EcommercePage.tsx @@ -1,5 +1,4 @@ import { - AppstoreOutlined, AppstoreAddOutlined, BorderOuterOutlined, ClearOutlined, @@ -14,7 +13,6 @@ import { FrownOutlined, GlobalOutlined, HighlightOutlined, - LayoutOutlined, LoadingOutlined, MenuFoldOutlined, MenuUnfoldOutlined, @@ -25,10 +23,8 @@ import { ReloadOutlined, ScissorOutlined, SettingOutlined, - SkinOutlined, TableOutlined, TranslationOutlined, - VideoCameraOutlined, } from "@ant-design/icons"; import { ArrowsCounterClockwise, @@ -40,7 +36,6 @@ 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"; @@ -83,6 +78,7 @@ import { defaultCloneDetailModuleIds, defaultCloneSetCounts, ecommerceHistoryStorageKey, + getEcommerceHistoryUserBucket, getTurnResults, normalizeEcommerceHistoryRecord, normalizeEcommerceHistoryTurn, @@ -103,134 +99,8 @@ import type { EcommerceHistoryStatus, EcommerceHistoryTurn, } from "./utils/clonePersistence"; - -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}。`; -}; - import { aiGenerationClient } from "../../api/aiGenerationClient"; -import { listEcommerceTemplates, type EcommerceTemplateManifestItem } from "../../api/ecommerceTemplateClient"; +import { listEcommerceTemplates } from "../../api/ecommerceTemplateClient"; import { ServerRequestError } from "../../api/serverConnection"; import { waitForTask } from "../../api/taskSubscription"; import { toast } from "../../components/toast/toastStore"; @@ -238,8 +108,6 @@ import { useGenerationTasks } from "../../hooks/useGenerationTasks"; import { useAppStore } from "../../stores"; import { normalizeEcommerceImageMime, - summarizeRejectedImages, - validateEcommerceImageFiles, } from "./ecommerceImageValidation"; import { clampNumber, @@ -260,889 +128,91 @@ import { parseRatioToAspectCss, quickSetRatioOptions, } from "./utils/ratioUtils"; - - -interface ProductClonePageProps { - onWorkspaceChromeChange?: (state: { isToolPage: boolean }) => void; - [key: string]: unknown; -} - -type ProductCloneStatus = "idle" | "ready" | "generating" | "done" | "failed"; -type CommerceScenarioKey = "popular" | "poster" | "mainImage" | "scene" | "festival" | "model" | "background" | "retouch" | "salesVideo"; -type CommerceDefaultImageScenarioKey = Exclude; -type CommerceDefaultIntent = - | { kind: "image"; scenario: CommerceDefaultImageScenarioKey } - | { kind: "video"; scenario: "salesVideo" }; -type ProductSetStatus = "idle" | "ready" | "generating" | "done" | "failed"; -type ProductKitToolKey = "set" | "detail" | "wear" | "clone"; -type ComposerMenuKey = "mode" | "platform" | "language" | "ratio" | "settings" | "assetLibrary" | "workMode" | "aiWrite"; -type ComposerAssetTabKey = "recent" | "recipe" | "model"; -type ComposerWorkModeKey = "quick" | "think"; -type CloneBasicSelectKey = "platform" | "market" | "language" | "ratio"; -type CloneModelSelectKey = "gender" | "age" | "ethnicity" | "body"; -type CloneTemplateAsset = { - id: string; - title: string; - prompt: string; - mediaUrl: string; - mediaType?: "image" | "video"; - sourceAssets?: Array<{ - url: string; - name: string; - ossKey?: string; - mimeType?: 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 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 EcommerceImagePromptOptions { - gender?: string; - age?: string; - ethnicity?: string; - body?: string; - appearance?: string; - scenes?: string[]; - customScene?: string; - smartScene?: boolean; - detailModules?: 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 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 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: "model", label: "模特图", desc: "真人展示", icon: 🕴️ }, - { key: "scene", label: "场景图", desc: "生活氛围", icon: 🌅 }, - { key: "festival", label: "节日风格图", desc: "节点营销", icon: 🎉 }, - { key: "salesVideo", label: "带货视频", desc: "短视频脚本", icon: 🎬 }, - { key: "background", label: "更换背景", desc: "背景重构", icon: }, - { key: "retouch", label: "无痕改图", desc: "精修优化", icon: 🪄 }, -]; -const primaryCommerceScenarioKeys: CommerceScenarioKey[] = ["popular", "poster", "mainImage", "model"]; -const scenarioSettingsKeys: CommerceScenarioKey[] = ["poster", "mainImage", "model", "scene", "festival", "salesVideo"]; -const scenarioAdvancedSettingsKeys: CommerceScenarioKey[] = ["model", "salesVideo"]; -const commerceScenarioOutputMap: Record, ProductSetOutputKey> = { - poster: "set", - mainImage: "set", - scene: "set", - festival: "set", - model: "model", - background: "set", - retouch: "set", - salesVideo: "video", -}; - -const ecommerceTemplateCategoryMap: Record> = { - poster: "poster", - "main-image": "mainImage", - "scene-image": "scene", - "festival-image": "festival", - "model-image": "model", - "background-replace": "background", - retouch: "retouch", - "sales-video": "salesVideo", -}; - -const getTemplateMediaType = (template: EcommerceTemplateManifestItem): "image" | "video" => { - const extension = template.preview?.extension?.toLowerCase() || template.preview?.url?.split("?")[0].split(".").pop()?.toLowerCase() || ""; - return extension.includes("mp4") || extension.includes("webm") || extension.includes("mov") ? "video" : "image"; -}; - -const mapRemoteTemplateToScenarioTemplate = (template: EcommerceTemplateManifestItem): CommerceScenarioTemplate | null => { - const scenario = ecommerceTemplateCategoryMap[String(template.categorySlug || "").trim()]; - const mediaUrl = template.preview?.url?.trim(); - if (!scenario || !template.id || !mediaUrl) return null; - - const title = template.templateName?.trim() || template.templateSlug?.trim() || template.id; - const prompt = template.prompt?.trim() || title; - const sourceAssets = (template.assets || []) - .filter((asset) => typeof asset.url === "string" && asset.url.trim()) - .map((asset, index) => { - const url = asset.url!.trim(); - const extension = asset.extension?.replace(/^\./, "") || url.split("?")[0].split(".").pop() || "png"; - return { - url, - name: asset.fileName?.trim() || `${title}-素材${asset.assetIndex || index + 1}.${extension}`, - ossKey: asset.ossKey, - mimeType: extension.toLowerCase() === "jpg" || extension.toLowerCase() === "jpeg" ? "image/jpeg" : "image/png", - }; - }); - - return { - id: template.id, - scenario, - output: commerceScenarioOutputMap[scenario], - title, - desc: template.category?.trim() || commerceScenarioOptions.find((option) => option.key === scenario)?.desc || "", - badge: template.category?.trim() || commerceScenarioOptions.find((option) => option.key === scenario)?.label || title, - prompt, - mediaUrl, - mediaType: getTemplateMediaType(template), - sourceAssets, - }; -}; - -const defaultCommerceIntentFallback: CommerceDefaultIntent = { kind: "image", scenario: "mainImage" }; - -const normalizeDefaultCommerceIntent = (value: unknown): CommerceDefaultIntent => { - if (!value || typeof value !== "object") return defaultCommerceIntentFallback; - const record = value as Record; - const kind = record.kind === "video" ? "video" : "image"; - const scenario = typeof record.scenario === "string" ? record.scenario : ""; - if (kind === "video" || scenario === "salesVideo") return { kind: "video", scenario: "salesVideo" }; - const imageScenarios: CommerceDefaultImageScenarioKey[] = ["poster", "mainImage", "scene", "festival", "model", "background", "retouch"]; - return imageScenarios.includes(scenario as CommerceDefaultImageScenarioKey) - ? { kind: "image", scenario: scenario as CommerceDefaultImageScenarioKey } - : defaultCommerceIntentFallback; -}; - -const commerceScenarioGenerationKind = (scenario: CommerceDefaultImageScenarioKey): "singleImage" | "imageEdit" => - scenario === "background" || scenario === "retouch" ? "imageEdit" : "singleImage"; - -const classifyDefaultCommerceIntent = async (input: { - prompt: string; - referenceCount: number; - ratio: string; - language: string; - platform: string; -}): Promise => { - const content = [ - "Classify this ecommerce creative request. Return only compact JSON.", - 'Schema: {"kind":"image"|"video","scenario":"poster"|"mainImage"|"scene"|"festival"|"model"|"background"|"retouch"|"salesVideo"}.', - "Use salesVideo for video, short-video, UGC, storyboard, or product-demo motion requests.", - "Use background for changing/replacing a product image background.", - "Use retouch for inpainting, cleanup, seamless edit, repair, or localized image modification.", - "Use model for try-on, human model, wearable, or mannequin requests.", - "Use poster for campaign posters, sale posters, banners, or marketing layouts.", - "Use scene for lifestyle/usage environment images.", - "Use festival for holiday/seasonal style images.", - "Use mainImage for product hero/main image requests or unclear image requests.", - `Prompt: ${input.prompt || "(empty)"}`, - `Reference image count: ${input.referenceCount}`, - `Platform: ${input.platform}`, - `Ratio: ${input.ratio}`, - `Language: ${input.language}`, - ].join("\n"); - - try { - const text = await aiGenerationClient.chatCompletion({ - messages: [ - { role: "system", content: "You are a strict ecommerce creative intent classifier. Respond with JSON only." }, - { role: "user", content }, - ], - stream: false, - temperature: 0, - }); - const jsonMatch = text.match(/\{[\s\S]*\}/); - return normalizeDefaultCommerceIntent(JSON.parse(jsonMatch?.[0] || text)); - } catch { - return defaultCommerceIntentFallback; - } -}; -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, - }, - { - id: "poster-festival-gift", - scenario: "poster", - output: "set", - title: "节日礼赠海报", - desc: "适合父亲节、母亲节等节点礼赠氛围", - badge: "节点营销", - prompt: "生成一张节日礼赠风格电商海报,突出礼盒质感、温馨氛围和送礼情绪,画面高级克制,适合节日活动投放。", - mediaUrl: ossAssets.ecommerce.inspiration.fathersDaySet, - }, - { - id: "poster-luxury-perfume", - scenario: "poster", - output: "set", - title: "奢品香水海报", - desc: "高端质感,适合美妆香氛品牌", - badge: "品牌感", - prompt: "生成一张奢品香水电商海报,突出瓶身质感、光影层次和高端氛围,画面精致有艺术感,适合品牌旗舰店投放。", - mediaUrl: ossAssets.ecommerce.inspiration.perfumeSet, - }, - { - id: "main-image-model", - scenario: "mainImage", - output: "set", - title: "模特展示主图", - desc: "真人上身,提升列表点击率", - badge: "点击率优先", - prompt: "生成一张真人模特展示商品主图,突出上身效果、版型和搭配,背景简洁,适合提升搜索列表点击率和转化。", - mediaUrl: ossAssets.ecommerce.productSet.model, - }, - { - id: "main-image-detail", - scenario: "mainImage", - output: "set", - title: "细节质感主图", - desc: "材质特写,强化购买信心", - badge: "转化优先", - prompt: "生成一张商品细节质感主图,突出材质纹理、工艺细节和真实触感,画面聚焦主体,适合强化用户购买信心。", - mediaUrl: ossAssets.ecommerce.productSet.detail, - }, - { - id: "model-jacket", - scenario: "model", - output: "model", - title: "男装夹克模特", - desc: "硬朗风格,突出版型和质感", - badge: "风格推荐", - prompt: "生成男装夹克模特展示图,模特姿态自然有型,突出夹克版型、面料质感和整体搭配,适合男装电商详情和主图。", - mediaUrl: ossAssets.ecommerce.tryOn.jacketResultA, - }, - { - id: "model-hat", - scenario: "model", - output: "model", - title: "帽子配饰模特", - desc: "细节展示,适合配饰品类", - badge: "高频推荐", - prompt: "生成帽子配饰模特展示图,突出帽型、佩戴效果和搭配细节,模特姿态自然,适合配饰、帽饰电商详情与主图。", - mediaUrl: ossAssets.ecommerce.tryOn.hatResultA, - }, - { - id: "scene-camping", - scenario: "scene", - output: "set", - title: "户外露营场景", - desc: "把商品放进自然野趣环境", - badge: "生活方式", - prompt: "生成户外露营风格商品场景图,把产品自然融入露营环境,突出使用场景、自由氛围和生活品质,适合户外品类推广。", - mediaUrl: ossAssets.ecommerce.inspiration.campingCart, - }, - { - id: "scene-beauty-spray", - scenario: "scene", - output: "set", - title: "美妆喷雾场景", - desc: "捕捉使用瞬间,增强氛围感", - badge: "氛围感", - prompt: "生成美妆喷雾使用场景图,捕捉产品使用瞬间和细腻喷雾,突出清爽感、仪式感和大片氛围,适合美妆护肤品类。", - mediaUrl: ossAssets.ecommerce.inspiration.sprayScene, - }, - { - id: "festival-fathers-gift", - scenario: "festival", - output: "set", - title: "父亲节礼盒图", - desc: "礼赠场景,适合节日送礼营销", - badge: "父亲节", - prompt: "生成父亲节礼赠风格商品图,突出礼盒质感、沉稳色调和送礼仪式感,画面温暖有格调,适合父亲节活动投放。", - mediaUrl: ossAssets.ecommerce.inspiration.fathersDaySet, - }, - { - id: "festival-candle-gift", - scenario: "festival", - output: "set", - title: "香薰蜡烛礼盒", - desc: "温暖氛围,适合节日礼赠场景", - badge: "热门模板", - prompt: "生成香薰蜡烛节日礼盒图,突出温暖烛光、包装质感和治愈氛围,画面柔和高级,适合节日礼赠和家居品类营销。", - mediaUrl: ossAssets.ecommerce.inspiration.etsyScentedCandleSet, - }, - { - id: "background-premium-gray", - scenario: "background", - output: "set", - title: "高级灰背景", - desc: "简约商业,提升产品高级感", - badge: "高频推荐", - prompt: "为商品更换高级灰商业背景,保留产品主体和细节,背景简约有层次,突出产品轮廓和质感,适合电商主图和广告。", - mediaUrl: ossAssets.ecommerce.detail.productA, - }, - { - id: "background-home-living", - scenario: "background", - output: "set", - title: "居家背景", - desc: "温馨生活场景,增强代入感", - badge: "场景增强", - prompt: "为商品更换温馨居家背景,保持主体自然融入,增强生活气息和使用代入感,适合家居、日用和生活方式品类。", - mediaUrl: ossAssets.ecommerce.productSet.hosting, - }, - { - id: "retouch-color-correction", - scenario: "retouch", - output: "set", - title: "色彩统一精修", - desc: "多色校正,保持系列一致", - badge: "精修模板", - prompt: "对商品图进行色彩统一精修,校正色偏、统一光影和色调,保持系列素材一致性,画面自然真实,适合电商套图。", - mediaUrl: ossAssets.ecommerce.detail.productB, - }, - { - id: "retouch-detail-sharpen", - scenario: "retouch", - output: "set", - title: "细节锐化精修", - desc: "纹理增强,提升商品质感", - badge: "高频推荐", - prompt: "对商品图进行细节锐化精修,增强纹理、边缘和材质细节,保持自然不过度,画面干净高级,适合主图和详情页。", - mediaUrl: ossAssets.ecommerce.productSet.detail, - }, - { - id: "sales-video-painpoint", - scenario: "salesVideo", - output: "video", - title: "痛点种草视频", - desc: "直击痛点,快速建立购买动机", - badge: "转化优先", - prompt: "生成痛点种草风格带货短视频脚本和分镜,先抛出生活痛点再展示产品解决方案,节奏紧凑,适合清洁家电和功能性产品。", - mediaUrl: ossAssets.ecommerce.inspiration.cleanerPainpointDouyin, - }, - { - id: "sales-video-unboxing", - scenario: "salesVideo", - output: "video", - title: "温馨开箱视频", - desc: "氛围产品,增强情感连接", - badge: "热门模板", - prompt: "生成温馨开箱风格带货短视频脚本和分镜,围绕拆箱仪式感、产品外观和初体验展开,画面温暖治愈,适合氛围类产品。", - mediaUrl: ossAssets.ecommerce.inspiration.nightLightUnboxingDouyin, - }, -]; -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 minCloneSetTotal = 1; -const maxCloneSetTotal = 16; -const maxCloneProductImages = 10; -const maxCloneReferenceImages = 20; -const cloneVideoDurationMin = 5; -const cloneVideoDurationMax = 45; -const composerDurationOptions = [5, 10, 15]; -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 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); - }); - -function createLocalImageItems(files: File[], limit: number, prefix: string): CloneImageItem[] { - const selectedFiles = Array.from(files).slice(0, limit); - const stamp = Date.now(); - return selectedFiles.map((file, index) => { - const localPreviewUrl = URL.createObjectURL(file); - const mimeType = normalizeEcommerceImageMime(file.type); - return { - id: `${prefix}-${stamp}-${index}`, - src: localPreviewUrl, - name: file.name, - file, - format: getImageFileFormat(file), - mimeType, - }; - }); -} - -async function uploadImageItem(item: CloneImageItem): Promise<{ src?: string; ossKey?: string; width?: number; height?: number }> { - if (!item.file) return {}; - const mimeType = normalizeEcommerceImageMime(item.file.type); - try { - const uploadBlob = item.file.type === mimeType ? item.file : new Blob([item.file], { type: mimeType }); - const [uploaded, dimensions] = await Promise.all([ - aiGenerationClient.uploadAssetBinary(uploadBlob, { - name: item.file.name, - mimeType, - scope: ecommerceOssScopes.productSource, - }), - readImageDimensions(item.src).catch(() => ({})), - ]); - return { src: uploaded.url, ossKey: uploaded.ossKey, ...dimensions }; - } catch { - return {}; - } -} - -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 clampCloneVideoDuration(value: number) { - return Math.min(cloneVideoDurationMax, Math.max(cloneVideoDurationMin, Math.round(value))); -} - -function mergeEcommerceHistoryRecords(...recordGroups: EcommerceHistoryRecord[][]): EcommerceHistoryRecord[] { - const recordsById = new Map(); - for (const records of recordGroups) { - for (const record of records) { - const normalized = normalizeEcommerceHistoryRecord(record); - const existing = recordsById.get(normalized.id); - if (!existing || normalized.createdAt >= existing.createdAt || normalized.turns?.length !== existing.turns?.length) { - recordsById.set(normalized.id, normalized); - } - } - } - return Array.from(recordsById.values()).sort((a, b) => b.createdAt - a.createdAt).slice(0, 30); -} +import type { + SmartCutoutImageItem, + ProductClonePageProps, + ProductCloneStatus, + CommerceScenarioKey, + CommerceDefaultImageScenarioKey, + CommerceDefaultIntent, + ProductSetStatus, + ProductKitToolKey, + ComposerMenuKey, + ComposerAssetTabKey, + ComposerWorkModeKey, + CloneBasicSelectKey, + CloneModelSelectKey, + CloneTemplateAsset, + CommerceScenarioTemplate, + TryOnModelSource, + TryOnStatus, + DetailStatus, + CanvasNode, + PreviewTouchPoint, + PreviewTouchGesture, + EcommerceImagePromptOptions, +} from "./ecommerceTypes"; +import { + smartCutoutColorPresets, + smartCutoutSizeOptions, + type SmartCutoutSizeKey, + buildInspirationPrompt, + primaryCommerceScenarioKeys, + scenarioSettingsKeys, + scenarioAdvancedSettingsKeys, + commerceScenarioOutputMap, + mapRemoteTemplateToScenarioTemplate, + commerceScenarioGenerationKind, + cloneSetCountOptions, + cloneSetCountKeys, + minCloneSetTotal, + maxCloneSetTotal, + maxCloneProductImages, + maxCloneReferenceImages, + cloneVideoDurationMin, + cloneVideoDurationMax, + composerDurationOptions, + cloneVideoQualityOptions, + cloneReplicateLevelOptions, + tryOnRatioOptions, + tryOnScenes, + normalizeCloneModelSceneSelection, + tryOnModelOptions, + detailTypeOptions, + detailModules, + defaultDetailModuleIds, + maxDetailModuleSelection, + cloneDetailModules, + getImageFileFormat, + getRemoteImageFormat, + getRemoteImageName, + readImageDimensions, + clampCloneVideoDuration, + mergeEcommerceHistoryRecords, +} from "./ecommerceConstants"; +import { + sideTools, + productSetOutputOptions, + cloneOutputOptions, + commerceScenarioOptions, +} from "./ecommerceJsxConstants"; +import { + ecommerceInspirationRows, + productSetPreviewCards, + tryOnAssets, + tryOnCards, + detailAssets, + detailProductSamples, + detailGridSamples, + commerceScenarioTemplates, +} from "./ecommerceAssets"; +import { + createLocalImageItems, + uploadImageItem, + persistGeneratedImageUrl, + notifyRejectedImages, +} from "./ecommerceImagePipeline"; +import { classifyDefaultCommerceIntent } from "./ecommerceIntentClassifier"; function ProductClonePage(_props: ProductClonePageProps = {}) { const { onWorkspaceChromeChange } = _props; @@ -1667,6 +737,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const [historyRefreshStamp, setHistoryRefreshStamp] = useState(0); const historyRefreshLockRef = useRef(false); const lastSavedHistorySignatureRef = useRef(""); + const historyUserBucketRef = useRef(getEcommerceHistoryUserBucket()); const imageAbortRef = useRef({ current: false }); const activeHistoryTurnIdRef = useRef(null); const activeEcommerceTaskIdsRef = useRef>(new Set()); @@ -1688,6 +759,18 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { } }, [status]); + // 用户身份变化(登入 / 登出 / 换账号)时,工作台保活不卸载,内存里的历史记录 + // 不会自动失效。这里检测分桶 key 变化并从当前用户的 bucket 重新加载, + // 避免未登录或换账号后仍显示上一个用户的历史。 + useEffect(() => { + const bucket = getEcommerceHistoryUserBucket(); + if (bucket === historyUserBucketRef.current) return; + historyUserBucketRef.current = bucket; + setActiveHistoryRecordId(null); + lastSavedHistorySignatureRef.current = ""; + setEcommerceHistoryRecords(readEcommerceHistoryRecords()); + }, [isAuthenticated]); + useEffect(() => { writeEcommerceHistoryRecords(ecommerceHistoryRecords); }, [ecommerceHistoryRecords]); @@ -4117,7 +3200,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { : null; const routedScenario = defaultIntent?.kind === "image" ? defaultIntent.scenario : explicitImageScenario; const effectiveOutput = routedScenario ? commerceScenarioOutputMap[routedScenario] : cloneOutput; - const shouldConfirmSetCount = !defaultIntent && activeCommerceScenario !== "popular" && effectiveOutput === "set" && cloneSetTotal > 5; + const shouldConfirmSetCount = !defaultIntent && !routedScenario && cloneOutput === "set" && cloneSetTotal > 5; if (shouldConfirmSetCount) { if (!window.confirm("将生成 " + String(cloneSetTotal) + " 张图片,可能消耗较多积分,是否继续?")) return; } @@ -5233,8 +4316,16 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { setEcommerceHistoryRecords(next); writeEcommerceHistoryRecords(next); if (activeHistoryRecordId === recordId) { + // 删除的是当前正在查看的记录:回到首页(空闲态),不要停留在已删除任务的预览上。 + resetTask(); + setCanvasNodes([]); + setPreviewZoom(1); + setPreviewOffset({ x: 0, y: 0 }); + setComposerMenu(null); + setIsCommandComposerCompact(false); setActiveHistoryRecordId(null); activeHistoryTurnIdRef.current = null; + lastSavedHistorySignatureRef.current = ""; } deleteEcommerceGenerationRecord(recordId).catch(() => {}); }; diff --git a/src/features/ecommerce/ecommerceAssets.ts b/src/features/ecommerce/ecommerceAssets.ts new file mode 100644 index 0000000..09dda6f --- /dev/null +++ b/src/features/ecommerce/ecommerceAssets.ts @@ -0,0 +1,438 @@ +import { ossAssets } from "../../data/ossAssets"; +import type { CommerceScenarioTemplate } from "./ecommerceTypes"; + +/** + * 依赖 OSS 资源的数据切片与模板常量,从 EcommercePage.tsx 抽出。 + * 所有 mediaUrl 均来自 ossAssets.ecommerce.*,符合 AGENTS.md「图片只走 OSS」规则。 + */ + +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 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 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]; + +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, + }, + { + id: "poster-festival-gift", + scenario: "poster", + output: "set", + title: "节日礼赠海报", + desc: "适合父亲节、母亲节等节点礼赠氛围", + badge: "节点营销", + prompt: "生成一张节日礼赠风格电商海报,突出礼盒质感、温馨氛围和送礼情绪,画面高级克制,适合节日活动投放。", + mediaUrl: ossAssets.ecommerce.inspiration.fathersDaySet, + }, + { + id: "poster-luxury-perfume", + scenario: "poster", + output: "set", + title: "奢品香水海报", + desc: "高端质感,适合美妆香氛品牌", + badge: "品牌感", + prompt: "生成一张奢品香水电商海报,突出瓶身质感、光影层次和高端氛围,画面精致有艺术感,适合品牌旗舰店投放。", + mediaUrl: ossAssets.ecommerce.inspiration.perfumeSet, + }, + { + id: "main-image-model", + scenario: "mainImage", + output: "set", + title: "模特展示主图", + desc: "真人上身,提升列表点击率", + badge: "点击率优先", + prompt: "生成一张真人模特展示商品主图,突出上身效果、版型和搭配,背景简洁,适合提升搜索列表点击率和转化。", + mediaUrl: ossAssets.ecommerce.productSet.model, + }, + { + id: "main-image-detail", + scenario: "mainImage", + output: "set", + title: "细节质感主图", + desc: "材质特写,强化购买信心", + badge: "转化优先", + prompt: "生成一张商品细节质感主图,突出材质纹理、工艺细节和真实触感,画面聚焦主体,适合强化用户购买信心。", + mediaUrl: ossAssets.ecommerce.productSet.detail, + }, + { + id: "model-jacket", + scenario: "model", + output: "model", + title: "男装夹克模特", + desc: "硬朗风格,突出版型和质感", + badge: "风格推荐", + prompt: "生成男装夹克模特展示图,模特姿态自然有型,突出夹克版型、面料质感和整体搭配,适合男装电商详情和主图。", + mediaUrl: ossAssets.ecommerce.tryOn.jacketResultA, + }, + { + id: "model-hat", + scenario: "model", + output: "model", + title: "帽子配饰模特", + desc: "细节展示,适合配饰品类", + badge: "高频推荐", + prompt: "生成帽子配饰模特展示图,突出帽型、佩戴效果和搭配细节,模特姿态自然,适合配饰、帽饰电商详情与主图。", + mediaUrl: ossAssets.ecommerce.tryOn.hatResultA, + }, + { + id: "scene-camping", + scenario: "scene", + output: "set", + title: "户外露营场景", + desc: "把商品放进自然野趣环境", + badge: "生活方式", + prompt: "生成户外露营风格商品场景图,把产品自然融入露营环境,突出使用场景、自由氛围和生活品质,适合户外品类推广。", + mediaUrl: ossAssets.ecommerce.inspiration.campingCart, + }, + { + id: "scene-beauty-spray", + scenario: "scene", + output: "set", + title: "美妆喷雾场景", + desc: "捕捉使用瞬间,增强氛围感", + badge: "氛围感", + prompt: "生成美妆喷雾使用场景图,捕捉产品使用瞬间和细腻喷雾,突出清爽感、仪式感和大片氛围,适合美妆护肤品类。", + mediaUrl: ossAssets.ecommerce.inspiration.sprayScene, + }, + { + id: "festival-fathers-gift", + scenario: "festival", + output: "set", + title: "父亲节礼盒图", + desc: "礼赠场景,适合节日送礼营销", + badge: "父亲节", + prompt: "生成父亲节礼赠风格商品图,突出礼盒质感、沉稳色调和送礼仪式感,画面温暖有格调,适合父亲节活动投放。", + mediaUrl: ossAssets.ecommerce.inspiration.fathersDaySet, + }, + { + id: "festival-candle-gift", + scenario: "festival", + output: "set", + title: "香薰蜡烛礼盒", + desc: "温暖氛围,适合节日礼赠场景", + badge: "热门模板", + prompt: "生成香薰蜡烛节日礼盒图,突出温暖烛光、包装质感和治愈氛围,画面柔和高级,适合节日礼赠和家居品类营销。", + mediaUrl: ossAssets.ecommerce.inspiration.etsyScentedCandleSet, + }, + { + id: "background-premium-gray", + scenario: "background", + output: "set", + title: "高级灰背景", + desc: "简约商业,提升产品高级感", + badge: "高频推荐", + prompt: "为商品更换高级灰商业背景,保留产品主体和细节,背景简约有层次,突出产品轮廓和质感,适合电商主图和广告。", + mediaUrl: ossAssets.ecommerce.detail.productA, + }, + { + id: "background-home-living", + scenario: "background", + output: "set", + title: "居家背景", + desc: "温馨生活场景,增强代入感", + badge: "场景增强", + prompt: "为商品更换温馨居家背景,保持主体自然融入,增强生活气息和使用代入感,适合家居、日用和生活方式品类。", + mediaUrl: ossAssets.ecommerce.productSet.hosting, + }, + { + id: "retouch-color-correction", + scenario: "retouch", + output: "set", + title: "色彩统一精修", + desc: "多色校正,保持系列一致", + badge: "精修模板", + prompt: "对商品图进行色彩统一精修,校正色偏、统一光影和色调,保持系列素材一致性,画面自然真实,适合电商套图。", + mediaUrl: ossAssets.ecommerce.detail.productB, + }, + { + id: "retouch-detail-sharpen", + scenario: "retouch", + output: "set", + title: "细节锐化精修", + desc: "纹理增强,提升商品质感", + badge: "高频推荐", + prompt: "对商品图进行细节锐化精修,增强纹理、边缘和材质细节,保持自然不过度,画面干净高级,适合主图和详情页。", + mediaUrl: ossAssets.ecommerce.productSet.detail, + }, + { + id: "sales-video-painpoint", + scenario: "salesVideo", + output: "video", + title: "痛点种草视频", + desc: "直击痛点,快速建立购买动机", + badge: "转化优先", + prompt: "生成痛点种草风格带货短视频脚本和分镜,先抛出生活痛点再展示产品解决方案,节奏紧凑,适合清洁家电和功能性产品。", + mediaUrl: ossAssets.ecommerce.inspiration.cleanerPainpointDouyin, + }, + { + id: "sales-video-unboxing", + scenario: "salesVideo", + output: "video", + title: "温馨开箱视频", + desc: "氛围产品,增强情感连接", + badge: "热门模板", + prompt: "生成温馨开箱风格带货短视频脚本和分镜,围绕拆箱仪式感、产品外观和初体验展开,画面温暖治愈,适合氛围类产品。", + mediaUrl: ossAssets.ecommerce.inspiration.nightLightUnboxingDouyin, + }, +]; + +export { + ecommerceInspirationAssets, + ecommerceInspirationRows, + sampleResults, + productSetAssets, + productSetPreviewCards, + tryOnAssets, + tryOnCards, + detailAssets, + detailProductSamples, + detailGridSamples, + commerceScenarioTemplates, +}; diff --git a/src/features/ecommerce/ecommerceConstants.ts b/src/features/ecommerce/ecommerceConstants.ts new file mode 100644 index 0000000..8d2d909 --- /dev/null +++ b/src/features/ecommerce/ecommerceConstants.ts @@ -0,0 +1,372 @@ +import type { EcommerceTemplateManifestItem } from "../../api/ecommerceTemplateClient"; +import type { EcommerceHistoryRecord } from "./utils/clonePersistence"; +import { normalizeEcommerceHistoryRecord } from "./utils/clonePersistence"; +import type { ProductSetOutputKey } from "./utils/platformRules"; +import type { CloneSetCountKey, CloneVideoQualityKey, CloneReplicateLevelKey } from "./utils/clonePersistence"; +import type { + CommerceDefaultImageScenarioKey, + CommerceDefaultIntent, + CommerceScenarioKey, + CommerceScenarioTemplate, +} from "./ecommerceTypes"; +import { commerceScenarioOptions } from "./ecommerceJsxConstants"; + +/** + * 模块级纯常量与纯函数(无 React / 无 I/O),从 EcommercePage.tsx 抽出。 + * 含 JSX 的常量(sideTools/commerceScenarioOptions/renderPlatformLogo)见 ecommerceConstants.tsx。 + */ + +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"]; + +const ecommerceInspirationTabs = ["最近打开", "一键同款", "海报模板", "热门", "商品图", "模特穿戴"]; + +// 把灵感卡片的标题 + 卖点要点合成一段可直接填入指令栏的提示词。 +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 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 primaryCommerceScenarioKeys: CommerceScenarioKey[] = ["popular", "poster", "mainImage", "model"]; +const scenarioSettingsKeys: CommerceScenarioKey[] = ["poster", "mainImage", "model", "scene", "festival", "salesVideo"]; +const scenarioAdvancedSettingsKeys: CommerceScenarioKey[] = ["model", "salesVideo"]; +const commerceScenarioOutputMap: Record, ProductSetOutputKey> = { + poster: "set", + mainImage: "set", + scene: "set", + festival: "set", + model: "model", + background: "set", + retouch: "set", + salesVideo: "video", +}; + +const ecommerceTemplateCategoryMap: Record> = { + poster: "poster", + "main-image": "mainImage", + "scene-image": "scene", + "festival-image": "festival", + "model-image": "model", + "background-replace": "background", + retouch: "retouch", + "sales-video": "salesVideo", +}; + +const getTemplateMediaType = (template: EcommerceTemplateManifestItem): "image" | "video" => { + const extension = template.preview?.extension?.toLowerCase() || template.preview?.url?.split("?")[0].split(".").pop()?.toLowerCase() || ""; + return extension.includes("mp4") || extension.includes("webm") || extension.includes("mov") ? "video" : "image"; +}; + +const mapRemoteTemplateToScenarioTemplate = (template: EcommerceTemplateManifestItem): CommerceScenarioTemplate | null => { + const scenario = ecommerceTemplateCategoryMap[String(template.categorySlug || "").trim()]; + const mediaUrl = template.preview?.url?.trim(); + if (!scenario || !template.id || !mediaUrl) return null; + + const title = template.templateName?.trim() || template.templateSlug?.trim() || template.id; + const prompt = template.prompt?.trim() || title; + const sourceAssets = (template.assets || []) + .filter((asset) => typeof asset.url === "string" && asset.url.trim()) + .map((asset, index) => { + const url = asset.url!.trim(); + const extension = asset.extension?.replace(/^\./, "") || url.split("?")[0].split(".").pop() || "png"; + return { + url, + name: asset.fileName?.trim() || `${title}-素材${asset.assetIndex || index + 1}.${extension}`, + ossKey: asset.ossKey, + mimeType: extension.toLowerCase() === "jpg" || extension.toLowerCase() === "jpeg" ? "image/jpeg" : "image/png", + }; + }); + + return { + id: template.id, + scenario, + output: commerceScenarioOutputMap[scenario], + title, + desc: template.category?.trim() || commerceScenarioOptions.find((option) => option.key === scenario)?.desc || "", + badge: template.category?.trim() || commerceScenarioOptions.find((option) => option.key === scenario)?.label || title, + prompt, + mediaUrl, + mediaType: getTemplateMediaType(template), + sourceAssets, + }; +}; + +const defaultCommerceIntentFallback: CommerceDefaultIntent = { kind: "image", scenario: "mainImage" }; + +const normalizeDefaultCommerceIntent = (value: unknown): CommerceDefaultIntent => { + if (!value || typeof value !== "object") return defaultCommerceIntentFallback; + const record = value as Record; + const kind = record.kind === "video" ? "video" : "image"; + const scenario = typeof record.scenario === "string" ? record.scenario : ""; + if (kind === "video" || scenario === "salesVideo") return { kind: "video", scenario: "salesVideo" }; + const imageScenarios: CommerceDefaultImageScenarioKey[] = ["poster", "mainImage", "scene", "festival", "model", "background", "retouch"]; + return imageScenarios.includes(scenario as CommerceDefaultImageScenarioKey) + ? { kind: "image", scenario: scenario as CommerceDefaultImageScenarioKey } + : defaultCommerceIntentFallback; +}; + +const commerceScenarioGenerationKind = (scenario: CommerceDefaultImageScenarioKey): "singleImage" | "imageEdit" => + scenario === "background" || scenario === "retouch" ? "imageEdit" : "singleImage"; + +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 minCloneSetTotal = 1; +const maxCloneSetTotal = 16; +const maxCloneProductImages = 10; +const maxCloneReferenceImages = 20; +const cloneVideoDurationMin = 5; +const cloneVideoDurationMax = 45; +const composerDurationOptions = [5, 10, 15]; +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 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 maxDetailModuleSelection = 6; +const cloneDetailModules = detailModules; + +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); + }); + +function clampCloneVideoDuration(value: number) { + return Math.min(cloneVideoDurationMax, Math.max(cloneVideoDurationMin, Math.round(value))); +} + +function mergeEcommerceHistoryRecords(...recordGroups: EcommerceHistoryRecord[][]): EcommerceHistoryRecord[] { + const recordsById = new Map(); + for (const records of recordGroups) { + for (const record of records) { + const normalized = normalizeEcommerceHistoryRecord(record); + const existing = recordsById.get(normalized.id); + if (!existing || normalized.createdAt >= existing.createdAt || normalized.turns?.length !== existing.turns?.length) { + recordsById.set(normalized.id, normalized); + } + } + } + return Array.from(recordsById.values()).sort((a, b) => b.createdAt - a.createdAt).slice(0, 30); +} + +export { + smartCutoutColorPresets, + smartCutoutSizeOptions, + type SmartCutoutSizeKey, + ecommerceInspirationTabs, + buildInspirationPrompt, + getPlatformLogoText, + getPlatformLogoVariant, + getPlatformLogoMarks, + primaryCommerceScenarioKeys, + scenarioSettingsKeys, + scenarioAdvancedSettingsKeys, + commerceScenarioOutputMap, + ecommerceTemplateCategoryMap, + getTemplateMediaType, + mapRemoteTemplateToScenarioTemplate, + defaultCommerceIntentFallback, + normalizeDefaultCommerceIntent, + commerceScenarioGenerationKind, + cloneSetCountOptions, + cloneSetCountKeys, + minCloneSetTotal, + maxCloneSetTotal, + maxCloneProductImages, + maxCloneReferenceImages, + cloneVideoDurationMin, + cloneVideoDurationMax, + composerDurationOptions, + cloneVideoQualityOptions, + cloneReplicateLevelOptions, + tryOnRatioOptions, + tryOnScenes, + normalizeCloneModelSceneSelection, + tryOnModelOptions, + detailTypeOptions, + detailModules, + defaultDetailModuleIds, + maxDetailModuleSelection, + cloneDetailModules, + getImageFileFormat, + getRemoteImageFormat, + getRemoteImageName, + readImageDimensions, + blobToDataUrl, + clampCloneVideoDuration, + mergeEcommerceHistoryRecords, +}; diff --git a/src/features/ecommerce/ecommerceImagePipeline.ts b/src/features/ecommerce/ecommerceImagePipeline.ts new file mode 100644 index 0000000..74eb995 --- /dev/null +++ b/src/features/ecommerce/ecommerceImagePipeline.ts @@ -0,0 +1,149 @@ +import { aiGenerationClient } from "../../api/aiGenerationClient"; +import { toast } from "../../components/toast/toastStore"; +import type { CloneImageItem } from "./utils/clonePersistence"; +import { ecommerceOssScopes } from "./ecommerceGenerationPersistence"; +import { + normalizeEcommerceImageMime, + summarizeRejectedImages, + validateEcommerceImageFiles, +} from "./ecommerceImageValidation"; +import { getImageFileFormat, readImageDimensions } from "./ecommerceConstants"; + +/** + * 图片上传/持久化/校验工具,从 EcommercePage.tsx 抽出。 + * 涉及网络 I/O(aiGenerationClient)与副作用(toast),按 AGENTS.md 走应用 API 上传至 OSS。 + */ + +function createLocalImageItems(files: File[], limit: number, prefix: string): CloneImageItem[] { + const selectedFiles = Array.from(files).slice(0, limit); + const stamp = Date.now(); + return selectedFiles.map((file, index) => { + const localPreviewUrl = URL.createObjectURL(file); + const mimeType = normalizeEcommerceImageMime(file.type); + return { + id: `${prefix}-${stamp}-${index}`, + src: localPreviewUrl, + name: file.name, + file, + format: getImageFileFormat(file), + mimeType, + }; + }); +} + +async function uploadImageItem(item: CloneImageItem): Promise<{ src?: string; ossKey?: string; width?: number; height?: number }> { + if (!item.file) return {}; + const mimeType = normalizeEcommerceImageMime(item.file.type); + try { + const uploadBlob = item.file.type === mimeType ? item.file : new Blob([item.file], { type: mimeType }); + const [uploaded, dimensions] = await Promise.all([ + aiGenerationClient.uploadAssetBinary(uploadBlob, { + name: item.file.name, + mimeType, + scope: ecommerceOssScopes.productSource, + }), + readImageDimensions(item.src).catch(() => ({})), + ]); + return { src: uploaded.url, ossKey: uploaded.ossKey, ...dimensions }; + } catch { + return {}; + } +} + +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; +} + +export { + createLocalImageItems, + uploadImageItem, + createUploadedImageItems, + persistGeneratedImageUrl, + notifyRejectedImages, +}; diff --git a/src/features/ecommerce/ecommerceIntentClassifier.ts b/src/features/ecommerce/ecommerceIntentClassifier.ts new file mode 100644 index 0000000..61f0a85 --- /dev/null +++ b/src/features/ecommerce/ecommerceIntentClassifier.ts @@ -0,0 +1,51 @@ +import { aiGenerationClient } from "../../api/aiGenerationClient"; +import type { CommerceDefaultIntent } from "./ecommerceTypes"; +import { defaultCommerceIntentFallback, normalizeDefaultCommerceIntent } from "./ecommerceConstants"; + +/** + * 电商创意意图分类器,从 EcommercePage.tsx 抽出。 + * 调用 aiGenerationClient.chatCompletion 做意图判定,失败时回退到默认意图。 + */ + +const classifyDefaultCommerceIntent = async (input: { + prompt: string; + referenceCount: number; + ratio: string; + language: string; + platform: string; +}): Promise => { + const content = [ + "Classify this ecommerce creative request. Return only compact JSON.", + 'Schema: {"kind":"image"|"video","scenario":"poster"|"mainImage"|"scene"|"festival"|"model"|"background"|"retouch"|"salesVideo"}.', + "Use salesVideo for video, short-video, UGC, storyboard, or product-demo motion requests.", + "Use background for changing/replacing a product image background.", + "Use retouch for inpainting, cleanup, seamless edit, repair, or localized image modification.", + "Use model for try-on, human model, wearable, or mannequin requests.", + "Use poster for campaign posters, sale posters, banners, or marketing layouts.", + "Use scene for lifestyle/usage environment images.", + "Use festival for holiday/seasonal style images.", + "Use mainImage for product hero/main image requests or unclear image requests.", + `Prompt: ${input.prompt || "(empty)"}`, + `Reference image count: ${input.referenceCount}`, + `Platform: ${input.platform}`, + `Ratio: ${input.ratio}`, + `Language: ${input.language}`, + ].join("\n"); + + try { + const text = await aiGenerationClient.chatCompletion({ + messages: [ + { role: "system", content: "You are a strict ecommerce creative intent classifier. Respond with JSON only." }, + { role: "user", content }, + ], + stream: false, + temperature: 0, + }); + const jsonMatch = text.match(/\{[\s\S]*\}/); + return normalizeDefaultCommerceIntent(JSON.parse(jsonMatch?.[0] || text)); + } catch { + return defaultCommerceIntentFallback; + } +}; + +export { classifyDefaultCommerceIntent }; diff --git a/src/features/ecommerce/ecommerceJsxConstants.tsx b/src/features/ecommerce/ecommerceJsxConstants.tsx new file mode 100644 index 0000000..33df60a --- /dev/null +++ b/src/features/ecommerce/ecommerceJsxConstants.tsx @@ -0,0 +1,57 @@ +import { AppstoreOutlined, FileImageOutlined, LayoutOutlined, SkinOutlined, VideoCameraOutlined } from "@ant-design/icons"; +import type { ReactNode } from "react"; +import type { ProductSetOutputKey } from "./utils/platformRules"; +import type { CommerceScenarioKey, ProductKitToolKey } from "./ecommerceTypes"; +import { getPlatformLogoMarks, getPlatformLogoVariant } from "./ecommerceConstants"; + +/** + * 含 JSX 的模块级常量,从 EcommercePage.tsx 抽出。 + * 与 ecommerceConstants.ts 分离,因这些常量返回 ReactNode,需 .tsx 扩展。 + */ + +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 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: "model", label: "模特图", desc: "真人展示", icon: 🕴️ }, + { key: "scene", label: "场景图", desc: "生活氛围", icon: 🌅 }, + { key: "festival", label: "节日风格图", desc: "节点营销", icon: 🎉 }, + { key: "salesVideo", label: "带货视频", desc: "短视频脚本", icon: 🎬 }, + { key: "background", label: "更换背景", desc: "背景重构", icon: }, + { key: "retouch", label: "无痕改图", desc: "精修优化", icon: 🪄 }, +]; + +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} + + ))} + + ); +}; + +export { sideTools, productSetOutputOptions, cloneOutputOptions, commerceScenarioOptions, renderPlatformLogo }; diff --git a/src/features/ecommerce/ecommerceTypes.ts b/src/features/ecommerce/ecommerceTypes.ts new file mode 100644 index 0000000..eefc17a --- /dev/null +++ b/src/features/ecommerce/ecommerceTypes.ts @@ -0,0 +1,112 @@ +import type { CloneResult } from "./utils/clonePersistence"; +import type { ProductSetOutputKey } from "./utils/platformRules"; + +/** + * 模块级类型与接口,从 EcommercePage.tsx 抽出。 + * 这些类型原为文件私有(EcommercePage 仅 default 导出),现集中于此供页面与新拆分文件共享。 + */ + +type SmartCutoutImageItem = { src: string; name: string; originalSrc?: string }; + +interface ProductClonePageProps { + onWorkspaceChromeChange?: (state: { isToolPage: boolean }) => void; + [key: string]: unknown; +} + +type ProductCloneStatus = "idle" | "ready" | "generating" | "done" | "failed"; +type CommerceScenarioKey = "popular" | "poster" | "mainImage" | "scene" | "festival" | "model" | "background" | "retouch" | "salesVideo"; +type CommerceDefaultImageScenarioKey = Exclude; +type CommerceDefaultIntent = + | { kind: "image"; scenario: CommerceDefaultImageScenarioKey } + | { kind: "video"; scenario: "salesVideo" }; +type ProductSetStatus = "idle" | "ready" | "generating" | "done" | "failed"; +type ProductKitToolKey = "set" | "detail" | "wear" | "clone"; +type ComposerMenuKey = "mode" | "platform" | "language" | "ratio" | "settings" | "assetLibrary" | "workMode" | "aiWrite"; +type ComposerAssetTabKey = "recent" | "recipe" | "model"; +type ComposerWorkModeKey = "quick" | "think"; +type CloneBasicSelectKey = "platform" | "market" | "language" | "ratio"; +type CloneModelSelectKey = "gender" | "age" | "ethnicity" | "body"; +type CloneTemplateAsset = { + id: string; + title: string; + prompt: string; + mediaUrl: string; + mediaType?: "image" | "video"; + sourceAssets?: Array<{ + url: string; + name: string; + ossKey?: string; + mimeType?: 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 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 EcommerceImagePromptOptions { + gender?: string; + age?: string; + ethnicity?: string; + body?: string; + appearance?: string; + scenes?: string[]; + customScene?: string; + smartScene?: boolean; + detailModules?: string[]; +} + +export type { + SmartCutoutImageItem, + ProductClonePageProps, + ProductCloneStatus, + CommerceScenarioKey, + CommerceDefaultImageScenarioKey, + CommerceDefaultIntent, + ProductSetStatus, + ProductKitToolKey, + ComposerMenuKey, + ComposerAssetTabKey, + ComposerWorkModeKey, + CloneBasicSelectKey, + CloneModelSelectKey, + CloneTemplateAsset, + CommerceScenarioTemplate, + TryOnModelSource, + TryOnStatus, + DetailStatus, + CanvasNode, + PreviewTouchPoint, + PreviewTouchGesture, + EcommerceImagePromptOptions, +}; diff --git a/src/features/ecommerce/utils/clonePersistence.ts b/src/features/ecommerce/utils/clonePersistence.ts index 8899996..e496455 100644 --- a/src/features/ecommerce/utils/clonePersistence.ts +++ b/src/features/ecommerce/utils/clonePersistence.ts @@ -113,8 +113,31 @@ export interface EcommerceHistoryRecord { } export const cloneLatestSettingStorageKey = "omniai.clone-ai.latest-setting"; +// 历史记录的存储前缀 + 元数据标记。真实读写按用户分桶(见 getEcommerceHistoryStorageKey), +// 此常量本身仍作为 metadata.localHistoryStorageKey 的稳定标记值,不能改动其值。 export const ecommerceHistoryStorageKey = "omniai.ecommerce.history.records"; +// 当前登录用户的分桶标识:未登录返回 "anon",避免登出/换账号读到上一个用户的历史。 +// 与 useGenerationStore 的 hashUserId 保持一致的隔离策略。 +export function getEcommerceHistoryUserBucket(): string { + if (typeof window === "undefined") return "anon"; + try { + const raw = window.localStorage.getItem("omniai-web-session"); + if (!raw) return "anon"; + const parsed = JSON.parse(raw) as { user?: { id?: number | string } }; + const id = parsed?.user?.id; + return id === undefined || id === null || id === "" ? "anon" : String(id); + } catch { + return "anon"; + } +} + +// 历史记录按用户分桶的实际 localStorage key。前缀仍是 omniai.ecommerce., +// 因此登出时 clearAllUserStorage 的前缀清理依旧覆盖到这些 key。 +export function getEcommerceHistoryStorageKey(): string { + return `${ecommerceHistoryStorageKey}:${getEcommerceHistoryUserBucket()}`; +} + export const defaultCloneSetCounts: Record = { selling: 3, white: 1, @@ -296,7 +319,7 @@ export function clearCloneLatestSetting(): void { export function readEcommerceHistoryRecords(): EcommerceHistoryRecord[] { if (typeof window === "undefined") return []; try { - const rawValue = window.localStorage.getItem(ecommerceHistoryStorageKey); + const rawValue = window.localStorage.getItem(getEcommerceHistoryStorageKey()); if (!rawValue) return []; const parsedValue: unknown = JSON.parse(rawValue); if (!Array.isArray(parsedValue)) return []; @@ -313,7 +336,7 @@ export function readEcommerceHistoryRecords(): EcommerceHistoryRecord[] { export function writeEcommerceHistoryRecords(records: EcommerceHistoryRecord[]): void { if (typeof window === "undefined") return; window.localStorage.setItem( - ecommerceHistoryStorageKey, + getEcommerceHistoryStorageKey(), JSON.stringify(records.map(normalizeEcommerceHistoryRecord).slice(0, 30)), ); }