Initial commit: OmniAI Web Frontend
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,347 @@
|
||||
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";
|
||||
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;
|
||||
const imageUrl = productImageDataUrls[0] || "";
|
||||
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) {
|
||||
setScenes((prev) => prev.map((s) =>
|
||||
s.sceneId === scene.sceneId ? { ...s, status: "failed", error: err instanceof Error ? err.message : "生成失败" } : s));
|
||||
}
|
||||
}
|
||||
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";
|
||||
const sourceImage = productImageDataUrls[0] || "";
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user