Files
omniai-ds-code-package/src/features/ecommerce/EcommercePage.tsx
T
2026-06-11 14:04:03 +08:00

6033 lines
258 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
AppstoreOutlined,
CloudUploadOutlined,
CloseOutlined,
DeleteOutlined,
FileImageOutlined,
FolderOpenOutlined,
FrownOutlined,
GlobalOutlined,
LoadingOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
QuestionCircleOutlined,
ReloadOutlined,
SettingOutlined,
SkinOutlined,
TableOutlined,
} from "@ant-design/icons";
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ChangeEvent, type DragEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
import { useTypewriter } from "../../hooks/useTypewriter";
import "../../styles/pages/ecommerce.css";
import "../../styles/pages/local-theme-parity.css";
import { ossAssets } from "../../data/ossAssets";
import { EcommerceProgressBar } from "./EcommerceProgressBar";
import ImageMentionMenu, { getImageMentionQuery, insertImageMentionValue, type MentionImageOption } from "./ImageMentionMenu";
import EcommerceVideoWorkspace from "./EcommerceVideoWorkspace";
import EcommerceVideoHistoryPanel from "./panels/EcommerceVideoHistoryPanel";
import EcommerceDetailPanel from "./panels/EcommerceDetailPanel";
import EcommerceSetPanel from "./panels/EcommerceSetPanel";
import EcommerceTryOnPanel from "./panels/EcommerceTryOnPanel";
import EcommerceClonePanel from "./panels/EcommerceClonePanel";
import aliexpressLogo from "../../assets/platform-logos/aliexpress.webp";
import amazonLogo from "../../assets/platform-logos/amazon.webp";
import douyinLogo from "../../assets/platform-logos/douyin.webp";
import ebayLogo from "../../assets/platform-logos/ebay.webp";
import instagramLogo from "../../assets/platform-logos/instagram.webp";
import jdLogo from "../../assets/platform-logos/jd.webp";
import lazadaLogo from "../../assets/platform-logos/lazada.webp";
import pinduoduoLogo from "../../assets/platform-logos/pinduoduo.webp";
import shopeeLogo from "../../assets/platform-logos/shopee.webp";
import taobaoLogo from "../../assets/platform-logos/taobao.webp";
import tiktokShopLogo from "../../assets/platform-logos/tiktok-shop.webp";
const smartCutoutColorPresets = [
"#ffffff",
"#111111",
"#ff3131",
"#ff7a1a",
"#f7c600",
"#29b34a",
"#25a9e0",
"#438df5",
"#9029d9",
"#8aa3ad",
"#6b7b86",
"#f46f7b",
"#ff9451",
"#f7d34f",
"#55c66f",
"#73c7f3",
"#6dabf5",
"#b45adb",
"#bcc8ce",
"#aeb7bd",
"#ffbec4",
"#ffd1ac",
"#f8e69d",
"#91de9e",
"#b7e5fb",
"#b9d9fb",
"#d7abe8",
"#dfe5e8",
"#d7dde0",
"#ffe2e4",
"#ffe5d1",
"#f8efcf",
"#c9efcf",
"#d8f0fb",
"#d8eafa",
"#ead2f1",
];
const smartCutoutSizeOptions = [
{ key: "original", label: "原尺寸", icon: "image", frameWidth: "min(520px, 78%)", frameAspect: "auto", imageMaxWidth: "78%", imageMaxHeight: "310px" },
{ key: "trim", label: "裁剪到边缘", icon: "crop", frameWidth: "min(420px, 70%)", frameAspect: "auto", imageMaxWidth: "92%", imageMaxHeight: "360px" },
{ key: "one-inch", label: "一寸头像", sizeLabel: "295*413", icon: "portrait", frameWidth: "min(290px, 50%)", frameAspect: "295 / 413", imageMaxWidth: "86%", imageMaxHeight: "86%", outputWidth: 295, outputHeight: 413 },
{ key: "two-inch", label: "二寸头像", sizeLabel: "413*579", icon: "portrait", frameWidth: "min(320px, 54%)", frameAspect: "413 / 579", imageMaxWidth: "86%", imageMaxHeight: "86%", outputWidth: 413, outputHeight: 579 },
{ key: "taobao-1-1", label: "淘宝1:1主图", sizeLabel: "800*800", icon: "shop", frameWidth: "min(430px, 72%)", frameAspect: "800 / 800", imageMaxWidth: "82%", imageMaxHeight: "82%", outputWidth: 800, outputHeight: 800 },
{ key: "taobao-3-4", label: "淘宝3:4主图", sizeLabel: "750*1000", icon: "shop", frameWidth: "min(330px, 56%)", frameAspect: "750 / 1000", imageMaxWidth: "82%", imageMaxHeight: "82%", outputWidth: 750, outputHeight: 1000 },
{ key: "pdd-main", label: "拼多多主图", sizeLabel: "800*800", icon: "pdd", frameWidth: "min(430px, 72%)", frameAspect: "800 / 800", imageMaxWidth: "82%", imageMaxHeight: "82%", outputWidth: 800, outputHeight: 800 },
{ key: "xiaohongshu-cover", label: "小红书封面", sizeLabel: "1242*1660", icon: "text", frameWidth: "min(330px, 56%)", frameAspect: "1242 / 1660", imageMaxWidth: "82%", imageMaxHeight: "82%", outputWidth: 1242, outputHeight: 1660 },
{ key: "ratio-1-1", label: "1:1", icon: "square", frameWidth: "min(430px, 72%)", frameAspect: "1 / 1", imageMaxWidth: "82%", imageMaxHeight: "82%" },
{ key: "ratio-3-2", label: "3:2", icon: "landscape", frameWidth: "min(520px, 78%)", frameAspect: "3 / 2", imageMaxWidth: "82%", imageMaxHeight: "82%" },
{ key: "ratio-2-3", label: "2:3", icon: "portrait-ratio", frameWidth: "min(330px, 56%)", frameAspect: "2 / 3", imageMaxWidth: "82%", imageMaxHeight: "82%" },
{ key: "ratio-4-3", label: "4:3", icon: "landscape", frameWidth: "min(520px, 78%)", frameAspect: "4 / 3", imageMaxWidth: "82%", imageMaxHeight: "82%" },
{ key: "ratio-3-4", label: "3:4", icon: "portrait-ratio", frameWidth: "min(330px, 56%)", frameAspect: "3 / 4", imageMaxWidth: "82%", imageMaxHeight: "82%" },
{ key: "ratio-16-9", label: "16:9", icon: "wide", frameWidth: "min(560px, 82%)", frameAspect: "16 / 9", imageMaxWidth: "82%", imageMaxHeight: "82%" },
{ key: "ratio-9-16", label: "9:16", icon: "phone", frameWidth: "min(260px, 46%)", frameAspect: "9 / 16", imageMaxWidth: "82%", imageMaxHeight: "82%" },
] as const;
type SmartCutoutSizeKey = (typeof smartCutoutSizeOptions)[number]["key"];
type SmartCutoutImageItem = { src: string; name: string; originalSrc?: string };
const clampNumber = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
const normalizeHexColor = (value: string) => {
const clean = value.trim().replace(/^#/, "");
if (!/^[0-9a-fA-F]{6}$/.test(clean)) return null;
return `#${clean.toLowerCase()}`;
};
const hexToRgb = (value: string) => {
const normalized = normalizeHexColor(value);
if (!normalized) return null;
const numeric = Number.parseInt(normalized.slice(1), 16);
return {
r: (numeric >> 16) & 255,
g: (numeric >> 8) & 255,
b: numeric & 255,
};
};
const rgbToHex = (r: number, g: number, b: number) =>
`#${[r, g, b].map((item) => clampNumber(Math.round(item), 0, 255).toString(16).padStart(2, "0")).join("")}`;
const parseSmartCutoutAspect = (aspect: string) => {
const match = aspect.match(/(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)/);
if (!match) return null;
const width = Number(match[1]);
const height = Number(match[2]);
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return null;
return width / height;
};
const parseSmartCutoutPercent = (value: string, fallback: number) => {
const numeric = Number(value.replace("%", ""));
if (!Number.isFinite(numeric)) return fallback;
return clampNumber(numeric / 100, 0.05, 1);
};
const hsvToRgb = (h: number, s: number, v: number) => {
const hue = ((h % 360) + 360) % 360;
const saturation = clampNumber(s, 0, 100) / 100;
const value = clampNumber(v, 0, 100) / 100;
const chroma = value * saturation;
const x = chroma * (1 - Math.abs(((hue / 60) % 2) - 1));
const match = value - chroma;
const [red, green, blue] =
hue < 60
? [chroma, x, 0]
: hue < 120
? [x, chroma, 0]
: hue < 180
? [0, chroma, x]
: hue < 240
? [0, x, chroma]
: hue < 300
? [x, 0, chroma]
: [chroma, 0, x];
return {
r: (red + match) * 255,
g: (green + match) * 255,
b: (blue + match) * 255,
};
};
const hexToHsv = (value: string) => {
const rgb = hexToRgb(value) ?? { r: 255, g: 255, b: 255 };
const red = rgb.r / 255;
const green = rgb.g / 255;
const blue = rgb.b / 255;
const max = Math.max(red, green, blue);
const min = Math.min(red, green, blue);
const delta = max - min;
const hue =
delta === 0
? 0
: max === red
? 60 * (((green - blue) / delta) % 6)
: max === green
? 60 * ((blue - red) / delta + 2)
: 60 * ((red - green) / delta + 4);
return {
h: Math.round((hue + 360) % 360),
s: max === 0 ? 0 : Math.round((delta / max) * 100),
v: Math.round(max * 100),
};
};
import tmallLogo from "../../assets/platform-logos/tmall.webp";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { ServerRequestError } from "../../api/serverConnection";
import { waitForTask } from "../../api/taskSubscription";
import { toast } from "../../components/toast/toastStore";
import { useGenerationTasks } from "../../hooks/useGenerationTasks";
import { useAppStore } from "../../stores";
import {
normalizeEcommerceImageMime,
summarizeRejectedImages,
validateEcommerceImageFiles,
} from "./ecommerceImageValidation";
interface ProductClonePageProps {
[key: string]: unknown;
}
type ProductCloneStatus = "idle" | "ready" | "generating" | "done" | "failed";
type ProductSetOutputKey = "set" | "detail" | "model" | "video";
type CloneOutputKey = ProductSetOutputKey | "hot" | "video-outfit";
type CloneSetCountKey = "selling" | "white" | "scene";
type CloneModelPanelTab = "scene" | "model";
type CloneVideoQualityKey = "standard" | "high" | "ultra";
type ProductSetStatus = "idle" | "ready" | "generating" | "done" | "failed";
type ProductKitToolKey = "set" | "detail" | "wear" | "clone";
type ComposerMenuKey = "mode" | "platform" | "language" | "ratio" | "settings";
type CloneBasicSelectKey = "platform" | "market" | "language" | "ratio";
type CloneModelSelectKey = "gender" | "age" | "ethnicity" | "body";
type CloneReferenceMode = "upload" | "link";
type CloneReplicateLevelKey = "style" | "high";
type TryOnModelSource = "ai" | "library";
type TryOnStatus = "idle" | "modeling" | "ready" | "generating" | "done" | "failed";
type DetailStatus = "idle" | "ready" | "generating" | "done" | "failed";
interface CloneImageItem {
id: string;
src: string;
name: string;
file?: File;
width?: number;
height?: number;
format?: string;
mimeType?: string;
ossKey?: string;
}
interface CloneResult {
id: string;
src: string;
label: string;
type?: "image" | "video";
}
interface CloneSavedSetting {
id: string;
name: string;
savedAt: string;
output: CloneOutputKey;
platform: string;
market: string;
language: string;
ratio: string;
setCounts: Record<CloneSetCountKey, number>;
detailModules: string[];
modelPanelTab: CloneModelPanelTab;
modelScenes: string[];
modelCustomScene: string;
modelGender: string;
modelAge: string;
modelEthnicity: string;
modelBody: string;
modelAppearance: string;
videoQuality: CloneVideoQualityKey;
videoDurationSeconds: number;
videoSmart: boolean;
referenceMode?: CloneReferenceMode;
replicateLevel?: CloneReplicateLevelKey;
requirement: string;
}
interface EcommerceHistoryRecord {
id: string;
title: string;
createdAt: number;
output: CloneOutputKey;
platform: string;
market: string;
language: string;
ratio: string;
requirement: string;
productImages: CloneImageItem[];
results: CloneResult[];
setResultImages: string[];
setCounts: Record<CloneSetCountKey, number>;
detailModules: string[];
modelScenes: string[];
referenceImages: CloneImageItem[];
replicateLevel: CloneReplicateLevelKey;
}
interface EcommerceImagePromptOptions {
gender?: string;
age?: string;
ethnicity?: string;
body?: string;
appearance?: string;
scenes?: string[];
customScene?: string;
smartScene?: boolean;
detailModules?: string[];
}
type PlatformRatioModeKey = ProductSetOutputKey | "hot" | "video-outfit";
interface PlatformRatioGroup {
ratios: string[];
defaultRatio: string;
}
const sideTools: Array<{ key: ProductKitToolKey; label: string; icon: ReactNode }> = [
{ key: "set", label: "商品套图", icon: <AppstoreOutlined /> },
{ key: "detail", label: "A+详情", icon: <FileImageOutlined /> },
{ key: "wear", label: "服饰穿搭", icon: <SkinOutlined /> },
{ key: "clone", label: "电商AI作图", icon: <AppstoreOutlined /> },
];
const platformSpecOptions: Array<{
label: string;
ratios: string[];
defaultRatio: string;
ratioGroups?: Partial<Record<PlatformRatioModeKey, PlatformRatioGroup>>;
specs: string[];
tip?: string;
aliases?: string[];
}> = [
{
label: "淘宝/天猫",
ratios: ["娣樺疂涓诲浘 / SKU 鍥?800脳800px", "璇︽儏椤靛 750px", "璇︽儏椤靛 790px"],
defaultRatio: "娣樺疂涓诲浘 / SKU 鍥?800脳800px",
ratioGroups: {
set: {
ratios: ["1000脳1000px\u00a0\u00a0\u00a01锛?", "800脳800px\u00a0\u00a0\u00a01锛?"],
defaultRatio: "1000脳1000px\u00a0\u00a0\u00a01锛?",
},
detail: {
ratios: [
"750脳1000px\u00a0\u00a0\u00a03锛?",
"790脳1053px\u00a0\u00a0\u00a03锛?",
"750脳1125px\u00a0\u00a0\u00a02锛?",
"790脳1185px\u00a0\u00a0\u00a02锛?",
],
defaultRatio: "750脳1000px\u00a0\u00a0\u00a03锛?",
},
model: {
ratios: ["750脳1000px\u00a0\u00a0\u00a03锛?"],
defaultRatio: "750脳1000px\u00a0\u00a0\u00a03锛?",
},
video: {
ratios: ["1080脳1920px\u00a0\u00a0\u00a09锛?6", "1080脳1440px\u00a0\u00a0\u00a03锛?", "1080脳1080px\u00a0\u00a0\u00a01锛?"],
defaultRatio: "1080脳1920px\u00a0\u00a0\u00a09锛?6",
},
hot: {
ratios: ["800脳800px\u00a0\u00a0\u00a01锛?"],
defaultRatio: "800脳800px\u00a0\u00a0\u00a01锛?",
},
},
specs: ["涓诲浘 / SKU 鍥?800脳800px锛屸墹3MB", "璇︽儏椤靛 750px 鎴?790px锛屽崟寮犻珮鈮?546px"],
tip: "寤鸿涓诲浘 200-400KB JPG锛岃秴杩?500KB 浼氬奖鍝嶅姞杞介€熷害銆?",
},
{
label: "京东",
ratios: ["浜笢涓诲浘 / SKU 鍥?800脳800px", "璇︽儏椤靛 750px", "棣栧浘涓讳綋鍗犳瘮 鈮?0%"],
defaultRatio: "浜笢涓诲浘 / SKU 鍥?800脳800px",
ratioGroups: {
set: {
ratios: ["1000脳1000px\u00a0\u00a0\u00a01锛?"],
defaultRatio: "1000脳1000px\u00a0\u00a0\u00a01锛?",
},
detail: {
ratios: [
"750脳1000px\u00a0\u00a0\u00a03锛?",
"990脳1320px\u00a0\u00a0\u00a03锛?",
"750脳1125px\u00a0\u00a0\u00a02锛?",
"990脳1485px\u00a0\u00a0\u00a02锛?",
],
defaultRatio: "750脳1000px\u00a0\u00a0\u00a03锛?",
},
model: {
ratios: ["750脳1125px\u00a0\u00a0\u00a02锛?", "990脳1485px\u00a0\u00a0\u00a02锛?"],
defaultRatio: "750脳1125px\u00a0\u00a0\u00a02锛?",
},
video: {
ratios: ["1080脳1920px\u00a0\u00a0\u00a09锛?6", "1920脳1080px\u00a0\u00a0\u00a016锛?"],
defaultRatio: "1080脳1920px\u00a0\u00a0\u00a09锛?6",
},
hot: {
ratios: ["800脳800px\u00a0\u00a0\u00a01锛?"],
defaultRatio: "800脳800px\u00a0\u00a0\u00a01锛?",
},
},
specs: ["涓诲浘 / SKU 鍥?800脳800px锛岀櫧搴曪紝鈮?MB", "璇︽儏椤靛 750px锛岄鍥句富浣撳崰姣?鈮?0%"],
},
{
label: "拼多多",
ratios: ["涓诲浘 750脳352px", "涓诲浘 800脳800px", "璇︽儏椤靛 750px"],
defaultRatio: "涓诲浘 750脳352px",
ratioGroups: {
set: {
ratios: ["800脳800px\u00a0\u00a0\u00a01锛?", "750脳1000px\u00a0\u00a0\u00a03锛?"],
defaultRatio: "800脳800px\u00a0\u00a0\u00a01锛?",
},
detail: {
ratios: ["750脳1000px\u00a0\u00a0\u00a03锛?", "750脳1125px\u00a0\u00a0\u00a02锛?"],
defaultRatio: "750脳1000px\u00a0\u00a0\u00a03锛?",
},
model: {
ratios: ["750脳1000px\u00a0\u00a0\u00a03锛?"],
defaultRatio: "750脳1000px\u00a0\u00a0\u00a03锛?",
},
video: {
ratios: ["1080脳1920px\u00a0\u00a0\u00a09锛?6"],
defaultRatio: "1080脳1920px\u00a0\u00a0\u00a09锛?6",
},
hot: {
ratios: ["800脳800px\u00a0\u00a0\u00a01锛?"],
defaultRatio: "800脳800px\u00a0\u00a0\u00a01锛?",
},
},
specs: ["涓诲浘 750脳352px 鎴?800脳800px锛屸墹1MB", "璇︽儏椤靛 750px锛岃姹傜函鐧藉簳銆佹棤姘村嵃銆佹棤鎷兼帴"],
},
{
label: "抖音电商",
ratios: ["鐭棰?1080脳1920px"],
defaultRatio: "鐭棰?1080脳1920px",
ratioGroups: {
video: {
ratios: ["1080脳1920px\u00a0\u00a0\u00a09锛?6"],
defaultRatio: "1080脳1920px\u00a0\u00a0\u00a09锛?6",
},
hot: {
ratios: ["800脳800px\u00a0\u00a0\u00a01锛?"],
defaultRatio: "800脳800px\u00a0\u00a0\u00a01锛?",
},
},
specs: ["鐭棰?1080脳1920px锛?:16", "30s 鍐呮渶浣?"],
},
{
label: "亚马逊 Amazon",
ratios: ["涓诲浘 鈮?600脳1600px", "寤鸿 2000脳2000px+", "鏈€灏?500脳500px"],
defaultRatio: "涓诲浘 鈮?600脳1600px",
ratioGroups: {
set: {
ratios: ["1600脳1600px\u00a0\u00a0\u00a01锛?"],
defaultRatio: "1600脳1600px\u00a0\u00a0\u00a01锛?",
},
detail: {
ratios: ["1600脳1600px\u00a0\u00a0\u00a01锛?", "1200脳1800px\u00a0\u00a0\u00a02锛?", "1200脳1600px\u00a0\u00a0\u00a03锛?"],
defaultRatio: "1200脳1800px\u00a0\u00a0\u00a02锛?",
},
model: {
ratios: ["1200脳1800px\u00a0\u00a0\u00a02锛?"],
defaultRatio: "1200脳1800px\u00a0\u00a0\u00a02锛?",
},
video: {
ratios: ["1920脳1080px\u00a0\u00a0\u00a016锛?"],
defaultRatio: "1920脳1080px\u00a0\u00a0\u00a016锛?",
},
hot: {
ratios: ["1600脳1600px\u00a0\u00a0\u00a01锛?"],
defaultRatio: "1600脳1600px\u00a0\u00a0\u00a01锛?",
},
},
specs: ["涓诲浘 1600脳1600px+锛岀函鐧藉簳锛屸墹10MB", "鏈€灏?500脳500px锛屽缓璁?2000px+ 浠ユ敮鎸佺缉鏀?"],
aliases: ["浜氶┈閫?"],
},
{
label: "Shopee",
ratios: ["鍟嗗搧涓诲浘 1024脳1024px", "鍩虹涓诲浘 800脳800px"],
defaultRatio: "鍟嗗搧涓诲浘 1024脳1024px",
ratioGroups: {
set: {
ratios: ["800脳800px\u00a0\u00a0\u00a01锛?"],
defaultRatio: "800脳800px\u00a0\u00a0\u00a01锛?",
},
detail: {
ratios: ["750脳1000px\u00a0\u00a0\u00a03锛?", "750脳1125px\u00a0\u00a0\u00a02锛?"],
defaultRatio: "750脳1000px\u00a0\u00a0\u00a03锛?",
},
model: {
ratios: ["750脳1000px\u00a0\u00a0\u00a03锛?"],
defaultRatio: "750脳1000px\u00a0\u00a0\u00a03锛?",
},
video: {
ratios: ["1080脳1920px\u00a0\u00a0\u00a09锛?6"],
defaultRatio: "1080脳1920px\u00a0\u00a0\u00a09锛?6",
},
hot: {
ratios: ["800脳800px\u00a0\u00a0\u00a01锛?"],
defaultRatio: "800脳800px\u00a0\u00a0\u00a01锛?",
},
},
specs: ["鍟嗗搧涓诲浘鎺ㄨ崘 1024脳1024px锛屽熀纭€ 800脳800px", "鈮?MB锛岀櫧搴曟垨娴呰壊搴?"],
aliases: ["铏剧毊 Shopee/Lazada", "铏剧毊"],
},
{
label: "Lazada",
ratios: ["鍟嗗搧涓诲浘 800脳800px"],
defaultRatio: "鍟嗗搧涓诲浘 800脳800px",
ratioGroups: {
set: {
ratios: ["800脳800px\u00a0\u00a0\u00a01锛?"],
defaultRatio: "800脳800px\u00a0\u00a0\u00a01锛?",
},
detail: {
ratios: ["750脳1000px\u00a0\u00a0\u00a03锛?", "750脳1125px\u00a0\u00a0\u00a02锛?"],
defaultRatio: "750脳1000px\u00a0\u00a0\u00a03锛?",
},
model: {
ratios: ["750脳1000px\u00a0\u00a0\u00a03锛?"],
defaultRatio: "750脳1000px\u00a0\u00a0\u00a03锛?",
},
video: {
ratios: ["1080脳1920px\u00a0\u00a0\u00a09锛?6"],
defaultRatio: "1080脳1920px\u00a0\u00a0\u00a09锛?6",
},
hot: {
ratios: ["800脳800px\u00a0\u00a0\u00a01锛?"],
defaultRatio: "800脳800px\u00a0\u00a0\u00a01锛?",
},
},
specs: ["鍟嗗搧涓诲浘 800脳800px锛?:1"],
},
{
label: "Instagram",
ratios: ["甯栧瓙 1080脳1350px", "甯栧瓙 1080脳1080px", "Stories / Reels 1080脳1920px", "澶村儚 320脳320px"],
defaultRatio: "甯栧瓙 1080脳1350px",
ratioGroups: {
set: {
ratios: ["1080脳1080px\u00a0\u00a0\u00a01锛?", "1080脳1350px\u00a0\u00a0\u00a04锛?"],
defaultRatio: "1080脳1080px\u00a0\u00a0\u00a01锛?",
},
detail: {
ratios: ["1080脳1350px\u00a0\u00a0\u00a04锛?"],
defaultRatio: "1080脳1350px\u00a0\u00a0\u00a04锛?",
},
model: {
ratios: ["1080脳1350px\u00a0\u00a0\u00a04锛?"],
defaultRatio: "1080脳1350px\u00a0\u00a0\u00a04锛?",
},
video: {
ratios: ["1080脳1920px\u00a0\u00a0\u00a09锛?6", "1080脳1350px\u00a0\u00a0\u00a04锛?"],
defaultRatio: "1080脳1920px\u00a0\u00a0\u00a09锛?6",
},
},
specs: ["甯栧瓙 1080脳1350px 鎴?1080脳1080px", "Stories / Reels 灏侀潰 1080脳1920px锛屽ご鍍?320脳320px"],
tip: "寤鸿 鈮?MB JPG銆?",
aliases: ["Instagram Reels"],
},
{
label: "速卖通",
ratios: ["涓诲浘 800脳800px", "涓诲浘 1000脳1000px+"],
defaultRatio: "涓诲浘 800脳800px",
ratioGroups: {
set: {
ratios: ["1000脳1000px\u00a0\u00a0\u00a01锛?"],
defaultRatio: "1000脳1000px\u00a0\u00a0\u00a01锛?",
},
detail: {
ratios: ["750脳1125px\u00a0\u00a0\u00a02锛?", "750脳1000px\u00a0\u00a0\u00a03锛?"],
defaultRatio: "750脳1125px\u00a0\u00a0\u00a02锛?",
},
model: {
ratios: ["750脳1125px\u00a0\u00a0\u00a02锛?"],
defaultRatio: "750脳1125px\u00a0\u00a0\u00a02锛?",
},
video: {
ratios: ["1080脳1920px\u00a0\u00a0\u00a09锛?6", "1920脳1080px\u00a0\u00a0\u00a016锛?"],
defaultRatio: "1080脳1920px\u00a0\u00a0\u00a09锛?6",
},
hot: {
ratios: ["800脳800px\u00a0\u00a0\u00a01锛?"],
defaultRatio: "800脳800px\u00a0\u00a0\u00a01锛?",
},
},
specs: ["涓诲浘寤鸿 800脳800px 鎴栨洿楂橈紝1:1", "閫傚悎璺ㄥ鐢靛晢涓诲浘銆丼KU 鍥惧拰鍦烘櫙鍥?"],
},
{
label: "eBay",
ratios: ["鍟嗗搧鍥?1:1", "鐧藉簳澶氳搴﹀睍绀哄浘 1:1"],
defaultRatio: "鍟嗗搧鍥?1:1",
ratioGroups: {
set: {
ratios: ["1600脳1600px\u00a0\u00a0\u00a01锛?"],
defaultRatio: "1600脳1600px\u00a0\u00a0\u00a01锛?",
},
detail: {
ratios: ["1000脳1500px\u00a0\u00a0\u00a02锛?", "1200脳1600px\u00a0\u00a0\u00a03锛?"],
defaultRatio: "1000脳1500px\u00a0\u00a0\u00a02锛?",
},
model: {
ratios: ["1000脳1500px\u00a0\u00a0\u00a02锛?"],
defaultRatio: "1000脳1500px\u00a0\u00a0\u00a02锛?",
},
video: {
ratios: ["1920脳1080px\u00a0\u00a0\u00a016锛?", "1080脳1920px\u00a0\u00a0\u00a09锛?6"],
defaultRatio: "1920脳1080px\u00a0\u00a0\u00a016锛?",
},
hot: {
ratios: ["1600脳1600px\u00a0\u00a0\u00a01锛?"],
defaultRatio: "1600脳1600px\u00a0\u00a0\u00a01锛?",
},
},
specs: ["鍟嗗搧鍥惧缓璁?1:1锛屼富浣撴竻鏅板眳涓?", "閫傚悎鐧藉簳涓诲浘鍜屽瑙掑害灞曠ず鍥?"],
},
{
label: "TikTok Shop",
ratios: ["鍟嗗搧涓诲浘 1:1", "鐭棰?/ 绔栫増灏侀潰 9:16"],
defaultRatio: "鍟嗗搧涓诲浘 1:1",
ratioGroups: {
set: {
ratios: ["1280脳1280px\u00a0\u00a0\u00a01锛?"],
defaultRatio: "1280脳1280px\u00a0\u00a0\u00a01锛?",
},
detail: {
ratios: ["1080脳1350px\u00a0\u00a0\u00a04锛?"],
defaultRatio: "1080脳1350px\u00a0\u00a0\u00a04锛?",
},
model: {
ratios: ["1080脳1350px\u00a0\u00a0\u00a04锛?"],
defaultRatio: "1080脳1350px\u00a0\u00a0\u00a04锛?",
},
video: {
ratios: ["1080脳1920px\u00a0\u00a0\u00a09锛?6"],
defaultRatio: "1080脳1920px\u00a0\u00a0\u00a09锛?6",
},
hot: {
ratios: ["800脳800px\u00a0\u00a0\u00a01锛?"],
defaultRatio: "800脳800px\u00a0\u00a0\u00a01锛?",
},
},
specs: ["鍟嗗搧涓诲浘寤鸿 1:1", "鐭棰?绔栫増灏侀潰寤鸿 9:16"],
},
];
const platformOptions = platformSpecOptions.map((option) => option.label);
const getPlatformLogoSources = (value: string) => {
const normalized = value.toLowerCase();
if (value.includes("淘宝") || value.includes("天猫") || value.includes("娣樺疂") || value.includes("澶╃尗")) return [taobaoLogo, tmallLogo];
if (value.includes("京东") || value.includes("浜笢")) return [jdLogo];
if (value.includes("拼多多") || value.includes("鎷煎澶")) return [pinduoduoLogo];
if (value.includes("抖音") || value.includes("鎶栭煶")) return [douyinLogo];
if (normalized.includes("amazon")) return [amazonLogo];
if (normalized.includes("shopee")) return [shopeeLogo];
if (normalized.includes("lazada")) return [lazadaLogo];
if (normalized.includes("instagram")) return [instagramLogo];
if (value.includes("速卖通") || value.includes("閫熷崠閫")) return [aliexpressLogo];
if (normalized.includes("ebay")) return [ebayLogo];
if (normalized.includes("tiktok")) return [tiktokShopLogo];
return [];
};
const renderPlatformLogo = (value: string) => {
const sources = getPlatformLogoSources(value);
return (
<span className={`ecom-platform-logo-mark${sources.length > 1 ? " ecom-platform-logo-mark--duo" : ""}`} aria-hidden="true">
{sources.map((src) => (
<img key={src} src={src} alt="" />
))}
</span>
);
};
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<string, string> = {
"鑻辨枃": "英文",
"涓枃": "中文",
"鑻辫": "英文",
"鏃ヨ": "日文",
"鏃ユ枃": "日文",
"寰疯": "德文",
"寰锋枃": "德文",
"娉曡": "法文",
"娉曟枃": "法文",
"闊╄": "韩文",
"闊╂枃": "韩文",
"瑗挎枃": "西班牙语",
"瑗跨彮鐗欒": "西班牙语",
"钁℃枃": "葡萄牙语",
"钁¤悇鐗欒": "葡萄牙语",
"鍗板凹璇?": "印度尼西亚语",
"鍗板害灏艰タ浜氳": "印度尼西亚语",
"鑿插緥瀹捐": "菲律宾语(他加禄语)",
"鑿插緥瀹捐锛堜粬鍔犵璇級": "菲律宾语(他加禄语)",
};
const defaultPlatformSpec = platformSpecOptions[0]!;
const getPlatformSpec = (value: string) =>
platformSpecOptions.find((option) => option.label === value || option.aliases?.includes(value)) ?? defaultPlatformSpec;
const legacyPlatformAliases: Record<string, string> = {
"娣樺疂/澶╃尗": "淘宝/天猫",
"浜笢": "京东",
"鎷煎澶?": "拼多多",
"鎶栭煶鐢靛晢": "抖音电商",
"浜氶┈閫?Amazon": "亚马逊 Amazon",
"閫熷崠閫?": "速卖通",
};
const normalizePlatform = (value: string) => getPlatformSpec(legacyPlatformAliases[value] ?? value).label;
const domesticPlatformLabels = new Set(["淘宝/天猫", "京东", "拼多多", "抖音电商"]);
const domesticPlatformLanguages = ["中文"];
const isDomesticPlatform = (platformValue: string) => domesticPlatformLabels.has(normalizePlatform(platformValue));
const getPlatformRatioGroup = (value: string, mode?: PlatformRatioModeKey): PlatformRatioGroup => {
const platformSpec = getPlatformSpec(value);
return (mode ? platformSpec.ratioGroups?.[mode] : null) ?? {
ratios: platformSpec.ratios,
defaultRatio: platformSpec.defaultRatio,
};
};
const getPlatformRatioOptions = (value: string, mode?: PlatformRatioModeKey) => getPlatformRatioGroup(value, mode).ratios;
const getPlatformDefaultRatio = (value: string, mode?: PlatformRatioModeKey) => getPlatformRatioGroup(value, mode).defaultRatio;
const getUniqueRatioOptions = (ratios: string[]) => Array.from(new Set(ratios));
const normalizeRatioToken = (value: string) => value.replaceAll("锛?", ":").replaceAll("", ":").replaceAll("脳", "×").trim();
const normalizeRatioForPlatform = (platformValue: string, ratioValue: string, mode?: PlatformRatioModeKey) => {
const platformRatios = getPlatformRatioOptions(platformValue, mode);
if (platformRatios.includes(ratioValue)) return ratioValue;
const normalizedRatio = normalizeRatioToken(ratioValue);
const matchedRatio = platformRatios.find((option) => normalizeRatioToken(option).includes(normalizedRatio));
return matchedRatio ?? getPlatformDefaultRatio(platformValue, mode);
};
const quickSetRatioOptions = ["1:1", "3:4", "9:16", "16:9"];
const getQuickSetRatioValue = (value: string) => {
const normalizedValue = normalizeRatioToken(value);
if (quickSetRatioOptions.includes(normalizedValue)) return normalizedValue;
const sizeMatch = normalizedValue.match(/(\d+)\s*[×xX]\s*(\d+)/u);
if (sizeMatch) {
const width = Number(sizeMatch[1]);
const height = Number(sizeMatch[2]);
if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) {
const aspect = formatAspectRatio(width, height);
if (quickSetRatioOptions.includes(aspect)) return aspect;
}
}
const ratioMatch = normalizedValue.match(/(\d+)\s*[:]\s*(\d+)/u);
if (ratioMatch) {
const aspect = `${Number(ratioMatch[1])}:${Number(ratioMatch[2])}`;
if (quickSetRatioOptions.includes(aspect)) return aspect;
}
return quickSetRatioOptions[0]!;
};
const formatRatioDisplayValue = (value: string) => {
const normalizedValue = normalizeRatioToken(value);
const sizeMatch = normalizedValue.match(/(\d+)\s*[×xX]\s*(\d+)\s*px?/u);
if (sizeMatch) {
const width = Number(sizeMatch[1]);
const height = Number(sizeMatch[2]);
return `${width}×${height}px\u00a0\u00a0\u00a0${formatAspectRatio(width, height)}`;
}
return normalizedValue
.replace("娣樺疂涓诲浘 / SKU 鍥?", "淘宝主图 / SKU 图 ")
.replace("浜笢涓诲浘 / SKU 鍥?", "京东主图 / SKU 图 ")
.replace("璇︽儏椤靛", "详情页宽")
.replace("鐭棰?", "短视频")
.replace("涓诲浘", "主图")
.replace("鍟嗗搧涓诲浘", "商品主图")
.replace("鍟嗗搧鍥?", "商品图");
};
/** Extract CSS aspect-ratio from a ratio string like "1000x1000px 1:1" -> "1 / 1" */
const parseRatioToAspectCss = (ratioStr: string): string => {
const match = ratioStr.match(/(\d+)\D+(\d+)/u);
if (!match) return "1 / 1";
return `${match[1]} / ${match[2]}`;
};
/** Normalize ratio display string ("1000脳1000px 1锛?") to API format ("1:1") */
const normalizeRatioForApi = (ratioStr: string): string => {
const match = ratioStr.match(/(\d+)\D+(\d+)/u);
if (!match) return "1:1";
return `${match[1]}:${match[2]}`;
};
const greatestCommonDivisor = (left: number, right: number): number => {
let a = Math.abs(left);
let b = Math.abs(right);
while (b) {
[a, b] = [b, a % b];
}
return a || 1;
};
const formatAspectRatio = (width: number, height: number) => {
const divisor = greatestCommonDivisor(width, height);
return `${Math.round(width / divisor)}${Math.round(height / divisor)}`;
};
const formatUploadedImageRatio = (image?: CloneImageItem) => {
if (!image) return null;
const format = image.format ? `\u00a0\u00a0\u00a0${image.format}` : "";
if (!image.width || !image.height) return `上传图片\u00a0\u00a0\u00a0原图比例${format}`;
return `上传图片 ${image.width}×${image.height}px\u00a0\u00a0\u00a0${formatAspectRatio(image.width, image.height)}${format}`;
};
const defaultMarketLanguageOption = marketLanguageOptions[0]!;
const normalizeMarket = (value: string) =>
marketLanguageOptions.some((option) => option.country === value) ? value : defaultMarketLanguageOption.country;
const normalizeLanguage = (value: string) => languageAliases[value] ?? value;
const uniqueLanguages = (languages: string[]) => Array.from(new Set(languages));
const appendEnglish = (languages: string[]) => Array.from(new Set([...languages, "英文"]));
const getMarketLanguageOptions = (marketValue: string) =>
appendEnglish((marketLanguageOptions.find((option) => option.country === marketValue) ?? defaultMarketLanguageOption).languages);
const getPlatformLanguageOptions = (platformValue: string, marketValue: string) => {
const marketLanguages = getMarketLanguageOptions(marketValue);
if (!isDomesticPlatform(platformValue)) return marketLanguages;
const localLanguages = marketLanguages.filter((item) => item !== "英文");
return uniqueLanguages([...localLanguages, ...domesticPlatformLanguages, "英文"]);
};
const getPlatformDefaultLanguage = (platformValue: string, marketValue: string) =>
isDomesticPlatform(platformValue) ? "中文" : (getPlatformLanguageOptions(platformValue, marketValue)[0] ?? languageOptions[0] ?? "英文");
const normalizeLanguageForPlatform = (platformValue: string, marketValue: string, languageValue: string) => {
const normalizedLanguage = normalizeLanguage(languageValue);
const platformLanguages = getPlatformLanguageOptions(platformValue, marketValue);
return platformLanguages.includes(normalizedLanguage) ? normalizedLanguage : getPlatformDefaultLanguage(platformValue, marketValue);
};
const productSetOutputOptions: Array<{ key: ProductSetOutputKey; label: string }> = [
{ key: "set", label: "套图" },
{ key: "detail", label: "详情图" },
{ key: "model", label: "模特图" },
{ key: "video", label: "短视频" },
];
const cloneOutputOptions: Array<{ key: CloneOutputKey; label: string }> = [
...productSetOutputOptions,
{ key: "hot", label: "爆款复刻" },
{ key: "video-outfit", label: "视频换装" },
];
const cloneSetCountOptions: Array<{
key: CloneSetCountKey;
title: string;
desc: string;
}> = [
{ key: "selling", title: "卖点图", desc: "展示商品核心卖点和细节特写" },
{ key: "white", title: "白底图", desc: "白底主图,多角度呈现商品细节" },
{ key: "scene", title: "场景图", desc: "展示商品生活使用场景和人物搭配" },
];
const cloneSetCountKeys = cloneSetCountOptions.map((option) => option.key);
const defaultCloneSetCounts: Record<CloneSetCountKey, number> = {
selling: 3,
white: 1,
scene: 3,
};
const minCloneSetTotal = 1;
const maxCloneSetTotal = 16;
const maxCloneProductImages = 7;
const maxCloneReferenceImages = 20;
const cloneVideoDurationMin = 5;
const cloneVideoDurationMax = 45;
const defaultEcommercePlatform = "淘宝/天猫";
const defaultProductSetOutput: ProductSetOutputKey = "set";
const defaultCloneOutput: CloneOutputKey = "set";
const cloneLatestSettingStorageKey = "omniai.clone-ai.latest-setting";
const ecommerceHistoryStorageKey = "omniai.ecommerce.history.records";
const cloneVideoQualityOptions: Array<{ key: CloneVideoQualityKey; label: string; desc: string }> = [
{ key: "standard", label: "标准", desc: "快速出片" },
{ key: "high", label: "高清", desc: "推荐" },
{ key: "ultra", label: "超清", desc: "细节增强" },
];
const cloneReplicateLevelOptions: Array<{ key: CloneReplicateLevelKey; title: string; desc: string }> = [
{ key: "style", title: "参考风格", desc: "参考整体风格和结构,自动调整色彩和重构场景。" },
{ key: "high", title: "高度复刻", desc: "参考视觉结构替换产品和文案,保留主要场景细节。" },
];
const tryOnRatioOptions = ["3:4", "1:1", "9:16"];
const tryOnScenes = ["纯色棚拍", "都市街头", "街角咖啡", "自然草坪", "度假海滩", "温馨居家", "艺术展馆"];
const normalizeCloneModelSceneSelection = (scenes: string[] | null | undefined) => {
const validScenes = (scenes ?? []).filter((scene) => typeof scene === "string" && scene.trim());
const latestScene = validScenes[validScenes.length - 1];
return latestScene ? [latestScene] : [];
};
const tryOnModelOptions = {
gender: ["女", "男"],
age: ["青年", "少年", "中年"],
ethnicity: ["欧美白人", "亚洲人", "拉美裔", "非洲裔"],
body: ["标准", "高挑", "微胖", "运动"],
};
const sampleResults = [
ossAssets.ecommerce.slides.slide4,
ossAssets.ecommerce.generated,
ossAssets.ecommerce.slides.slide5,
];
const productSetAssets = ossAssets.ecommerce.productSet;
const productSetPreviewCards = [
{ id: "main", label: "01 涓诲浘 (鐧藉簳/鍚堣)", src: productSetAssets.main },
{ id: "scene", label: "02 鍦烘櫙灞曠ず", src: productSetAssets.scene },
{ id: "model", label: "03 妯$壒鍦烘櫙鍥?", src: productSetAssets.model },
{ id: "detail", label: "04 缁嗚妭璇存槑", src: productSetAssets.detail },
{ id: "selling", label: "05 鍗栫偣璇﹁В", src: productSetAssets.selling },
];
const tryOnAssets = ossAssets.ecommerce.tryOn;
const tryOnCards = [
{
title: "澶氫欢娣锋惌鑷姩铻嶅悎",
tone: "red",
inputs: [tryOnAssets.dressA, tryOnAssets.dressB, tryOnAssets.modelWoman],
results: [tryOnAssets.tryA, tryOnAssets.tryB],
},
{
title: "涓€浠朵篃鑳藉嚭澶х墖",
tone: "brown",
inputs: [tryOnAssets.jacket, tryOnAssets.modelMan],
results: [tryOnAssets.jacketResultA, tryOnAssets.jacketResultB],
},
{
title: "闉嬪附楗板搧瀹岀編閫傞厤",
tone: "gold",
inputs: [tryOnAssets.hat, tryOnAssets.modelAsian],
results: [tryOnAssets.hatResultA, tryOnAssets.hatResultB],
},
];
const detailTypeOptions = ["普通A+", "品牌A+", "标准详情页", "移动端长图"];
const detailModules = [
{ id: "hero", title: "首页焦点图", desc: "集中呈现核心利益点" },
{ id: "selling", title: "卖点强化图", desc: "放大产品优势" },
{ id: "usage", title: "使用情境图", desc: "还原实际使用画面" },
{ id: "angle", title: "外观角度图", desc: "展示不同视角造型" },
{ id: "scene", title: "氛围场景图", desc: "营造产品应用环境" },
{ id: "detail", title: "细节特写图", desc: "突出材质和做工" },
{ id: "story", title: "品牌理念图", desc: "表达品牌主张" },
{ id: "size", title: "规格尺寸图", desc: "说明尺寸容量尺码" },
{ id: "compare", title: "效果对照图", desc: "呈现前后差异" },
{ id: "spec", title: "参数信息表", desc: "整理商品关键数据" },
{ id: "craft", title: "工艺流程图", desc: "说明制作与处理步骤" },
{ id: "gift", title: "清单配件图", desc: "展示包装内全部内容" },
{ id: "series", title: "SKU组合图", desc: "呈现颜色款式组合" },
{ id: "ingredient", title: "成分材质图", desc: "说明配方或材料构成" },
{ id: "service", title: "保障说明图", desc: "传达质保退换承诺" },
{ id: "tips", title: "使用提示图", desc: "提醒操作与保养要点" },
];
const defaultDetailModuleIds: string[] = [];
const defaultCloneDetailModuleIds = ["hero", "selling", "usage", "angle", "scene", "detail"];
const maxDetailModuleSelection = 6;
const cloneDetailModules = detailModules;
const detailAssets = ossAssets.ecommerce.detail;
const detailProductSamples = [detailAssets.productA, detailAssets.productB, detailAssets.productC];
const detailGridSamples = [detailAssets.gridA, detailAssets.gridB, detailAssets.gridC, detailAssets.gridD, detailAssets.gridE, detailAssets.gridF];
function getImageFileFormat(file: File) {
const mimeFormat = file.type.split("/")[1]?.replace("jpeg", "jpg").toUpperCase();
if (mimeFormat) return mimeFormat;
return file.name.split(".").pop()?.toUpperCase() ?? "";
}
function readImageDimensions(src: string): Promise<{ width: number; height: number }> {
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve({ width: image.naturalWidth, height: image.naturalHeight });
image.onerror = reject;
image.src = src;
});
}
const blobToDataUrl = (blob: Blob): Promise<string> =>
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<CloneImageItem[]> {
const selectedFiles = Array.from(files).slice(0, limit);
const stamp = Date.now();
const items = await Promise.all(selectedFiles.map(async (file, index) => {
const localPreviewUrl = URL.createObjectURL(file);
let src = localPreviewUrl;
let ossKey: string | undefined;
let shouldRevokeLocalPreview = false;
let dimensions: { width?: number; height?: number } = {};
try {
dimensions = await readImageDimensions(localPreviewUrl);
} catch {
dimensions = {};
}
const mimeType = normalizeEcommerceImageMime(file.type);
try {
const uploadBlob = file.type === mimeType ? file : new Blob([file], { type: mimeType });
const uploaded = await aiGenerationClient.uploadAssetBinary(uploadBlob, {
name: file.name,
mimeType,
scope: "ecommerce-product",
});
src = uploaded.url;
ossKey = uploaded.ossKey;
shouldRevokeLocalPreview = true;
} catch {
src = localPreviewUrl;
} finally {
if (shouldRevokeLocalPreview) URL.revokeObjectURL(localPreviewUrl);
}
return {
id: `${prefix}-${stamp}-${index}`,
src,
name: file.name,
file,
format: getImageFileFormat(file),
mimeType,
ossKey,
...dimensions,
};
}));
return items;
}
async function persistGeneratedImageUrl(sourceUrl: string, scope: string, namePrefix: string): Promise<string> {
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<CloneSavedSetting>;
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<CloneImageItem>;
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<CloneResult>;
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<EcommerceHistoryRecord>;
return (
typeof candidate.id === "string" &&
typeof candidate.title === "string" &&
typeof candidate.createdAt === "number" &&
typeof candidate.output === "string" &&
typeof candidate.platform === "string" &&
typeof candidate.market === "string" &&
typeof candidate.language === "string" &&
typeof candidate.ratio === "string" &&
typeof candidate.requirement === "string" &&
Array.isArray(candidate.productImages) &&
candidate.productImages.every(isCloneImageItem) &&
Array.isArray(candidate.results) &&
candidate.results.every(isCloneResult)
);
}
function removeFilePayloadFromImages(images: CloneImageItem[]): CloneImageItem[] {
return images.map(({ file: _file, ...image }) => image);
}
function normalizeEcommerceHistoryRecord(record: EcommerceHistoryRecord): EcommerceHistoryRecord {
return {
...record,
productImages: removeFilePayloadFromImages(record.productImages),
referenceImages: removeFilePayloadFromImages(record.referenceImages ?? []),
results: record.results ?? [],
setResultImages: record.setResultImages ?? [],
setCounts: record.setCounts ?? defaultCloneSetCounts,
detailModules: record.detailModules ?? defaultCloneDetailModuleIds,
modelScenes: record.modelScenes ?? [],
replicateLevel: record.replicateLevel ?? "high",
};
}
function readEcommerceHistoryRecords() {
if (typeof window === "undefined") return [];
try {
const rawValue = window.localStorage.getItem(ecommerceHistoryStorageKey);
if (!rawValue) return [];
const parsedValue: unknown = JSON.parse(rawValue);
if (!Array.isArray(parsedValue)) return [];
return parsedValue
.filter(isEcommerceHistoryRecord)
.map(normalizeEcommerceHistoryRecord)
.sort((a, b) => b.createdAt - a.createdAt)
.slice(0, 30);
} catch {
return [];
}
}
function writeEcommerceHistoryRecords(records: EcommerceHistoryRecord[]) {
if (typeof window === "undefined") return;
window.localStorage.setItem(ecommerceHistoryStorageKey, JSON.stringify(records.map(normalizeEcommerceHistoryRecord).slice(0, 30)));
}
function clampCloneVideoDuration(value: number) {
return Math.min(cloneVideoDurationMax, Math.max(cloneVideoDurationMin, Math.round(value)));
}
function ProductClonePage(_props: ProductClonePageProps = {}) {
const setInputRef = useRef<HTMLInputElement>(null);
const productInputRef = useRef<HTMLInputElement>(null);
const quickProductInputRef = useRef<HTMLInputElement>(null);
const cloneReferenceInputRef = useRef<HTMLInputElement>(null);
const smartCutoutInputRef = useRef<HTMLInputElement>(null);
const imageWorkbenchInputRef = useRef<HTMLInputElement>(null);
const watermarkInputRef = useRef<HTMLInputElement>(null);
const watermarkProcessTimeoutRef = useRef<number | null>(null);
const smartCutoutTransitionTimeoutRef = useRef<number | null>(null);
const smartCutoutPendingUrlsRef = useRef<string[]>([]);
const smartCutoutPaletteRef = useRef<HTMLDivElement>(null);
const smartCutoutToolsRef = useRef<HTMLDivElement>(null);
const composerMenuCloseTimeoutRef = useRef<number | null>(null);
const requirementTextareaRef = useRef<HTMLTextAreaElement>(null);
const commandComposerWrapRef = useRef<HTMLElement | null>(null);
const garmentInputRef = useRef<HTMLInputElement>(null);
const detailInputRef = useRef<HTMLInputElement>(null);
const countHoldTimeoutRef = useRef<number | null>(null);
const countHoldIntervalRef = useRef<number | null>(null);
const isAuthenticated = Boolean((_props as Record<string, unknown>).isAuthenticated);
const requestLogin = () => {
const handler = (_props as Record<string, unknown>).onRequireLogin;
if (typeof handler === "function") handler();
};
const imageGen = useGenerationTasks({ sourceView: "ecommerce" });
const appUsage = useAppStore((s) => s.usage);
const latestCloneSettingRef = useRef<CloneSavedSetting | null>(null);
const skipInitialCloneAutoSaveRef = useRef(true);
const skipNextCloneAutoSaveRef = useRef(false);
const [activeTool, setActiveTool] = useState<ProductKitToolKey>("clone");
useEffect(() => {
setPreviewZoom(1);
setIsCommandComposerCompact(false);
}, [activeTool]);
const [setImages, setSetImages] = useState<CloneImageItem[]>([]);
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<ProductSetOutputKey>(defaultProductSetOutput);
const [productSetStatus, setProductSetStatus] = useState<ProductSetStatus>("idle");
const [productSetResultImages, setProductSetResultImages] = useState<string[]>([]);
const [isSetUploadDragging, setIsSetUploadDragging] = useState(false);
const [selectedProductSetPreview, setSelectedProductSetPreview] = useState<{ src: string; label: string } | null>(null);
const [showHostingModal, setShowHostingModal] = useState(false);
const [productImages, setProductImages] = useState<CloneImageItem[]>([]);
const [activeQuickTool, setActiveQuickTool] = useState<"cutout" | "set" | "detail" | "watermark" | "image-edit" | "hot-video" | null>(null);
const [smartCutoutImage, setSmartCutoutImage] = useState<SmartCutoutImageItem | null>(null);
const [smartCutoutBatchImages, setSmartCutoutBatchImages] = useState<SmartCutoutImageItem[]>([]);
const [smartCutoutBackgroundColor, setSmartCutoutBackgroundColor] = useState("#ffffff");
const [smartCutoutBackgroundAlpha, setSmartCutoutBackgroundAlpha] = useState(100);
const [smartCutoutHexDraft, setSmartCutoutHexDraft] = useState("#ffffff");
const [isSmartCutoutPaletteOpen, setIsSmartCutoutPaletteOpen] = useState(false);
const [smartCutoutSizeKey, setSmartCutoutSizeKey] = useState<SmartCutoutSizeKey>("original");
const [isSmartCutoutDragging, setIsSmartCutoutDragging] = useState(false);
const [isSmartCutoutComparing, setIsSmartCutoutComparing] = useState(false);
const [isSmartCutoutTransitioning, setIsSmartCutoutTransitioning] = useState(false);
const [smartCutoutTransitionMessage, setSmartCutoutTransitionMessage] = useState({
title: "正在切换页面",
subtitle: "请稍候",
});
const [watermarkImage, setWatermarkImage] = useState<{ src: string; name: string; format: string } | null>(null);
const [watermarkStatus, setWatermarkStatus] = useState<"idle" | "processing" | "done">("idle");
const [isWatermarkDragging, setIsWatermarkDragging] = useState(false);
const [imageWorkbenchImage, setImageWorkbenchImage] = useState<{ src: string; name: string; format: string } | null>(null);
const [imageWorkbenchPrompt, setImageWorkbenchPrompt] = useState("");
const [imageWorkbenchBrushSize, setImageWorkbenchBrushSize] = useState(50);
const [imageWorkbenchRatio, setImageWorkbenchRatio] = useState("1:1");
const [imageWorkbenchStatus, setImageWorkbenchStatus] = useState<"idle" | "processing" | "done">("idle");
const [isImageWorkbenchDragging, setIsImageWorkbenchDragging] = useState(false);
const [imageWorkbenchMaskStrokes, setImageWorkbenchMaskStrokes] = useState<Array<{ id: string; size: number; points: Array<{ x: number; y: number }> }>>([]);
const [imageWorkbenchBrushCursor, setImageWorkbenchBrushCursor] = useState<{ x: number; y: number } | null>(null);
const [isProductUploadDragging, setIsProductUploadDragging] = useState(false);
const [cloneOutput, setCloneOutput] = useState<CloneOutputKey>(defaultCloneOutput);
const [videoHistoryVisible, setVideoHistoryVisible] = useState(false);
const [videoPlanTrigger, setVideoPlanTrigger] = useState(0);
const [openCloneBasicSelect, setOpenCloneBasicSelect] = useState<CloneBasicSelectKey | null>(null);
const [openQuickSetSelect, setOpenQuickSetSelect] = useState<CloneBasicSelectKey | null>(null);
const [visibleQuickSetSelect, setVisibleQuickSetSelect] = useState<CloneBasicSelectKey | null>(null);
const [isQuickSetSelectClosing, setIsQuickSetSelectClosing] = useState(false);
const [composerMenu, setComposerMenu] = useState<ComposerMenuKey | null>(null);
const [visibleComposerMenu, setVisibleComposerMenu] = useState<ComposerMenuKey | null>(null);
const [isComposerMenuClosing, setIsComposerMenuClosing] = useState(false);
const [composerPopoverLeft, setComposerPopoverLeft] = useState(0);
const [isCommandHistoryCollapsed, setIsCommandHistoryCollapsed] = useState(() => (typeof window !== "undefined" ? window.innerWidth <= 1180 : false));
const [isQuickPanelCollapsed, setIsQuickPanelCollapsed] = useState(false);
const [openCloneModelSelect, setOpenCloneModelSelect] = useState<CloneModelSelectKey | null>(null);
const [cloneModelSelectDropUp, setCloneModelSelectDropUp] = useState(false);
const [cloneReferenceMode, setCloneReferenceMode] = useState<CloneReferenceMode>("upload");
const [cloneReferenceImages, setCloneReferenceImages] = useState<CloneImageItem[]>([]);
const [cloneReplicateLevel, setCloneReplicateLevel] = useState<CloneReplicateLevelKey>("high");
const [cloneSetCounts, setCloneSetCounts] = useState(defaultCloneSetCounts);
const [selectedCloneDetailModules, setSelectedCloneDetailModules] = useState<string[]>(defaultCloneDetailModuleIds);
const [cloneModelPanelTab, setCloneModelPanelTab] = useState<CloneModelPanelTab>("scene");
const [selectedCloneModelScenes, setSelectedCloneModelScenes] = useState<string[]>([]);
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<CloneVideoQualityKey>("high");
const [cloneVideoDuration, setCloneVideoDuration] = useState(10);
const [cloneVideoSmart, setCloneVideoSmart] = useState(true);
const [videoOutfitVideoFile, setVideoOutfitVideoFile] = useState<File | null>(null);
const [videoOutfitRefFile, setVideoOutfitRefFile] = useState<File | null>(null);
const videoOutfitPreviewUrl = useMemo(() => (videoOutfitVideoFile ? URL.createObjectURL(videoOutfitVideoFile) : ""), [videoOutfitVideoFile]);
const [isCloneSettingsCollapsed, setIsCloneSettingsCollapsed] = useState(false);
const [previewZoom, setPreviewZoom] = useState(1);
const quickSetSelectTimerRef = useRef<number | null>(null);
const openQuickSetSelectRef = useRef<CloneBasicSelectKey | null>(null);
const visibleQuickSetSelectRef = useRef<CloneBasicSelectKey | null>(null);
const [previewOffset, setPreviewOffset] = useState({ x: 0, y: 0 });
const previewSurfaceRef = useRef<HTMLElement | null>(null);
const previewZoomRef = useRef(previewZoom);
const previewOffsetRef = useRef(previewOffset);
const imageWorkbenchMaskPaintingRef = useRef(false);
const imageWorkbenchActiveStrokeIdRef = useRef<string | null>(null);
const imageWorkbenchMaskCanvasRef = useRef<HTMLCanvasElement>(null);
const imageWorkbenchLastMaskPointRef = useRef<{ x: number; y: number } | null>(null);
const previewPanRef = useRef<{ active: boolean; startX: number; startY: number; offsetX: number; offsetY: number }>({
active: false,
startX: 0,
startY: 0,
offsetX: 0,
offsetY: 0,
});
const [isCommandComposerCompact, setIsCommandComposerCompact] = useState(false);
const typewriterText = useTypewriter("万物皆可AI,广告素材一键生成");
useEffect(() => {
return () => {
if (videoOutfitPreviewUrl) URL.revokeObjectURL(videoOutfitPreviewUrl);
};
}, [videoOutfitPreviewUrl]);
useEffect(() => {
previewZoomRef.current = previewZoom;
}, [previewZoom]);
useEffect(() => {
previewOffsetRef.current = previewOffset;
}, [previewOffset]);
useEffect(() => {
if (typeof window === "undefined") return undefined;
const syncHistoryPanel = () => {
setIsCommandHistoryCollapsed(window.innerWidth <= 1180);
};
syncHistoryPanel();
window.addEventListener("resize", syncHistoryPanel);
return () => window.removeEventListener("resize", syncHistoryPanel);
}, []);
const previewTransformStyle = useMemo<CSSProperties>(
() => ({
transform: `translate3d(${previewOffset.x}px, ${previewOffset.y}px, 0) scale(${previewZoom})`,
}),
[previewOffset.x, previewOffset.y, previewZoom],
);
useEffect(() => {
const container = previewSurfaceRef.current;
if (!container) return undefined;
const handleWheel = (event: WheelEvent) => {
const target = event.target as HTMLElement | null;
if (target?.closest(".ecom-command-composer-wrap, .clone-ai-preview-header")) return;
event.preventDefault();
const currentZoom = previewZoomRef.current;
const rect = container.getBoundingClientRect();
const cursorX = event.clientX - rect.left;
const cursorY = event.clientY - rect.top;
const zoomDelta = event.deltaY < 0 ? 1.08 : 0.92;
const nextZoom = Math.min(2, Math.max(0.25, currentZoom * zoomDelta));
if (nextZoom === currentZoom) return;
const currentOffset = previewOffsetRef.current;
const contentX = (cursorX - currentOffset.x) / currentZoom;
const contentY = (cursorY - currentOffset.y) / currentZoom;
const nextOffset = {
x: cursorX - contentX * nextZoom,
y: cursorY - contentY * nextZoom,
};
previewZoomRef.current = nextZoom;
previewOffsetRef.current = nextOffset;
setPreviewZoom(nextZoom);
setPreviewOffset(nextOffset);
};
container.addEventListener("wheel", handleWheel, { passive: false, capture: true });
return () => container.removeEventListener("wheel", handleWheel, { capture: true });
}, [activeTool, cloneOutput]);
useEffect(() => {
const container = previewSurfaceRef.current;
if (!container) return undefined;
const listenerOptions = { capture: true };
const handleMouseDown = (event: MouseEvent) => {
if (event.button !== 1) return;
const target = event.target as HTMLElement | null;
if (target?.closest(".ecom-command-composer-wrap, .clone-ai-preview-header, input, textarea, select, a")) return;
event.preventDefault();
const currentOffset = previewOffsetRef.current;
previewPanRef.current = {
active: true,
startX: event.clientX,
startY: event.clientY,
offsetX: currentOffset.x,
offsetY: currentOffset.y,
};
container.classList.add("is-middle-panning");
};
const handleMouseMove = (event: MouseEvent) => {
const pan = previewPanRef.current;
if (!pan.active) return;
event.preventDefault();
const nextOffset = {
x: pan.offsetX + event.clientX - pan.startX,
y: pan.offsetY + event.clientY - pan.startY,
};
previewOffsetRef.current = nextOffset;
setPreviewOffset(nextOffset);
};
const stopMousePan = () => {
const pan = previewPanRef.current;
if (!pan.active) return;
previewPanRef.current = { ...pan, active: false };
container.classList.remove("is-middle-panning");
};
const preventAuxClick = (event: MouseEvent) => {
if (event.button === 1) event.preventDefault();
};
container.addEventListener("mousedown", handleMouseDown, listenerOptions);
container.addEventListener("auxclick", preventAuxClick, listenerOptions);
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", stopMousePan);
window.addEventListener("blur", stopMousePan);
return () => {
container.removeEventListener("mousedown", handleMouseDown, listenerOptions);
container.removeEventListener("auxclick", preventAuxClick, listenerOptions);
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", stopMousePan);
window.removeEventListener("blur", stopMousePan);
};
}, [activeTool, cloneOutput]);
const bindPreviewSurface = (node: HTMLElement | null) => {
previewSurfaceRef.current = node;
};
const getPreviewSurfaceProps = () => ({
ref: bindPreviewSurface,
onMouseDown: (event: ReactMouseEvent<HTMLElement>) => {
if (event.button === 1) event.preventDefault();
},
onAuxClick: (event: ReactMouseEvent<HTMLElement>) => {
if (event.button === 1) event.preventDefault();
},
});
const handlePreviewWheel = (event: React.WheelEvent<HTMLElement>) => {
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<HTMLDivElement>) => {
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<HTMLElement>) => {
const panel = event.currentTarget;
if (panel.scrollHeight <= panel.clientHeight) return;
event.stopPropagation();
panel.scrollTop += event.deltaY;
};
const [requirement, setRequirement] = useState("");
const [requirementImageMentionQuery, setRequirementImageMentionQuery] = useState<string | null>(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<ProductCloneStatus>("idle");
const [results, setResults] = useState<CloneResult[]>([]);
const [ecommerceHistoryRecords, setEcommerceHistoryRecords] = useState<EcommerceHistoryRecord[]>(() => readEcommerceHistoryRecords());
const [activeHistoryRecordId, setActiveHistoryRecordId] = useState<string | null>(null);
const [historyRefreshTick, setHistoryRefreshTick] = useState(0);
const [isHistoryRefreshing, setIsHistoryRefreshing] = useState(false);
const [historyRefreshMessage, setHistoryRefreshMessage] = useState("");
const [historyRefreshStamp, setHistoryRefreshStamp] = useState(0);
const historyRefreshLockRef = useRef(false);
const lastSavedHistorySignatureRef = useRef("");
const imageAbortRef = useRef({ current: false });
const activeEcommerceTaskIdsRef = useRef<Set<string>>(new Set());
const lastFailedActionRef = useRef<(() => void) | null>(null);
const [garmentImages, setGarmentImages] = useState<CloneImageItem[]>([]);
const [modelSource, setModelSource] = useState<TryOnModelSource>("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<string[]>([]);
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<TryOnStatus>("idle");
const [tryOnResultImages, setTryOnResultImages] = useState<string[]>([]);
const [detailProductImages, setDetailProductImages] = useState<CloneImageItem[]>([]);
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<string[]>(defaultDetailModuleIds);
const [detailStatus, setDetailStatus] = useState<DetailStatus>("idle");
const [detailResultUrl, setDetailResultUrl] = useState<string | null>(null);
const productSetRatioOptions = useMemo(
() => getPlatformRatioOptions(productSetPlatform, productSetOutput),
[productSetOutput, productSetPlatform],
);
const hotUploadedRatioOption = useMemo(
() => cloneOutput === "hot" ? formatUploadedImageRatio(cloneReferenceImages[0]) : null,
[cloneOutput, cloneReferenceImages],
);
const baseCloneRatioOptions = useMemo(
() => getPlatformRatioOptions(platform, cloneOutput),
[cloneOutput, platform],
);
const cloneRatioOptions = useMemo(
() => hotUploadedRatioOption
? getUniqueRatioOptions([...baseCloneRatioOptions, hotUploadedRatioOption])
: baseCloneRatioOptions,
[baseCloneRatioOptions, hotUploadedRatioOption],
);
const productSetLanguageOptions = useMemo(
() => getPlatformLanguageOptions(productSetPlatform, productSetMarket),
[productSetMarket, productSetPlatform],
);
const cloneLanguageOptions = useMemo(
() => getPlatformLanguageOptions(platform, market),
[market, platform],
);
const detailLanguageOptions = useMemo(
() => getPlatformLanguageOptions(detailPlatform, detailMarket),
[detailMarket, detailPlatform],
);
const ecommerceMentionImages: MentionImageOption[] = [
...productImages.map((image, index) => ({ ...image, label: `商品图 ${index + 1}` })),
...cloneReferenceImages.map((image, index) => ({ ...image, label: `参考图 ${index + 1}` })),
];
const ecommerceVideoImageDataUrls = useMemo(
() => productImages.map((img) => img.src),
[productImages],
);
const ecommerceVideoImageFiles = useMemo(
() => productImages.map((img) => img.file),
[productImages],
);
const selectedProductSetOutput =
productSetOutputOptions.find((option) => option.key === productSetOutput) ?? productSetOutputOptions[0]!;
const selectedCloneOutput = cloneOutputOptions.find((option) => option.key === cloneOutput) ?? cloneOutputOptions[1]!;
const cloneRequirementPlaceholder =
cloneOutput === "model"
? "建议包含以下信息:产品名称、核心卖点、期望场景、模特外貌描述(如小麦色皮肤、齐刘海、眼角有泪痣)、具体参数"
: "建议包含以下信息:产品名称、核心卖点、期望场景、具体参数";
const productSetPreviewReady = productSetStatus === "done";
const cloneSetTotal = useMemo(
() => Object.values(cloneSetCounts).reduce((sum, value) => sum + value, 0),
[cloneSetCounts],
);
const canGenerateSet = setImages.length > 0 && productSetStatus !== "generating";
const canGenerate = (cloneOutput === "video-outfit"
? Boolean(videoOutfitVideoFile && videoOutfitRefFile)
: productImages.length > 0) && status !== "generating";
const canGenerateTryOn = garmentImages.length > 0 && tryOnStatus !== "generating" && tryOnStatus !== "modeling";
const canGenerateDetail = detailProductImages.length > 0 && detailStatus !== "generating";
const cloneVideoDurationProgress =
((cloneVideoDuration - cloneVideoDurationMin) / (cloneVideoDurationMax - cloneVideoDurationMin)) * 100;
const cloneVideoDurationStyle: CSSProperties = useMemo(
() => ({
"--clone-video-duration-progress": `${cloneVideoDurationProgress}%`,
}) as CSSProperties,
[cloneVideoDurationProgress],
);
const trackEcommerceTask = (taskId: string) => {
activeEcommerceTaskIdsRef.current.add(taskId);
};
const untrackEcommerceTask = (taskId: string) => {
activeEcommerceTaskIdsRef.current.delete(taskId);
};
const handleCancelGenerate = () => {
imageAbortRef.current.current = true;
const taskIds = Array.from(activeEcommerceTaskIdsRef.current);
activeEcommerceTaskIdsRef.current.clear();
taskIds.forEach((taskId) => {
aiGenerationClient.cancelTask(taskId).catch(() => {});
});
lastFailedActionRef.current = null;
if (productSetStatus === "generating") setProductSetStatus("idle");
if (status === "generating") setStatus("idle");
if (detailStatus === "generating") setDetailStatus("idle");
if (tryOnStatus === "generating") setTryOnStatus("idle");
if (tryOnStatus === "modeling") setTryOnStatus("ready");
toast.info("\u5df2\u53d6\u6d88\u751f\u6210");
};
const syncRequirementMentionQuery = (value: string, selectionStart: number | null | undefined) => {
setRequirementImageMentionQuery(ecommerceMentionImages.length ? getImageMentionQuery(value, selectionStart) : null);
};
const insertRequirementImageMention = (image: MentionImageOption) => {
const textarea = requirementTextareaRef.current;
const cursor = textarea?.selectionStart ?? requirement.length;
const next = insertImageMentionValue(requirement, cursor, image.name, 500);
setRequirement(next.value);
setRequirementImageMentionQuery(null);
window.requestAnimationFrame(() => {
requirementTextareaRef.current?.focus();
requirementTextareaRef.current?.setSelectionRange(next.selectionStart, next.selectionStart);
});
};
const addSetImages = async (files: File[]) => {
if (setImages.length >= 3) return;
const imageFiles = notifyRejectedImages(files);
if (!imageFiles.length) return;
try {
const nextImages = await createUploadedImageItems(imageFiles, 3 - setImages.length, "set");
setSetImages((current) => {
if (current.length >= 3) return current;
return nextImages.length ? [...current, ...nextImages].slice(0, 3) : current;
});
setProductSetStatus("ready");
} catch (err) {
toast.error(err instanceof Error ? err.message : "鍟嗗搧鍥句笂浼犲け璐?");
}
};
const handleSetUpload = (event: ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files?.length) return;
void addSetImages(Array.from(files));
event.target.value = "";
};
const handleSetDrop = (event: DragEvent<HTMLButtonElement>) => {
event.preventDefault();
setIsSetUploadDragging(false);
const files = Array.from(event.dataTransfer.files);
if (files.length) void addSetImages(files);
};
const revokeSmartCutoutItem = (item: SmartCutoutImageItem | null) => {
if (!item) return;
URL.revokeObjectURL(item.src);
if (item.originalSrc && item.originalSrc !== item.src) URL.revokeObjectURL(item.originalSrc);
};
const revokeSmartCutoutItems = (items: SmartCutoutImageItem[]) => {
items.forEach(revokeSmartCutoutItem);
};
const clearSmartCutoutTransition = () => {
if (smartCutoutTransitionTimeoutRef.current !== null) {
window.clearTimeout(smartCutoutTransitionTimeoutRef.current);
smartCutoutTransitionTimeoutRef.current = null;
}
if (smartCutoutPendingUrlsRef.current.length) {
smartCutoutPendingUrlsRef.current.forEach((url) => URL.revokeObjectURL(url));
smartCutoutPendingUrlsRef.current = [];
}
setIsSmartCutoutTransitioning(false);
};
const runSmartCutoutPageTransition = (message: { title: string; subtitle: string }, action: () => void, delay = 460) => {
clearSmartCutoutTransition();
setSmartCutoutTransitionMessage(message);
setIsSmartCutoutTransitioning(true);
smartCutoutTransitionTimeoutRef.current = window.setTimeout(() => {
smartCutoutTransitionTimeoutRef.current = null;
action();
setIsSmartCutoutTransitioning(false);
}, delay);
};
const openSmartCutoutUpload = () => {
clearSmartCutoutTransition();
setSmartCutoutTransitionMessage({
title: "正在进入智能抠图",
subtitle: "为你打开图片处理工具",
});
setActiveQuickTool("cutout");
setSmartCutoutBatchImages((current) => {
revokeSmartCutoutItems(current);
return [];
});
setSmartCutoutImage((current) => {
revokeSmartCutoutItem(current);
return null;
});
setIsSmartCutoutComparing(false);
setComposerMenu(null);
};
const openWatermarkRemovalPage = () => {
clearSmartCutoutTransition();
setActiveQuickTool("watermark");
setComposerMenu(null);
setIsCloneSettingsCollapsed(false);
};
const closeWatermarkRemovalPage = () => {
if (watermarkProcessTimeoutRef.current !== null) {
window.clearTimeout(watermarkProcessTimeoutRef.current);
watermarkProcessTimeoutRef.current = null;
}
setActiveQuickTool(null);
setWatermarkStatus("idle");
setWatermarkImage((current) => {
if (current?.src) URL.revokeObjectURL(current.src);
return null;
});
};
const addWatermarkImage = (file: File) => {
const nextImage = {
src: URL.createObjectURL(file),
name: file.name,
format: getImageFileFormat(file) || "PNG / JPG / WebP",
};
setWatermarkImage((current) => {
if (current?.src) URL.revokeObjectURL(current.src);
return nextImage;
});
setWatermarkStatus("idle");
setActiveQuickTool("watermark");
};
const removeWatermarkImage = () => {
if (watermarkProcessTimeoutRef.current !== null) {
window.clearTimeout(watermarkProcessTimeoutRef.current);
watermarkProcessTimeoutRef.current = null;
}
setWatermarkStatus("idle");
setWatermarkImage((current) => {
if (current?.src) URL.revokeObjectURL(current.src);
return null;
});
};
const handleWatermarkUpload = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
addWatermarkImage(file);
event.target.value = "";
};
const handleWatermarkDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
setIsWatermarkDragging(false);
const file = Array.from(event.dataTransfer.files).find((item) => item.type.startsWith("image/"));
if (file) addWatermarkImage(file);
};
const handleWatermarkGenerate = () => {
if (!watermarkImage || watermarkStatus === "processing") return;
if (watermarkProcessTimeoutRef.current !== null) window.clearTimeout(watermarkProcessTimeoutRef.current);
setWatermarkStatus("processing");
watermarkProcessTimeoutRef.current = window.setTimeout(() => {
watermarkProcessTimeoutRef.current = null;
setWatermarkStatus("done");
toast.success("去水印处理完成");
}, 900);
};
const handleWatermarkDownload = () => {
if (!watermarkImage || watermarkStatus !== "done") {
toast.info("请先完成去水印");
return;
}
const link = document.createElement("a");
const safeName = (watermarkImage.name || "watermark-result").replace(/\.[^.]+$/, "").replace(/[\\/:*?"<>|]+/g, "-");
link.href = watermarkImage.src;
link.download = `${safeName || "watermark-result"}-去水印.png`;
document.body.appendChild(link);
link.click();
link.remove();
};
const openImageWorkbenchPage = () => {
clearSmartCutoutTransition();
setActiveQuickTool("image-edit");
setComposerMenu(null);
setIsCloneSettingsCollapsed(false);
setImageWorkbenchStatus("idle");
};
const closeImageWorkbenchPage = () => {
setActiveQuickTool(null);
setImageWorkbenchStatus("idle");
setImageWorkbenchPrompt("");
setImageWorkbenchMaskStrokes([]);
setImageWorkbenchBrushCursor(null);
clearImageWorkbenchMaskCanvas();
imageWorkbenchMaskPaintingRef.current = false;
imageWorkbenchActiveStrokeIdRef.current = null;
setImageWorkbenchImage((current) => {
if (current?.src) URL.revokeObjectURL(current.src);
return null;
});
};
const addImageWorkbenchImage = (file: File) => {
if (!file.type.startsWith("image/")) {
toast.error("请上传图片文件");
return;
}
const nextImage = {
src: URL.createObjectURL(file),
name: file.name,
format: getImageFileFormat(file) || "PNG / JPG / WebP",
};
setImageWorkbenchImage((current) => {
if (current?.src) URL.revokeObjectURL(current.src);
return nextImage;
});
setImageWorkbenchStatus("idle");
setImageWorkbenchMaskStrokes([]);
setImageWorkbenchBrushCursor(null);
clearImageWorkbenchMaskCanvas();
imageWorkbenchActiveStrokeIdRef.current = null;
setActiveQuickTool("image-edit");
};
const removeImageWorkbenchImage = () => {
setImageWorkbenchStatus("idle");
setImageWorkbenchMaskStrokes([]);
setImageWorkbenchBrushCursor(null);
clearImageWorkbenchMaskCanvas();
imageWorkbenchMaskPaintingRef.current = false;
imageWorkbenchActiveStrokeIdRef.current = null;
setImageWorkbenchImage((current) => {
if (current?.src) URL.revokeObjectURL(current.src);
return null;
});
};
const handleImageWorkbenchUpload = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
addImageWorkbenchImage(file);
event.target.value = "";
};
const handleImageWorkbenchDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
setIsImageWorkbenchDragging(false);
const file = Array.from(event.dataTransfer.files).find((item) => item.type.startsWith("image/"));
if (file) addImageWorkbenchImage(file);
};
const handleImageWorkbenchGenerate = () => {
if (!imageWorkbenchImage) {
toast.info("请先上传图片");
return;
}
setImageWorkbenchStatus("processing");
window.setTimeout(() => {
setImageWorkbenchStatus("done");
toast.success("局部重绘已完成");
}, 900);
};
const syncImageWorkbenchMaskCanvas = () => {
const canvas = imageWorkbenchMaskCanvasRef.current;
if (!canvas) return null;
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const width = Math.max(1, Math.round(rect.width * dpr));
const height = Math.max(1, Math.round(rect.height * dpr));
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
const context = canvas.getContext("2d");
if (!context) return null;
context.setTransform(dpr, 0, 0, dpr, 0, 0);
context.lineCap = "round";
context.lineJoin = "round";
context.strokeStyle = "rgba(30, 189, 219, 0.52)";
context.fillStyle = "rgba(30, 189, 219, 0.52)";
context.lineWidth = imageWorkbenchBrushSize;
return { canvas, context, rect };
};
const clearImageWorkbenchMaskCanvas = () => {
const canvas = imageWorkbenchMaskCanvasRef.current;
if (!canvas) return;
const context = canvas.getContext("2d");
if (!context) return;
context.setTransform(1, 0, 0, 1, 0, 0);
context.clearRect(0, 0, canvas.width, canvas.height);
};
const getImageWorkbenchMaskPoint = (event: ReactPointerEvent<HTMLElement>) => {
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<HTMLElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
if (!imageWorkbenchImage) return;
const point = getImageWorkbenchMaskPoint(event);
const rect = imageWorkbenchMaskCanvasRef.current?.getBoundingClientRect();
setImageWorkbenchBrushCursor(rect ? { x: (point.x / Math.max(rect.width, 1)) * 100, y: (point.y / Math.max(rect.height, 1)) * 100 } : null);
if (!imageWorkbenchMaskPaintingRef.current) return;
appendImageWorkbenchMaskPoint(event);
};
const stopImageWorkbenchMaskPainting = () => {
imageWorkbenchMaskPaintingRef.current = false;
imageWorkbenchActiveStrokeIdRef.current = null;
imageWorkbenchLastMaskPointRef.current = null;
};
const clearQuickSetSelectTimer = () => {
if (quickSetSelectTimerRef.current) {
window.clearTimeout(quickSetSelectTimerRef.current);
quickSetSelectTimerRef.current = null;
}
};
const resetQuickSetSelectState = () => {
clearQuickSetSelectTimer();
openQuickSetSelectRef.current = null;
visibleQuickSetSelectRef.current = null;
setOpenQuickSetSelect(null);
setVisibleQuickSetSelect(null);
setIsQuickSetSelectClosing(false);
};
const closeQuickSetSelect = () => {
if (!visibleQuickSetSelectRef.current) {
openQuickSetSelectRef.current = null;
setOpenQuickSetSelect(null);
return;
}
clearQuickSetSelectTimer();
openQuickSetSelectRef.current = null;
setOpenQuickSetSelect(null);
setIsQuickSetSelectClosing(true);
quickSetSelectTimerRef.current = window.setTimeout(() => {
visibleQuickSetSelectRef.current = null;
setVisibleQuickSetSelect(null);
setIsQuickSetSelectClosing(false);
quickSetSelectTimerRef.current = null;
}, 420);
};
const showQuickSetSelect = (key: CloneBasicSelectKey) => {
clearQuickSetSelectTimer();
if (visibleQuickSetSelectRef.current && visibleQuickSetSelectRef.current !== key && openQuickSetSelectRef.current) {
openQuickSetSelectRef.current = null;
setOpenQuickSetSelect(null);
setIsQuickSetSelectClosing(true);
quickSetSelectTimerRef.current = window.setTimeout(() => {
visibleQuickSetSelectRef.current = key;
openQuickSetSelectRef.current = key;
setVisibleQuickSetSelect(key);
setOpenQuickSetSelect(key);
setIsQuickSetSelectClosing(false);
quickSetSelectTimerRef.current = null;
}, 180);
return;
}
visibleQuickSetSelectRef.current = key;
openQuickSetSelectRef.current = key;
setVisibleQuickSetSelect(key);
setOpenQuickSetSelect(key);
setIsQuickSetSelectClosing(false);
};
const toggleQuickSetSelect = (key: CloneBasicSelectKey) => {
if (openQuickSetSelectRef.current === key) {
closeQuickSetSelect();
return;
}
showQuickSetSelect(key);
};
const openQuickProductSetPage = () => {
clearSmartCutoutTransition();
setActiveQuickTool("set");
setComposerMenu(null);
setIsCloneSettingsCollapsed(false);
setIsQuickPanelCollapsed(false);
setPreviewZoom(1);
resetQuickSetSelectState();
};
const openQuickDetailPage = () => {
clearSmartCutoutTransition();
setActiveQuickTool("detail");
setComposerMenu(null);
setIsCloneSettingsCollapsed(false);
setIsQuickPanelCollapsed(false);
setPreviewZoom(1);
resetQuickSetSelectState();
if (!selectedDetailModules.length) setSelectedDetailModules(defaultCloneDetailModuleIds);
};
const openHotVideoPage = () => {
clearSmartCutoutTransition();
setActiveQuickTool("hot-video");
setComposerMenu(null);
setIsCloneSettingsCollapsed(true);
setIsCommandHistoryCollapsed(true);
};
const closeHotVideoPage = () => {
setActiveQuickTool(null);
setComposerMenu(null);
setIsCloneSettingsCollapsed(false);
setIsCommandHistoryCollapsed(false);
};
const closeSmartCutoutTool = () => {
runSmartCutoutPageTransition(
{
title: "正在返回首页",
subtitle: "回到电商智能体",
},
() => {
setSmartCutoutBatchImages((current) => {
revokeSmartCutoutItems(current);
return [];
});
setSmartCutoutImage((current) => {
revokeSmartCutoutItem(current);
return null;
});
setIsSmartCutoutComparing(false);
setActiveQuickTool(null);
setComposerMenu(null);
},
);
};
const goSmartCutoutPrevious = () => {
if (!smartCutoutImage) {
closeSmartCutoutTool();
return;
}
runSmartCutoutPageTransition(
{
title: "正在返回上一页",
subtitle: "回到图片上传页",
},
() => {
setSmartCutoutBatchImages((current) => {
revokeSmartCutoutItems(current);
return [];
});
setSmartCutoutImage((current) => {
revokeSmartCutoutItem(current);
return null;
});
setIsSmartCutoutComparing(false);
},
);
};
const addSmartCutoutImage = (files: File[]) => {
const imageFiles = files.filter((file) => file.type.startsWith("image/"));
if (!imageFiles.length) {
toast.error("请上传图片文件");
return;
}
clearSmartCutoutTransition();
setSmartCutoutBatchImages((current) => {
revokeSmartCutoutItems(current);
return [];
});
setSmartCutoutImage((current) => {
revokeSmartCutoutItem(current);
return null;
});
setIsSmartCutoutComparing(false);
const nextImages = imageFiles.map((file) => {
const originalSrc = URL.createObjectURL(file);
return { src: originalSrc, originalSrc, name: file.name };
});
smartCutoutPendingUrlsRef.current = nextImages.map((item) => item.src);
setActiveQuickTool("cutout");
setSmartCutoutSizeKey("original");
setSmartCutoutTransitionMessage({
title: imageFiles.length > 1 ? "正在批量抠图" : "正在智能抠图",
subtitle: imageFiles.length > 1 ? `正在处理 ${imageFiles.length} 张图片` : "即将进入图片编辑室",
});
setIsSmartCutoutTransitioning(true);
smartCutoutTransitionTimeoutRef.current = window.setTimeout(() => {
smartCutoutTransitionTimeoutRef.current = null;
smartCutoutPendingUrlsRef.current = [];
setSmartCutoutBatchImages(nextImages);
setSmartCutoutImage(nextImages[0]);
setIsSmartCutoutTransitioning(false);
}, 620);
};
const handleSmartCutoutUpload = (event: ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files?.length) return;
addSmartCutoutImage(Array.from(files));
event.target.value = "";
};
const handleSmartCutoutDrop = (event: DragEvent<HTMLDivElement>) => {
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<CSSProperties>(
() => ({
"--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<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
updateSmartCutoutColorFromPoint(event.currentTarget, event.clientX, event.clientY);
};
const handleSmartCutoutColorPlaneMove = (event: ReactPointerEvent<HTMLButtonElement>) => {
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<void>((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<Blob | null>((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<HTMLInputElement>) => {
const files = event.target.files;
if (!files?.length) return;
void addProductImages(Array.from(files));
event.target.value = "";
};
const addComposerAssets = (files: File[]) => {
const imageFiles = files.filter((file) => file.type.startsWith("image/"));
const videoFiles = files.filter((file) => file.type.startsWith("video/"));
const unsupportedCount = files.length - imageFiles.length - videoFiles.length;
if (imageFiles.length) void addProductImages(imageFiles);
if (videoFiles[0]) {
setVideoOutfitVideoFile(videoFiles[0]);
setStatus("ready");
setResults([]);
}
if (unsupportedCount > 0) toast.info("浠呮敮鎸佷笂浼犲浘鐗囨垨瑙嗛鏂囦欢");
};
const handleComposerAssetUpload = (event: ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files?.length) return;
addComposerAssets(Array.from(files));
event.target.value = "";
};
const handleProductDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
setIsProductUploadDragging(false);
const files = Array.from(event.dataTransfer.files);
if (files.length) void addProductImages(files);
};
const handleQuickProductDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
const files = Array.from(event.dataTransfer.files);
if (files.length) void addProductImages(files);
};
const removeProductImage = (imageId: string) => {
setProductImages((current) => {
const next = current.filter((item) => item.id !== imageId);
if (next.length === 0) {
setStatus("idle");
setResults([]);
}
return next;
});
};
const hydrateCloneReferenceImageMeta = (items: CloneImageItem[]) => {
items.forEach((item) => {
readImageDimensions(item.src)
.then(({ width, height }) => {
setCloneReferenceImages((current) =>
current.map((currentItem) => (currentItem.id === item.id ? { ...currentItem, width, height } : currentItem)),
);
})
.catch(() => undefined);
});
};
const addCloneReferenceImages = async (files: File[]) => {
const imageFiles = notifyRejectedImages(files);
if (!imageFiles.length) return;
const remainingSlots = maxCloneReferenceImages - cloneReferenceImages.length;
if (remainingSlots <= 0) return;
try {
const nextImages = await createUploadedImageItems(imageFiles, remainingSlots, "reference");
if (!nextImages.length) return;
setCloneReferenceImages((current) => {
if (current.length >= maxCloneReferenceImages) return current;
return nextImages.length ? [...current, ...nextImages].slice(0, maxCloneReferenceImages) : current;
});
hydrateCloneReferenceImageMeta(nextImages);
} catch (err) {
toast.error(err instanceof Error ? err.message : "鍙傝€冨浘涓婁紶澶辫触");
}
};
const handleCloneReferenceUpload = (event: ChangeEvent<HTMLInputElement>) => {
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<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
if (event.dataTransfer.types.includes("Files")) {
setIsCloneReferenceDragging(true);
}
};
const handleCloneReferenceDragLeave = (event: DragEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
if (event.currentTarget === event.target || !event.currentTarget.contains(event.relatedTarget as Node)) {
setIsCloneReferenceDragging(false);
}
};
const handleCloneReferenceDrop = (event: DragEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
setIsCloneReferenceDragging(false);
const files = Array.from(event.dataTransfer.files);
if (files.length) addCloneReferenceImages(files);
};
const updateCloneSetCount = (key: CloneSetCountKey, delta: -1 | 1) => {
setCloneSetCounts((current) => {
const total = Object.values(current).reduce((sum, value) => sum + value, 0);
const nextValue = current[key] + delta;
if (delta < 0 && (current[key] <= 0 || total <= minCloneSetTotal)) return current;
if (delta > 0 && total >= maxCloneSetTotal) return current;
return { ...current, [key]: Math.max(0, Math.min(maxCloneSetTotal, nextValue)) };
});
};
const clearCloneSetCountHold = () => {
window.removeEventListener("pointerup", clearCloneSetCountHold);
window.removeEventListener("pointercancel", clearCloneSetCountHold);
if (countHoldTimeoutRef.current !== null) {
window.clearTimeout(countHoldTimeoutRef.current);
countHoldTimeoutRef.current = null;
}
if (countHoldIntervalRef.current !== null) {
window.clearInterval(countHoldIntervalRef.current);
countHoldIntervalRef.current = null;
}
};
const startCloneSetCountHold = (key: CloneSetCountKey, delta: -1 | 1, disabled: boolean) => {
if (disabled) return;
clearCloneSetCountHold();
updateCloneSetCount(key, delta);
window.addEventListener("pointerup", clearCloneSetCountHold, { once: true });
window.addEventListener("pointercancel", clearCloneSetCountHold, { once: true });
countHoldTimeoutRef.current = window.setTimeout(() => {
countHoldIntervalRef.current = window.setInterval(() => updateCloneSetCount(key, delta), 110);
}, 320);
};
const toggleCloneDetailModule = (moduleId: string) => {
setSelectedCloneDetailModules((current) => {
if (current.includes(moduleId)) return current.filter((item) => item !== moduleId);
if (current.length >= maxDetailModuleSelection) {
toast.info(`最多选择 ${maxDetailModuleSelection} 个模块`);
return current;
}
return [...current, moduleId];
});
};
const toggleCloneModelScene = (scene: string) => {
setSelectedCloneModelScenes((current) => (current[0] === scene ? [] : [scene]));
};
const handleProductSetPlatformChange = (nextPlatform: string) => {
const normalizedPlatform = normalizePlatform(nextPlatform);
setProductSetPlatform(normalizedPlatform);
setProductSetRatio((current) => normalizeRatioForPlatform(normalizedPlatform, current, productSetOutput));
setProductSetLanguage(getPlatformDefaultLanguage(normalizedPlatform, productSetMarket));
};
const handleProductSetOutputChange = (nextOutput: ProductSetOutputKey) => {
setProductSetOutput(nextOutput);
setProductSetRatio((current) => normalizeRatioForPlatform(productSetPlatform, current, nextOutput));
};
const handleProductSetMarketChange = (nextMarket: string) => {
const normalizedMarket = normalizeMarket(nextMarket);
setProductSetMarket(normalizedMarket);
setProductSetLanguage(getPlatformDefaultLanguage(productSetPlatform, normalizedMarket));
};
const handleClonePlatformChange = (nextPlatform: string) => {
const normalizedPlatform = normalizePlatform(nextPlatform);
setPlatform(normalizedPlatform);
setRatio((current) =>
cloneOutput === "hot" && current.startsWith("涓婁紶鍥剧墖") && hotUploadedRatioOption
? hotUploadedRatioOption
: normalizeRatioForPlatform(normalizedPlatform, current, cloneOutput),
);
setLanguage(getPlatformDefaultLanguage(normalizedPlatform, market));
};
const handleCloneOutputChange = (nextOutput: CloneOutputKey) => {
setCloneOutput(nextOutput);
setRatio((current) =>
nextOutput === "hot" && current.startsWith("涓婁紶鍥剧墖") && hotUploadedRatioOption
? hotUploadedRatioOption
: normalizeRatioForPlatform(platform, current, nextOutput),
);
};
const handleCloneMarketChange = (nextMarket: string) => {
const normalizedMarket = normalizeMarket(nextMarket);
setMarket(normalizedMarket);
setLanguage(getPlatformDefaultLanguage(platform, normalizedMarket));
};
const handleDetailPlatformChange = (nextPlatform: string) => {
const normalizedPlatform = normalizePlatform(nextPlatform);
setDetailPlatform(normalizedPlatform);
setDetailLanguage(getPlatformDefaultLanguage(normalizedPlatform, detailMarket));
setDetailRatio((current) => getQuickSetRatioValue(current));
};
const handleDetailMarketChange = (nextMarket: string) => {
const normalizedMarket = normalizeMarket(nextMarket);
setDetailMarket(normalizedMarket);
setDetailLanguage(getPlatformDefaultLanguage(detailPlatform, normalizedMarket));
};
const createCloneSettingSnapshot = (name: string, id = `clone-setting-${Date.now()}`): CloneSavedSetting => ({
id,
name,
savedAt: new Date().toISOString(),
output: cloneOutput,
platform,
market,
language,
ratio,
setCounts: { ...cloneSetCounts },
detailModules: [...selectedCloneDetailModules],
modelPanelTab: cloneModelPanelTab,
modelScenes: [...selectedCloneModelScenes],
modelCustomScene: cloneModelCustomScene,
modelGender: cloneModelGender,
modelAge: cloneModelAge,
modelEthnicity: cloneModelEthnicity,
modelBody: cloneModelBody,
modelAppearance: cloneModelAppearance,
videoQuality: cloneVideoQuality,
videoDurationSeconds: cloneVideoDuration,
videoSmart: cloneVideoSmart,
referenceMode: cloneReferenceMode,
replicateLevel: cloneReplicateLevel,
requirement,
});
const latestCloneSettingSnapshot = useMemo(
() => createCloneSettingSnapshot(cloneSettingName, "clone-setting-latest"),
[
cloneOutput,
platform,
market,
language,
ratio,
cloneSetCounts,
selectedCloneDetailModules,
cloneModelPanelTab,
selectedCloneModelScenes,
cloneModelCustomScene,
cloneModelGender,
cloneModelAge,
cloneModelEthnicity,
cloneModelBody,
cloneModelAppearance,
cloneVideoQuality,
cloneVideoDuration,
cloneVideoSmart,
cloneReferenceMode,
cloneReplicateLevel,
requirement,
cloneSettingName,
],
);
const persistLatestCloneSetting = () => {
const snapshot = createCloneSettingSnapshot(cloneSettingName, "clone-setting-latest");
latestCloneSettingRef.current = snapshot;
writeCloneLatestSetting(snapshot);
return snapshot;
};
const applyCloneSavedSetting = (setting: CloneSavedSetting) => {
const nextCounts = {
selling: Number.isFinite(setting.setCounts?.selling) ? setting.setCounts.selling : defaultCloneSetCounts.selling,
white: Number.isFinite(setting.setCounts?.white) ? setting.setCounts.white : defaultCloneSetCounts.white,
scene: Number.isFinite(setting.setCounts?.scene) ? setting.setCounts.scene : defaultCloneSetCounts.scene,
};
const nextPlatform = normalizePlatform(setting.platform);
const nextMarket = normalizeMarket(setting.market);
const nextOutput = cloneOutputOptions.some((option) => option.key === setting.output) ? setting.output : defaultCloneOutput;
setCloneOutput(nextOutput);
setPlatform(nextPlatform);
setMarket(nextMarket);
setLanguage(normalizeLanguageForPlatform(nextPlatform, nextMarket, setting.language));
setRatio(normalizeRatioForPlatform(nextPlatform, setting.ratio, nextOutput));
setCloneSetCounts(nextCounts);
setSelectedCloneDetailModules((setting.detailModules?.length ? setting.detailModules : defaultCloneDetailModuleIds).slice(0, maxDetailModuleSelection));
setCloneModelPanelTab(setting.modelPanelTab === "model" ? "model" : "scene");
setSelectedCloneModelScenes(normalizeCloneModelSceneSelection(setting.modelScenes));
setCloneModelCustomScene(setting.modelCustomScene ?? "");
setCloneModelGender(tryOnModelOptions.gender.includes(setting.modelGender) ? setting.modelGender : tryOnModelOptions.gender[0]);
setCloneModelAge(tryOnModelOptions.age.includes(setting.modelAge) ? setting.modelAge : tryOnModelOptions.age[0]);
setCloneModelEthnicity(
tryOnModelOptions.ethnicity.includes(setting.modelEthnicity) ? setting.modelEthnicity : tryOnModelOptions.ethnicity[0],
);
setCloneModelBody(tryOnModelOptions.body.includes(setting.modelBody) ? setting.modelBody : tryOnModelOptions.body[0]);
setCloneModelAppearance(setting.modelAppearance ?? "");
setCloneVideoQuality(
cloneVideoQualityOptions.some((option) => option.key === setting.videoQuality) ? setting.videoQuality : "high",
);
setCloneVideoDuration(clampCloneVideoDuration(setting.videoDurationSeconds));
setCloneVideoSmart(Boolean(setting.videoSmart));
setCloneReferenceMode(setting.referenceMode === "link" ? "link" : "upload");
setCloneReplicateLevel(setting.replicateLevel === "style" ? "style" : "high");
setRequirement((setting.requirement ?? "").slice(0, 500));
setCloneSettingName(setting.name);
latestCloneSettingRef.current = setting;
writeCloneLatestSetting(setting);
};
useEffect(() => {
latestCloneSettingRef.current = latestCloneSettingSnapshot;
}, [latestCloneSettingSnapshot]);
useEffect(() => {
window.localStorage.removeItem(cloneLatestSettingStorageKey);
}, []);
useEffect(() => {
setProductSetRatio((current) => normalizeRatioForPlatform(productSetPlatform, current, productSetOutput));
}, [productSetOutput, productSetPlatform]);
useEffect(() => {
if (activeQuickTool === "set") {
setRatio((current) => getQuickSetRatioValue(current));
return;
}
setRatio((current) => {
const platformRatios = getPlatformRatioOptions(platform, cloneOutput);
const availableRatios = hotUploadedRatioOption ? getUniqueRatioOptions([...platformRatios, hotUploadedRatioOption]) : platformRatios;
if (current.startsWith("涓婁紶鍥剧墖") && hotUploadedRatioOption) return hotUploadedRatioOption;
if (availableRatios.includes(current)) return current;
const normalizedRatio = normalizeRatioToken(current);
const matchedRatio = availableRatios.find((option) => normalizeRatioToken(option).includes(normalizedRatio));
return matchedRatio ?? getPlatformDefaultRatio(platform, cloneOutput);
});
}, [activeQuickTool, cloneOutput, hotUploadedRatioOption, platform]);
useEffect(() => {
if (skipInitialCloneAutoSaveRef.current) {
skipInitialCloneAutoSaveRef.current = false;
return undefined;
}
if (skipNextCloneAutoSaveRef.current) {
skipNextCloneAutoSaveRef.current = false;
return undefined;
}
const timeoutId = window.setTimeout(() => {
persistLatestCloneSetting();
}, 300);
return () => window.clearTimeout(timeoutId);
}, [
activeTool,
cloneOutput,
platform,
market,
language,
ratio,
cloneSetCounts,
selectedCloneDetailModules,
cloneModelPanelTab,
selectedCloneModelScenes,
cloneModelCustomScene,
cloneModelGender,
cloneModelAge,
cloneModelEthnicity,
cloneModelBody,
cloneModelAppearance,
cloneVideoQuality,
cloneVideoDuration,
cloneVideoSmart,
cloneReferenceMode,
cloneReplicateLevel,
requirement,
cloneSettingName,
]);
useEffect(() => {
const persistSnapshot = () => {
if (latestCloneSettingRef.current) writeCloneLatestSetting(latestCloneSettingRef.current);
};
const handleVisibilityChange = () => {
if (document.visibilityState === "hidden") persistSnapshot();
};
window.addEventListener("pagehide", persistSnapshot);
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
persistSnapshot();
window.removeEventListener("pagehide", persistSnapshot);
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, []);
useEffect(() => clearCloneSetCountHold, []);
useEffect(() => {
if (!openCloneBasicSelect) return undefined;
const handlePointerDown = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Element) || target.closest("[data-clone-basic-select]")) return;
setOpenCloneBasicSelect(null);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpenCloneBasicSelect(null);
};
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [openCloneBasicSelect]);
useEffect(() => {
if (!composerMenu && !(status === "done" && !isCommandComposerCompact)) return undefined;
const handlePointerDown = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Node)) return;
const composer = commandComposerWrapRef.current;
if (composer?.contains(target)) return;
if (composerMenu) setComposerMenu(null);
if (status === "done" && !isCommandComposerCompact) setIsCommandComposerCompact(true);
};
document.addEventListener("pointerdown", handlePointerDown);
return () => document.removeEventListener("pointerdown", handlePointerDown);
}, [composerMenu, isCommandComposerCompact, status]);
useEffect(() => {
if (composerMenuCloseTimeoutRef.current !== null) {
window.clearTimeout(composerMenuCloseTimeoutRef.current);
composerMenuCloseTimeoutRef.current = null;
}
if (composerMenu) {
setVisibleComposerMenu(composerMenu);
setIsComposerMenuClosing(false);
return;
}
if (!visibleComposerMenu) return;
setIsComposerMenuClosing(true);
composerMenuCloseTimeoutRef.current = window.setTimeout(() => {
composerMenuCloseTimeoutRef.current = null;
setVisibleComposerMenu(null);
setIsComposerMenuClosing(false);
}, 220);
}, [composerMenu, visibleComposerMenu]);
useEffect(
() => () => {
if (composerMenuCloseTimeoutRef.current !== null) {
window.clearTimeout(composerMenuCloseTimeoutRef.current);
}
},
[],
);
useEffect(() => {
if (!openCloneModelSelect) return undefined;
const handlePointerDown = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Element) || target.closest("[data-clone-model-select]")) return;
setOpenCloneModelSelect(null);
setCloneModelSelectDropUp(false);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setOpenCloneModelSelect(null);
setCloneModelSelectDropUp(false);
}
};
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [openCloneModelSelect]);
const handleGarmentUpload = (event: ChangeEvent<HTMLInputElement>) => {
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<HTMLInputElement>) => {
const files = event.target.files;
if (!files?.length) return;
void addDetailImages(Array.from(files));
event.target.value = "";
};
const handleDetailDrop = (event: DragEvent<HTMLDivElement>) => {
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<string> =>
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<string[]> => {
const urls: string[] = [];
for (const item of images) {
try {
if (!item.file && item.src.startsWith("blob:")) {
throw new Error("鏈湴棰勮鍥剧己灏戝師濮嬫枃浠讹紝鏃犳硶涓婁紶");
}
const rawBlob = item.file ?? (item.src.startsWith("data:") ? null : await (await fetch(item.src)).blob());
const mimeType = normalizeEcommerceImageMime(
rawBlob?.type || item.src.match(/^data:([^;,]+)/)?.[1] || "image/png",
);
const blob = rawBlob ? (rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType })) : null;
const dataUrl = item.src.startsWith("data:") ? item.src : await blobToDataUrl(blob!);
const { url } = await aiGenerationClient.uploadAsset({ dataUrl, name: item.name, mimeType, scope: "ecommerce-product" });
urls.push(url);
} catch {
// skip images that fail to upload
}
}
return urls;
};
const IMAGE_MODEL = "gpt-image-2";
const setCountLabels: Record<CloneSetCountKey, { label: string; promptDesc: string }> = {
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<CloneSetCountKey, number>,
userText: string,
pPlatform: string,
pRatio: string,
pLanguage: string,
pMarket: string,
setStatusFn: (status: "generating" | "done" | "idle" | "failed") => void,
setResultFn: (urls: string[]) => void,
): Promise<void> => {
setStatusFn("generating");
try {
const referenceUrls = await uploadCloneImages(images);
if (!referenceUrls.length) {
setStatusFn("idle");
return;
}
if (imageAbortRef.current.current) {
setStatusFn("idle");
return;
}
const generatedUrls: string[] = [];
const stamp = Date.now();
for (const countKey of cloneSetCountKeys) {
if (imageAbortRef.current.current) break;
const count = counts[countKey];
for (let i = 0; i < count; i++) {
if (imageAbortRef.current.current) break;
const subPrompt = buildSetSubPrompt(countKey, i, count, pPlatform, pRatio, pLanguage, pMarket);
const fullPrompt = userText.trim() ? `${subPrompt} Additional user requirements: ${userText.trim()}` : subPrompt;
const { taskId } = await aiGenerationClient.createImageTask({
model: IMAGE_MODEL,
prompt: fullPrompt,
ratio: normalizeRatioForApi(pRatio),
quality: pRatio.includes("720") ? "720P" : "1080P",
gridMode: "single",
referenceUrls,
});
trackEcommerceTask(taskId);
const storeId = imageGen.submitTask({ title: `${setCountLabels[countKey].label} ${i + 1}`, type: "image", status: "running", progress: 5, prompt: fullPrompt, sourceView: "ecommerce", taskId });
let resultUrl: string | null = null;
try {
resultUrl = await waitForTask(taskId, {
abortRef: imageAbortRef.current,
onProgress: () => {},
});
} finally {
untrackEcommerceTask(taskId);
}
if (imageAbortRef.current.current) break;
if (resultUrl) {
const persistedUrl = await persistGeneratedImageUrl(resultUrl, "ecommerce-generated", `ecommerce-${countKey}-${i + 1}`);
generatedUrls.push(persistedUrl);
imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl: persistedUrl });
} else {
generatedUrls.push("");
imageGen.updateTask(storeId, { status: "failed", error: "鐢熸垚鏈繑鍥炵粨鏋?" });
}
}
}
if (imageAbortRef.current.current) {
setStatusFn("idle");
return;
}
setResultFn(generatedUrls);
setStatusFn(generatedUrls.some(Boolean) ? "done" : "failed");
} catch (err) {
if (imageAbortRef.current.current) {
setStatusFn("idle");
return;
}
if (err instanceof ServerRequestError && err.status === 402) {
setResultFn([]);
toast.error("浣欓涓嶈冻锛岃鍏呭€煎悗缁х画");
} else {
const msg = err instanceof Error ? err.message : "鐢熸垚澶辫触";
toast.error(msg);
}
setStatusFn("failed");
}
};
const generateEcommerceImage = async (
outputKey: CloneOutputKey,
images: CloneImageItem[],
userText: string,
pPlatform: string,
pRatio: string,
pLanguage: string,
pMarket: string,
tryOnOptions?: EcommerceImagePromptOptions,
statusFn?: (status: "generating" | "done" | "idle" | "failed") => void,
resultFn?: (results: CloneResult[]) => void,
): Promise<void> => {
statusFn?.("generating");
try {
const referenceUrls = await uploadCloneImages(images);
if (!referenceUrls.length) {
statusFn?.("idle");
return;
}
if (imageAbortRef.current.current) {
statusFn?.("idle");
return;
}
const prompt = buildEcommerceImagePrompt(outputKey, userText, pPlatform, pRatio, pLanguage, pMarket, tryOnOptions);
const stamp = Date.now();
const { taskId } = await aiGenerationClient.createImageTask({
model: IMAGE_MODEL,
prompt,
ratio: normalizeRatioForApi(pRatio),
quality: pRatio.includes("720") ? "720P" : "1080P",
gridMode: "single",
referenceUrls,
});
trackEcommerceTask(taskId);
const storeId = imageGen.submitTask({ title: `电商${outputKey}`, type: "image", status: "running", progress: 5, prompt, sourceView: "ecommerce", taskId });
let resultUrl: string | null = null;
try {
resultUrl = await waitForTask(taskId, {
abortRef: imageAbortRef.current,
onProgress: () => {},
});
} finally {
untrackEcommerceTask(taskId);
}
if (imageAbortRef.current.current) {
statusFn?.("idle");
return;
}
if (resultUrl) {
const persistedUrl = await persistGeneratedImageUrl(resultUrl, "ecommerce-generated", `ecommerce-${outputKey}`);
resultFn?.([{ id: `ecommerce-${stamp}`, src: persistedUrl, label: selectedCloneOutput.label }]);
statusFn?.("done");
imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl: persistedUrl });
} else {
statusFn?.("idle");
imageGen.updateTask(storeId, { status: "failed", error: "鐢熸垚鏈繑鍥炵粨鏋?" });
}
} catch (err) {
if (imageAbortRef.current.current) {
statusFn?.("idle");
return;
}
if (err instanceof ServerRequestError && err.status === 402) {
resultFn?.([{ id: `ecommerce-error-402`, src: "", label: "浣欓涓嶈冻锛岃鍏呭€煎悗缁х画" }]);
toast.error("浣欓涓嶈冻锛岃鍏呭€煎悗缁х画");
} else {
const msg = err instanceof Error ? err.message : "鐢熸垚澶辫触";
toast.error(msg);
}
statusFn?.("failed");
}
};
const handleVideoOutfitGenerate = async () => {
if (!videoOutfitVideoFile || !videoOutfitRefFile) return;
setStatus("generating");
try {
const readAsDataUrl = (file: File): Promise<string> => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(new Error("鏂囦欢璇诲彇澶辫触"));
reader.readAsDataURL(file);
});
const videoDataUrl = await readAsDataUrl(videoOutfitVideoFile);
const refDataUrl = await readAsDataUrl(videoOutfitRefFile);
const videoAsset = await aiGenerationClient.uploadAsset({
dataUrl: videoDataUrl, name: videoOutfitVideoFile.name,
mimeType: videoOutfitVideoFile.type || "video/mp4", scope: "video-outfit",
});
const refAsset = await aiGenerationClient.uploadAsset({
dataUrl: refDataUrl, name: videoOutfitRefFile.name,
mimeType: videoOutfitRefFile.type || "image/png", scope: "video-outfit",
});
if (imageAbortRef.current.current) {
setStatus("idle");
return;
}
const { taskId } = await aiGenerationClient.createVideoEditTask({
videoUrl: videoAsset.url,
referenceUrls: [refAsset.url],
prompt: requirement || undefined,
});
trackEcommerceTask(taskId);
const { waitForTask } = await import("../../api/taskSubscription");
let resultUrl: string | null = null;
try {
resultUrl = await waitForTask(taskId, { abortRef: imageAbortRef.current });
} finally {
untrackEcommerceTask(taskId);
}
if (imageAbortRef.current.current) {
setStatus("idle");
return;
}
if (resultUrl) {
setResults([{ id: crypto.randomUUID(), src: resultUrl, label: "鎹㈣瑙嗛" }]);
}
setStatus("done");
} catch (err) {
if (imageAbortRef.current.current) {
setStatus("idle");
return;
}
setStatus("failed");
toast.error(err instanceof Error ? err.message : "瑙嗛鎹㈣鐢熸垚澶辫触");
}
};
const handleGenerate = () => {
if (!canGenerate) return;
if ((appUsage?.balanceCents ?? 0) <= 0) {
toast.error("绉垎涓嶈冻锛岃鍏呭€煎悗缁х画");
return;
}
if (cloneOutput === "set" && cloneSetTotal > 5) {
if (!window.confirm("将生成 " + String(cloneSetTotal) + " 张图片,可能消耗较多积分,是否继续?")) return;
}
imageAbortRef.current = { current: false };
lastFailedActionRef.current = null;
if (cloneOutput === "video-outfit") {
void handleVideoOutfitGenerate();
} else if (cloneOutput === "set") {
void generateSetImages(
productImages, cloneSetCounts, requirement,
platform, ratio, language, market,
(s) => setStatus(s as ProductCloneStatus),
(urls) => setProductSetResultImages(urls),
);
} else {
const clonePromptOptions: EcommerceImagePromptOptions | undefined =
cloneOutput === "model"
? {
gender: cloneModelGender,
age: cloneModelAge,
ethnicity: cloneModelEthnicity,
body: cloneModelBody,
scenes: selectedCloneModelScenes,
customScene: cloneModelCustomScene,
}
: cloneOutput === "detail"
? { detailModules: selectedCloneDetailModules }
: undefined;
void generateEcommerceImage(
cloneOutput, productImages, requirement,
platform, ratio, language, market,
clonePromptOptions,
(s: string) => setStatus(s as ProductCloneStatus), setResults,
);
lastFailedActionRef.current = () => handleGenerate();
}
};
const handleGenerateModel = () => {
imageAbortRef.current = { current: false };
lastFailedActionRef.current = null;
setTryOnStatus("modeling");
void generateEcommerceImage(
"model", garmentImages, requirement,
platform, ratio, language, market,
{ gender: modelGender, age: modelAge, ethnicity: modelEthnicity, body: modelBody, appearance, scenes: selectedScenes, smartScene },
(s) => {
if (s === "done") setTryOnStatus("ready");
else setTryOnStatus(s as TryOnStatus);
},
() => { setTryOnStatus("ready"); },
);
lastFailedActionRef.current = () => handleGenerateModel();
};
const handleTryOnGenerate = () => {
if (!canGenerateTryOn) return;
imageAbortRef.current = { current: false };
lastFailedActionRef.current = null;
void generateEcommerceImage(
"model", garmentImages, requirement,
platform, ratio, language, market,
{ gender: modelGender, age: modelAge, ethnicity: modelEthnicity, body: modelBody, appearance, scenes: selectedScenes, smartScene },
(s) => setTryOnStatus(s as TryOnStatus),
(res) => {
const urls: string[] = [];
for (const item of res) {
if (item.src) urls.push(item.src);
}
setTryOnResultImages(urls);
},
);
lastFailedActionRef.current = () => handleTryOnGenerate();
};
const toggleScene = (scene: string) => {
setSelectedScenes((current) =>
current.includes(scene) ? current.filter((item) => item !== scene) : [...current, scene],
);
};
const toggleDetailModule = (moduleId: string) => {
setSelectedDetailModules((current) => {
if (current.includes(moduleId)) return current.filter((item) => item !== moduleId);
if (current.length >= maxDetailModuleSelection) {
toast.info(`最多选择 ${maxDetailModuleSelection} 个模块`);
return current;
}
return [...current, moduleId];
});
};
const handleSetGenerate = () => {
if (!canGenerateSet) return;
imageAbortRef.current = { current: false };
lastFailedActionRef.current = null;
void generateSetImages(
setImages, cloneSetCounts, productSetRequirement,
productSetPlatform, productSetRatio, productSetLanguage, productSetMarket,
(s) => setProductSetStatus(s as ProductSetStatus),
(urls) => setProductSetResultImages(urls),
);
lastFailedActionRef.current = () => handleSetGenerate();
};
const openProductSetPreview = (card: { src: string; label: string }) => {
setSelectedProductSetPreview(card);
};
const handleDetailAiWrite = () => {
setDetailRequirement(
"1.浜у搧鍚嶇О锛氭棤绾块檷鍣摑鐗欒€虫満\n2.鏍稿績鍗栫偣锛氫富鍔ㄩ檷鍣€?4H缁埅銆佷綆寤惰繜杩炴帴銆佽垝閫備僵鎴碶n3.閫傜敤浜虹兢锛氶€氬嫟銆佸姙鍏€佽繍鍔ㄥ拰鏃呰鐢ㄦ埛\n4.鏈熸湜鍦烘櫙锛氬湴閾侀€氬嫟銆佸眳瀹跺姙鍏€佹埛澶栬繍鍔╘n5.鍏蜂綋鍙傛暟锛氳摑鐗?.3銆両PX4闃叉按銆佸揩鍏?0鍒嗛挓浣跨敤2灏忔椂",
);
};
const handleDetailGenerate = () => {
if (!canGenerateDetail) return;
imageAbortRef.current = { current: false };
lastFailedActionRef.current = null;
void generateEcommerceImage(
"detail", detailProductImages, detailRequirement,
detailPlatform, detailRatio, detailLanguage, detailMarket,
{ detailModules: selectedDetailModules },
(s: string) => setDetailStatus(s as DetailStatus),
(res) => setDetailResultUrl(res[0]?.src ?? null),
);
};
const resetTask = () => {
setSetImages([]);
setProductSetRequirement("");
setProductSetPlatform(defaultEcommercePlatform);
setProductSetLanguage(getPlatformDefaultLanguage(defaultEcommercePlatform, productSetMarket));
setProductSetOutput(defaultProductSetOutput);
setProductSetRatio(getPlatformDefaultRatio(defaultEcommercePlatform, defaultProductSetOutput));
setProductSetStatus("idle");
setProductSetResultImages([]);
setIsSetUploadDragging(false);
setSelectedProductSetPreview(null);
setShowHostingModal(false);
setProductImages([]);
setIsProductUploadDragging(false);
setCloneOutput(defaultCloneOutput);
setPlatform(defaultEcommercePlatform);
setLanguage(getPlatformDefaultLanguage(defaultEcommercePlatform, market));
setRatio(getPlatformDefaultRatio(defaultEcommercePlatform, defaultCloneOutput));
setCloneSetCounts(defaultCloneSetCounts);
setSelectedCloneDetailModules(defaultCloneDetailModuleIds);
setCloneModelPanelTab("scene");
setSelectedCloneModelScenes([]);
setCloneModelCustomScene("");
setCloneModelGender(tryOnModelOptions.gender[0]);
setCloneModelAge(tryOnModelOptions.age[0]);
setCloneModelEthnicity(tryOnModelOptions.ethnicity[0]);
setCloneModelBody(tryOnModelOptions.body[0]);
setCloneModelAppearance("");
setCloneVideoQuality("high");
setCloneVideoDuration(10);
setCloneVideoSmart(true);
setCloneReferenceMode("upload");
setCloneReferenceImages([]);
setCloneReplicateLevel("high");
setRequirement("");
setCloneSettingName("鏂板缓鍒涗綔");
setResults([]);
setStatus("idle");
setGarmentImages([]);
setAppearance("");
setSelectedScenes([]);
setCustomScene("");
setSmartScene(false);
setTryOnRatio(tryOnRatioOptions[0]);
setTryOnStatus("idle");
setTryOnResultImages([]);
setDetailProductImages([]);
setDetailRequirement("");
setSelectedDetailModules(defaultDetailModuleIds);
setDetailStatus("idle");
};
const activeToolMeta = sideTools.find((tool) => tool.key === activeTool);
const isSetTool = activeTool === "set";
const isDetail = activeTool === "detail";
const isTryOn = activeTool === "wear";
const isCloneTool = activeTool === "clone";
const isSmartCutoutTool = isCloneTool && activeQuickTool === "cutout";
const isQuickSetTool = isCloneTool && activeQuickTool === "set";
const isQuickDetailTool = isCloneTool && activeQuickTool === "detail";
const isWatermarkTool = isCloneTool && activeQuickTool === "watermark";
const isImageEditTool = isCloneTool && activeQuickTool === "image-edit";
const isHotVideoTool = isCloneTool && activeQuickTool === "hot-video";
const pageLabel = isSetTool ? "鍟嗗搧濂楀浘" : isDetail ? "A+/璇︽儏椤?" : isTryOn ? "AI鏈嶉グ绌挎埓" : activeToolMeta?.label || "鍟嗗搧宸ュ叿";
const setPrimaryLabel =
setImages.length === 0
? "璇峰厛涓婁紶鍟嗗搧鍘熷浘"
: productSetStatus === "generating"
? "鐢熸垚涓?.."
: "鐢熸垚" + selectedProductSetOutput.label;
const tryOnPrimaryLabel =
garmentImages.length === 0 ? "璇峰厛涓婁紶鏈嶈鍥剧墖" : tryOnStatus === "generating" ? "鐢熸垚涓?.." : "鐢熸垚鏈嶉グ绌挎埓鍥?";
const detailPrimaryLabel =
detailProductImages.length === 0 ? "璇蜂笂浼犱骇鍝佸浘" : detailStatus === "generating" ? "鐢熸垚涓?.." : "鐢熸垚A+璇︽儏椤?";
const clonePrimaryLabel =
productImages.length === 0 ? "璇峰厛涓婁紶鍟嗗搧鍘熷浘" : status === "generating" ? "鐢熸垚涓?.." : "鐢熸垚" + selectedCloneOutput.label;
const setPreviewCards: CloneResult[] = [];
let setIndex = 0;
for (const countKey of cloneSetCountKeys) {
const count = cloneSetCounts[countKey];
const info = setCountLabels[countKey];
for (let i = 0; i < count; i++) {
setPreviewCards.push({
id: String(countKey) + "-" + String(i),
src: productSetResultImages[setIndex] || productSetPreviewCards[setIndex % productSetPreviewCards.length]?.src || "",
label: info.label + (count > 1 ? " " + String(i + 1) : ""),
});
setIndex++;
}
}
const clonePreviewCards: CloneResult[] = [];
let cloneIndex = 0;
for (const countKey of cloneSetCountKeys) {
const count = cloneSetCounts[countKey];
const info = setCountLabels[countKey];
for (let i = 0; i < count; i++) {
clonePreviewCards.push({
id: String(countKey) + "-" + String(i),
src: productSetResultImages[cloneIndex] || productSetPreviewCards[cloneIndex % productSetPreviewCards.length]?.src || "",
label: info.label + (count > 1 ? " " + String(i + 1) : ""),
});
cloneIndex++;
}
}
const getCurrentHistoryResults = () =>
cloneOutput === "set"
? productSetResultImages
.filter(Boolean)
.map((src, index) => ({ id: "history-set-" + String(index), src, label: clonePreviewCards[index]?.label || "濂楀浘 " + String(index + 1) }))
: results.filter((item) => item.src);
const buildHistorySignature = (output: CloneOutputKey, prompt: string, historyResults: CloneResult[], sourceImages: CloneImageItem[]) =>
[
output,
prompt.trim(),
historyResults.map((item) => item.src).join("|"),
sourceImages.map((item) => item.src).join("|"),
].join("::");
const formatHistoryTime = (timestamp: number) => {
const diff = Math.max(0, Date.now() - timestamp);
const minute = 60 * 1000;
const hour = 60 * minute;
const day = 24 * hour;
if (diff < minute) return "鍒氬垰";
if (diff < hour) return String(Math.floor(diff / minute)) + " 分钟前";
if (diff < day) return String(Math.floor(diff / hour)) + " 小时前";
return String(Math.floor(diff / day)) + " 天前";
};
const saveCurrentEcommerceHistory = () => {
const historyResults = getCurrentHistoryResults();
if (!historyResults.length) return null;
const signature = buildHistorySignature(cloneOutput, requirement, historyResults, productImages);
if (lastSavedHistorySignatureRef.current === signature && activeHistoryRecordId) return activeHistoryRecordId;
const createdAt = Date.now();
const outputLabel = cloneOutputOptions.find((option) => option.key === cloneOutput)?.label || "鐢熸垚璁板綍";
const title = requirement.trim() || outputLabel + " " + new Date(createdAt).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
const record: EcommerceHistoryRecord = {
id: crypto.randomUUID(),
title,
createdAt,
output: cloneOutput,
platform,
market,
language,
ratio,
requirement,
productImages,
results: historyResults,
setResultImages: cloneOutput === "set" ? historyResults.map((item) => item.src) : [],
setCounts: cloneSetCounts,
detailModules: selectedCloneDetailModules,
modelScenes: selectedCloneModelScenes,
referenceImages: cloneReferenceImages,
replicateLevel: cloneReplicateLevel,
};
lastSavedHistorySignatureRef.current = signature;
setEcommerceHistoryRecords((current) => {
const nextRecords = [record, ...current.filter((item) => item.id !== record.id)].slice(0, 30);
writeEcommerceHistoryRecords(nextRecords);
return nextRecords;
});
setActiveHistoryRecordId(record.id);
return record.id;
};
const openEcommerceHistoryRecord = (record: EcommerceHistoryRecord) => {
setActiveTool("clone");
setCloneOutput(record.output);
setPlatform(record.platform);
setMarket(record.market);
setLanguage(record.language);
setRatio(record.ratio);
setRequirement(record.requirement);
setProductImages(record.productImages);
setCloneSetCounts(record.setCounts);
setSelectedCloneDetailModules(record.detailModules.slice(0, maxDetailModuleSelection));
setSelectedCloneModelScenes(record.modelScenes);
setCloneReferenceImages(record.referenceImages);
setCloneReplicateLevel(record.replicateLevel);
setProductSetResultImages(record.setResultImages);
setResults(record.output === "set" ? [] : record.results);
setStatus("done");
setPreviewZoom(1);
setComposerMenu(null);
setActiveHistoryRecordId(record.id);
lastSavedHistorySignatureRef.current = buildHistorySignature(record.output, record.requirement, record.results, record.productImages);
setIsCommandComposerCompact(true);
};
const handleNewEcommerceConversation = () => {
saveCurrentEcommerceHistory();
resetTask();
setPreviewZoom(1);
setPreviewOffset({ x: 0, y: 0 });
setComposerMenu(null);
setIsCommandComposerCompact(false);
setActiveHistoryRecordId(null);
lastSavedHistorySignatureRef.current = "";
};
const refreshEcommerceHistory = () => {
if (historyRefreshLockRef.current) return;
historyRefreshLockRef.current = true;
setIsHistoryRefreshing(true);
setHistoryRefreshMessage("鍒锋柊涓?..");
setHistoryRefreshStamp(Date.now());
window.setTimeout(() => {
const storedRecords = readEcommerceHistoryRecords();
const mergedRecords = [...ecommerceHistoryRecords, ...storedRecords]
.reduce<EcommerceHistoryRecord[]>((records, record) => {
if (!records.some((item) => item.id === record.id)) records.push(record);
return records;
}, [])
.sort((a, b) => b.createdAt - a.createdAt)
.slice(0, 30);
writeEcommerceHistoryRecords(mergedRecords);
setHistoryRefreshTick((tick) => tick + 1);
setEcommerceHistoryRecords(mergedRecords);
setHistoryRefreshMessage(mergedRecords.length ? "已刷新 " + String(mergedRecords.length) + " 条记录" : "暂无可刷新记录");
setHistoryRefreshStamp(Date.now());
setIsHistoryRefreshing(false);
historyRefreshLockRef.current = false;
}, 180);
window.setTimeout(() => setHistoryRefreshMessage(""), 3000);
};
useEffect(() => {
if (status === "done") saveCurrentEcommerceHistory();
}, [status, results, productSetResultImages]);
const detailSourcePreviewImages = detailProductImages.length
? detailProductImages.reduce<string[]>((urls, item) => {
urls.push(item.src);
return urls;
}, [])
: detailProductSamples;
const cloneBasicSelects: Array<{
key: CloneBasicSelectKey;
label: string;
value: string;
options: string[];
onChange: (value: string) => void;
}> = [
{ key: "platform", label: "平台", value: platform, options: platformOptions, onChange: handleClonePlatformChange },
{ key: "market", label: "国家", value: market, options: marketOptions, onChange: handleCloneMarketChange },
{ key: "language", label: "语种", value: language, options: cloneLanguageOptions, onChange: setLanguage },
{ key: "ratio", label: "尺寸/比例", value: ratio, options: cloneRatioOptions, onChange: setRatio },
];
const handleQuickSetPlatformChange = (nextPlatform: string) => {
const normalizedPlatform = normalizePlatform(nextPlatform);
setPlatform(normalizedPlatform);
setLanguage(getPlatformDefaultLanguage(normalizedPlatform, market));
};
const quickSetBasicSelects: Array<{
key: CloneBasicSelectKey;
label: string;
value: string;
options: string[];
onChange: (value: string) => void;
}> = [
{ key: "platform", label: "平台", value: platform, options: platformOptions, onChange: handleQuickSetPlatformChange },
{ key: "market", label: "国家", value: market, options: marketOptions, onChange: handleCloneMarketChange },
{ key: "language", label: "语种", value: language, options: cloneLanguageOptions, onChange: setLanguage },
{ key: "ratio", label: "尺寸/比例", value: getQuickSetRatioValue(ratio), options: quickSetRatioOptions, onChange: setRatio },
];
const quickDetailBasicSelects: Array<{
key: CloneBasicSelectKey;
label: string;
value: string;
options: string[];
onChange: (value: string) => void;
}> = [
{ key: "platform", label: "平台", value: detailPlatform, options: platformOptions, onChange: handleDetailPlatformChange },
{ key: "market", label: "国家", value: detailMarket, options: marketOptions, onChange: handleDetailMarketChange },
{ key: "language", label: "语种", value: detailLanguage, options: detailLanguageOptions, onChange: setDetailLanguage },
{ key: "ratio", label: "尺寸/比例", value: getQuickSetRatioValue(detailRatio), options: quickSetRatioOptions, onChange: setDetailRatio },
];
const cloneModelSelects: Array<{
key: CloneModelSelectKey;
label: string;
value: string;
options: string[];
onChange: (value: string) => void;
}> = [
{ key: "gender", label: "性别", value: cloneModelGender, options: tryOnModelOptions.gender, onChange: setCloneModelGender },
{ key: "age", label: "年龄", value: cloneModelAge, options: tryOnModelOptions.age, onChange: setCloneModelAge },
{
key: "ethnicity",
label: "人种",
value: cloneModelEthnicity,
options: tryOnModelOptions.ethnicity,
onChange: setCloneModelEthnicity,
},
{ key: "body", label: "体型", value: cloneModelBody, options: tryOnModelOptions.body, onChange: setCloneModelBody },
];
const setPanel = (
<EcommerceSetPanel
setInputRef={setInputRef}
setImages={setImages}
isSetUploadDragging={isSetUploadDragging}
productSetOutputOptions={productSetOutputOptions}
productSetOutput={productSetOutput}
platformOptions={platformOptions}
marketOptions={marketOptions}
productSetLanguageOptions={productSetLanguageOptions}
productSetRatioOptions={productSetRatioOptions}
productSetPlatform={productSetPlatform}
productSetMarket={productSetMarket}
productSetLanguage={productSetLanguage}
productSetRatio={productSetRatio}
setIsSetUploadDragging={setIsSetUploadDragging}
handleSetDrop={handleSetDrop}
handleSetUpload={handleSetUpload}
removeSetImage={removeSetImage}
handleProductSetOutputChange={handleProductSetOutputChange}
handleProductSetPlatformChange={handleProductSetPlatformChange}
handleProductSetMarketChange={handleProductSetMarketChange}
setProductSetLanguage={setProductSetLanguage}
setProductSetRatio={setProductSetRatio}
formatRatioDisplayValue={formatRatioDisplayValue}
/>
);
const clonePanel = (
<EcommerceClonePanel
productInputRef={productInputRef}
cloneReferenceInputRef={cloneReferenceInputRef}
productImages={productImages}
isProductUploadDragging={isProductUploadDragging}
cloneOutput={cloneOutput}
cloneOutputOptions={cloneOutputOptions}
cloneBasicSelects={cloneBasicSelects}
openCloneBasicSelect={openCloneBasicSelect}
cloneReferenceMode={cloneReferenceMode}
cloneReferenceImages={cloneReferenceImages}
maxCloneReferenceImages={maxCloneReferenceImages}
cloneReplicateLevel={cloneReplicateLevel}
cloneReplicateLevelOptions={cloneReplicateLevelOptions}
cloneSetCounts={cloneSetCounts}
cloneSetCountOptions={cloneSetCountOptions}
cloneSetTotal={cloneSetTotal}
minCloneSetTotal={minCloneSetTotal}
maxCloneSetTotal={maxCloneSetTotal}
selectedCloneDetailModules={selectedCloneDetailModules}
cloneDetailModules={cloneDetailModules}
cloneModelPanelTab={cloneModelPanelTab}
tryOnScenes={tryOnScenes}
selectedCloneModelScenes={selectedCloneModelScenes}
cloneModelCustomScene={cloneModelCustomScene}
cloneModelSelects={cloneModelSelects}
openCloneModelSelect={openCloneModelSelect}
cloneModelSelectDropUp={cloneModelSelectDropUp}
cloneVideoQuality={cloneVideoQuality}
cloneVideoQualityOptions={cloneVideoQualityOptions}
cloneVideoDuration={cloneVideoDuration}
cloneVideoDurationMin={cloneVideoDurationMin}
cloneVideoDurationMax={cloneVideoDurationMax}
cloneVideoDurationStyle={cloneVideoDurationStyle}
cloneVideoSmart={cloneVideoSmart}
canGenerate={canGenerate}
status={status}
lastFailedActionRef={lastFailedActionRef}
setIsProductUploadDragging={setIsProductUploadDragging}
handleProductDrop={handleProductDrop}
removeProductImage={removeProductImage}
handleProductUpload={handleProductUpload}
handleCloneOutputChange={handleCloneOutputChange}
setOpenCloneBasicSelect={setOpenCloneBasicSelect}
setCloneReferenceMode={setCloneReferenceMode}
handleCloneReferenceUpload={handleCloneReferenceUpload}
isCloneReferenceDragging={isCloneReferenceDragging}
handleCloneReferenceDragOver={handleCloneReferenceDragOver}
handleCloneReferenceDragLeave={handleCloneReferenceDragLeave}
handleCloneReferenceDrop={handleCloneReferenceDrop}
setCloneReplicateLevel={setCloneReplicateLevel}
startCloneSetCountHold={startCloneSetCountHold}
clearCloneSetCountHold={clearCloneSetCountHold}
toggleCloneDetailModule={toggleCloneDetailModule}
setCloneModelPanelTab={setCloneModelPanelTab}
toggleCloneModelScene={toggleCloneModelScene}
setCloneModelCustomScene={setCloneModelCustomScene}
setOpenCloneModelSelect={setOpenCloneModelSelect}
setCloneModelSelectDropUp={setCloneModelSelectDropUp}
setCloneVideoQuality={setCloneVideoQuality}
setCloneVideoDuration={setCloneVideoDuration}
clampCloneVideoDuration={clampCloneVideoDuration}
setCloneVideoSmart={setCloneVideoSmart}
handleGenerate={handleGenerate}
onCancelGenerate={handleCancelGenerate}
formatRatioDisplayValue={formatRatioDisplayValue}
setVideoOutfitFiles={(video, ref) => { setVideoOutfitVideoFile(video); setVideoOutfitRefFile(ref); }}
onStartVideoPlan={() => setVideoPlanTrigger((n) => n + 1)}
/>
);
const detailPanel = (
<EcommerceDetailPanel
detailInputRef={detailInputRef}
detailProductImages={detailProductImages}
detailPlatform={detailPlatform}
detailMarket={detailMarket}
detailLanguage={detailLanguage}
detailType={detailType}
detailRequirement={detailRequirement}
selectedDetailModules={selectedDetailModules}
detailStatus={detailStatus}
canGenerateDetail={canGenerateDetail}
detailPrimaryLabel={detailPrimaryLabel}
platformOptions={platformOptions}
marketOptions={marketOptions}
detailLanguageOptions={detailLanguageOptions}
detailTypeOptions={detailTypeOptions}
detailModules={detailModules}
handleDetailUpload={handleDetailUpload}
handleDetailPlatformChange={handleDetailPlatformChange}
handleDetailMarketChange={handleDetailMarketChange}
setDetailLanguage={setDetailLanguage}
setDetailType={setDetailType}
setDetailRequirement={setDetailRequirement}
handleDetailAiWrite={handleDetailAiWrite}
toggleDetailModule={toggleDetailModule}
handleDetailGenerate={handleDetailGenerate}
onCancelGenerate={handleCancelGenerate}
/>
);
const tryOnPanel = (
<EcommerceTryOnPanel
garmentInputRef={garmentInputRef}
garmentImages={garmentImages}
modelSource={modelSource}
modelGender={modelGender}
modelAge={modelAge}
modelEthnicity={modelEthnicity}
modelBody={modelBody}
appearance={appearance}
selectedScenes={selectedScenes}
customScene={customScene}
smartScene={smartScene}
tryOnRatio={tryOnRatio}
tryOnStatus={tryOnStatus}
canGenerateTryOn={canGenerateTryOn}
tryOnPrimaryLabel={tryOnPrimaryLabel}
tryOnModelOptions={tryOnModelOptions}
tryOnAssets={tryOnAssets}
tryOnScenes={tryOnScenes}
tryOnRatioOptions={tryOnRatioOptions}
handleGarmentUpload={handleGarmentUpload}
setModelSource={setModelSource}
setModelGender={setModelGender}
setModelAge={setModelAge}
setModelEthnicity={setModelEthnicity}
setModelBody={setModelBody}
setAppearance={setAppearance}
handleGenerateModel={handleGenerateModel}
toggleScene={toggleScene}
setCustomScene={setCustomScene}
setSmartScene={setSmartScene}
setTryOnRatio={setTryOnRatio}
handleTryOnGenerate={handleTryOnGenerate}
onCancelGenerate={handleCancelGenerate}
/>
);
const placeholderPanel = (
<>
<div className="product-clone-panel__scroll">
<section className="product-clone-empty-panel">
<span>{activeToolMeta?.icon}</span>
<h2>{activeToolMeta?.label}</h2>
<p>使 AI A+ 穿</p>
</section>
</div>
<footer className="product-clone-panel__footer">
<button type="button" className="product-clone-primary" disabled>
</button>
</footer>
</>
);
const setPreview = (
<main className="product-clone-preview product-clone-preview--set" aria-label="AI商品套图预览" onWheel={handlePreviewWheel}>
<div className="product-clone-preview__headline">
<h1></h1>
<p>
AI <span></span>
</p>
</div>
{productSetPreviewReady ? (
<section className="product-set-demo-board">
<button
type="button"
className="product-set-main-card"
onClick={() => openProductSetPreview(setPreviewCards[0] ?? productSetPreviewCards[0])}
>
<img src={setImages[0]?.src ?? (setPreviewCards[0]?.src ?? productSetPreviewCards[0].src)} alt="商品原图" />
<span></span>
</button>
<div className="product-set-flow-arrow" aria-hidden="true" />
<div className="product-set-card-grid result-reveal">
{setPreviewCards.map((card) => (
<button key={card.id} type="button" onClick={() => openProductSetPreview(card)}>
<img src={card.src} alt={card.label} />
<span>{card.label}</span>
</button>
))}
</div>
</section>
) : (
<section className="product-set-empty-preview" aria-live="polite">
{productSetStatus === "generating" ? <LoadingOutlined /> : <FileImageOutlined />}
<strong>{productSetStatus === "generating" ? "正在生成" : "等待生成"}</strong>
{productSetStatus === "generating" ? <EcommerceProgressBar status="generating" label="商品套图" /> : null}
<span>{productSetStatus === "generating" ? "AI 正在整理主图、场景、细节与卖点图。" : "上传商品原图并填写信息后,AI 将为您生成专业的电商商品图。"}</span>
</section>
)}
{productSetStatus === "done" ? <p className="product-set-generated-note">{selectedProductSetOutput.label}</p> : null}
<section className="product-set-floating-detail" aria-label="淇℃伅璇︽儏">
<div className="product-set-floating-detail__head">
<strong></strong>
<span>{productSetRequirement.length}/500</span>
</div>
<textarea
value={productSetRequirement}
onChange={(event) => setProductSetRequirement(event.target.value)}
maxLength={500}
placeholder="寤鸿鍖呭惈浠ヤ笅淇℃伅锛氫骇鍝佸悕绉般€佹牳蹇冨崠鐐广€佹湡鏈涘満鏅€佸叿浣撳弬鏁?"
/>
<button type="button" className="product-set-floating-submit" disabled={!canGenerateSet} onClick={handleSetGenerate}>
{productSetStatus === "generating" ? <LoadingOutlined /> : null}
{setPrimaryLabel}
</button>
{productSetStatus === "generating" ? (
<button type="button" className="product-set-floating-submit product-set-floating-submit--cancel" onClick={handleCancelGenerate}>
{"\u53d6\u6d88\u751f\u6210"}
</button>
) : null}
</section>
<button type="button" className="product-clone-help" aria-label="甯姪">
<QuestionCircleOutlined />
</button>
</main>
);
const composerSettingLabel =
cloneOutput === "set"
? "套图 " + String(cloneSetTotal) + "张"
: cloneOutput === "detail"
? "详情 " + String(selectedCloneDetailModules.length) + "项"
: cloneOutput === "model"
? cloneModelPanelTab === "scene" ? "场景设置" : "模特设置"
: cloneOutput === "video"
? String(cloneVideoDuration) + "秒 " + (cloneVideoQuality === "standard" ? "720P" : "1080P")
: cloneOutput === "hot"
? cloneReplicateLevel === "style" ? "风格复刻" : "高度复刻"
: "换装素材";
const renderComposerMenu = () => {
const visibleCloneOutputOptions = cloneOutputOptions.filter((option) => option.key !== "video-outfit");
const composerLanguageOptions = Array.from(new Set(marketLanguageOptions.flatMap((option) => option.languages))).map((item) => ({
language: item,
countries: marketLanguageOptions.filter((option) => option.languages.includes(item)).map((option) => option.country),
}));
const composerPopoverStyle: CSSProperties = { left: composerPopoverLeft };
const menuToRender = composerMenu ?? visibleComposerMenu;
if (!menuToRender) return null;
const popoverClosingClass = !composerMenu && isComposerMenuClosing ? " is-closing" : "";
const composerPopoverKey = `${menuToRender}-${cloneOutput}-${popoverClosingClass ? "closing" : "open"}`;
if (menuToRender === "mode") {
return (
<div key={composerPopoverKey} className={`ecom-command-popover ecom-command-popover--grid${popoverClosingClass}`} style={composerPopoverStyle}>
{visibleCloneOutputOptions.map((option) => (
<button key={option.key} type="button" className={cloneOutput === option.key ? "is-active" : ""} onClick={() => { handleCloneOutputChange(option.key); setComposerMenu(null); }}>
{option.label}
</button>
))}
</div>
);
}
if (menuToRender === "platform") {
return (
<div key={composerPopoverKey} className={`ecom-command-popover ecom-command-popover--list ecom-command-popover--ratio ecom-command-popover--platform${popoverClosingClass}`} style={composerPopoverStyle}>
{platformOptions.map((option) => (
<button key={option} type="button" className={platform === option ? "is-active" : ""} onClick={() => { handleClonePlatformChange(option); setComposerMenu(null); }}>
{renderPlatformLogo(option)}
<span className="ecom-platform-name">{option}</span>
</button>
))}
</div>
);
}
if (menuToRender === "language") {
return (
<div key={composerPopoverKey} className={`ecom-command-popover ecom-command-popover--languages${popoverClosingClass}`} style={composerPopoverStyle}>
{composerLanguageOptions.map((option) => (
<button key={option.language} type="button" className={language === option.language ? "is-active" : ""} onClick={() => { setLanguage(option.language); setComposerMenu(null); }}>
<strong>{option.language}</strong>
<span>({option.countries.slice(0, 3).join(" / ")}{option.countries.length > 3 ? " +" + String(option.countries.length - 3) : ""})</span>
</button>
))}
</div>
);
}
if (menuToRender === "ratio") {
return (
<div key={composerPopoverKey} className={`ecom-command-popover ecom-command-popover--list${popoverClosingClass}`} style={composerPopoverStyle}>
{cloneRatioOptions.map((option) => (
<button key={option} type="button" className={ratio === option ? "is-active" : ""} onClick={() => { setRatio(option); setComposerMenu(null); }}>
<span className="ecom-command-button-text">{formatRatioDisplayValue(option)}</span>
</button>
))}
</div>
);
}
return (
<div key={composerPopoverKey} className={`ecom-command-popover ecom-command-popover--settings ecom-command-popover--settings-${cloneOutput}${popoverClosingClass}`} style={composerPopoverStyle}>
{cloneOutput === "set" ? (
<>
<header><strong></strong><span> 1-16 </span></header>
{cloneSetCountOptions.map((item) => (
<div key={item.key} className="ecom-command-count-row">
<span><strong>{item.title}</strong><em>{item.desc}</em></span>
<div>
<button type="button" onClick={() => updateCloneSetCount(item.key, -1)} disabled={cloneSetCounts[item.key] <= 0 || cloneSetTotal <= minCloneSetTotal}>-</button>
<b>{cloneSetCounts[item.key]}</b>
<button type="button" onClick={() => updateCloneSetCount(item.key, 1)} disabled={cloneSetTotal >= maxCloneSetTotal}>+</button>
</div>
</div>
))}
</>
) : cloneOutput === "detail" ? (
<>
<header><strong></strong><span></span></header>
<div className="ecom-command-module-grid ecom-command-module-grid--detail">
{cloneDetailModules.map((module) => (
<button key={module.id} type="button" className={selectedCloneDetailModules.includes(module.id) ? "is-active" : ""} onClick={() => toggleCloneDetailModule(module.id)}>
<strong>{module.title}</strong><span>{module.desc}</span>
</button>
))}
</div>
</>
) : cloneOutput === "model" ? (
<>
<header><strong></strong><span> / </span></header>
<div className="ecom-command-module-grid ecom-command-module-grid--model">
{tryOnScenes.map((scene) => (
<button key={scene} type="button" className={selectedCloneModelScenes.includes(scene) ? "is-active" : ""} onClick={() => toggleCloneModelScene(scene)}>{scene}</button>
))}
</div>
<div className="ecom-command-model-profile">
<strong className="ecom-command-model-profile__title"></strong>
{cloneModelSelects.map((item) => (
<section key={item.key}>
<strong>{item.label}</strong>
<div>
{item.options.map((option) => (
<button key={option} type="button" className={item.value === option ? "is-active" : ""} onClick={() => item.onChange(option)}>{option}</button>
))}
</div>
</section>
))}
</div>
</>
) : cloneOutput === "video" ? (
<>
<header><strong></strong><span>{cloneVideoDuration}</span></header>
<div className="ecom-command-module-grid ecom-command-module-grid--video">
{cloneVideoQualityOptions.map((option) => (
<button key={option.key} type="button" className={cloneVideoQuality === option.key ? "is-active" : ""} onClick={() => setCloneVideoQuality(option.key)}>
<strong>{option.label}</strong><span>{option.desc}</span>
</button>
))}
</div>
<label className="ecom-command-range">
<span> {cloneVideoDuration}</span>
<input type="range" min={cloneVideoDurationMin} max={cloneVideoDurationMax} step={5} value={cloneVideoDuration} onChange={(event) => setCloneVideoDuration(clampCloneVideoDuration(Number(event.target.value)))} />
</label>
</>
) : cloneOutput === "hot" ? (
<>
<header><strong></strong><span>{cloneReferenceImages.length}/{maxCloneReferenceImages}</span></header>
<div className="ecom-command-hot-layout">
<button
type="button"
className={`ecom-command-hot-upload${isCloneReferenceDragging ? " is-dragging" : ""}${cloneReferenceImages.length ? " has-image" : ""}`}
onClick={() => cloneReferenceInputRef.current?.click()}
onDragOver={handleCloneReferenceDragOver}
onDragLeave={handleCloneReferenceDragLeave}
onDrop={handleCloneReferenceDrop}
>
{cloneReferenceImages[0]?.src ? (
<>
<span className="ecom-command-hot-thumb-grid">
{cloneReferenceImages.map((image, index) => (
<span key={image.id} className="ecom-command-hot-thumb">
<img src={image.src} alt={`参考图 ${index + 1}`} />
<span className="ecom-command-hot-zoom" aria-hidden="true">
<img src={image.src} alt="" />
</span>
</span>
))}
</span>
<span> {cloneReferenceImages.length} </span>
</>
) : (
<>
<strong>+ </strong>
<span></span>
</>
)}
</button>
<div className="ecom-command-hot-levels">
{cloneReplicateLevelOptions.map((option) => (
<button key={option.key} type="button" className={cloneReplicateLevel === option.key ? "is-active" : ""} onClick={() => setCloneReplicateLevel(option.key)}>
<strong>{option.title}</strong><span>{option.desc}</span>
</button>
))}
</div>
</div>
</>
) : (
<>
<header><strong></strong><span></span></header>
<p className="ecom-command-popover-note"></p>
</>
)}
</div>
);
};
const toggleComposerMenu = (menuKey: ComposerMenuKey, event: ReactMouseEvent<HTMLButtonElement>) => {
const composerRect = event.currentTarget.closest(".clone-ai-input-wrapper.ecom-command-composer")?.getBoundingClientRect();
const buttonRect = event.currentTarget.getBoundingClientRect();
setComposerPopoverLeft(Math.max(0, buttonRect.left - (composerRect?.left ?? 0)));
setComposerMenu((menu) => (menu === menuKey ? null : menuKey));
};
const commandGenerateDisabled =
cloneOutput === "video"
? !isAuthenticated || (!productImages.length && !requirement.trim())
: !canGenerate;
const handleCommandGenerate = () => {
if (cloneOutput === "video") {
if (!isAuthenticated) {
requestLogin();
return;
}
setVideoPlanTrigger((value) => value + 1);
return;
}
handleGenerate();
};
const clonePreview = (
<main className="product-clone-preview clone-ai-preview" data-status={status} aria-label="电商 AI 作图预览" {...getPreviewSurfaceProps()}>
<header className="clone-ai-preview-header">
<strong></strong>
<span>
AI <b></b>
</span>
<div className="clone-ai-preview-zoom">
<button type="button" onClick={() => setPreviewZoom((z) => Math.max(0.25, z - 0.1))} disabled={previewZoom <= 0.25} aria-label="缩小">-</button>
<span>{Math.round(previewZoom * 100)}%</span>
<button type="button" onClick={() => setPreviewZoom((z) => Math.min(2, z + 0.1))} disabled={previewZoom >= 2} aria-label="放大">+</button>
</div>
</header>
{cloneOutput === "video" ? (
<>
<section className="clone-ai-flow-pipeline" aria-label="生成流程">
{/* Source Node 鈥?鍘熷浘绱犳潗 */}
<div className="clone-ai-flow-source">
<div className="clone-ai-flow-node clone-ai-flow-node--source">
{productImages[0]?.src ? (
<img src={productImages[0].src} alt="商品原图" />
) : (
<div className="clone-ai-flow-node__placeholder">
<FileImageOutlined />
</div>
)}
</div>
<span className="clone-ai-flow-node__label"></span>
</div>
{/* Connector 鈥?鍒嗘敮杩炴帴绾?*/}
<div className="clone-ai-flow-connector" aria-hidden="true">
<div className="clone-ai-flow-connector__trunk" />
<div className="clone-ai-flow-connector__branches">
<div className="clone-ai-flow-connector__branch" />
<div className="clone-ai-flow-connector__branch" />
<div className="clone-ai-flow-connector__branch" />
</div>
</div>
{/* Branches 鈥?鐢熸垚璺緞鍒嗘敮 */}
{status === "done" ? (
<div className="clone-ai-flow-branches">
{results[0]?.src ? (
<div className="clone-ai-flow-branch">
<div className="clone-ai-flow-node clone-ai-flow-node--text">
<div className="clone-ai-flow-node__text-content">
<span className="clone-ai-flow-node__text-title">{selectedCloneOutput.label}</span>
<span className="clone-ai-flow-node__text-desc">{requirement || "AI 智能生成"}</span>
</div>
</div>
<div className="clone-ai-flow-arrow" aria-hidden="true" />
<button
type="button"
className="clone-ai-flow-node clone-ai-flow-node--result"
onClick={() => openProductSetPreview(results[0])}
>
<img src={results[0].src} alt={selectedCloneOutput.label} />
<span className="clone-ai-flow-node__tag">{selectedCloneOutput.label}</span>
</button>
<div className="clone-ai-flow-arrow" aria-hidden="true" />
<div className="clone-ai-flow-node clone-ai-flow-node--video">
<img src={results[0].src} alt="分镜视频" />
<span className="clone-ai-flow-node__tag clone-ai-flow-node__tag--accent"></span>
</div>
</div>
) : null}
</div>
) : (
<div className="clone-ai-flow-branches clone-ai-flow-branches--empty">
{[1, 2, 3].map((branchIndex) => (
<div
key={branchIndex}
className={`clone-ai-flow-branch${status === "generating" ? " is-generating" : ""}${status === "failed" ? " is-failed" : ""}`}
>
<div className="clone-ai-flow-node clone-ai-flow-node--text">
<div className="clone-ai-flow-node__text-content">
<span className="clone-ai-flow-node__text-title">{branchIndex}</span>
<span className="clone-ai-flow-node__text-desc">
{status === "generating" ? "AI 解析中..." : "等待生成"}
</span>
</div>
</div>
<div className="clone-ai-flow-arrow" aria-hidden="true" />
<div className="clone-ai-flow-node clone-ai-flow-node--result">
<div className="clone-ai-flow-node__placeholder">
{status === "generating" ? <LoadingOutlined /> : <FileImageOutlined />}
</div>
<span className="clone-ai-flow-node__tag">{branchIndex}</span>
</div>
<div className="clone-ai-flow-arrow" aria-hidden="true" />
<div className="clone-ai-flow-node clone-ai-flow-node--video">
<div className="clone-ai-flow-node__placeholder">
{status === "generating" ? <LoadingOutlined /> : <FileImageOutlined />}
</div>
<span className="clone-ai-flow-node__tag">{branchIndex}</span>
</div>
</div>
))}
</div>
)}
</section>
{/* Status Overlay 鈥?鐢熸垚鐘舵€佽鐩栧眰 */}
{status === "generating" || status === "failed" ? (
<section className="clone-ai-flow-status" aria-live="polite">
{status === "generating" ? (
<>
<LoadingOutlined style={{ fontSize: 28 }} />
<strong></strong>
<EcommerceProgressBar status="generating" label={`${selectedCloneOutput.label}生成`} />
<span>AI {platform} / {market} {selectedCloneOutput.label}</span>
</>
) : status === "failed" ? (
<>
<FrownOutlined style={{ fontSize: 28 }} />
<strong></strong>
<span></span>
{lastFailedActionRef.current ? (
<button type="button" className="clone-ai-retry-btn" onClick={lastFailedActionRef.current}>
<ReloadOutlined />
</button>
) : null}
</>
) : null}
</section>
) : null}
</>
) : (
<>
{status === "done" ? (
<div className="clone-ai-preview-zoom-wrap" style={previewTransformStyle}>
<section className="clone-ai-preview-showcase" aria-label="生成结果">
<button
type="button"
className="clone-ai-main-result"
style={{ aspectRatio: parseRatioToAspectCss(ratio) }}
onClick={() => {
const sourceImage = productImages[0];
if (sourceImage?.src) openProductSetPreview({ src: sourceImage.src, label: "原图素材" });
}}
>
<img src={productImages[0]?.src ?? (cloneOutput === "set" ? clonePreviewCards[0].src : results[0]?.src ?? "")} alt="上传商品原图" />
<span></span>
</button>
<div className="clone-ai-flow-arrow" aria-hidden="true" />
<div className="clone-ai-result-grid result-reveal">
{cloneOutput === "set" ? (
clonePreviewCards.map((card) => (
<button key={card.id} type="button" style={{ aspectRatio: parseRatioToAspectCss(ratio) }} onClick={() => openProductSetPreview(card)}>
<img src={card.src} alt={card.label} />
<span>{card.label}</span>
</button>
))
) : results[0]?.src ? (
<button type="button" style={{ aspectRatio: parseRatioToAspectCss(ratio) }} onClick={() => openProductSetPreview(results[0])}>
<img src={results[0].src} alt={selectedCloneOutput.label} />
<span>{selectedCloneOutput.label}</span>
</button>
) : null}
</div>
</section>
</div>
) : status === "idle" || status === "ready" ? null : (
<section className="clone-ai-empty-state" aria-live="polite">
{status === "generating" ? <LoadingOutlined /> : status === "failed" ? <FrownOutlined /> : <FileImageOutlined />}
<strong>{status === "generating" ? "正在生成" : status === "failed" ? "生成失败" : "等待生成"}</strong>
{status === "generating" ? <EcommerceProgressBar status="generating" label={`${selectedCloneOutput.label}生成`} /> : null}
<span>
{status === "generating"
? "AI 正在为 " + platform + " / " + market + " 整理" + selectedCloneOutput.label + "。"
: status === "failed"
? "请检查网络后点击下方重试"
: "上传商品原图并填写信息后,AI 将在这里展示生成结果。"}
</span>
{status === "failed" && lastFailedActionRef.current ? (
<button type="button" className="clone-ai-retry-btn" onClick={lastFailedActionRef.current}>
<ReloadOutlined />
</button>
) : null}
</section>
)}
</>
)}
<section
ref={commandComposerWrapRef}
className={`clone-ai-bottom-input ecom-command-composer-wrap${status === "done" ? " has-generated" : " is-before-generate"}${isCommandComposerCompact && status === "done" ? " is-compact" : ""}`}
aria-label="生成指令"
onClick={() => {
if (isCommandComposerCompact) setIsCommandComposerCompact(false);
}}
>
<h1 className={`ecom-command-title${status === "done" ? " is-after-generate" : ""}`}>
{typewriterText}
<span className="typewriter-cursor" aria-hidden="true">|</span>
</h1>
<input
ref={cloneReferenceInputRef}
type="file"
accept="image/*"
multiple
className="ecom-command-hidden-file"
onChange={handleCloneReferenceUpload}
/>
<input
ref={productInputRef}
type="file"
accept="image/*,video/*"
multiple
className="ecom-command-hidden-file"
onChange={handleComposerAssetUpload}
/>
<input
ref={smartCutoutInputRef}
type="file"
accept="image/*"
multiple
className="ecom-command-hidden-file"
onChange={handleSmartCutoutUpload}
/>
<div className="clone-ai-input-wrapper ecom-command-composer">
<button
type="button"
className={`ecom-command-reference${productImages.length || videoOutfitVideoFile ? " has-images" : ""}${isProductUploadDragging ? " is-dragging" : ""}`}
onClick={() => productInputRef.current?.click()}
onDragEnter={(event) => {
event.preventDefault();
setIsProductUploadDragging(true);
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={() => setIsProductUploadDragging(false)}
onDrop={(event) => {
event.preventDefault();
setIsProductUploadDragging(false);
const files = Array.from(event.dataTransfer.files);
if (files.length) addComposerAssets(files);
}}
>
<span aria-hidden="true"><CloudUploadOutlined /></span>
<strong></strong>
</button>
{productImages.length || videoOutfitVideoFile ? (
<div className="ecom-command-asset-popover" aria-label="宸蹭笂浼犵礌鏉?">
{productImages.map((image) => (
<figure key={image.id} className="ecom-command-asset-thumb">
<img src={image.src} alt={image.name || "上传图片"} />
<span className="ecom-command-asset-zoom" aria-hidden="true">
<img src={image.src} alt="" />
</span>
<button type="button" onClick={() => removeProductImage(image.id)} aria-label="删除图片">
<DeleteOutlined />
</button>
</figure>
))}
{videoOutfitVideoFile && videoOutfitPreviewUrl ? (
<figure className="ecom-command-asset-thumb ecom-command-asset-thumb--video">
<video src={videoOutfitPreviewUrl} muted playsInline />
<span className="ecom-command-asset-zoom" aria-hidden="true">
<video src={videoOutfitPreviewUrl} muted playsInline />
</span>
<button type="button" onClick={() => setVideoOutfitVideoFile(null)} aria-label="删除视频">
<DeleteOutlined />
</button>
</figure>
) : null}
<button type="button" className="ecom-command-asset-add" onClick={() => productInputRef.current?.click()} aria-label="继续上传">+</button>
</div>
) : null}
<textarea
ref={requirementTextareaRef}
value={requirement}
onChange={(event) => {
const nextValue = event.target.value.slice(0, 500);
setRequirement(nextValue);
syncRequirementMentionQuery(nextValue, event.target.selectionStart);
}}
onClick={(event) => syncRequirementMentionQuery(requirement, event.currentTarget.selectionStart)}
onKeyUp={(event) => syncRequirementMentionQuery(requirement, event.currentTarget.selectionStart)}
onKeyDown={(event) => {
if (event.key === "Escape") setRequirementImageMentionQuery(null);
}}
maxLength={500}
placeholder={cloneRequirementPlaceholder}
/>
{requirementImageMentionQuery !== null && ecommerceMentionImages.length ? (
<ImageMentionMenu images={ecommerceMentionImages} query={requirementImageMentionQuery} onSelect={insertRequirementImageMention} />
) : null}
<div className="ecom-command-toolbar" aria-label="生成设置">
<div className="ecom-command-option-row">
<button
type="button"
className={`ecom-command-reference ecom-command-reference--inline${productImages.length || videoOutfitVideoFile ? " has-images" : ""}${isProductUploadDragging ? " is-dragging" : ""}`}
onClick={() => productInputRef.current?.click()}
onDragEnter={(event) => {
event.preventDefault();
setIsProductUploadDragging(true);
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={() => setIsProductUploadDragging(false)}
onDrop={(event) => {
event.preventDefault();
setIsProductUploadDragging(false);
const files = Array.from(event.dataTransfer.files);
if (files.length) addComposerAssets(files);
}}
>
<span aria-hidden="true"><CloudUploadOutlined /></span>
<strong></strong>
</button>
<button type="button" className={composerMenu === "mode" ? "is-active" : ""} onClick={(event) => toggleComposerMenu("mode", event)}>
<span className="ecom-command-option-icon" aria-hidden="true"><AppstoreOutlined /></span>
{selectedCloneOutput.label}<span></span>
</button>
<button type="button" className={composerMenu === "platform" ? "is-active" : ""} onClick={(event) => toggleComposerMenu("platform", event)}>
<span className="ecom-command-option-icon" aria-hidden="true"><GlobalOutlined /></span>
<span></span>{platform}
</button>
<button type="button" className={composerMenu === "language" ? "is-active" : ""} onClick={(event) => toggleComposerMenu("language", event)}>
<span className="ecom-command-option-icon" aria-hidden="true"><FileImageOutlined /></span>
<span></span>{language}
</button>
<button type="button" className={composerMenu === "ratio" ? "is-active" : ""} onClick={(event) => toggleComposerMenu("ratio", event)}>
<span className="ecom-command-option-icon" aria-hidden="true"><TableOutlined /></span>
<span></span>{formatRatioDisplayValue(ratio)}
</button>
<button type="button" className={composerMenu === "settings" ? "is-active" : ""} onClick={(event) => toggleComposerMenu("settings", event)}>
<span className="ecom-command-option-icon" aria-hidden="true"><SettingOutlined /></span>
<span></span>{composerSettingLabel}
</button>
</div>
<div className="ecom-command-submit-row">
<button type="button" className="clone-ai-send-button ecom-command-send" disabled={commandGenerateDisabled} onClick={handleCommandGenerate} aria-label={clonePrimaryLabel}>
{status === "generating" ? <LoadingOutlined /> : "➤"}
</button>
</div>
</div>
{renderComposerMenu()}
</div>
{status !== "done" ? (
<section className="ecom-command-quick-board" aria-label="快捷功能">
{[
{ label: "智能抠图", icon: <FileImageOutlined />, onClick: openSmartCutoutUpload },
{ label: "商品套图", icon: <AppstoreOutlined />, onClick: openQuickProductSetPage },
{ label: "图片修改", icon: <SettingOutlined />, onClick: openImageWorkbenchPage },
{ label: "增/去水印", icon: <FileImageOutlined />, onClick: openWatermarkRemovalPage },
{ label: "图片批处理", icon: <FolderOpenOutlined /> },
{ label: "一键翻译", icon: <FileImageOutlined /> },
{ label: "A+/详情页", icon: <FileImageOutlined />, onClick: openQuickDetailPage },
{ label: "变清晰", icon: <FileImageOutlined /> },
{ label: "AI消除", icon: <SettingOutlined /> },
{ label: "证件照", icon: <SkinOutlined /> },
{ label: "爆款视频", icon: <CloudUploadOutlined />, onClick: openHotVideoPage },
{ label: "拼图", icon: <TableOutlined /> },
].map((item) => (
<button
key={item.label}
type="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
item.onClick?.();
}}
>
<span aria-hidden="true">{item.icon}</span>
<strong>{item.label}</strong>
</button>
))}
</section>
) : null}
<span className="clone-ai-char-count">{requirement.length}/500</span>
</section>
</main>
);
const smartCutoutPreview = (
<main className={`ecom-smart-cutout-page${smartCutoutImage ? " is-editor" : " is-upload"}${isSmartCutoutTransitioning ? " is-transitioning" : ""}`} aria-label="智能抠图">
<input
ref={smartCutoutInputRef}
type="file"
accept="image/*"
multiple
className="ecom-command-hidden-file"
onChange={handleSmartCutoutUpload}
/>
<nav className="ecom-smart-cutout-nav" aria-label="智能抠图导航">
<button type="button" onClick={closeSmartCutoutTool}>
</button>
<button type="button" onClick={goSmartCutoutPrevious}>
</button>
</nav>
{isSmartCutoutTransitioning ? (
<div className="ecom-smart-cutout-transition" role="status" aria-live="polite">
<span aria-hidden="true" />
<strong>{smartCutoutTransitionMessage.title}</strong>
<em>{smartCutoutTransitionMessage.subtitle}</em>
</div>
) : null}
{!smartCutoutImage ? (
<section className="ecom-smart-cutout-upload">
<div className="ecom-smart-cutout-head">
<strong></strong>
<span>3s </span>
</div>
<div className="ecom-smart-cutout-upload__body">
<div className="ecom-smart-cutout-demo" aria-hidden="true">
<div className="ecom-smart-cutout-demo__tile ecom-smart-cutout-demo__tile--flower" />
<div className="ecom-smart-cutout-demo__tile ecom-smart-cutout-demo__tile--product" />
<div className="ecom-smart-cutout-demo__tile ecom-smart-cutout-demo__tile--poster" />
<div className="ecom-smart-cutout-demo__tile ecom-smart-cutout-demo__tile--object" />
</div>
<div
className={`ecom-smart-cutout-upload-box${isSmartCutoutDragging ? " is-dragging" : ""}`}
role="button"
tabIndex={0}
onClick={() => smartCutoutInputRef.current?.click()}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
smartCutoutInputRef.current?.click();
}
}}
onDragEnter={(event) => {
event.preventDefault();
setIsSmartCutoutDragging(true);
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={() => setIsSmartCutoutDragging(false)}
onDrop={handleSmartCutoutDrop}
>
<button type="button" className="ecom-smart-cutout-upload__primary">
<CloudUploadOutlined />
</button>
<button type="button" className="ecom-smart-cutout-upload__secondary">
<FolderOpenOutlined />
</button>
<span> &gt;</span>
</div>
</div>
</section>
) : (
<section className="ecom-smart-editor">
<div className="ecom-smart-editor__workspace">
<div className="ecom-smart-editor__canvas">
<div className={`ecom-smart-editor__checker is-size-${previewSmartCutoutSizeKey}`} style={smartCutoutFrameStyle}>
<div className="ecom-smart-editor__background-layer" aria-hidden="true" />
<img src={previewSmartCutoutImageSrc ?? smartCutoutImage.src} alt={smartCutoutImage.name} />
</div>
<div className="ecom-smart-editor__canvas-actions">
<button
type="button"
onPointerDown={showSmartCutoutOriginalCompare}
onPointerUp={hideSmartCutoutOriginalCompare}
onPointerCancel={hideSmartCutoutOriginalCompare}
onBlur={hideSmartCutoutOriginalCompare}
onKeyDown={(event) => {
if (event.key === " " || event.key === "Enter") setIsSmartCutoutComparing(true);
}}
onKeyUp={hideSmartCutoutOriginalCompare}
>
</button>
</div>
</div>
<div className="ecom-smart-editor__tools-shell">
<strong className="ecom-smart-editor__tools-title"></strong>
<button type="button" className="ecom-smart-editor__tools-nav" onClick={() => scrollSmartCutoutTools(-1)} aria-label="查看上一组尺寸">
</button>
<div className="ecom-smart-editor__tools" ref={smartCutoutToolsRef}>
{smartCutoutSizeOptions.map((item) => (
<div className="ecom-smart-editor__tool-item" key={item.key}>
<button
type="button"
className={smartCutoutSizeKey === item.key ? "is-active" : ""}
onClick={() => setSmartCutoutSizeKey(item.key)}
aria-label={`${item.label}${"sizeLabel" in item ? ` ${item.sizeLabel}` : ""}`}
aria-pressed={smartCutoutSizeKey === item.key}
>
<span className={`ecom-smart-editor__tool-icon ecom-smart-editor__tool-icon--${item.icon}`} aria-hidden="true" />
</button>
<span className="ecom-smart-editor__tool-text">
<span>{item.label}</span>
{"sizeLabel" in item ? <span>{item.sizeLabel}</span> : null}
</span>
</div>
))}
</div>
<button type="button" className="ecom-smart-editor__tools-nav" onClick={() => scrollSmartCutoutTools(1)} aria-label="查看更多尺寸">
</button>
</div>
{smartCutoutBatchImages.length > 1 ? (
<section className="ecom-smart-editor__batch" aria-label="批量图片">
<header>
<strong></strong>
<span>{smartCutoutBatchImages.length} </span>
</header>
<div>
{smartCutoutBatchImages.map((image, index) => (
<button
key={`${image.src}-${index}`}
type="button"
className={smartCutoutImage.src === image.src ? "is-active" : ""}
onClick={() => setSmartCutoutImage(image)}
>
<img src={image.src} alt={image.name || `上传图片 ${index + 1}`} />
<span>{index + 1}</span>
</button>
))}
</div>
</section>
) : null}
<section className="ecom-smart-editor__gallery">
<header><strong></strong><button type="button"> &gt;</button></header>
<div className="ecom-smart-editor__swatches">
{["#ffffff", "#eeeae3", "#f2e3cf", "#000000", "#a89682", "#c9c9c9"].map((color) => (
<button
key={color}
type="button"
className={smartCutoutBackgroundColor.toLowerCase() === color ? "is-active" : ""}
style={{ "--smart-cutout-swatch-bg": color } as CSSProperties}
onClick={() => {
setSmartCutoutBackgroundColor(color);
setSmartCutoutBackgroundAlpha(100);
}}
>
<span className="ecom-smart-editor__swatch-bg" aria-hidden="true" />
<img src={smartCutoutImage.src} alt="" />
</button>
))}
</div>
<header><strong>AI换背景</strong><button type="button"> &gt;</button></header>
<div className="ecom-smart-editor__scenes">
<button type="button" className="ecom-smart-editor__generate"><SettingOutlined /></button>
{["客厅陈列", "桌面日光", "香氛产品", "绿植窗边", "居家空间"].map((item) => (
<button key={item} type="button"><span>{item}</span></button>
))}
</div>
</section>
</div>
<aside className="ecom-smart-editor__side">
<strong></strong>
<div className="ecom-smart-editor__color-row">
<div className="ecom-smart-editor__color-wrap" ref={smartCutoutPaletteRef} style={{ "--smart-cutout-bg": smartCutoutBackgroundValue } as CSSProperties}>
<button
type="button"
className={`ecom-smart-editor__custom-color${isSmartCutoutPaletteOpen ? " is-active" : ""}`}
style={{ background: smartCutoutBackgroundColor }}
onClick={() => setIsSmartCutoutPaletteOpen((open) => !open)}
aria-label="打开背景调色盘"
>
<span></span>
</button>
{isSmartCutoutPaletteOpen ? (
<div className="ecom-smart-color-picker" role="dialog" aria-label="背景调色盘">
<button
type="button"
className="ecom-smart-color-picker__plane"
style={{ background: `linear-gradient(to top, #000000, transparent), linear-gradient(to right, #ffffff, hsl(${smartCutoutColorHsv.h} 100% 50%))` }}
onPointerDown={handleSmartCutoutColorPlanePointer}
onPointerMove={handleSmartCutoutColorPlaneMove}
aria-label="选择颜色明度和饱和度"
>
<span style={{ left: `${smartCutoutColorHsv.s}%`, top: `${100 - smartCutoutColorHsv.v}%` }} />
</button>
<div className="ecom-smart-color-picker__slider ecom-smart-color-picker__slider--hue">
<span style={{ background: `hsl(${smartCutoutColorHsv.h} 100% 50%)` }} />
<input
type="range"
min={0}
max={360}
value={smartCutoutColorHsv.h}
onChange={(event) => applySmartCutoutHsv(Number(event.target.value), smartCutoutColorHsv.s, smartCutoutColorHsv.v)}
aria-label="色相"
/>
</div>
<div className="ecom-smart-color-picker__slider ecom-smart-color-picker__slider--alpha">
<span style={{ background: smartCutoutBackgroundValue }} />
<input
type="range"
min={0}
max={100}
value={smartCutoutBackgroundAlpha}
onChange={(event) => setSmartCutoutBackgroundAlpha(Number(event.target.value))}
aria-label="透明度"
/>
</div>
<div className="ecom-smart-color-picker__fields">
<span aria-hidden="true"></span>
<input
value={smartCutoutHexDraft}
onChange={(event) => handleSmartCutoutHexChange(event.target.value)}
onBlur={() => setSmartCutoutHexDraft(smartCutoutBackgroundColor)}
aria-label="HEX 色值"
/>
<input
value={Math.round(smartCutoutBackgroundAlpha)}
onChange={(event) => setSmartCutoutBackgroundAlpha(clampNumber(Number(event.target.value) || 0, 0, 100))}
aria-label="透明度百分比"
/>
<strong>%</strong>
</div>
<p></p>
<div className="ecom-smart-color-picker__presets">
{smartCutoutColorPresets.map((color) => (
<button
key={color}
type="button"
className={smartCutoutBackgroundColor.toLowerCase() === color ? "is-active" : ""}
style={{ background: color }}
onClick={() => {
setSmartCutoutBackgroundColor(color);
setSmartCutoutBackgroundAlpha(100);
}}
aria-label={`选择颜色 ${color}`}
/>
))}
</div>
</div>
) : null}
</div>
{["#ffffff", "#f8f9fa", "#000000", "#bdbdbd"].map((color) => (
<button
key={color}
type="button"
className={smartCutoutBackgroundColor.toLowerCase() === color ? "is-active" : ""}
style={{ background: color }}
onClick={() => {
setSmartCutoutBackgroundColor(color);
setSmartCutoutBackgroundAlpha(100);
}}
aria-label={`背景颜色 ${color}`}
/>
))}
</div>
<div className="ecom-smart-editor__side-actions">
<button type="button" className="ecom-smart-editor__download" onClick={handleSmartCutoutDownload}></button>
<button type="button" onClick={() => smartCutoutInputRef.current?.click()}></button>
</div>
</aside>
</section>
)}
</main>
);
const imageWorkbenchPreview = (
<main className="ecom-image-workbench-page" aria-label="图片修改局部重绘">
<input
ref={imageWorkbenchInputRef}
type="file"
accept="image/*"
className="ecom-command-hidden-file"
onChange={handleImageWorkbenchUpload}
/>
<nav className="ecom-image-workbench-nav" aria-label="图片修改导航">
<button type="button" onClick={closeImageWorkbenchPage}></button>
<button type="button" onClick={closeImageWorkbenchPage}></button>
</nav>
<aside className="ecom-image-workbench-side">
<div className="ecom-image-workbench-heading">
<span></span>
<strong></strong>
<p> AI </p>
</div>
<section className="ecom-image-workbench-panel">
<header>
<strong></strong>
<span>{imageWorkbenchImage ? "已上传" : "待上传"}</span>
</header>
<div
className={`ecom-image-workbench-upload${isImageWorkbenchDragging ? " is-dragging" : ""}${imageWorkbenchImage ? " has-image" : ""}`}
role="button"
tabIndex={0}
onClick={() => imageWorkbenchInputRef.current?.click()}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
imageWorkbenchInputRef.current?.click();
}
}}
onDragEnter={(event) => {
event.preventDefault();
setIsImageWorkbenchDragging(true);
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={() => setIsImageWorkbenchDragging(false)}
onDrop={handleImageWorkbenchDrop}
>
{imageWorkbenchImage ? (
<>
<button
type="button"
className="ecom-image-workbench-remove"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
removeImageWorkbenchImage();
}}
aria-label="删除素材"
>
×
</button>
<figure>
<img src={imageWorkbenchImage.src} alt={imageWorkbenchImage.name} />
</figure>
<div>
<strong>{imageWorkbenchImage.name}</strong>
<span>{imageWorkbenchImage.format || "PNG / JPG / WebP"}</span>
</div>
</>
) : (
<>
<FileImageOutlined />
<strong></strong>
<span>PNG / JPG / WebP</span>
</>
)}
</div>
<div className="ecom-image-workbench-url-row">
<input placeholder="粘贴图片 URL" aria-label="粘贴图片 URL" />
<button type="button" onClick={() => toast.info("请先使用本地上传")}></button>
</div>
</section>
<section className="ecom-image-workbench-panel">
<header>
<strong></strong>
<span>{imageWorkbenchMaskStrokes.length ? `已遮罩 ${imageWorkbenchMaskStrokes.length}` : "未遮罩"}</span>
</header>
<label className="ecom-image-workbench-slider">
<span></span>
<input
type="range"
min={10}
max={120}
value={imageWorkbenchBrushSize}
onChange={(event) => setImageWorkbenchBrushSize(Number(event.target.value))}
/>
<em>{imageWorkbenchBrushSize}px</em>
</label>
<button
type="button"
className="ecom-image-workbench-clear"
onClick={() => {
setImageWorkbenchMaskStrokes([]);
setImageWorkbenchBrushCursor(null);
clearImageWorkbenchMaskCanvas();
setImageWorkbenchStatus("idle");
}}
disabled={!imageWorkbenchMaskStrokes.length}
>
</button>
</section>
<section className="ecom-image-workbench-panel">
<strong></strong>
<div className="ecom-image-workbench-ratios" aria-label="输出分辨率">
{["9:16", "16:9", "4:3", "3:4", "1:1"].map((item) => (
<button
key={item}
type="button"
className={imageWorkbenchRatio === item ? "is-active" : ""}
onClick={() => setImageWorkbenchRatio(item)}
>
{item}
</button>
))}
</div>
</section>
<section className="ecom-image-workbench-panel">
<strong></strong>
<textarea
value={imageWorkbenchPrompt}
onChange={(event) => setImageWorkbenchPrompt(event.target.value.slice(0, 300))}
placeholder="描述需要重绘的内容,例如:将背景替换为森林"
/>
</section>
<button
type="button"
className="ecom-image-workbench-primary"
onClick={handleImageWorkbenchGenerate}
disabled={!imageWorkbenchImage || imageWorkbenchStatus === "processing"}
>
{imageWorkbenchStatus === "processing" ? <LoadingOutlined /> : <SettingOutlined />}
{imageWorkbenchStatus === "processing" ? "重绘中" : "开始重绘"}
</button>
</aside>
<section className="ecom-image-workbench-stage">
<div
className={`ecom-image-workbench-canvas${isImageWorkbenchDragging ? " is-dragging" : ""}${imageWorkbenchImage ? " has-image" : ""}`}
role="button"
tabIndex={0}
onClick={() => {
if (!imageWorkbenchImage) imageWorkbenchInputRef.current?.click();
}}
onKeyDown={(event) => {
if (!imageWorkbenchImage && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
imageWorkbenchInputRef.current?.click();
}
}}
onDragEnter={(event) => {
event.preventDefault();
setIsImageWorkbenchDragging(true);
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={() => setIsImageWorkbenchDragging(false)}
onDrop={handleImageWorkbenchDrop}
>
{imageWorkbenchImage ? (
<div className="ecom-image-workbench-preview">
<div
className="ecom-image-workbench-image-frame"
onPointerDown={handleImageWorkbenchMaskPointerDown}
onPointerMove={handleImageWorkbenchMaskPointerMove}
onPointerUp={stopImageWorkbenchMaskPainting}
onPointerCancel={stopImageWorkbenchMaskPainting}
onPointerLeave={() => {
stopImageWorkbenchMaskPainting();
setImageWorkbenchBrushCursor(null);
}}
>
<img src={imageWorkbenchImage.src} alt="局部重绘素材" draggable={false} />
<canvas ref={imageWorkbenchMaskCanvasRef} className="ecom-image-workbench-mask-layer" aria-label="遮罩区域" />
<span
className="ecom-image-workbench-brush"
style={{
left: `${imageWorkbenchBrushCursor?.x ?? 50}%`,
top: `${imageWorkbenchBrushCursor?.y ?? 50}%`,
width: imageWorkbenchBrushSize,
height: imageWorkbenchBrushSize,
}}
/>
</div>
<em>{imageWorkbenchStatus === "done" ? "重绘预览" : imageWorkbenchStatus === "processing" ? "正在生成重绘结果" : "按住鼠标涂抹需要修改的区域"}</em>
</div>
) : (
<div className="ecom-image-workbench-empty">
<FileImageOutlined />
<strong></strong>
<span> PNG / JPG / WebP使</span>
</div>
)}
</div>
</section>
</main>
);
const watermarkPreview = (
<main className="ecom-watermark-page" aria-label="去水印">
<input
ref={watermarkInputRef}
type="file"
accept="image/*"
className="ecom-command-hidden-file"
onChange={handleWatermarkUpload}
/>
<nav className="ecom-watermark-nav" aria-label="去水印导航">
<button type="button" onClick={closeWatermarkRemovalPage}></button>
<button type="button" onClick={closeWatermarkRemovalPage}></button>
</nav>
<aside className="ecom-watermark-side">
<div className="ecom-watermark-heading">
<span>AI </span>
<strong>/</strong>
<p></p>
</div>
<section className="ecom-watermark-panel">
<header>
<strong></strong>
<span>{watermarkImage ? "已上传" : "待上传"}</span>
</header>
<div
className={`ecom-watermark-upload-card${isWatermarkDragging ? " is-dragging" : ""}${watermarkImage ? " has-image" : ""}`}
role="button"
tabIndex={0}
onClick={() => watermarkInputRef.current?.click()}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
watermarkInputRef.current?.click();
}
}}
onDragEnter={(event) => {
event.preventDefault();
setIsWatermarkDragging(true);
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={() => setIsWatermarkDragging(false)}
onDrop={handleWatermarkDrop}
>
{watermarkImage ? (
<>
<button
type="button"
className="ecom-watermark-remove"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
removeWatermarkImage();
}}
aria-label="删除素材"
>
×
</button>
<figure>
<img src={watermarkImage.src} alt={watermarkImage.name} />
</figure>
<div>
<strong>{watermarkImage.name}</strong>
<span>{watermarkImage.format || "PNG / JPG / WebP"}</span>
</div>
</>
) : (
<>
<CloudUploadOutlined />
<strong></strong>
<span> PNG / JPG / WebP</span>
</>
)}
</div>
<div className="ecom-watermark-url-row">
<input placeholder="粘贴图片 URL" aria-label="粘贴图片 URL" />
<button type="button" onClick={() => toast.info("请先使用本地上传")}></button>
</div>
</section>
<section className="ecom-watermark-panel">
<strong></strong>
<p></p>
</section>
<button
type="button"
className="ecom-watermark-primary"
onClick={handleWatermarkGenerate}
disabled={!watermarkImage || watermarkStatus === "processing"}
>
{watermarkStatus === "processing" ? <LoadingOutlined /> : <FileImageOutlined />}
{watermarkStatus === "processing" ? "处理中" : "开始去水印"}
</button>
</aside>
<section className="ecom-watermark-workspace">
{!watermarkImage ? (
<div
className={`ecom-watermark-dropzone${isWatermarkDragging ? " is-dragging" : ""}`}
role="button"
tabIndex={0}
onClick={() => watermarkInputRef.current?.click()}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
watermarkInputRef.current?.click();
}
}}
onDragEnter={(event) => {
event.preventDefault();
setIsWatermarkDragging(true);
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={() => setIsWatermarkDragging(false)}
onDrop={handleWatermarkDrop}
>
<CloudUploadOutlined />
<strong></strong>
<span> PNG / JPG / WebP</span>
</div>
) : (
<div className="ecom-watermark-grid">
<article className="ecom-watermark-preview-card">
<span></span>
<img src={watermarkImage.src} alt="原图" />
</article>
<article className="ecom-watermark-preview-card">
<span></span>
{watermarkStatus === "processing" ? (
<div className="ecom-watermark-processing" role="status" aria-live="polite">
<LoadingOutlined />
<strong></strong>
<em>AI </em>
</div>
) : watermarkStatus === "done" ? (
<>
<img src={watermarkImage.src} alt="去水印结果" />
<button type="button" className="ecom-watermark-zoom" aria-label="查看大图">
<QuestionCircleOutlined />
</button>
</>
) : (
<div className="ecom-watermark-empty">
<FileImageOutlined />
<strong></strong>
<em></em>
</div>
)}
<div className="ecom-watermark-actions">
<button type="button" onClick={() => toast.success("已加入资产库")} disabled={watermarkStatus !== "done"}>
<FolderOpenOutlined />
</button>
<button type="button" onClick={handleWatermarkDownload} disabled={watermarkStatus !== "done"}>
<CloudUploadOutlined />
</button>
</div>
</article>
</div>
)}
</section>
</main>
);
const quickSetVisibleSelect = quickSetBasicSelects.find((item) => item.key === visibleQuickSetSelect) ?? null;
const openQuickUploadWithKeyboard = (
event: ReactKeyboardEvent<HTMLDivElement>,
inputRef: { current: HTMLInputElement | null },
) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
inputRef.current?.click();
};
const renderQuickUploadThumbs = (items: CloneImageItem[], onRemove: (imageId: string) => void) => (
<div className="ecom-quick-upload-thumbs" aria-label="已上传商品原图">
{items.map((item) => (
<figure key={item.id} className="ecom-command-asset-thumb ecom-quick-upload-thumb">
<img src={item.src} alt={item.name} />
<span className="ecom-command-asset-zoom" aria-hidden="true">
<img src={item.src} alt="" />
</span>
<button
type="button"
aria-label="删除图片"
onClick={(event) => {
event.stopPropagation();
onRemove(item.id);
}}
>
×
</button>
</figure>
))}
</div>
);
const hotVideoPreview = (
<main className="ecom-hot-video-page" aria-label="爆款视频">
<nav className="ecom-hot-video-nav">
<button type="button" className="ecom-hot-video-back" onClick={closeHotVideoPage}>
</button>
<div className="ecom-hot-video-nav-title">
<h1></h1>
<span>AI智能策划 · </span>
</div>
<div className="ecom-hot-video-nav-meta">
<span>{platform} / {formatRatioDisplayValue(ratio)} / {cloneVideoDuration} / {cloneVideoQuality === "standard" ? "720P" : "1080P"}</span>
</div>
</nav>
<div className="ecom-hot-video-body">
<aside className="ecom-hot-video-settings" aria-label="视频设置">
<section className="ecom-hot-video-section">
<strong></strong>
<div
role="button"
tabIndex={0}
className={`ecom-hot-video-upload${productImages.length ? " has-images" : ""}${isProductUploadDragging ? " is-dragging" : ""}`}
onClick={() => quickProductInputRef.current?.click()}
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") quickProductInputRef.current?.click(); }}
onDragOver={(event) => { event.preventDefault(); setIsProductUploadDragging(true); }}
onDragLeave={(event) => { event.preventDefault(); setIsProductUploadDragging(false); }}
onDrop={handleProductDrop}
>
<CloudUploadOutlined />
<span></span>
<em> JPG / PNG / WebP</em>
{productImages.length > 0 ? (
<div className="ecom-hot-video-upload-thumbs">
{productImages.map((img) => (
<figure key={img.id}>
<img src={img.src} alt={img.name} />
<button
type="button"
aria-label="删除"
onClick={(event) => { event.stopPropagation(); removeProductImage(img.id); }}
>×</button>
</figure>
))}
</div>
) : null}
</div>
<input
ref={quickProductInputRef}
type="file"
accept="image/*"
multiple
className="ecom-command-hidden-file"
onChange={handleProductUpload}
/>
</section>
<section className="ecom-hot-video-section">
<strong></strong>
<textarea
className="ecom-hot-video-textarea"
placeholder="描述你的商品特点、卖点、使用场景等,帮助AI更好地策划视频内容..."
rows={4}
value={requirement}
onChange={(event) => setRequirement(event.target.value)}
maxLength={500}
/>
</section>
<section className="ecom-hot-video-section">
<strong></strong>
<div className="ecom-hot-video-options">
{platformOptions.map((option) => (
<button
key={option}
type="button"
className={platform === option ? "is-active" : ""}
onClick={() => setPlatform(option)}
>
{renderPlatformLogo(option)}
<span>{option}</span>
</button>
))}
</div>
</section>
<section className="ecom-hot-video-section">
<strong></strong>
<div className="ecom-hot-video-options ecom-hot-video-options--ratio">
{cloneRatioOptions.map((option) => (
<button
key={option}
type="button"
className={ratio === option ? "is-active" : ""}
onClick={() => setRatio(option)}
>
{formatRatioDisplayValue(option)}
</button>
))}
</div>
</section>
<section className="ecom-hot-video-section">
<strong></strong>
<div className="ecom-hot-video-options">
{cloneVideoQualityOptions.map((option) => (
<button
key={option.key}
type="button"
className={cloneVideoQuality === option.key ? "is-active" : ""}
onClick={() => setCloneVideoQuality(option.key)}
>
<strong>{option.label}</strong>
<span>{option.desc}</span>
</button>
))}
</div>
</section>
<section className="ecom-hot-video-section">
<strong> · {cloneVideoDuration}</strong>
<input
type="range"
className="ecom-hot-video-range"
min={cloneVideoDurationMin}
max={cloneVideoDurationMax}
step={5}
value={cloneVideoDuration}
onChange={(event) => setCloneVideoDuration(clampCloneVideoDuration(Number(event.target.value)))}
style={cloneVideoDurationStyle}
/>
<div className="ecom-hot-video-range-labels">
<span>{cloneVideoDurationMin}</span>
<span>{cloneVideoDurationMax}</span>
</div>
</section>
<button
type="button"
className="ecom-hot-video-start"
disabled={!productImages.length && !requirement.trim()}
onClick={() => setVideoPlanTrigger((prev) => prev + 1)}
>
</button>
</aside>
<section className="ecom-hot-video-workspace">
<EcommerceVideoWorkspace
isAuthenticated={isAuthenticated}
productImageDataUrls={ecommerceVideoImageDataUrls}
productImageFiles={ecommerceVideoImageFiles}
requirement={requirement}
platform={platform}
aspectRatio={ratio.includes("916") || ratio.includes("9:16") ? "9:16" : ratio.includes("169") || ratio.includes("16:9") ? "16:9" : ratio.includes("34") || ratio.includes("3:4") ? "3:4" : "9:16"}
durationSeconds={cloneVideoDuration}
resolution={cloneVideoQuality === "standard" ? "720P" : "1080P"}
onRequestLogin={() => (isAuthenticated ? undefined : requestLogin())}
onOpenHistory={() => setVideoHistoryVisible(true)}
triggerPlan={videoPlanTrigger}
/>
</section>
</div>
</main>
);
const quickProductSetPreview = (
<main className={`ecom-quick-set-page${isQuickPanelCollapsed ? " is-panel-collapsed" : ""}`} aria-label="AI商品套图">
<div className="ecom-quick-set-body">
<aside className="ecom-quick-set-panel" aria-label="商品套图设置" onWheel={handleQuickPanelWheel}>
<header className="ecom-quick-set-panel-head">
<button type="button" className="ecom-quick-set-back" onClick={() => setActiveQuickTool(null)}>
</button>
<button type="button" className="ecom-quick-set-back" onClick={() => setActiveQuickTool(null)}>
</button>
</header>
<section>
<strong><CloudUploadOutlined /> </strong>
<div
role="button"
tabIndex={0}
className={`ecom-quick-set-upload${productImages.length ? " has-images" : ""}`}
onClick={() => quickProductInputRef.current?.click()}
onKeyDown={(event) => openQuickUploadWithKeyboard(event, quickProductInputRef)}
onDragOver={(event) => event.preventDefault()}
onDrop={handleQuickProductDrop}
>
<FileImageOutlined />
<span></span>
<em> 7 </em>
<b>+ </b>
{productImages.length ? renderQuickUploadThumbs(productImages, removeProductImage) : null}
</div>
<input
ref={quickProductInputRef}
type="file"
accept="image/*"
multiple
className="ecom-command-hidden-file"
onChange={handleProductUpload}
/>
</section>
<section className="ecom-quick-set-basic-section">
<span className="ecom-quick-set-label"></span>
<div className="ecom-quick-set-select-anchor">
<div className="ecom-quick-set-selects">
{quickSetBasicSelects.map((item) => (
<button
key={item.key}
type="button"
className={openQuickSetSelect === item.key ? "is-active" : ""}
onClick={() => toggleQuickSetSelect(item.key)}
>
<span>{item.label}</span><strong>{item.value}</strong><em></em>
</button>
))}
</div>
{quickSetVisibleSelect ? (
<div
className={`ecom-quick-set-dropdown ecom-quick-set-dropdown--${quickSetVisibleSelect.key}${isQuickSetSelectClosing ? " is-closing" : ""}`}
role="listbox"
aria-label={quickSetVisibleSelect.label}
>
{quickSetVisibleSelect.options.map((option) => (
<button
key={option}
type="button"
className={quickSetVisibleSelect.value === option ? "is-active" : ""}
onClick={() => {
quickSetVisibleSelect.onChange(option);
closeQuickSetSelect();
}}
>
{option}
</button>
))}
</div>
) : null}
</div>
</section>
<section>
<strong> 1-16 </strong>
<div className="ecom-quick-set-counts">
{cloneSetCountOptions.map((item) => (
<article key={item.key}>
<div>
<strong>{item.title}</strong>
<span>{item.desc}</span>
</div>
<p>
<button type="button" onClick={() => updateCloneSetCount(item.key, -1)} disabled={cloneSetCounts[item.key] <= 0 || cloneSetTotal <= minCloneSetTotal}>-</button>
<b>{cloneSetCounts[item.key]}</b>
<button type="button" onClick={() => updateCloneSetCount(item.key, 1)} disabled={cloneSetTotal >= maxCloneSetTotal}>+</button>
</p>
</article>
))}
</div>
</section>
<button type="button" className="ecom-quick-set-primary" onClick={handleGenerate} disabled={!canGenerate}>
</button>
</aside>
<section className="ecom-quick-set-stage">
<header className="ecom-quick-set-preview-head">
<h1></h1>
<p>AI <span></span> </p>
<div>
<button type="button" onClick={() => setPreviewZoom((value) => Math.max(0.25, value - 0.1))}>-</button>
<strong>{Math.round(previewZoom * 100)}%</strong>
<button type="button" onClick={() => setPreviewZoom((value) => Math.min(2, value + 0.1))}>+</button>
</div>
</header>
<div className="ecom-quick-set-canvas" onWheel={handleQuickPreviewWheel}>
{productImages.length ? (
<section className="ecom-quick-set-result-card" style={{ transform: `scale(${previewZoom})` }}>
<figure>
<img src={productImages[0].src} alt="商品原图" />
<span></span>
</figure>
<div>
{productSetPreviewCards.slice(1, 5).map((card) => (
<figure key={card.id}>
<img src={card.src} alt={card.label} />
<span>{card.id === "scene" ? "场景图" : card.id === "model" ? "模特图" : card.id === "detail" ? "细节图" : "卖点图"}</span>
</figure>
))}
</div>
</section>
) : (
<section className="ecom-quick-set-empty">
<CloudUploadOutlined />
<strong></strong>
<span>AI </span>
</section>
)}
</div>
<section className="ecom-quick-set-prompt">
<textarea
value={requirement}
onChange={(event) => setRequirement(event.target.value.slice(0, 500))}
placeholder="建议包含以下信息,产品名称,核心卖点,期望场景,具体参数"
/>
<button type="button" onClick={handleGenerate} disabled={!canGenerate}></button>
<span>{requirement.length}/500</span>
</section>
</section>
</div>
<button
type="button"
className="ecom-quick-set-collapse"
aria-label={isQuickPanelCollapsed ? "展开设置" : "收起设置"}
onClick={() => setIsQuickPanelCollapsed((value) => !value)}
>
{isQuickPanelCollapsed ? "" : ""}
</button>
<button type="button" className="ecom-quick-set-help" aria-label="帮助" onClick={() => toast.info("上传商品原图后,可选择平台、语言、比例并开始生成。")}>?</button>
</main>
);
const quickDetailVisibleSelect = quickDetailBasicSelects.find((item) => item.key === visibleQuickSetSelect) ?? null;
const quickDetailPreview = (
<main className={`ecom-quick-set-page ecom-quick-detail-page${isQuickPanelCollapsed ? " is-panel-collapsed" : ""}`} aria-label="A+详情页生成">
<div className="ecom-quick-set-body">
<aside className="ecom-quick-set-panel" aria-label="A+详情页设置" onWheel={handleQuickPanelWheel}>
<header className="ecom-quick-set-panel-head">
<button type="button" className="ecom-quick-set-back" onClick={() => setActiveQuickTool(null)}>
</button>
<button type="button" className="ecom-quick-set-back" onClick={() => setActiveQuickTool(null)}>
</button>
</header>
<section>
<strong><CloudUploadOutlined /> </strong>
<div
role="button"
tabIndex={0}
className={`ecom-quick-set-upload${detailProductImages.length ? " has-images" : ""}`}
onClick={() => detailInputRef.current?.click()}
onKeyDown={(event) => openQuickUploadWithKeyboard(event, detailInputRef)}
onDragOver={(event) => event.preventDefault()}
onDrop={handleDetailDrop}
>
<FileImageOutlined />
<span></span>
<em> 3 </em>
<b>+ </b>
{detailProductImages.length ? renderQuickUploadThumbs(detailProductImages, removeDetailImage) : null}
</div>
<input
ref={detailInputRef}
type="file"
accept="image/*"
multiple
className="ecom-command-hidden-file"
onChange={handleDetailUpload}
/>
</section>
<section className="ecom-quick-set-basic-section">
<span className="ecom-quick-set-label"></span>
<div className="ecom-quick-detail-types">
{detailTypeOptions.map((item) => (
<button
key={item}
type="button"
className={detailType === item ? "is-active" : ""}
onClick={() => setDetailType(item)}
>
{item}
</button>
))}
</div>
<span className="ecom-quick-set-label"></span>
<div className="ecom-quick-set-select-anchor">
<div className="ecom-quick-set-selects">
{quickDetailBasicSelects.map((item) => (
<button
key={item.key}
type="button"
className={openQuickSetSelect === item.key ? "is-active" : ""}
onClick={() => toggleQuickSetSelect(item.key)}
>
<span>{item.label}</span><strong>{formatRatioDisplayValue(item.value)}</strong><em></em>
</button>
))}
</div>
{quickDetailVisibleSelect ? (
<div
className={`ecom-quick-set-dropdown ecom-quick-set-dropdown--${quickDetailVisibleSelect.key}${isQuickSetSelectClosing ? " is-closing" : ""}`}
role="listbox"
aria-label={quickDetailVisibleSelect.label}
>
{quickDetailVisibleSelect.options.map((option) => (
<button
key={option}
type="button"
className={quickDetailVisibleSelect.value === option ? "is-active" : ""}
onClick={() => {
quickDetailVisibleSelect.onChange(option);
closeQuickSetSelect();
}}
>
{formatRatioDisplayValue(option)}
</button>
))}
</div>
) : null}
</div>
</section>
<section>
<strong></strong>
<div className="ecom-quick-detail-modules">
{detailModules.map((module) => (
<button
key={module.id}
type="button"
className={selectedDetailModules.includes(module.id) ? "is-active" : ""}
onClick={() => toggleDetailModule(module.id)}
>
<strong>{module.title}</strong>
<span>{module.desc}</span>
</button>
))}
</div>
</section>
<button type="button" className="ecom-quick-set-primary" onClick={handleDetailGenerate} disabled={!canGenerateDetail}>
{detailStatus === "generating" ? <LoadingOutlined /> : "✦"}
</button>
</aside>
<section className="ecom-quick-set-stage">
<header className="ecom-quick-set-preview-head">
<h1></h1>
<p>AI <span></span> </p>
<div>
<button type="button" onClick={() => setPreviewZoom((value) => Math.max(0.25, value - 0.1))}>-</button>
<strong>{Math.round(previewZoom * 100)}%</strong>
<button type="button" onClick={() => setPreviewZoom((value) => Math.min(2, value + 0.1))}>+</button>
</div>
</header>
<div className="ecom-quick-set-canvas" onWheel={handleQuickPreviewWheel}>
{detailStatus === "done" && detailResultUrl ? (
<section className="ecom-quick-detail-result" style={{ transform: `scale(${previewZoom})` }}>
<img src={detailResultUrl} alt="A+详情页生成结果" />
</section>
) : detailProductImages.length ? (
<section className="ecom-quick-detail-preview-card" style={{ transform: `scale(${previewZoom})` }}>
{detailGridSamples.slice(0, 6).map((src, index) => (
<figure key={src}>
<img src={src} alt={`详情页模块预览 ${index + 1}`} />
<span>{detailModules[index]?.title ?? "详情模块"}</span>
</figure>
))}
</section>
) : (
<section className="ecom-quick-set-empty">
<FileImageOutlined />
<strong></strong>
<span>AI </span>
</section>
)}
</div>
<section className="ecom-quick-set-prompt">
<textarea
value={detailRequirement}
onChange={(event) => setDetailRequirement(event.target.value.slice(0, 500))}
placeholder="建议包含以下信息,产品名称,核心卖点,期望场景,具体参数"
/>
<div className="ecom-quick-detail-prompt-actions">
<button type="button" onClick={handleDetailAiWrite} aria-label="AI帮写">AI</button>
<button type="button" onClick={handleDetailGenerate} disabled={!canGenerateDetail}></button>
</div>
<span>{detailRequirement.length}/500</span>
</section>
</section>
</div>
<button
type="button"
className="ecom-quick-set-collapse"
aria-label={isQuickPanelCollapsed ? "展开设置" : "收起设置"}
onClick={() => setIsQuickPanelCollapsed((value) => !value)}
>
{isQuickPanelCollapsed ? "" : ""}
</button>
<button type="button" className="ecom-quick-set-help" aria-label="帮助" onClick={() => toast.info("上传商品图后,选择平台和详情模块即可生成 A+ 详情页。")}>?</button>
</main>
);
const detailPreview = (
<main className="product-clone-preview product-clone-preview--detail" aria-label="A+详情预览" onWheel={handlePreviewWheel}>
<div className="product-clone-preview__headline">
<h1>A+/</h1>
<p>
AI <span></span>
</p>
</div>
<section className="product-detail-demo-board">
<div className="product-detail-source-stack">
{detailSourcePreviewImages.map((src, index) => (
<figure key={`${src}-${index}`}>
<img src={src} alt={`商品原图 ${index + 1}`} />
</figure>
))}
<span></span>
</div>
<div className="product-detail-flow-arrow" aria-hidden="true" />
<div className="product-detail-long-result">
<img src={detailResultUrl ?? detailAssets.longPage} alt="生成电商长图" />
<span>{detailStatus === "done" ? "已生成电商长图" : "生成电商长图"}</span>
</div>
<div className="product-detail-grid-result">
{detailGridSamples.map((src, index) => (
<img key={src} src={src} alt={`详情页模块 ${index + 1}`} />
))}
<span></span>
</div>
</section>
<button type="button" className="product-clone-help" aria-label="帮助">
<QuestionCircleOutlined />
</button>
</main>
);
const tryOnPreview = (
<main className="product-clone-preview product-clone-preview--try-on" aria-label="服饰穿搭预览" onWheel={handlePreviewWheel}>
<div className="product-clone-preview__headline">
<h1>AI服饰穿搭</h1>
<p>姿</p>
</div>
{tryOnResultImages.length ? (
<section className="product-try-on-generated" aria-label="生成结果">
{tryOnResultImages.map((src, index) => (
<figure key={src}>
<img src={src} alt={`生成结果 ${index + 1}`} />
<figcaption>{selectedScenes[index % Math.max(selectedScenes.length, 1)] || "智能场景"}</figcaption>
</figure>
))}
</section>
) : null}
<section className="product-try-on-demo-board">
{tryOnCards.map((card) => (
<article key={card.title} className={`product-try-on-card product-try-on-card--${card.tone}`}>
<h2>{card.title}</h2>
<div className="product-try-on-inputs">
{card.inputs.map((src, index) => (
<div className="product-try-on-input-group" key={`${card.title}-${src}`}>
<img src={src} alt={`${card.title} 输入 ${index + 1}`} />
{index < card.inputs.length - 1 ? <span className="product-try-on-plus">+</span> : null}
</div>
))}
</div>
<div className="product-try-on-arrow" aria-hidden="true" />
<div className="product-try-on-results">
{card.results.map((src, index) => (
<img key={src} src={src} alt={`${card.title} 示例 ${index + 1}`} />
))}
</div>
</article>
))}
</section>
<button type="button" className="product-clone-help" aria-label="帮助">
<QuestionCircleOutlined />
</button>
</main>
);
const placeholderPreview = (
<main className="product-clone-preview product-clone-preview--placeholder" aria-label={`${pageLabel}预览`}>
<div className="product-clone-preview__headline">
<h1>{pageLabel}</h1>
<p></p>
</div>
<button type="button" className="product-clone-help" aria-label="帮助">
<QuestionCircleOutlined />
</button>
</main>
);
const activePreview = isSetTool
? setPreview
: isDetail
? detailPreview
: isTryOn
? tryOnPreview
: isCloneTool
? isWatermarkTool
? watermarkPreview
: isImageEditTool
? imageWorkbenchPreview
: isSmartCutoutTool
? smartCutoutPreview
: isQuickSetTool
? quickProductSetPreview
: isQuickDetailTool
? quickDetailPreview
: isHotVideoTool
? hotVideoPreview
: cloneOutput === "video-outfit" && results.length > 0 && results[0].type === "video"
? (
<main className="product-clone-preview product-clone-preview--video-outfit" style={{ display: "flex", alignItems: "center", justifyContent: "center" }}>
<div style={{ maxWidth: "100%", maxHeight: "100%" }}>
<video src={results[0].src} controls style={{ maxWidth: "100%", maxHeight: "70vh", borderRadius: "12px" }} />
</div>
</main>
)
: clonePreview
: placeholderPreview;
return (
<section
className={`product-clone-page page-motion${isCloneTool && isCloneSettingsCollapsed ? " is-settings-collapsed" : ""}${isCloneTool && isCommandHistoryCollapsed ? " is-history-collapsed" : ""}${isSmartCutoutTool ? " is-smart-cutout-page" : ""}${isQuickSetTool || isQuickDetailTool ? " is-quick-set-page" : ""}${isWatermarkTool ? " is-watermark-page" : ""}${isImageEditTool ? " is-image-workbench-page" : ""}${isHotVideoTool ? " is-hot-video-page" : ""}`}
data-tool={activeTool}
aria-label={pageLabel}
>
<div className="product-clone-shell">
<aside className="product-clone-rail" aria-label="商品工具">
{sideTools.map((tool) => (
<button key={tool.key} type="button" className={activeTool === tool.key ? "is-active" : ""} onClick={() => setActiveTool(tool.key)}>
{tool.icon}
<span>{tool.label}</span>
</button>
))}
</aside>
<aside
id={isCloneTool ? "ecommerce-clone-settings-panel" : undefined}
className={`product-clone-panel tool-panel-enter`}
key={activeTool}
aria-label={`${pageLabel}鍙傛暟`}
aria-hidden={isCloneTool && isCloneSettingsCollapsed ? true : undefined}
>
{isSetTool ? setPanel : isDetail ? detailPanel : isTryOn ? tryOnPanel : isCloneTool ? clonePanel : placeholderPanel}
</aside>
{isCloneTool && !isSmartCutoutTool && !isQuickSetTool && !isQuickDetailTool && !isWatermarkTool && !isImageEditTool && !isHotVideoTool ? (
<button
type="button"
className="clone-ai-settings-toggle"
onClick={() => setIsCloneSettingsCollapsed((current) => !current)}
aria-label={isCloneSettingsCollapsed ? "灞曞紑璁剧疆闈㈡澘" : "鏀惰捣璁剧疆闈㈡澘"}
aria-controls="ecommerce-clone-settings-panel"
aria-expanded={!isCloneSettingsCollapsed}
title={isCloneSettingsCollapsed ? "灞曞紑璁剧疆闈㈡澘" : "鏀惰捣璁剧疆闈㈡澘"}
>
{isCloneSettingsCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</button>
) : null}
{false ? (
<main className="product-clone-preview product-clone-preview--video" style={{ padding: 0 }}>
<EcommerceVideoWorkspace
isAuthenticated={isAuthenticated}
productImageDataUrls={ecommerceVideoImageDataUrls}
productImageFiles={ecommerceVideoImageFiles}
requirement={requirement}
platform={platform}
aspectRatio={ratio.includes("9锛?6") || ratio.includes("9:16") ? "9:16" : ratio.includes("16锛?") || ratio.includes("16:9") ? "16:9" : ratio.includes("3锛?") || ratio.includes("3:4") ? "3:4" : "9:16"}
durationSeconds={cloneVideoDuration}
resolution={cloneVideoQuality === "standard" ? "720P" : "1080P"}
onRequestLogin={() => (isAuthenticated ? undefined : requestLogin())}
onOpenHistory={() => setVideoHistoryVisible(true)}
triggerPlan={videoPlanTrigger}
/>
</main>
) : activePreview}
</div>
<aside className="ecom-command-history" aria-label="生成历史">
<div className="ecom-command-history__tools">
<button
type="button"
className="ecom-command-history__toggle"
onClick={() => setIsCommandHistoryCollapsed((current) => !current)}
title={isCommandHistoryCollapsed ? "展开记录" : "收起记录"}
aria-label={isCommandHistoryCollapsed ? "展开记录" : "收起记录"}
aria-expanded={!isCommandHistoryCollapsed}
>
{isCommandHistoryCollapsed ? "" : ""}
</button>
<button type="button" className="ecom-command-history__new" onClick={handleNewEcommerceConversation}></button>
<button
type="button"
className={`ecom-command-history__refresh${isHistoryRefreshing ? " is-refreshing" : ""}`}
aria-label={isHistoryRefreshing ? "刷新中" : "刷新历史"}
title={isHistoryRefreshing ? "刷新中" : "刷新历史"}
onPointerDown={refreshEcommerceHistory}
onClick={refreshEcommerceHistory}
disabled={isHistoryRefreshing}
>
</button>
</div>
<div className="ecom-command-history__heading">
<strong></strong>
<span>{ecommerceHistoryRecords.length} </span>
</div>
{historyRefreshMessage ? (
<p key={historyRefreshStamp} className="ecom-command-history__refresh-note" role="status">{historyRefreshMessage}</p>
) : null}
<nav className="ecom-command-history__list" aria-label="历史对话">
{ecommerceHistoryRecords.length ? (
ecommerceHistoryRecords.map((record) => {
const outputLabel = cloneOutputOptions.find((option) => option.key === record.output)?.label || "生成记录";
return (
<button
type="button"
key={`${record.id}-${historyRefreshTick}`}
className={activeHistoryRecordId === record.id ? "is-active" : ""}
onClick={() => openEcommerceHistoryRecord(record)}
>
<strong>{record.title}</strong>
<span>{outputLabel} · {formatHistoryTime(record.createdAt)}</span>
</button>
);
})
) : (
<p className="ecom-command-history__empty"></p>
)}
</nav>
</aside>
{selectedProductSetPreview ? (
<div className="product-set-preview-backdrop" role="presentation" onClick={() => setSelectedProductSetPreview(null)}>
<section
className="product-set-preview-modal"
role="dialog"
aria-modal="true"
aria-label={selectedProductSetPreview.label}
onClick={(event) => event.stopPropagation()}
>
<button
type="button"
className="product-set-preview-close"
onClick={() => setSelectedProductSetPreview(null)}
aria-label="关闭预览"
>
<CloseOutlined />
</button>
<img src={selectedProductSetPreview.src} alt={selectedProductSetPreview.label} />
<strong>{selectedProductSetPreview.label}</strong>
</section>
</div>
) : null}
{showHostingModal ? (
<div className="product-set-hosting-backdrop" role="presentation">
<section className="product-set-hosting-modal" role="dialog" aria-modal="true" aria-label="批量托管上线">
<img src={productSetAssets.hosting} alt="托管模式" />
<div className="product-set-hosting-content">
<button type="button" className="product-set-hosting-close" onClick={() => setShowHostingModal(false)} aria-label="关闭">
×
</button>
<h2>
线
<span>6</span>
</h2>
<strong></strong>
<ul>
<li>
<b></b>
<span>线</span>
</li>
<li>
<b>40%</b>
<span>线</span>
</li>
<li>
<b>AI智能提取</b>
<span></span>
</li>
</ul>
<button type="button" className="product-set-hosting-confirm" onClick={() => setShowHostingModal(false)}>
</button>
</div>
</section>
</div>
) : null}
<EcommerceVideoHistoryPanel
visible={videoHistoryVisible}
onClose={() => setVideoHistoryVisible(false)}
/>
</section>
);
}
export default ProductClonePage;