merge: resolve EcommercePage.tsx conflict, integrate master into profile-account-polish

Keep master's EcommercePage.tsx (has more complete upload logic from prior conflict resolution). Accept all other master changes including canvas tool panels, task lifecycle, and workbench updates.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 14:05:39 +08:00
37 changed files with 3182 additions and 130 deletions
@@ -34,6 +34,7 @@ import {
interface EcommerceVideoWorkspaceProps {
isAuthenticated: boolean;
productImageDataUrls: string[];
productImageFiles?: Array<File | undefined>;
requirement: string;
platform: string;
aspectRatio: string;
@@ -97,6 +98,7 @@ function stepCompletedFromProgress(step: PlanStep, p: EcommerceVideoPlanProgress
export default function EcommerceVideoWorkspace({
isAuthenticated,
productImageDataUrls,
productImageFiles = [],
requirement,
platform,
aspectRatio,
@@ -376,8 +378,9 @@ export default function EcommerceVideoWorkspace({
});
};
try {
const productImageSources = productImageDataUrls.map((url, index) => productImageFiles[index] ?? url);
const result = await runVideoPlan(
productImageDataUrls, requirement, buildConfig(),
productImageSources, requirement, buildConfig(),
{
onStepStart: (step) => setCurrentStep(step),
onStepDone: (step) => {
+242 -40
View File
@@ -19,6 +19,102 @@ import type {
PlanStep,
} from "./ecommerceVideoTypes";
type UploadAssetByUrl = typeof aiGenerationClient.uploadAssetByUrl;
interface DurableMediaUrl {
url: string | null;
originalUrl?: string | null;
ossKey?: string | null;
}
const TEMP_MEDIA_HOST_RE = /^file\d*\.aitohumanize\.com$/i;
const OSS_MEDIA_HOST_RE = /\.oss-[^.]+\.aliyuncs\.com$/i;
function isTemporaryProviderUrl(url: string): boolean {
try {
return TEMP_MEDIA_HOST_RE.test(new URL(url).hostname);
} catch {
return false;
}
}
function isDurableOssUrl(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.protocol === "https:" && OSS_MEDIA_HOST_RE.test(parsed.hostname);
} catch {
return false;
}
}
function getMediaExtension(url: string, mimeType: string): string {
const normalizedMime = mimeType.split(";")[0]?.trim().toLowerCase();
if (normalizedMime === "image/jpeg") return "jpg";
if (normalizedMime === "image/png") return "png";
if (normalizedMime === "image/webp") return "webp";
if (normalizedMime === "image/gif") return "gif";
if (normalizedMime === "video/mp4") return "mp4";
if (normalizedMime === "video/webm") return "webm";
if (normalizedMime === "video/quicktime") return "mov";
try {
const matched = new URL(url).pathname.match(/\.([a-z0-9]{2,5})$/i);
if (matched?.[1]) return matched[1].toLowerCase();
} catch {
// Keep mime fallback below.
}
return mimeType.startsWith("video/") ? "mp4" : "png";
}
function buildDurableMediaName(prefix: string, url: string, mimeType: string): string {
const normalized = prefix
.trim()
.replace(/[\\/:*?"<>|]+/g, "-")
.replace(/\s+/g, " ")
.slice(0, 80)
.trim();
return `${normalized || "ecommerce-video-media"}.${getMediaExtension(url, mimeType)}`;
}
export async function resolveDurableMediaUrl(
url: string | null | undefined,
options: {
mediaType: "image" | "video";
namePrefix: string;
scope?: string;
uploadAssetByUrl?: UploadAssetByUrl;
},
): Promise<DurableMediaUrl> {
const sourceUrl = String(url || "").trim();
if (!sourceUrl) return { url: null };
if (isDurableOssUrl(sourceUrl)) return { url: sourceUrl };
const mimeType = options.mediaType === "video" ? "video/mp4" : "image/png";
const uploadAssetByUrl = options.uploadAssetByUrl || aiGenerationClient.uploadAssetByUrl.bind(aiGenerationClient);
try {
const uploaded = await uploadAssetByUrl({
sourceUrl,
name: buildDurableMediaName(options.namePrefix, sourceUrl, mimeType),
mimeType,
scope: options.scope || "ecommerce-video-history",
});
return {
url: uploaded.url || null,
originalUrl: sourceUrl,
ossKey: uploaded.ossKey || null,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error || "");
console.warn("[ecommerce-video] history media persistence failed:", message);
if (isTemporaryProviderUrl(sourceUrl)) {
return { url: null, originalUrl: sourceUrl };
}
return { url: sourceUrl };
}
}
export interface PlanCallbacks {
onStepStart: (step: PlanStep) => void;
onStepDone: (step: PlanStep) => void;
@@ -30,13 +126,61 @@ export interface PlanCallbacks {
resumeFrom?: EcommerceVideoPlanProgress;
}
const LOCAL_PREVIEW_MISSING_FILE_MESSAGE = "Please re-upload the product image before generating the short video.";
function readBlobAsDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(reader.error || new Error("File read failed"));
reader.readAsDataURL(blob);
});
}
function normalizeRemoteImageUrl(source: string): string | null {
try {
const url = new URL(source, typeof window !== "undefined" ? window.location.href : undefined);
return url.protocol === "http:" || url.protocol === "https:" ? url.href : null;
} catch {
return null;
}
}
async function uploadProductImageSource(source: string | Blob): Promise<string> {
if (typeof source === "string") {
if (source.startsWith("blob:")) {
throw new Error(LOCAL_PREVIEW_MISSING_FILE_MESSAGE);
}
if (source.startsWith("data:")) {
const mimeType = normalizeEcommerceImageMime(source.match(/^data:([^;,]+)/)?.[1] || "image/png");
const result = await aiGenerationClient.uploadAsset({ dataUrl: source, mimeType, scope: "ecommerce-product" });
return result.url;
}
const remoteUrl = normalizeRemoteImageUrl(source);
if (remoteUrl) {
const result = await aiGenerationClient.uploadAssetByUrl({ sourceUrl: remoteUrl, scope: "ecommerce-product" });
return result.url;
}
throw new Error("Unsupported product image URL. Please re-upload the product image.");
}
const mimeType = normalizeEcommerceImageMime(source.type || "image/png");
const blob = source.type === mimeType ? source : new Blob([source], { type: mimeType });
const dataUrl = await readBlobAsDataUrl(blob);
const result = await aiGenerationClient.uploadAsset({ dataUrl, mimeType, scope: "ecommerce-product" });
return result.url;
}
/**
* Run the full ad video planning pipeline.
* Supports resumption: if `resumeFrom` contains data for a step, that step is skipped.
* After each step, `onPartialProgress` fires so callers can persist intermediate state.
*/
export async function runVideoPlan(
imageDataUrls: string[],
imageSources: Array<string | Blob>,
manualText: string,
config: AdVideoUserConfig,
callbacks: PlanCallbacks,
@@ -45,41 +189,30 @@ export async function runVideoPlan(
const progress: EcommerceVideoPlanProgress = { ...resumeFrom };
const emit = () => callbacks.onPartialProgress?.({ ...progress });
// ── Step: upload ──────────────────────────────────────
// Step: upload
if (!progress.imageUrls?.length) {
onStepStart("upload");
const imageUrls: string[] = [];
const rejected: string[] = [];
for (const srcUrl of imageDataUrls) {
for (const source of imageSources) {
try {
const resp = await fetch(srcUrl);
const rawBlob = await resp.blob();
const mimeType = normalizeEcommerceImageMime(rawBlob.type);
const blob = rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType });
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(reader.error || new Error("文件读取失败"));
reader.readAsDataURL(blob);
});
const result = await aiGenerationClient.uploadAsset({ dataUrl, mimeType, scope: "ecommerce-product" });
imageUrls.push(result.url);
imageUrls.push(await uploadProductImageSource(source));
} catch (err) {
rejected.push(err instanceof Error ? err.message : "图片上传失败");
rejected.push(err instanceof Error ? err.message : "Image upload failed");
}
}
if (rejected.length) {
progress.uploadWarnings = rejected;
callbacks.onUploadRejected?.(rejected);
}
if (!imageUrls.length) throw new Error("图片上传失败,请检查图片格式或网络后重试");
if (!imageUrls.length) throw new Error("Image upload failed. Please check the image format or network and try again.");
progress.imageUrls = imageUrls;
onStepDone("upload");
callbacks.onImagesUploaded?.(imageUrls);
emit();
}
// ── Step: analyze ─────────────────────────────────────
// Step: analyze
if (progress.imageDescription === undefined) {
onStepStart("analyze");
progress.imageDescription = await analyzeProductImages(progress.imageUrls!, signal);
@@ -87,7 +220,7 @@ export async function runVideoPlan(
emit();
}
// ── Step: summary ─────────────────────────────────────
// Step: summary
if (!progress.summary) {
onStepStart("summary");
progress.summary = await buildProductSummary(progress.imageDescription || "", manualText, signal);
@@ -95,7 +228,7 @@ export async function runVideoPlan(
emit();
}
// ── Step: selling ─────────────────────────────────────
// Step: selling
if (!progress.selling) {
onStepStart("selling");
progress.selling = await extractSellingPoints(progress.summary, signal);
@@ -103,16 +236,16 @@ export async function runVideoPlan(
emit();
}
// ── Step: creative ────────────────────────────────────
// Step: creative
if (!progress.creatives?.length) {
onStepStart("creative");
progress.creatives = await generateCreativeOptions(progress.selling, config, signal);
if (!progress.creatives.length) throw new Error("未能生成有效的广告创意");
if (!progress.creatives.length) throw new Error("Failed to generate valid ad creatives.");
onStepDone("creative");
emit();
}
// ── Step: storyboard ──────────────────────────────────
// Step: storyboard
if (!progress.storyboard) {
onStepStart("storyboard");
progress.storyboard = await generateStoryboard(progress.creatives[0], progress.summary, config, signal);
@@ -120,7 +253,7 @@ export async function runVideoPlan(
emit();
}
// ── Step: prompts ─────────────────────────────────────
// Step: prompts
if (!progress.videoPrompts) {
onStepStart("prompts");
progress.videoPrompts = await generateVideoPrompts(progress.storyboard, progress.summary, signal);
@@ -128,7 +261,7 @@ export async function runVideoPlan(
emit();
}
// ── Step: compliance ──────────────────────────────────
// Step: compliance
if (!progress.compliance) {
onStepStart("compliance");
progress.compliance = await checkCompliance(progress.summary, progress.selling, progress.storyboard, signal);
@@ -179,13 +312,15 @@ export async function renderSceneImage(
const resultUrl = await waitForTask(taskId, {
abortRef,
kind: "image",
model: "gpt-image-2",
onProgress: (e) => callbacks.onSceneImageProgress(input.sceneId, e.progress),
});
if (resultUrl) {
callbacks.onSceneImageCompleted(input.sceneId, resultUrl);
} else {
callbacks.onSceneImageFailed(input.sceneId, "图片生成未返回结果");
callbacks.onSceneImageFailed(input.sceneId, "Image generation returned no result.");
}
}
@@ -234,13 +369,15 @@ export async function renderScene(
const resultUrl = await waitForTask(taskId, {
abortRef,
kind: "video",
model,
onProgress: (e) => callbacks.onSceneProgress(input.sceneId, e.progress),
});
if (resultUrl) {
callbacks.onSceneCompleted(input.sceneId, resultUrl);
} else {
callbacks.onSceneFailed(input.sceneId, "任务未返回结果");
callbacks.onSceneFailed(input.sceneId, "Task returned no result.");
}
}
@@ -259,7 +396,7 @@ export function buildSceneTasks(
});
}
// ── Video History API ──────────────────────────────────
// Video History API
export interface VideoHistoryScene {
sceneId: number;
@@ -268,6 +405,15 @@ export interface VideoHistoryScene {
videoUrl?: string | null;
}
interface SaveVideoHistoryPayload {
title: string;
config: Record<string, unknown>;
plan: Record<string, unknown>;
scenes: VideoHistoryScene[];
sourceImageUrls: string[];
uploadAssetByUrl?: UploadAssetByUrl;
}
export interface VideoHistoryItem {
id: number;
title: string;
@@ -293,22 +439,74 @@ function getAuthHeaders(): Record<string, string> {
return token ? { Authorization: `Bearer ${token}` } : {};
}
export async function saveVideoHistory(payload: {
title: string;
config: Record<string, unknown>;
plan: Record<string, unknown>;
scenes: VideoHistoryScene[];
sourceImageUrls: string[];
}): Promise<{ id: number; createdAt: string }> {
export async function buildDurableVideoHistoryPayload(payload: SaveVideoHistoryPayload): Promise<SaveVideoHistoryPayload> {
const uploadAssetByUrl = payload.uploadAssetByUrl;
const scenes = await Promise.all(
payload.scenes.map(async (scene) => {
const [image, video] = await Promise.all([
resolveDurableMediaUrl(scene.imageUrl, {
mediaType: "image",
namePrefix: `ecommerce-scene-${scene.sceneId}-image`,
uploadAssetByUrl,
}),
resolveDurableMediaUrl(scene.videoUrl, {
mediaType: "video",
namePrefix: `ecommerce-scene-${scene.sceneId}-video`,
uploadAssetByUrl,
}),
]);
return {
...scene,
imageUrl: image.url,
videoUrl: video.url,
};
}),
);
const sourceImageUrls = (
await Promise.all(
payload.sourceImageUrls.map((url, index) =>
resolveDurableMediaUrl(url, {
mediaType: "image",
namePrefix: `ecommerce-source-${index + 1}`,
uploadAssetByUrl,
}),
),
)
)
.map((item) => item.url)
.filter((url): url is string => Boolean(url));
return {
...payload,
scenes,
sourceImageUrls,
};
}
export async function saveVideoHistory(payload: SaveVideoHistoryPayload): Promise<{ id: number; createdAt: string }> {
const { uploadAssetByUrl: _uploadAssetByUrl, ...historyPayload } = await buildDurableVideoHistoryPayload(payload);
const res = await fetch(API_BASE, {
method: "POST",
headers: { "Content-Type": "application/json", ...getAuthHeaders() },
body: JSON.stringify(payload),
body: JSON.stringify(historyPayload),
});
if (!res.ok) throw new Error("保存历史记录失败");
if (!res.ok) throw new Error("Failed to save video history");
return res.json();
}
function removeTemporaryHistoryUrls(item: VideoHistoryItem): VideoHistoryItem {
return {
...item,
scenes: item.scenes.map((scene) => ({
...scene,
imageUrl: scene.imageUrl && !isTemporaryProviderUrl(scene.imageUrl) ? scene.imageUrl : null,
videoUrl: scene.videoUrl && !isTemporaryProviderUrl(scene.videoUrl) ? scene.videoUrl : null,
})),
sourceImageUrls: item.sourceImageUrls.filter((url) => !isTemporaryProviderUrl(url)),
};
}
export async function fetchVideoHistory(
limit = 20,
offset = 0,
@@ -317,8 +515,12 @@ export async function fetchVideoHistory(
`${API_BASE}?limit=${limit}&offset=${offset}`,
{ headers: getAuthHeaders() },
);
if (!res.ok) throw new Error("获取历史记录失败");
return res.json();
if (!res.ok) throw new Error("Failed to fetch video history");
const history = (await res.json()) as VideoHistoryListResponse;
return {
...history,
items: history.items.map(removeTemporaryHistoryUrls),
};
}
export async function deleteVideoHistory(id: number): Promise<void> {
@@ -326,5 +528,5 @@ export async function deleteVideoHistory(id: number): Promise<void> {
method: "DELETE",
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error("删除失败");
if (!res.ok) throw new Error("Failed to delete video history");
}
@@ -133,6 +133,7 @@ interface EcommerceClonePanelProps {
clampCloneVideoDuration: (value: number) => number;
setCloneVideoSmart: (updater: (current: boolean) => boolean) => void;
handleGenerate: () => void;
onCancelGenerate: () => void;
formatRatioDisplayValue: (value: string) => string;
setVideoOutfitFiles?: (video: File | null, ref: File | null) => void;
onStartVideoPlan?: () => void;
@@ -200,6 +201,7 @@ export default function EcommerceClonePanel({
clampCloneVideoDuration,
setCloneVideoSmart,
handleGenerate,
onCancelGenerate,
formatRatioDisplayValue,
setVideoOutfitFiles,
onStartVideoPlan,
@@ -746,6 +748,11 @@ export default function EcommerceClonePanel({
{status === "generating" ? <LoadingOutlined /> : status === "failed" ? <ReloadOutlined /> : null}
{status === "generating" ? "生成中..." : status === "failed" ? "重新生成" : cloneOutput === "video-outfit" ? "✦ 开始换装" : "✦ 开始生成"}
</button>
{status === "generating" && cloneOutput !== "video" ? (
<button type="button" className="clone-ai-generate clone-ai-generate--cancel" onClick={onCancelGenerate}>
{"\u53d6\u6d88\u751f\u6210"}
</button>
) : null}
</div>
</>
);
@@ -28,6 +28,7 @@ interface EcommerceDetailPanelProps {
handleDetailAiWrite: () => void;
toggleDetailModule: (id: string) => void;
handleDetailGenerate: () => void;
onCancelGenerate: () => void;
}
export default function EcommerceDetailPanel({
@@ -56,6 +57,7 @@ export default function EcommerceDetailPanel({
handleDetailAiWrite,
toggleDetailModule,
handleDetailGenerate,
onCancelGenerate,
}: EcommerceDetailPanelProps) {
return (
<>
@@ -162,6 +164,11 @@ export default function EcommerceDetailPanel({
{detailStatus === "generating" ? <LoadingOutlined /> : null}
{detailPrimaryLabel}
</button>
{detailStatus === "generating" ? (
<button type="button" className="product-clone-primary product-clone-primary--cancel" onClick={onCancelGenerate}>
{"\u53d6\u6d88\u751f\u6210"}
</button>
) : null}
</footer>
</>
);
@@ -35,6 +35,7 @@ interface EcommerceTryOnPanelProps {
setSmartScene: (updater: (current: boolean) => boolean) => void;
setTryOnRatio: (value: string) => void;
handleTryOnGenerate: () => void;
onCancelGenerate: () => void;
}
export default function EcommerceTryOnPanel({
@@ -70,6 +71,7 @@ export default function EcommerceTryOnPanel({
setSmartScene,
setTryOnRatio,
handleTryOnGenerate,
onCancelGenerate,
}: EcommerceTryOnPanelProps) {
return (
<>
@@ -213,6 +215,11 @@ export default function EcommerceTryOnPanel({
{tryOnStatus === "generating" ? <LoadingOutlined /> : null}
{tryOnPrimaryLabel}
</button>
{tryOnStatus === "generating" ? (
<button type="button" className="product-clone-primary product-clone-primary--cancel" onClick={onCancelGenerate}>
{"\u53d6\u6d88\u751f\u6210"}
</button>
) : null}
</footer>
</>
);