468d1d27dd
- 移除未登录全页面拦截,改为浏览自由 + 功能使用时弹窗 - 修复PageTransition退出动画卡死导致黑屏的bug - CanvasPage添加加载中状态避免首次访问黑屏假死 - 全站7个工具页添加页面保活机制,切页后台任务不中断 - 修复未登录时401误触发"用户已在别处登录"弹窗 - 删除MorePage模板板块、微信登录、EcommerceTemplates/SizeTemplate路由 - 剧本评分接入DashScope qwen3.7-max直连API - 电商视频生成重构为3阶段可视管线(策划→生成图片→生成视频) - 电商视频保活增强:异步函数直接写localStorage避免卸载丢失 - Workbench侧边栏移除mode过滤,三模式共用同一对话列表 - 首页更新轮播图/背景视频、按钮跳转修正、文案优化 - AppShell顶栏新增网站备案信息按钮 - 多个页面的terminate/cancel按钮覆盖、单镜头重试、批量保存下载 Co-Authored-By: Claude Code <noreply@anthropic.com>
600 lines
30 KiB
TypeScript
600 lines
30 KiB
TypeScript
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
|
||
import {
|
||
CopyOutlined,
|
||
DownloadOutlined,
|
||
FolderAddOutlined,
|
||
LoadingOutlined,
|
||
PlayCircleOutlined,
|
||
ReloadOutlined,
|
||
SendOutlined,
|
||
StopOutlined,
|
||
} from "@ant-design/icons";
|
||
import { runVideoPlan, renderSceneImage, renderScene, buildSceneTasks } from "./ecommerceVideoService";
|
||
import {
|
||
PLAN_STEP_LABELS,
|
||
PLAN_STEPS_DISPLAY,
|
||
type EcommerceVideoStage,
|
||
type EcommerceVideoSceneTask,
|
||
type EcommerceVideoPlanResult,
|
||
type PlanStep,
|
||
} from "./ecommerceVideoTypes";
|
||
import type { AdVideoUserConfig } from "../../api/adVideoPlanClient";
|
||
import { ServerRequestError } from "../../api/serverConnection";
|
||
import { saveToolResultToLocal, addToolResultToAssetLibrary } from "../workbench/toolResultActions";
|
||
import { useAppStore } from "../../stores";
|
||
import {
|
||
saveEcommerceVideoState,
|
||
loadEcommerceVideoState,
|
||
clearEcommerceVideoState,
|
||
} from "./ecommerceVideoKeepalive";
|
||
|
||
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 [sourceImageUrls, setSourceImageUrls] = useState<string[]>([]);
|
||
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 keepaliveRestoredRef = useRef(false);
|
||
const keepalivePollingStartedRef = useRef(false);
|
||
|
||
// ── Keep-alive: restore saved state on mount ─────────────
|
||
useEffect(() => {
|
||
if (keepaliveRestoredRef.current) return;
|
||
keepaliveRestoredRef.current = true;
|
||
const saved = loadEcommerceVideoState();
|
||
if (!saved) return;
|
||
if (saved.stage === "idle" || saved.stage === "cancelled") return;
|
||
// Restore completed / in-progress states — results persist across page switches
|
||
setStage(saved.stage);
|
||
setCompletedSteps(saved.completedSteps || []);
|
||
setPlanResult(saved.planResult);
|
||
setScenes(saved.scenes || []);
|
||
setSourceImageUrls(saved.sourceImageUrls || saved.planResult?.imageUrls || []);
|
||
}, []);
|
||
|
||
// ── Keep-alive: save state on changes ───────────────────
|
||
useEffect(() => {
|
||
if (stage === "idle" || stage === "cancelled") return;
|
||
saveEcommerceVideoState({ stage, completedSteps, planResult, scenes, sourceImageUrls });
|
||
}, [stage, completedSteps, planResult, scenes, sourceImageUrls]);
|
||
|
||
// ── Keep-alive: resume polling for running tasks ──────────
|
||
useEffect(() => {
|
||
if (keepalivePollingStartedRef.current) return;
|
||
if (!scenes.length || stage === "idle" || stage === "cancelled" || stage === "completed") return;
|
||
|
||
const hasRunningScenes = scenes.some((s) => s.status === "running" || s.status === "pending");
|
||
if (!hasRunningScenes) return;
|
||
keepalivePollingStartedRef.current = true;
|
||
|
||
// Resume polling for image generation tasks
|
||
if (stage === "imaging") {
|
||
renderAbortRef.current = { current: false };
|
||
void (async () => {
|
||
for (const scene of scenes) {
|
||
if (renderAbortRef.current.current) break;
|
||
if (scene.status !== "running" && scene.status !== "pending") continue;
|
||
if (!scene.imageTaskId) continue;
|
||
try {
|
||
const { waitForTask } = await import("../../api/taskSubscription");
|
||
const resultUrl = await waitForTask(scene.imageTaskId, {
|
||
abortRef: renderAbortRef.current,
|
||
onProgress: (e) =>
|
||
setScenes((prev) => prev.map((s) => (s.sceneId === scene.sceneId ? { ...s, progress: e.progress } : s))),
|
||
});
|
||
if (resultUrl) {
|
||
setScenes((prev) =>
|
||
prev.map((s) => (s.sceneId === scene.sceneId ? { ...s, status: "idle", progress: 100, imageUrl: resultUrl } : s)),
|
||
);
|
||
}
|
||
} catch {
|
||
setScenes((prev) =>
|
||
prev.map((s) => (s.sceneId === scene.sceneId ? { ...s, status: "idle", error: "恢复任务失败" } : s)),
|
||
);
|
||
}
|
||
}
|
||
setScenes((current) => {
|
||
const allImaged = current.every((s) => s.imageUrl);
|
||
if (allImaged) setStage("imaged");
|
||
return current;
|
||
});
|
||
})();
|
||
}
|
||
|
||
// Resume polling for video rendering tasks
|
||
if (stage === "rendering") {
|
||
renderAbortRef.current = { current: false };
|
||
void (async () => {
|
||
for (const scene of scenes) {
|
||
if (renderAbortRef.current.current) break;
|
||
if (scene.status !== "running" && scene.status !== "pending") continue;
|
||
if (!scene.taskId) continue;
|
||
try {
|
||
const { waitForTask } = await import("../../api/taskSubscription");
|
||
const resultUrl = await waitForTask(scene.taskId, {
|
||
abortRef: renderAbortRef.current,
|
||
onProgress: (e) =>
|
||
setScenes((prev) => prev.map((s) => (s.sceneId === scene.sceneId ? { ...s, progress: e.progress } : s))),
|
||
});
|
||
if (resultUrl) {
|
||
setScenes((prev) =>
|
||
prev.map((s) =>
|
||
s.sceneId === scene.sceneId ? { ...s, status: "completed", progress: 100, resultUrl: resultUrl } : s,
|
||
),
|
||
);
|
||
}
|
||
} catch {
|
||
setScenes((prev) =>
|
||
prev.map((s) => (s.sceneId === scene.sceneId ? { ...s, status: "failed", error: "恢复任务失败" } : 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;
|
||
});
|
||
})();
|
||
}
|
||
}, [scenes, stage]);
|
||
|
||
// Note: keep-alive is NOT cleared on completion — results persist across page switches.
|
||
// Only cleared when user explicitly starts a new plan via handlePlan.
|
||
|
||
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 handleSaveAllAssets = async () => {
|
||
if (!completedScenes.length) return;
|
||
let saved = 0;
|
||
for (const scene of completedScenes) {
|
||
try {
|
||
await addToolResultToAssetLibrary({
|
||
url: scene.resultUrl!, name: `电商短视频-镜头${scene.sceneId}-${Date.now()}.mp4`,
|
||
description: `电商广告视频 - 镜头${scene.sceneId}`,
|
||
type: "video", isVideo: true, tags: ["电商", "短视频", "广告视频"],
|
||
metadata: { source: "ecommerce-video", platform, sceneId: scene.sceneId },
|
||
});
|
||
saved++;
|
||
} catch { /* continue */ }
|
||
}
|
||
showNotice(saved > 0 ? `已保存 ${saved}/${completedScenes.length} 个视频到资产库` : "保存失败");
|
||
};
|
||
|
||
const handleDownloadAll = async () => {
|
||
for (const scene of completedScenes) {
|
||
await new Promise((r) => setTimeout(r, 300));
|
||
const a = document.createElement("a");
|
||
a.href = scene.resultUrl!;
|
||
a.download = `ecommerce-video-scene-${scene.sceneId}.mp4`;
|
||
a.click();
|
||
}
|
||
showNotice(`正在下载 ${completedScenes.length} 个视频`);
|
||
};
|
||
|
||
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]);
|
||
|
||
// ── Phase 1: Planning ──────────────────────────────────────
|
||
|
||
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([]); setSourceImageUrls([]);
|
||
try {
|
||
const result = await runVideoPlan(
|
||
productImageDataUrls, requirement, buildConfig(),
|
||
{
|
||
onStepStart: (step) => setCurrentStep(step),
|
||
onStepDone: (step) => setCompletedSteps((prev) => [...prev, step]),
|
||
onImagesUploaded: (urls) => { setSourceImageUrls(urls); saveEcommerceVideoState({ stage: "planning", completedSteps: ["upload"], planResult: null, scenes: [], sourceImageUrls: urls }); },
|
||
signal: controller.signal,
|
||
},
|
||
);
|
||
const builtScenes = buildSceneTasks(result);
|
||
setPlanResult(result);
|
||
setScenes(builtScenes);
|
||
setStage("planned");
|
||
// Persist immediately — component may be unmounted by the time React re-renders
|
||
saveEcommerceVideoState({ stage: "planned", completedSteps: [...ALL_STEPS], planResult: result, scenes: builtScenes, sourceImageUrls: result.imageUrls });
|
||
} catch (err) {
|
||
if ((err as Error).name === "AbortError") return;
|
||
setError(err instanceof Error ? err.message : "策划失败");
|
||
setStage("idle");
|
||
} finally { setCurrentStep(null); }
|
||
};
|
||
|
||
// ── Phase 2: Image generation per scene ──────────────────────
|
||
|
||
const handleGenerateImages = async () => {
|
||
if (!planResult || !scenes.length) return;
|
||
setStage("imaging"); setError(null);
|
||
renderAbortRef.current = { current: false };
|
||
const ratio = aspectRatio.includes("9:16") || aspectRatio.includes("9:16") ? "9:16"
|
||
: aspectRatio.includes("16:9") || aspectRatio.includes("16:9") ? "16:9"
|
||
: "1:1";
|
||
let currentScenes = [...scenes];
|
||
const persistScenes = (next: EcommerceVideoSceneTask[]) => {
|
||
currentScenes = next;
|
||
setScenes(next);
|
||
saveEcommerceVideoState({ stage: "imaging", completedSteps, planResult, scenes: next, sourceImageUrls });
|
||
};
|
||
for (const scene of currentScenes) {
|
||
if (renderAbortRef.current.current) break;
|
||
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending" } : s));
|
||
try {
|
||
await renderSceneImage(
|
||
{ sceneId: scene.sceneId, prompt: scene.prompt, aspectRatio: ratio },
|
||
{
|
||
onSceneImageSubmitted: (id, taskId) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, imageTaskId: taskId, status: "running" } : s)),
|
||
onSceneImageProgress: (id, progress) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, progress } : s)),
|
||
onSceneImageCompleted: (id, url) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "idle", progress: 100, imageUrl: url } : s)),
|
||
onSceneImageFailed: (id, err2) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "idle", error: err2 } : s)),
|
||
},
|
||
renderAbortRef.current,
|
||
);
|
||
} catch (err) {
|
||
const message = err instanceof Error ? err.message : "图片生成失败";
|
||
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "idle", error: message } : s));
|
||
}
|
||
}
|
||
const allHaveImages = currentScenes.every((s) => s.imageUrl);
|
||
const finalStage = allHaveImages ? "imaged" as const : "partial_failed" as const;
|
||
setStage(finalStage);
|
||
saveEcommerceVideoState({ stage: finalStage, completedSteps, planResult, scenes: currentScenes, sourceImageUrls });
|
||
};
|
||
|
||
// ── Phase 3: Video rendering from generated images ──────────
|
||
|
||
const handleRenderVideos = async () => {
|
||
if (!scenes.length) return;
|
||
const firstImage = scenes[0]?.imageUrl;
|
||
if (!firstImage) { setError("请先生成分镜图片"); return; }
|
||
setStage("rendering"); setError(null);
|
||
renderAbortRef.current = { current: false };
|
||
const quality = mapResolutionToQuality(resolution);
|
||
let currentScenes = [...scenes];
|
||
const persistScenes = (next: EcommerceVideoSceneTask[]) => {
|
||
currentScenes = next;
|
||
setScenes(next);
|
||
saveEcommerceVideoState({ stage: "rendering", completedSteps, planResult, scenes: next, sourceImageUrls });
|
||
};
|
||
for (const scene of currentScenes) {
|
||
if (renderAbortRef.current.current) break;
|
||
if (!scene.imageUrl) continue;
|
||
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending" } : s));
|
||
try {
|
||
await renderScene(
|
||
{ sceneId: scene.sceneId, prompt: scene.prompt, durationSeconds: scene.durationSeconds, imageUrl: scene.imageUrl, aspectRatio, resolution: quality },
|
||
{
|
||
onSceneSubmitted: (id, taskId) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, taskId, status: "running" } : s)),
|
||
onSceneProgress: (id, progress) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, progress } : s)),
|
||
onSceneCompleted: (id, url) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "completed", progress: 100, resultUrl: url } : s)),
|
||
onSceneFailed: (id, err2) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "failed", error: err2 } : s)),
|
||
},
|
||
renderAbortRef.current,
|
||
);
|
||
} catch (err) {
|
||
const msg = err instanceof Error ? err.message : "生成失败";
|
||
const isPayment = err instanceof ServerRequestError && err.status === 402;
|
||
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "failed", error: isPayment ? "余额不足,请充值后继续" : msg } : s));
|
||
if (isPayment) { setError("余额不足,请充值后再生成视频"); renderAbortRef.current.current = true; break; }
|
||
}
|
||
}
|
||
const hasFailed = currentScenes.some((s) => s.status === "failed");
|
||
const allDone = currentScenes.every((s) => s.status === "completed" || s.status === "failed");
|
||
const finalStage = allDone ? (hasFailed ? "partial_failed" as const : "completed" as const) : "rendering" as const;
|
||
setScenes(currentScenes);
|
||
setStage(finalStage);
|
||
saveEcommerceVideoState({ stage: finalStage, completedSteps, planResult, scenes: currentScenes, sourceImageUrls });
|
||
};
|
||
|
||
const handleCancel = () => { abortControllerRef.current?.abort(); renderAbortRef.current.current = true; setStage("cancelled"); };
|
||
|
||
const handleRetryScene = async (scene: EcommerceVideoSceneTask) => {
|
||
if (!scene.imageUrl) return;
|
||
setScenes((prev) => prev.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending", error: undefined } : s));
|
||
try {
|
||
await renderScene(
|
||
{ sceneId: scene.sceneId, prompt: scene.prompt, durationSeconds: scene.durationSeconds, imageUrl: scene.imageUrl!, aspectRatio, resolution: mapResolutionToQuality(resolution) },
|
||
{
|
||
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 as Error).message } : s));
|
||
}
|
||
};
|
||
|
||
// ── Derived state ───────────────────────────────────────────
|
||
|
||
const completedScenes = scenes.filter((s) => s.status === "completed" && s.resultUrl);
|
||
const imagedScenes = scenes.filter((s) => s.imageUrl);
|
||
const primaryVideo = completedScenes[0]?.resultUrl;
|
||
const sourceImage = sourceImageUrls[0] || planResult?.imageUrls[0] || productImageDataUrls[0] || "";
|
||
const flowHasStarted = stage !== "idle" || completedSteps.length > 0;
|
||
const flowMeta = `${platform} / ${aspectRatio} / ${durationSeconds}s / ${resolution}`;
|
||
const hasImaging = stage === "imaging" || stage === "imaged" || stage === "rendering" || stage === "completed" || stage === "partial_failed";
|
||
const hasRendering = stage === "rendering" || stage === "completed" || stage === "partial_failed";
|
||
const visiblePlanSteps = PLAN_STEPS_DISPLAY.filter((s) => completedSteps.includes(s));
|
||
|
||
return (
|
||
<div className="ecom-video-workspace" data-stage={stage}>
|
||
{/* ── Flow bar ──────────────────────────────────── */}
|
||
<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;
|
||
return <span key={step} className={`ecom-video-step-dot ${isDone ? "is-done" : isActive ? "is-active" : ""}`} title={PLAN_STEP_LABELS[step]} />;
|
||
})}
|
||
</div>
|
||
|
||
<div className="ecom-video-flowbar__actions">
|
||
{error ? <span className="ecom-video-flowbar__error" role="alert">{error}</span> : null}
|
||
{stage !== "planning" && stage !== "imaging" && stage !== "rendering" ? (
|
||
<button type="button" className="ecom-video-flow-action"
|
||
onClick={() => void handlePlan()} title="一键策划">
|
||
<PlayCircleOutlined />
|
||
</button>
|
||
) : null}
|
||
{stage === "planned" ? (
|
||
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost"
|
||
onClick={() => void handleGenerateImages()} title="生成图片">
|
||
<SendOutlined />
|
||
</button>
|
||
) : null}
|
||
{stage === "imaged" ? (
|
||
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost"
|
||
onClick={() => void handleRenderVideos()} title="生成视频">
|
||
<SendOutlined />
|
||
</button>
|
||
) : null}
|
||
{stage === "planning" ? (
|
||
<span className="ecom-video-flowbar__stage-label"><LoadingOutlined /> 策划中</span>
|
||
) : null}
|
||
{stage === "imaging" ? (
|
||
<span className="ecom-video-flowbar__stage-label"><LoadingOutlined /> 生成图片中</span>
|
||
) : null}
|
||
{stage === "rendering" ? (
|
||
<span className="ecom-video-flowbar__stage-label"><LoadingOutlined /> 生成视频中</span>
|
||
) : null}
|
||
{stage === "planning" || stage === "imaging" || stage === "rendering" ? (
|
||
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--danger" onClick={handleCancel} title="终止">
|
||
<StopOutlined />
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
</header>
|
||
|
||
{/* ── Flow canvas ──────────────────────────────────── */}
|
||
<section className="ecom-video-flow-canvas" aria-label="视频分镜流程图">
|
||
{!sourceImage ? (
|
||
<div className="ecom-video-empty">
|
||
<span>上传商品图并点击"一键策划"开始</span>
|
||
</div>
|
||
) : (
|
||
<div className="ecom-video-flow-map">
|
||
{/* Source image node */}
|
||
<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__label">商品原图</span>
|
||
<span className="ecom-video-flow-node__status-orb" aria-hidden="true" />
|
||
</article>
|
||
|
||
{/* Connector: source → plan text nodes */}
|
||
{visiblePlanSteps.length > 0 ? (
|
||
<div className="ecom-video-flow-connector is-active" aria-hidden="true"><i /></div>
|
||
) : null}
|
||
|
||
{/* Plan text nodes — side by side */}
|
||
{visiblePlanSteps.length > 0 ? (
|
||
<div className="ecom-video-scene-strip ecom-video-scene-strip--text" aria-label="策划节点">
|
||
{visiblePlanSteps.map((step, idx) => (
|
||
<Fragment key={step}>
|
||
<article className={`ecom-video-flow-node ecom-video-flow-node--text is-completed${currentStep === step ? " is-pulsing" : ""}`}
|
||
aria-label={PLAN_STEP_LABELS[step]} title={PLAN_STEP_LABELS[step]}>
|
||
<span className="ecom-video-flow-node__text-icon">
|
||
{currentStep === step ? <LoadingOutlined /> : "✓"}
|
||
</span>
|
||
<span className="ecom-video-flow-node__label">{PLAN_STEP_LABELS[step]}</span>
|
||
</article>
|
||
{idx < visiblePlanSteps.length - 1 ? (
|
||
<div className="ecom-video-scene-link is-active" aria-hidden="true"><i /></div>
|
||
) : null}
|
||
</Fragment>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
|
||
{/* Connector: plan → images */}
|
||
{hasImaging ? (
|
||
<div className="ecom-video-flow-connector is-active" aria-hidden="true"><i /></div>
|
||
) : null}
|
||
|
||
{/* Storyboard image nodes — side by side per scene */}
|
||
{hasImaging ? (
|
||
<div className="ecom-video-scene-strip" aria-label="分镜图片节点">
|
||
{scenes.map((scene, idx) => {
|
||
const imgReady = !!scene.imageUrl;
|
||
const imgRunning = stage === "imaging" && (scene.status === "running" || scene.status === "pending") && !scene.imageUrl;
|
||
const cls = imgReady ? "is-completed" : imgRunning ? "is-active" : "";
|
||
return (
|
||
<Fragment key={`img-${scene.sceneId}`}>
|
||
<article className={`ecom-video-flow-node ecom-video-flow-node--image ${cls}`}
|
||
aria-label={`分镜 ${scene.sceneId}`} title={`分镜 ${scene.sceneId}`}>
|
||
<div className="ecom-video-flow-node__media">
|
||
{imgReady ? <img src={scene.imageUrl!} alt={`分镜${scene.sceneId}`} />
|
||
: imgRunning ? <div className="ecom-video-flow-node__placeholder"><LoadingOutlined /></div>
|
||
: <div className="ecom-video-flow-node__placeholder">待生成</div>}
|
||
</div>
|
||
{imgRunning ? <span className="ecom-video-flow-node__progress">{scene.progress || 0}%</span> : null}
|
||
<span className="ecom-video-flow-node__label">分镜{scene.sceneId}</span>
|
||
<span className="ecom-video-flow-node__status-orb" aria-hidden="true" />
|
||
</article>
|
||
{idx < scenes.length - 1 ? (
|
||
<div className="ecom-video-scene-link is-active" aria-hidden="true"><i /></div>
|
||
) : null}
|
||
</Fragment>
|
||
);
|
||
})}
|
||
</div>
|
||
) : null}
|
||
|
||
{/* Connector: images → videos */}
|
||
{hasRendering ? (
|
||
<div className="ecom-video-flow-connector is-active" aria-hidden="true"><i /></div>
|
||
) : null}
|
||
|
||
{/* Video nodes — side by side per scene */}
|
||
{hasRendering ? (
|
||
<div className="ecom-video-scene-strip" aria-label="视频分镜节点">
|
||
{scenes.map((scene, idx) => {
|
||
const vidReady = scene.status === "completed" && scene.resultUrl;
|
||
const vidRunning = stage === "rendering" && (scene.status === "running" || scene.status === "pending");
|
||
const vidFailed = scene.status === "failed";
|
||
const cls = vidReady ? "is-completed" : vidRunning ? "is-active" : vidFailed ? "is-failed" : "";
|
||
return (
|
||
<Fragment key={`vid-${scene.sceneId}`}>
|
||
<article className={`ecom-video-flow-node ecom-video-flow-node--video ${cls}`}
|
||
aria-label={`镜头 ${scene.sceneId}`} title={`镜头 ${scene.sceneId}`}>
|
||
<div className="ecom-video-flow-node__media">
|
||
{vidReady ? <video src={scene.resultUrl!} muted playsInline loop autoPlay />
|
||
: vidRunning ? <div className="ecom-video-flow-node__placeholder"><LoadingOutlined /></div>
|
||
: vidFailed ? <div className="ecom-video-flow-node__placeholder">失败</div>
|
||
: <div className="ecom-video-flow-node__placeholder">待生成</div>}
|
||
</div>
|
||
{vidRunning ? <span className="ecom-video-flow-node__progress">{scene.progress || 0}%</span> : null}
|
||
<span className="ecom-video-flow-node__label">镜头{scene.sceneId}</span>
|
||
{vidFailed ? (
|
||
<button type="button" className="ecom-video-flow-node__retry"
|
||
onClick={(e) => { e.stopPropagation(); void handleRetryScene(scene); }}
|
||
title="重试此镜头">
|
||
<ReloadOutlined />
|
||
</button>
|
||
) : null}
|
||
{vidFailed && scene.error ? (
|
||
<span className="ecom-video-flow-node__error" title={scene.error}>{scene.error.slice(0, 20)}</span>
|
||
) : null}
|
||
<span className="ecom-video-flow-node__status-orb" aria-hidden="true" />
|
||
</article>
|
||
{idx < scenes.length - 1 ? (
|
||
<div className="ecom-video-scene-link is-active" aria-hidden="true"><i /></div>
|
||
) : null}
|
||
</Fragment>
|
||
);
|
||
})}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Delivery dock ────────────────────────────── */}
|
||
{primaryVideo ? (
|
||
<div className="ecom-video-flow-dock" aria-label="视频交付操作">
|
||
<button type="button" onClick={() => void handleDownloadAll()} title={`下载全部 ${completedScenes.length} 个视频`}><DownloadOutlined /></button>
|
||
<button type="button" onClick={() => void handleSaveAllAssets()} title={`保存全部 ${completedScenes.length} 个视频到资产库`}><FolderAddOutlined /></button>
|
||
{primaryVideo ? <button type="button" onClick={() => void handleImportToCanvas(primaryVideo)} title="导入画布"><SendOutlined /></button> : null}
|
||
{primaryVideo ? <button type="button" onClick={() => void navigator.clipboard.writeText(primaryVideo)} title="复制链接"><CopyOutlined /></button> : null}
|
||
</div>
|
||
) : null}
|
||
{actionNotice ? <div className="ecom-video-flow-notice">{actionNotice}</div> : null}
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|