merge: 合并 master,保留拖拽上传样式和工具面板样式
This commit is contained in:
@@ -63,6 +63,8 @@ interface CloneImageItem {
|
||||
width?: number;
|
||||
height?: number;
|
||||
format?: string;
|
||||
mimeType?: string;
|
||||
ossKey?: string;
|
||||
}
|
||||
|
||||
interface CloneResult {
|
||||
@@ -99,6 +101,18 @@ interface CloneSavedSetting {
|
||||
requirement: string;
|
||||
}
|
||||
|
||||
interface EcommerceImagePromptOptions {
|
||||
gender?: string;
|
||||
age?: string;
|
||||
ethnicity?: string;
|
||||
body?: string;
|
||||
appearance?: string;
|
||||
scenes?: string[];
|
||||
customScene?: string;
|
||||
smartScene?: boolean;
|
||||
detailModules?: string[];
|
||||
}
|
||||
|
||||
type PlatformRatioModeKey = ProductSetOutputKey | "hot" | "video-outfit";
|
||||
|
||||
interface PlatformRatioGroup {
|
||||
@@ -672,16 +686,85 @@ function readImageDimensions(src: string): Promise<{ width: number; height: numb
|
||||
});
|
||||
}
|
||||
|
||||
function createObjectImageItems(files: File[], limit: number, prefix: string) {
|
||||
return Array.from(files)
|
||||
.slice(0, limit)
|
||||
.map<CloneImageItem>((file, index) => ({
|
||||
id: `${prefix}-${Date.now()}-${index}`,
|
||||
src: URL.createObjectURL(file),
|
||||
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 dimensions: { width?: number; height?: number } = {};
|
||||
try {
|
||||
dimensions = await readImageDimensions(localPreviewUrl);
|
||||
} catch {
|
||||
dimensions = {};
|
||||
} finally {
|
||||
URL.revokeObjectURL(localPreviewUrl);
|
||||
}
|
||||
|
||||
const mimeType = normalizeEcommerceImageMime(file.type);
|
||||
const uploadBlob = file.type === mimeType ? file : new Blob([file], { type: mimeType });
|
||||
const { url, ossKey } = await aiGenerationClient.uploadAssetBinary(uploadBlob, {
|
||||
name: file.name,
|
||||
mimeType,
|
||||
scope: "ecommerce-product",
|
||||
});
|
||||
|
||||
return {
|
||||
id: `${prefix}-${stamp}-${index}`,
|
||||
src: url,
|
||||
name: file.name,
|
||||
file,
|
||||
format: getImageFileFormat(file),
|
||||
}));
|
||||
mimeType,
|
||||
ossKey,
|
||||
...dimensions,
|
||||
};
|
||||
}));
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
async function persistGeneratedImageUrl(sourceUrl: string, scope: string, namePrefix: string): Promise<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[] {
|
||||
@@ -888,21 +971,26 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
});
|
||||
};
|
||||
|
||||
const addSetImages = (files: File[]) => {
|
||||
const addSetImages = async (files: File[]) => {
|
||||
if (setImages.length >= 3) return;
|
||||
const imageFiles = notifyRejectedImages(files);
|
||||
if (!imageFiles.length) return;
|
||||
setSetImages((current) => {
|
||||
const nextImages = createObjectImageItems(imageFiles, 3 - current.length, "set");
|
||||
return nextImages.length ? [...current, ...nextImages].slice(0, 3) : current;
|
||||
});
|
||||
setProductSetStatus("ready");
|
||||
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;
|
||||
addSetImages(Array.from(files));
|
||||
void addSetImages(Array.from(files));
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
@@ -910,7 +998,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
event.preventDefault();
|
||||
setIsSetUploadDragging(false);
|
||||
const files = Array.from(event.dataTransfer.files);
|
||||
if (files.length) addSetImages(files);
|
||||
if (files.length) void addSetImages(files);
|
||||
};
|
||||
|
||||
const removeSetImage = (imageId: string) => {
|
||||
@@ -921,22 +1009,26 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
});
|
||||
};
|
||||
|
||||
const addProductImages = (files: File[]) => {
|
||||
const addProductImages = async (files: File[]) => {
|
||||
const imageFiles = notifyRejectedImages(files);
|
||||
if (!imageFiles.length) return;
|
||||
setProductImages((current) => {
|
||||
if (current.length >= maxCloneProductImages) return current;
|
||||
const nextImages = createObjectImageItems(imageFiles, maxCloneProductImages - current.length, "product");
|
||||
return nextImages.length ? [...current, ...nextImages].slice(0, maxCloneProductImages) : current;
|
||||
});
|
||||
setStatus("ready");
|
||||
setResults([]);
|
||||
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;
|
||||
addProductImages(Array.from(files));
|
||||
void addProductImages(Array.from(files));
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
@@ -944,7 +1036,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
event.preventDefault();
|
||||
setIsProductUploadDragging(false);
|
||||
const files = Array.from(event.dataTransfer.files);
|
||||
if (files.length) addProductImages(files);
|
||||
if (files.length) void addProductImages(files);
|
||||
};
|
||||
|
||||
const removeProductImage = (imageId: string) => {
|
||||
@@ -970,24 +1062,28 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
});
|
||||
};
|
||||
|
||||
const addCloneReferenceImages = (files: File[]) => {
|
||||
const addCloneReferenceImages = async (files: File[]) => {
|
||||
const imageFiles = notifyRejectedImages(files);
|
||||
if (!imageFiles.length) return;
|
||||
const remainingSlots = maxCloneReferenceImages - cloneReferenceImages.length;
|
||||
if (remainingSlots <= 0) return;
|
||||
const nextImages = createObjectImageItems(imageFiles, remainingSlots, "reference");
|
||||
if (!nextImages.length) return;
|
||||
setCloneReferenceImages((current) => {
|
||||
if (current.length >= maxCloneReferenceImages) return current;
|
||||
return nextImages.length ? [...current, ...nextImages].slice(0, maxCloneReferenceImages) : current;
|
||||
});
|
||||
hydrateCloneReferenceImageMeta(nextImages);
|
||||
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;
|
||||
addCloneReferenceImages(Array.from(files));
|
||||
void addCloneReferenceImages(Array.from(files));
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
@@ -1328,8 +1424,15 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
event.target.value = "";
|
||||
return;
|
||||
}
|
||||
setGarmentImages((current) => [...current, ...createObjectImageItems(uploadedFiles, 5 - current.length, "garment")].slice(0, 5));
|
||||
setTryOnStatus("ready");
|
||||
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 = "";
|
||||
};
|
||||
|
||||
@@ -1341,8 +1444,15 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
event.target.value = "";
|
||||
return;
|
||||
}
|
||||
setDetailProductImages((current) => [...current, ...createObjectImageItems(uploadedFiles, 3 - current.length, "detail")].slice(0, 3));
|
||||
setDetailStatus("ready");
|
||||
void (async () => {
|
||||
try {
|
||||
const nextImages = await createUploadedImageItems(uploadedFiles, 3 - detailProductImages.length, "detail");
|
||||
setDetailProductImages((current) => [...current, ...nextImages].slice(0, 3));
|
||||
setDetailStatus("ready");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "详情图上传失败");
|
||||
}
|
||||
})();
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
@@ -1384,11 +1494,32 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
scene: { label: "场景图", promptDesc: "lifestyle scene image showing the product in a realistic usage environment with natural surroundings" },
|
||||
};
|
||||
|
||||
const buildDetailModulePrompt = (moduleIds: string[]): string => {
|
||||
if (!moduleIds.length) {
|
||||
return "Generate a complete A+ detail layout with hero, selling points, usage scene, product detail, and specification modules.";
|
||||
}
|
||||
|
||||
const selectedModules = cloneDetailModules.filter((module) => moduleIds.includes(module.id));
|
||||
if (!selectedModules.length) return "";
|
||||
|
||||
const moduleList = selectedModules.map((module) => `${module.title}: ${module.desc}`).join("; ");
|
||||
return `Only generate these selected A+ detail modules, no extra modules: ${moduleList}. Keep the output focused even if only one or two modules are selected.`;
|
||||
};
|
||||
|
||||
const buildSetSubPrompt = (countKey: CloneSetCountKey, index: number, totalCount: number, pPlatform: string, pRatio: string, pLanguage: string, pMarket: string): string => {
|
||||
const info = setCountLabels[countKey];
|
||||
const parts: string[] = [];
|
||||
parts.push(`Generate an e-commerce ${info.label.toLowerCase()} for a product listing.`);
|
||||
parts.push(info.promptDesc);
|
||||
if (countKey === "white") {
|
||||
parts.push("The output must be a clean white-background product image. Do not use lifestyle backgrounds, props, text overlays, or people.");
|
||||
}
|
||||
if (countKey === "scene") {
|
||||
parts.push("The output must be a realistic usage scene image. Keep the product clearly visible and preserve its shape, color, and key details.");
|
||||
}
|
||||
if (countKey === "selling") {
|
||||
parts.push("The output must be a selling-point graphic with clear hierarchy, concise copy, and product detail callouts.");
|
||||
}
|
||||
if (totalCount > 1) {
|
||||
parts.push(`This is variant ${index + 1} of ${totalCount} — vary the angle, composition, or emphasis to make each distinct.`);
|
||||
}
|
||||
@@ -1400,13 +1531,14 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
const buildEcommerceImagePrompt = (
|
||||
outputKey: CloneOutputKey, userText: string,
|
||||
pPlatform: string, pRatio: string, pLanguage: string, pMarket: string,
|
||||
tryOnOptions?: { gender?: string; age?: string; ethnicity?: string; body?: string; appearance?: string; scenes?: string[]; smartScene?: boolean },
|
||||
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.");
|
||||
@@ -1419,6 +1551,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
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.");
|
||||
@@ -1492,8 +1625,9 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
if (imageAbortRef.current.current) break;
|
||||
|
||||
if (resultUrl) {
|
||||
generatedUrls.push(resultUrl);
|
||||
imageGen.updateTask(storeId, { status: "completed", progress: 100, 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: "生成未返回结果" });
|
||||
@@ -1531,7 +1665,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
pRatio: string,
|
||||
pLanguage: string,
|
||||
pMarket: string,
|
||||
tryOnOptions?: { gender?: string; age?: string; ethnicity?: string; body?: string; appearance?: string; scenes?: string[]; smartScene?: boolean },
|
||||
tryOnOptions?: EcommerceImagePromptOptions,
|
||||
statusFn?: (status: "generating" | "done" | "idle" | "failed") => void,
|
||||
resultFn?: (results: CloneResult[]) => void,
|
||||
): Promise<void> => {
|
||||
@@ -1578,9 +1712,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
}
|
||||
|
||||
if (resultUrl) {
|
||||
resultFn?.([{ id: `ecommerce-${stamp}`, src: resultUrl, label: selectedCloneOutput.label }]);
|
||||
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 });
|
||||
imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl: persistedUrl });
|
||||
} else {
|
||||
statusFn?.("idle");
|
||||
imageGen.updateTask(storeId, { status: "failed", error: "生成未返回结果" });
|
||||
@@ -1684,10 +1819,24 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
(urls) => setProductSetResultImages(urls),
|
||||
);
|
||||
} else {
|
||||
const clonePromptOptions: EcommerceImagePromptOptions | undefined =
|
||||
cloneOutput === "model"
|
||||
? {
|
||||
gender: cloneModelGender,
|
||||
age: cloneModelAge,
|
||||
ethnicity: cloneModelEthnicity,
|
||||
body: cloneModelBody,
|
||||
appearance: cloneModelAppearance,
|
||||
scenes: selectedCloneModelScenes,
|
||||
customScene: cloneModelCustomScene,
|
||||
}
|
||||
: cloneOutput === "detail"
|
||||
? { detailModules: selectedCloneDetailModules }
|
||||
: undefined;
|
||||
void generateEcommerceImage(
|
||||
cloneOutput, productImages, requirement,
|
||||
platform, ratio, language, market,
|
||||
undefined,
|
||||
clonePromptOptions,
|
||||
(s: string) => setStatus(s as ProductCloneStatus), setResults,
|
||||
);
|
||||
lastFailedActionRef.current = () => handleGenerate();
|
||||
@@ -1767,7 +1916,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
void generateEcommerceImage(
|
||||
"detail", detailProductImages, detailRequirement,
|
||||
detailPlatform, getPlatformDefaultRatio(detailPlatform), detailLanguage, detailMarket,
|
||||
undefined,
|
||||
{ detailModules: selectedDetailModules },
|
||||
(s: string) => setDetailStatus(s as DetailStatus),
|
||||
(res) => setDetailResultUrl(res[0]?.src ?? null),
|
||||
);
|
||||
@@ -1846,7 +1995,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
setPreviewCards.push({
|
||||
id: `${countKey}-${i}`,
|
||||
src: productSetResultImages[setIndex] ?? productSetPreviewCards[setIndex % productSetPreviewCards.length]?.src ?? "",
|
||||
src: productSetResultImages[setIndex] || productSetPreviewCards[setIndex % productSetPreviewCards.length]?.src || "",
|
||||
label: `${info.label}${count > 1 ? ` ${i + 1}` : ""}`,
|
||||
});
|
||||
setIndex++;
|
||||
@@ -1861,7 +2010,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
clonePreviewCards.push({
|
||||
id: `${countKey}-${i}`,
|
||||
src: results[cloneIndex]?.src ?? productSetPreviewCards[cloneIndex % productSetPreviewCards.length]?.src ?? "",
|
||||
src: results[cloneIndex]?.src || productSetPreviewCards[cloneIndex % productSetPreviewCards.length]?.src || "",
|
||||
label: `${info.label}${count > 1 ? ` ${i + 1}` : ""}`,
|
||||
});
|
||||
cloneIndex++;
|
||||
|
||||
@@ -312,6 +312,8 @@ export async function renderSceneImage(
|
||||
|
||||
const resultUrl = await waitForTask(taskId, {
|
||||
abortRef,
|
||||
kind: "image",
|
||||
model: "gpt-image-2",
|
||||
onProgress: (e) => callbacks.onSceneImageProgress(input.sceneId, e.progress),
|
||||
});
|
||||
|
||||
@@ -367,6 +369,8 @@ export async function renderScene(
|
||||
|
||||
const resultUrl = await waitForTask(taskId, {
|
||||
abortRef,
|
||||
kind: "video",
|
||||
model,
|
||||
onProgress: (e) => callbacks.onSceneProgress(input.sceneId, e.progress),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user