2026-06-02 12:38:01 +08:00
|
|
|
import { Fragment, useCallback, useRef, useState } from "react";
|
|
|
|
|
import {
|
|
|
|
|
CopyOutlined,
|
|
|
|
|
DownloadOutlined,
|
|
|
|
|
FolderAddOutlined,
|
|
|
|
|
LoadingOutlined,
|
|
|
|
|
PlayCircleOutlined,
|
|
|
|
|
ReloadOutlined,
|
|
|
|
|
SendOutlined,
|
|
|
|
|
StopOutlined,
|
|
|
|
|
} from "@ant-design/icons";
|
|
|
|
|
import { runVideoPlan, renderScene, buildSceneTasks } from "./ecommerceVideoService";
|
|
|
|
|
import {
|
|
|
|
|
PLAN_STEP_LABELS,
|
|
|
|
|
type EcommerceVideoStage,
|
|
|
|
|
type EcommerceVideoSceneTask,
|
|
|
|
|
type EcommerceVideoPlanResult,
|
|
|
|
|
type PlanStep,
|
|
|
|
|
} from "./ecommerceVideoTypes";
|
|
|
|
|
import type { AdVideoUserConfig } from "../../api/adVideoPlanClient";
|
2026-06-02 19:42:20 +08:00
|
|
|
import { ServerRequestError } from "../../api/serverConnection";
|
2026-06-02 12:38:01 +08:00
|
|
|
import { saveToolResultToLocal, addToolResultToAssetLibrary } from "../workbench/toolResultActions";
|
|
|
|
|
import { useAppStore } from "../../stores";
|
|
|
|
|
|
|
|
|
|
interface EcommerceVideoWorkspaceProps {
|
|
|
|
|
isAuthenticated: boolean;
|
|
|
|
|
productImageDataUrls: string[];
|
|
|
|
|
requirement: string;
|
|
|
|
|
platform: string;
|
|
|
|
|
aspectRatio: string;
|
|
|
|
|
durationSeconds: number;
|
|
|
|
|
resolution: string;
|
|
|
|
|
onRequestLogin?: () => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ALL_STEPS: PlanStep[] = [
|
|
|
|
|
"upload", "analyze", "summary", "selling",
|
|
|
|
|
"creative", "storyboard", "prompts", "compliance",
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
function mapResolutionToQuality(res: string): "720P" | "1080P" {
|
|
|
|
|
return res.includes("720") ? "720P" : "1080P";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function EcommerceVideoWorkspace({
|
|
|
|
|
isAuthenticated,
|
|
|
|
|
productImageDataUrls,
|
|
|
|
|
requirement,
|
|
|
|
|
platform,
|
|
|
|
|
aspectRatio,
|
|
|
|
|
durationSeconds,
|
|
|
|
|
resolution,
|
|
|
|
|
onRequestLogin,
|
|
|
|
|
}: EcommerceVideoWorkspaceProps) {
|
|
|
|
|
const [stage, setStage] = useState<EcommerceVideoStage>("idle");
|
|
|
|
|
const [planResult, setPlanResult] = useState<EcommerceVideoPlanResult | null>(null);
|
|
|
|
|
const [scenes, setScenes] = useState<EcommerceVideoSceneTask[]>([]);
|
|
|
|
|
const [completedSteps, setCompletedSteps] = useState<PlanStep[]>([]);
|
|
|
|
|
const [currentStep, setCurrentStep] = useState<PlanStep | null>(null);
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
const [actionNotice, setActionNotice] = useState<string | null>(null);
|
|
|
|
|
const abortControllerRef = useRef<AbortController | null>(null);
|
|
|
|
|
const renderAbortRef = useRef({ current: false });
|
|
|
|
|
const setView = useAppStore((s) => s.setView);
|
|
|
|
|
|
|
|
|
|
const showNotice = (msg: string) => {
|
|
|
|
|
setActionNotice(msg);
|
|
|
|
|
setTimeout(() => setActionNotice(null), 3000);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleDownload = async (url: string) => {
|
|
|
|
|
try {
|
|
|
|
|
await saveToolResultToLocal({
|
|
|
|
|
url,
|
|
|
|
|
name: `ecommerce-video-${Date.now()}`,
|
|
|
|
|
type: "video",
|
|
|
|
|
isVideo: true,
|
|
|
|
|
tags: ["电商", "短视频", "生成视频"],
|
|
|
|
|
});
|
|
|
|
|
showNotice("下载完成");
|
|
|
|
|
} catch {
|
|
|
|
|
const a = document.createElement("a");
|
|
|
|
|
a.href = url;
|
|
|
|
|
a.download = "ecommerce-video.mp4";
|
|
|
|
|
a.click();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleSaveAsset = async (url: string) => {
|
|
|
|
|
try {
|
|
|
|
|
const result = await addToolResultToAssetLibrary({
|
|
|
|
|
url,
|
|
|
|
|
name: `电商短视频-${Date.now()}.mp4`,
|
|
|
|
|
description: "电商广告视频生成结果",
|
|
|
|
|
type: "video",
|
|
|
|
|
isVideo: true,
|
|
|
|
|
tags: ["电商", "短视频", "广告视频"],
|
|
|
|
|
metadata: { source: "ecommerce-video", platform },
|
|
|
|
|
});
|
|
|
|
|
showNotice(result === "server" ? "已保存到资产库" : "已保存到本地资产库");
|
|
|
|
|
} catch {
|
|
|
|
|
showNotice("保存失败");
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleImportToCanvas = async (url: string) => {
|
|
|
|
|
try {
|
|
|
|
|
await addToolResultToAssetLibrary({
|
|
|
|
|
url,
|
|
|
|
|
name: `电商短视频-${Date.now()}.mp4`,
|
|
|
|
|
description: "电商广告视频 - 导入画布",
|
|
|
|
|
type: "video",
|
|
|
|
|
isVideo: true,
|
|
|
|
|
tags: ["电商", "短视频", "画布导入"],
|
|
|
|
|
metadata: { source: "ecommerce-video", platform },
|
|
|
|
|
});
|
|
|
|
|
setView("canvas");
|
|
|
|
|
showNotice("已保存资产并跳转画布");
|
|
|
|
|
} catch {
|
|
|
|
|
showNotice("导入失败");
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const buildConfig = useCallback((): AdVideoUserConfig => ({
|
|
|
|
|
platform,
|
|
|
|
|
aspectRatio,
|
|
|
|
|
durationSeconds,
|
|
|
|
|
style: "痛点解决",
|
|
|
|
|
language: "中文",
|
|
|
|
|
market: "中国",
|
|
|
|
|
needVoiceover: true,
|
|
|
|
|
needSubtitle: true,
|
|
|
|
|
conversionFocus: "conversion",
|
|
|
|
|
}), [platform, aspectRatio, durationSeconds]);
|
|
|
|
|
|
|
|
|
|
const handlePlan = async () => {
|
|
|
|
|
if (!isAuthenticated) { onRequestLogin?.(); return; }
|
|
|
|
|
if (!productImageDataUrls.length && !requirement.trim()) {
|
|
|
|
|
setError("请先上传产品图片或填写商品说明");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
abortControllerRef.current?.abort();
|
|
|
|
|
const controller = new AbortController();
|
|
|
|
|
abortControllerRef.current = controller;
|
|
|
|
|
setStage("planning");
|
|
|
|
|
setError(null);
|
|
|
|
|
setCompletedSteps([]);
|
|
|
|
|
setCurrentStep(null);
|
|
|
|
|
setPlanResult(null);
|
|
|
|
|
setScenes([]);
|
|
|
|
|
try {
|
|
|
|
|
const result = await runVideoPlan(
|
|
|
|
|
productImageDataUrls, requirement, buildConfig(),
|
|
|
|
|
{
|
|
|
|
|
onStepStart: (step) => setCurrentStep(step),
|
|
|
|
|
onStepDone: (step) => setCompletedSteps((prev) => [...prev, step]),
|
|
|
|
|
signal: controller.signal,
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
setPlanResult(result);
|
|
|
|
|
setScenes(buildSceneTasks(result));
|
|
|
|
|
setStage("planned");
|
|
|
|
|
} catch (err) {
|
|
|
|
|
if ((err as Error).name === "AbortError") return;
|
|
|
|
|
setError(err instanceof Error ? err.message : "策划失败");
|
|
|
|
|
setStage("idle");
|
|
|
|
|
} finally {
|
|
|
|
|
setCurrentStep(null);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleRender = async () => {
|
|
|
|
|
if (!planResult || !scenes.length) return;
|
2026-06-02 19:37:29 +08:00
|
|
|
const imageUrl = planResult.imageUrls[0] || "";
|
2026-06-02 12:38:01 +08:00
|
|
|
setStage("rendering");
|
|
|
|
|
setError(null);
|
|
|
|
|
renderAbortRef.current = { current: false };
|
|
|
|
|
const quality = mapResolutionToQuality(resolution);
|
|
|
|
|
|
|
|
|
|
for (const scene of scenes) {
|
|
|
|
|
if (renderAbortRef.current.current) break;
|
|
|
|
|
setScenes((prev) => prev.map((s) =>
|
|
|
|
|
s.sceneId === scene.sceneId ? { ...s, status: "pending" } : s));
|
|
|
|
|
try {
|
|
|
|
|
await renderScene(
|
|
|
|
|
{ sceneId: scene.sceneId, prompt: scene.prompt, durationSeconds: scene.durationSeconds, imageUrl, aspectRatio, resolution: quality },
|
|
|
|
|
{
|
|
|
|
|
onSceneSubmitted: (id, taskId) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, taskId, status: "running" } : s)),
|
|
|
|
|
onSceneProgress: (id, progress) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, progress } : s)),
|
|
|
|
|
onSceneCompleted: (id, url) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, status: "completed", progress: 100, resultUrl: url } : s)),
|
|
|
|
|
onSceneFailed: (id, err2) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, status: "failed", error: err2 } : s)),
|
|
|
|
|
},
|
|
|
|
|
renderAbortRef.current,
|
|
|
|
|
);
|
|
|
|
|
} catch (err) {
|
2026-06-02 19:42:20 +08:00
|
|
|
const message = err instanceof Error ? err.message : "生成失败";
|
|
|
|
|
const isPaymentError = err instanceof ServerRequestError && err.status === 402;
|
2026-06-02 12:38:01 +08:00
|
|
|
setScenes((prev) => prev.map((s) =>
|
2026-06-02 19:42:20 +08:00
|
|
|
s.sceneId === scene.sceneId ? { ...s, status: "failed", error: isPaymentError ? "余额不足,请充值后继续" : message } : s));
|
|
|
|
|
if (isPaymentError) {
|
|
|
|
|
setError("余额不足,请充值后再生成视频");
|
|
|
|
|
renderAbortRef.current.current = true;
|
|
|
|
|
break;
|
|
|
|
|
}
|
2026-06-02 12:38:01 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
setScenes((current) => {
|
|
|
|
|
const hasFailed = current.some((s) => s.status === "failed");
|
|
|
|
|
const allDone = current.every((s) => s.status === "completed" || s.status === "failed");
|
|
|
|
|
if (allDone) setStage(hasFailed ? "partial_failed" : "completed");
|
|
|
|
|
return current;
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleCancel = () => {
|
|
|
|
|
abortControllerRef.current?.abort();
|
|
|
|
|
renderAbortRef.current.current = true;
|
|
|
|
|
setStage("cancelled");
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const completedScenes = scenes.filter((s) => s.status === "completed" && s.resultUrl);
|
|
|
|
|
const primaryVideo = completedScenes[0]?.resultUrl;
|
|
|
|
|
const canRender = planResult?.compliance.allow_video_generation && stage === "planned";
|
2026-06-02 19:37:29 +08:00
|
|
|
const sourceImage = planResult?.imageUrls[0] || productImageDataUrls[0] || "";
|
2026-06-02 12:38:01 +08:00
|
|
|
const flowHasStarted = stage !== "idle" || completedSteps.length > 0 || scenes.length > 0;
|
|
|
|
|
const flowMeta = `${platform} / ${aspectRatio} / ${durationSeconds}s / ${resolution}`;
|
|
|
|
|
const planActionLabel = stage === "planning"
|
|
|
|
|
? "策划中"
|
|
|
|
|
: (stage === "planned" || stage === "completed" || stage === "partial_failed") ? "重新策划" : "一键策划";
|
|
|
|
|
const renderActionLabel = stage === "rendering" ? "生成中" : "确认生成";
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="ecom-video-workspace" data-stage={stage}>
|
|
|
|
|
<header className="ecom-video-flowbar">
|
|
|
|
|
<div className="ecom-video-flowbar__title" aria-label={`短视频分镜流,${flowMeta}`} title={flowMeta}>
|
|
|
|
|
<span className={`ecom-video-flowbar__pulse${flowHasStarted ? " is-active" : ""}`} aria-hidden="true" />
|
|
|
|
|
<span className="ecom-video-flowbar__wave" aria-hidden="true"><i /><i /><i /></span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="ecom-video-step-dots" aria-label="策划进度">
|
|
|
|
|
{ALL_STEPS.map((step) => {
|
|
|
|
|
const isDone = completedSteps.includes(step);
|
|
|
|
|
const isActive = currentStep === step;
|
|
|
|
|
const cls = isDone ? "is-done" : isActive ? "is-active" : "";
|
|
|
|
|
return (
|
|
|
|
|
<span
|
|
|
|
|
key={step}
|
|
|
|
|
className={`ecom-video-step-dot ${cls}`}
|
|
|
|
|
title={PLAN_STEP_LABELS[step]}
|
|
|
|
|
aria-label={PLAN_STEP_LABELS[step]}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="ecom-video-flowbar__actions">
|
|
|
|
|
{error ? <span className="ecom-video-flowbar__error" role="alert">{error}</span> : null}
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="ecom-video-flow-action"
|
|
|
|
|
disabled={stage === "planning" || stage === "rendering"}
|
|
|
|
|
onClick={() => void handlePlan()}
|
|
|
|
|
aria-label={planActionLabel}
|
|
|
|
|
title={planActionLabel}
|
|
|
|
|
>
|
|
|
|
|
{stage === "planning" ? <LoadingOutlined /> : (stage === "planned" || stage === "completed" || stage === "partial_failed") ? <ReloadOutlined /> : <PlayCircleOutlined />}
|
|
|
|
|
</button>
|
|
|
|
|
{(stage === "rendering" || stage === "planned") ? (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="ecom-video-flow-action ecom-video-flow-action--ghost"
|
|
|
|
|
disabled={!canRender}
|
|
|
|
|
onClick={() => void handleRender()}
|
|
|
|
|
aria-label={renderActionLabel}
|
|
|
|
|
title={renderActionLabel}
|
|
|
|
|
>
|
|
|
|
|
{stage === "rendering" ? <LoadingOutlined /> : <SendOutlined />}
|
|
|
|
|
</button>
|
|
|
|
|
) : null}
|
|
|
|
|
{stage === "rendering" ? (
|
|
|
|
|
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--danger" onClick={handleCancel} aria-label="取消生成" title="取消生成">
|
|
|
|
|
<StopOutlined />
|
|
|
|
|
</button>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
<section className="ecom-video-flow-canvas" aria-label="视频分镜流程图">
|
|
|
|
|
{completedScenes.length === 0 && !sourceImage ? (
|
|
|
|
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", color: "#697486", fontSize: 13 }}>
|
|
|
|
|
<span>上传商品图并点击"一键策划"开始</span>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="ecom-video-flow-map">
|
|
|
|
|
{sourceImage ? (
|
|
|
|
|
<article className="ecom-video-flow-node ecom-video-flow-node--source is-ready" aria-label="商品图节点">
|
|
|
|
|
<div className="ecom-video-flow-node__media">
|
|
|
|
|
<img src={sourceImage} alt="商品图" />
|
|
|
|
|
</div>
|
|
|
|
|
<span className="ecom-video-flow-node__status-orb" aria-hidden="true" />
|
|
|
|
|
</article>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
{sourceImage && completedScenes.length > 0 ? (
|
|
|
|
|
<div className="ecom-video-flow-connector is-active" aria-hidden="true"><i /></div>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
<div className="ecom-video-scene-strip" aria-label="已完成分镜节点">
|
|
|
|
|
{completedScenes.map((scene, index) => (
|
|
|
|
|
<Fragment key={scene.sceneId}>
|
|
|
|
|
<article
|
|
|
|
|
className="ecom-video-flow-node ecom-video-flow-node--scene is-completed"
|
|
|
|
|
aria-label={`镜头 ${scene.sceneId},完成`}
|
|
|
|
|
title={`镜头 ${scene.sceneId}`}
|
|
|
|
|
>
|
|
|
|
|
<div className="ecom-video-flow-node__media">
|
|
|
|
|
<video src={scene.resultUrl!} muted playsInline loop autoPlay />
|
|
|
|
|
</div>
|
|
|
|
|
<span className="ecom-video-flow-node__status-orb" aria-hidden="true" />
|
|
|
|
|
</article>
|
|
|
|
|
{index < completedScenes.length - 1 ? (
|
|
|
|
|
<div className="ecom-video-scene-link is-active" aria-hidden="true"><i /></div>
|
|
|
|
|
) : null}
|
|
|
|
|
</Fragment>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{completedScenes.length > 0 && primaryVideo ? (
|
|
|
|
|
<div className="ecom-video-flow-connector is-active" aria-hidden="true"><i /></div>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
{primaryVideo ? (
|
|
|
|
|
<article className="ecom-video-flow-node ecom-video-flow-node--final is-completed" aria-label="成片节点,已完成">
|
|
|
|
|
<div className="ecom-video-flow-node__media">
|
|
|
|
|
<video src={primaryVideo} muted playsInline loop autoPlay />
|
|
|
|
|
</div>
|
|
|
|
|
<span className="ecom-video-flow-node__status-orb" aria-hidden="true" />
|
|
|
|
|
</article>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{primaryVideo ? (
|
|
|
|
|
<div className="ecom-video-flow-dock" aria-label="视频交付操作">
|
|
|
|
|
<button type="button" aria-label="下载当前视频" title="下载当前视频" onClick={() => void handleDownload(primaryVideo)}><DownloadOutlined /></button>
|
|
|
|
|
<button type="button" aria-label="保存到资产库" title="保存到资产库" onClick={() => void handleSaveAsset(primaryVideo)}><FolderAddOutlined /></button>
|
|
|
|
|
<button type="button" aria-label="导入画布" title="导入画布" onClick={() => void handleImportToCanvas(primaryVideo)}><SendOutlined /></button>
|
|
|
|
|
<button type="button" aria-label="复制视频链接" title="复制视频链接" onClick={() => void navigator.clipboard.writeText(primaryVideo)}><CopyOutlined /></button>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
{actionNotice ? <div className="ecom-video-flow-notice">{actionNotice}</div> : null}
|
|
|
|
|
</section>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|