2026-06-10 14:06:16 +08:00
|
|
|
|
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
|
|
|
|
import "../../styles/pages/ecommerce-video.css";
|
|
|
|
|
|
import {
|
|
|
|
|
|
CloseOutlined,
|
|
|
|
|
|
CopyOutlined,
|
|
|
|
|
|
DownloadOutlined,
|
|
|
|
|
|
FolderAddOutlined,
|
|
|
|
|
|
HistoryOutlined,
|
|
|
|
|
|
LoadingOutlined,
|
|
|
|
|
|
ReloadOutlined,
|
|
|
|
|
|
SendOutlined,
|
|
|
|
|
|
StopOutlined,
|
|
|
|
|
|
} from "@ant-design/icons";
|
2026-06-12 17:25:30 +08:00
|
|
|
|
import {
|
|
|
|
|
|
runVideoPlan,
|
|
|
|
|
|
renderSceneImage,
|
|
|
|
|
|
renderScene,
|
|
|
|
|
|
buildSceneTasks,
|
|
|
|
|
|
saveVideoHistory,
|
|
|
|
|
|
buildComplianceFailureMessage,
|
|
|
|
|
|
} from "./ecommerceVideoService";
|
2026-06-15 14:42:37 +08:00
|
|
|
|
import { waitForTask } from "../../api/taskSubscription";
|
2026-06-10 14:06:16 +08:00
|
|
|
|
import {
|
|
|
|
|
|
PLAN_STEP_LABELS,
|
|
|
|
|
|
PLAN_STEPS_DISPLAY,
|
|
|
|
|
|
type EcommerceVideoStage,
|
|
|
|
|
|
type EcommerceVideoSceneTask,
|
|
|
|
|
|
type EcommerceVideoPlanProgress,
|
|
|
|
|
|
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 { useGenerationTasks } from "../../hooks/useGenerationTasks";
|
|
|
|
|
|
import {
|
|
|
|
|
|
saveEcommerceVideoState,
|
|
|
|
|
|
loadEcommerceVideoState,
|
|
|
|
|
|
clearEcommerceVideoState,
|
|
|
|
|
|
} from "./ecommerceVideoKeepalive";
|
2026-06-12 11:12:55 +08:00
|
|
|
|
import { saveUnifiedEcommerceGenerationRecord } from "./ecommerceGenerationPersistence";
|
2026-06-10 14:06:16 +08:00
|
|
|
|
|
|
|
|
|
|
interface EcommerceVideoWorkspaceProps {
|
|
|
|
|
|
isAuthenticated: boolean;
|
|
|
|
|
|
productImageDataUrls: string[];
|
|
|
|
|
|
productImageFiles?: Array<File | undefined>;
|
|
|
|
|
|
requirement: string;
|
|
|
|
|
|
platform: string;
|
|
|
|
|
|
aspectRatio: string;
|
|
|
|
|
|
durationSeconds: number;
|
|
|
|
|
|
resolution: string;
|
|
|
|
|
|
onRequestLogin?: () => void;
|
|
|
|
|
|
onOpenHistory?: () => void;
|
|
|
|
|
|
triggerPlan?: number;
|
2026-06-11 20:38:35 +08:00
|
|
|
|
saveRef?: { current: (() => void) | null };
|
2026-06-10 14:06:16 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const ALL_STEPS: PlanStep[] = [
|
|
|
|
|
|
"upload", "analyze", "summary", "selling",
|
|
|
|
|
|
"creative", "storyboard", "prompts", "compliance",
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
function hashString(value: string): string {
|
|
|
|
|
|
let hash = 2166136261;
|
|
|
|
|
|
for (let index = 0; index < value.length; index += 1) {
|
|
|
|
|
|
hash ^= value.charCodeAt(index);
|
|
|
|
|
|
hash = Math.imul(hash, 16777619);
|
|
|
|
|
|
}
|
|
|
|
|
|
return (hash >>> 0).toString(36);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildInputFingerprint(input: {
|
|
|
|
|
|
productImageDataUrls: string[];
|
|
|
|
|
|
requirement: string;
|
|
|
|
|
|
platform: string;
|
|
|
|
|
|
aspectRatio: string;
|
|
|
|
|
|
durationSeconds: number;
|
|
|
|
|
|
resolution: string;
|
|
|
|
|
|
}): string {
|
2026-06-12 17:25:30 +08:00
|
|
|
|
const imageSignature = input.productImageDataUrls
|
|
|
|
|
|
.map((source) => `${source.length}:${hashString(source)}`)
|
|
|
|
|
|
.join("|");
|
2026-06-10 14:06:16 +08:00
|
|
|
|
return hashString([
|
2026-06-12 17:25:30 +08:00
|
|
|
|
imageSignature,
|
2026-06-10 14:06:16 +08:00
|
|
|
|
input.requirement.trim(),
|
|
|
|
|
|
input.platform,
|
|
|
|
|
|
input.aspectRatio,
|
|
|
|
|
|
input.durationSeconds,
|
|
|
|
|
|
input.resolution,
|
|
|
|
|
|
].join("::"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-12 17:25:30 +08:00
|
|
|
|
function planAllowsVideoGeneration(plan: EcommerceVideoPlanResult | null): boolean {
|
|
|
|
|
|
return plan?.compliance.allow_video_generation !== false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-10 14:06:16 +08:00
|
|
|
|
function mapResolutionToQuality(res: string): "720P" | "1080P" {
|
|
|
|
|
|
return res.includes("720") ? "720P" : "1080P";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function stepCompletedFromProgress(step: PlanStep, p: EcommerceVideoPlanProgress): boolean {
|
|
|
|
|
|
switch (step) {
|
|
|
|
|
|
case "upload": return Boolean(p.imageUrls?.length);
|
|
|
|
|
|
case "analyze": return p.imageDescription !== undefined;
|
|
|
|
|
|
case "summary": return Boolean(p.summary);
|
|
|
|
|
|
case "selling": return Boolean(p.selling);
|
|
|
|
|
|
case "creative": return Boolean(p.creatives?.length);
|
|
|
|
|
|
case "storyboard": return Boolean(p.storyboard);
|
|
|
|
|
|
case "prompts": return Boolean(p.videoPrompts);
|
|
|
|
|
|
case "compliance": return Boolean(p.compliance);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default function EcommerceVideoWorkspace({
|
|
|
|
|
|
isAuthenticated,
|
|
|
|
|
|
productImageDataUrls,
|
|
|
|
|
|
productImageFiles = [],
|
|
|
|
|
|
requirement,
|
|
|
|
|
|
platform,
|
|
|
|
|
|
aspectRatio,
|
|
|
|
|
|
durationSeconds,
|
|
|
|
|
|
resolution,
|
|
|
|
|
|
onRequestLogin,
|
|
|
|
|
|
onOpenHistory,
|
|
|
|
|
|
triggerPlan,
|
2026-06-11 20:38:35 +08:00
|
|
|
|
saveRef,
|
2026-06-10 14:06:16 +08:00
|
|
|
|
}: EcommerceVideoWorkspaceProps) {
|
|
|
|
|
|
const [stage, setStage] = useState<EcommerceVideoStage>("idle");
|
|
|
|
|
|
const [planResult, setPlanResult] = useState<EcommerceVideoPlanResult | null>(null);
|
|
|
|
|
|
const [planProgress, setPlanProgress] = useState<EcommerceVideoPlanProgress | 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 [failedStep, setFailedStep] = useState<PlanStep | null>(null);
|
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
|
const [actionNotice, setActionNotice] = useState<string | null>(null);
|
|
|
|
|
|
const [previewMedia, setPreviewMedia] = useState<{ url: string; type: "image" | "video" } | null>(null);
|
|
|
|
|
|
const [flowZoom, setFlowZoom] = useState(1);
|
|
|
|
|
|
const abortControllerRef = useRef<AbortController | null>(null);
|
|
|
|
|
|
const renderAbortRef = useRef({ current: false });
|
|
|
|
|
|
const actionNoticeTimerRef = useRef<number | null>(null);
|
|
|
|
|
|
const setView = useAppStore((s) => s.setView);
|
|
|
|
|
|
const keepaliveRestoredFingerprintRef = useRef<string | null>(null);
|
|
|
|
|
|
const keepalivePollingStartedRef = useRef(false);
|
|
|
|
|
|
const generation = useGenerationTasks({ sourceView: "ecommerce" });
|
|
|
|
|
|
const sceneStoreIdMap = useRef<Map<number, string>>(new Map());
|
|
|
|
|
|
const inputFingerprint = useMemo(
|
|
|
|
|
|
() => buildInputFingerprint({ productImageDataUrls, requirement, platform, aspectRatio, durationSeconds, resolution }),
|
|
|
|
|
|
[productImageDataUrls, requirement, platform, aspectRatio, durationSeconds, resolution],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// ── Keep-alive: restore saved state on mount ─────────────
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (keepaliveRestoredFingerprintRef.current === inputFingerprint) return;
|
|
|
|
|
|
keepaliveRestoredFingerprintRef.current = inputFingerprint;
|
|
|
|
|
|
const saved = loadEcommerceVideoState(inputFingerprint);
|
|
|
|
|
|
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);
|
|
|
|
|
|
setPlanProgress((saved as { planProgress?: EcommerceVideoPlanProgress | null }).planProgress || null);
|
|
|
|
|
|
setScenes(saved.scenes || []);
|
|
|
|
|
|
setSourceImageUrls(saved.sourceImageUrls || saved.planResult?.imageUrls || []);
|
|
|
|
|
|
}, [inputFingerprint]);
|
|
|
|
|
|
|
|
|
|
|
|
// ── Keep-alive: save state on changes ───────────────────
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (stage === "idle" || stage === "cancelled") return;
|
|
|
|
|
|
saveEcommerceVideoState({ inputFingerprint, stage, completedSteps, planResult, planProgress, scenes, sourceImageUrls });
|
|
|
|
|
|
}, [inputFingerprint, stage, completedSteps, planResult, planProgress, scenes, sourceImageUrls]);
|
|
|
|
|
|
|
|
|
|
|
|
// ── Auto-advance: automatically run the full pipeline ─────────
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const delay = 600;
|
|
|
|
|
|
if (stage === "planned" && planResult && scenes.length > 0) {
|
2026-06-12 17:25:30 +08:00
|
|
|
|
if (!planAllowsVideoGeneration(planResult)) {
|
|
|
|
|
|
setError(buildComplianceFailureMessage(planResult.compliance));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-06-10 14:06:16 +08:00
|
|
|
|
const timer = setTimeout(() => { void handleGenerateImages(); }, delay);
|
|
|
|
|
|
return () => clearTimeout(timer);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (stage === "imaged" && scenes.every((s) => s.imageUrl)) {
|
|
|
|
|
|
const timer = setTimeout(() => { void handleRenderVideos(); }, delay);
|
|
|
|
|
|
return () => clearTimeout(timer);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [stage, scenes, planResult]);
|
|
|
|
|
|
|
|
|
|
|
|
// ── External trigger: start plan from parent ────────────────
|
2026-06-12 11:12:55 +08:00
|
|
|
|
const triggerPlanPrevRef = useRef(0);
|
2026-06-10 14:06:16 +08:00
|
|
|
|
useEffect(() => {
|
2026-06-12 11:12:55 +08:00
|
|
|
|
if (typeof triggerPlan === "number" && triggerPlan > 0 && triggerPlan !== triggerPlanPrevRef.current) {
|
2026-06-10 14:06:16 +08:00
|
|
|
|
triggerPlanPrevRef.current = triggerPlan;
|
|
|
|
|
|
void handlePlan();
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [triggerPlan]);
|
|
|
|
|
|
|
|
|
|
|
|
// ── Auto-save: persist completed results to server ──────────
|
|
|
|
|
|
const historySavedRef = useRef(false);
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (stage !== "completed") { historySavedRef.current = false; return; }
|
|
|
|
|
|
if (historySavedRef.current) return;
|
|
|
|
|
|
if (!planResult || !scenes.length) return;
|
|
|
|
|
|
historySavedRef.current = true;
|
2026-06-12 11:12:55 +08:00
|
|
|
|
const title = planResult.storyboard?.video_title || planResult.summary?.product_name || "电商短视频";
|
2026-06-10 14:06:16 +08:00
|
|
|
|
saveVideoHistory({
|
|
|
|
|
|
title,
|
|
|
|
|
|
config: { platform, aspectRatio, durationSeconds, resolution },
|
|
|
|
|
|
plan: planResult as unknown as Record<string, unknown>,
|
|
|
|
|
|
scenes: scenes.map((s) => ({ sceneId: s.sceneId, prompt: s.prompt, imageUrl: s.imageUrl, videoUrl: s.resultUrl })),
|
|
|
|
|
|
sourceImageUrls,
|
|
|
|
|
|
}).catch(() => {});
|
2026-06-12 11:12:55 +08:00
|
|
|
|
void saveUnifiedEcommerceGenerationRecord({
|
|
|
|
|
|
clientRecordId: `ecommerce-video-${inputFingerprint}-${Date.now()}`,
|
|
|
|
|
|
title,
|
|
|
|
|
|
mode: "short-video",
|
|
|
|
|
|
prompt: requirement,
|
|
|
|
|
|
sourceImages: sourceImageUrls.map((url, index) => ({ url, label: `source-${index + 1}` })),
|
|
|
|
|
|
results: scenes
|
|
|
|
|
|
.filter((scene) => Boolean(scene.resultUrl))
|
|
|
|
|
|
.map((scene) => ({
|
|
|
|
|
|
url: scene.resultUrl!,
|
|
|
|
|
|
label: `scene-${scene.sceneId}`,
|
|
|
|
|
|
mediaType: "video",
|
|
|
|
|
|
taskId: scene.taskId,
|
|
|
|
|
|
})),
|
|
|
|
|
|
taskIds: scenes.map((scene) => scene.taskId).filter((taskId): taskId is string => Boolean(taskId)),
|
|
|
|
|
|
config: { platform, aspectRatio, durationSeconds, resolution },
|
|
|
|
|
|
result: {
|
|
|
|
|
|
plan: planResult as unknown as Record<string, unknown>,
|
|
|
|
|
|
scenes: scenes.map((scene) => ({
|
|
|
|
|
|
sceneId: scene.sceneId,
|
|
|
|
|
|
prompt: scene.prompt,
|
|
|
|
|
|
imageUrl: scene.imageUrl,
|
|
|
|
|
|
videoUrl: scene.resultUrl,
|
|
|
|
|
|
status: scene.status,
|
|
|
|
|
|
})),
|
|
|
|
|
|
},
|
|
|
|
|
|
metadata: { inputFingerprint },
|
|
|
|
|
|
});
|
|
|
|
|
|
}, [stage, planResult, scenes, sourceImageUrls, platform, aspectRatio, durationSeconds, resolution, inputFingerprint, requirement]);
|
2026-06-10 14:06:16 +08:00
|
|
|
|
|
2026-06-11 20:38:35 +08:00
|
|
|
|
// ── Expose manual save via ref ──────────────────────────
|
|
|
|
|
|
const planResultRef = useRef(planResult);
|
|
|
|
|
|
planResultRef.current = planResult;
|
|
|
|
|
|
const scenesRef = useRef(scenes);
|
|
|
|
|
|
scenesRef.current = scenes;
|
|
|
|
|
|
const sourceImageUrlsRef = useRef(sourceImageUrls);
|
|
|
|
|
|
sourceImageUrlsRef.current = sourceImageUrls;
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!saveRef) return;
|
|
|
|
|
|
saveRef.current = () => {
|
|
|
|
|
|
const currentPlan = planResultRef.current;
|
|
|
|
|
|
const currentScenes = scenesRef.current;
|
|
|
|
|
|
const currentSources = sourceImageUrlsRef.current;
|
|
|
|
|
|
if (!currentPlan || !currentScenes.length) return;
|
2026-06-12 11:12:55 +08:00
|
|
|
|
const title = currentPlan.storyboard?.video_title || currentPlan.summary?.product_name || "电商短视频";
|
2026-06-11 20:38:35 +08:00
|
|
|
|
saveVideoHistory({
|
|
|
|
|
|
title,
|
|
|
|
|
|
config: { platform, aspectRatio, durationSeconds, resolution },
|
|
|
|
|
|
plan: currentPlan as unknown as Record<string, unknown>,
|
|
|
|
|
|
scenes: currentScenes.map((s) => ({ sceneId: s.sceneId, prompt: s.prompt, imageUrl: s.imageUrl, videoUrl: s.resultUrl })),
|
|
|
|
|
|
sourceImageUrls: currentSources,
|
|
|
|
|
|
}).catch(() => {});
|
2026-06-12 11:12:55 +08:00
|
|
|
|
void saveUnifiedEcommerceGenerationRecord({
|
|
|
|
|
|
clientRecordId: `ecommerce-video-manual-${inputFingerprint}-${Date.now()}`,
|
|
|
|
|
|
title,
|
|
|
|
|
|
mode: "short-video",
|
|
|
|
|
|
prompt: requirement,
|
|
|
|
|
|
sourceImages: currentSources.map((url, index) => ({ url, label: `source-${index + 1}` })),
|
|
|
|
|
|
results: currentScenes
|
|
|
|
|
|
.filter((scene) => Boolean(scene.resultUrl))
|
|
|
|
|
|
.map((scene) => ({
|
|
|
|
|
|
url: scene.resultUrl!,
|
|
|
|
|
|
label: `scene-${scene.sceneId}`,
|
|
|
|
|
|
mediaType: "video",
|
|
|
|
|
|
taskId: scene.taskId,
|
|
|
|
|
|
})),
|
|
|
|
|
|
taskIds: currentScenes.map((scene) => scene.taskId).filter((taskId): taskId is string => Boolean(taskId)),
|
|
|
|
|
|
config: { platform, aspectRatio, durationSeconds, resolution },
|
|
|
|
|
|
result: {
|
|
|
|
|
|
plan: currentPlan as unknown as Record<string, unknown>,
|
|
|
|
|
|
scenes: currentScenes.map((scene) => ({
|
|
|
|
|
|
sceneId: scene.sceneId,
|
|
|
|
|
|
prompt: scene.prompt,
|
|
|
|
|
|
imageUrl: scene.imageUrl,
|
|
|
|
|
|
videoUrl: scene.resultUrl,
|
|
|
|
|
|
status: scene.status,
|
|
|
|
|
|
})),
|
|
|
|
|
|
},
|
|
|
|
|
|
metadata: { inputFingerprint, manual: true },
|
|
|
|
|
|
});
|
2026-06-11 20:38:35 +08:00
|
|
|
|
};
|
2026-06-12 11:12:55 +08:00
|
|
|
|
}, [saveRef, platform, aspectRatio, durationSeconds, resolution, inputFingerprint, requirement]);
|
2026-06-11 20:38:35 +08:00
|
|
|
|
|
2026-06-10 14:06:16 +08:00
|
|
|
|
// ── 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 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 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.
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
if (actionNoticeTimerRef.current !== null) {
|
|
|
|
|
|
window.clearTimeout(actionNoticeTimerRef.current);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
const showNotice = (msg: string) => {
|
|
|
|
|
|
setActionNotice(msg);
|
|
|
|
|
|
if (actionNoticeTimerRef.current !== null) {
|
|
|
|
|
|
window.clearTimeout(actionNoticeTimerRef.current);
|
|
|
|
|
|
}
|
|
|
|
|
|
actionNoticeTimerRef.current = window.setTimeout(() => {
|
|
|
|
|
|
actionNoticeTimerRef.current = null;
|
|
|
|
|
|
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({
|
2026-06-12 11:12:55 +08:00
|
|
|
|
url, name: `电商短视频-${Date.now()}.mp4`, description: "电商短视频生成结果",
|
|
|
|
|
|
type: "video", isVideo: true, tags: ["电商", "短视频"],
|
2026-06-10 14:06:16 +08:00
|
|
|
|
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`,
|
2026-06-12 11:12:55 +08:00
|
|
|
|
description: `电商短视频 - 镜头${scene.sceneId}`,
|
|
|
|
|
|
type: "video", isVideo: true, tags: ["电商", "短视频"],
|
2026-06-10 14:06:16 +08:00
|
|
|
|
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({
|
2026-06-12 11:12:55 +08:00
|
|
|
|
url, name: `电商短视频-${Date.now()}.mp4`, description: "电商短视频 - 导入画布",
|
2026-06-10 14:06:16 +08:00
|
|
|
|
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 runPlanFlow = async (resume: EcommerceVideoPlanProgress | null) => {
|
|
|
|
|
|
abortControllerRef.current?.abort();
|
|
|
|
|
|
const controller = new AbortController();
|
|
|
|
|
|
abortControllerRef.current = controller;
|
|
|
|
|
|
setStage("planning"); setError(null); setFailedStep(null);
|
|
|
|
|
|
if (!resume) {
|
|
|
|
|
|
setCompletedSteps([]); setPlanResult(null); setScenes([]); setSourceImageUrls([]); setPlanProgress(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
setCurrentStep(null);
|
|
|
|
|
|
// Mutable snapshot — async handlers must persist to localStorage directly since the component may unmount
|
|
|
|
|
|
let livePlanProgress: EcommerceVideoPlanProgress = resume ? { ...resume } : {};
|
|
|
|
|
|
let liveCompletedSteps: PlanStep[] = resume
|
|
|
|
|
|
? ALL_STEPS.filter((s) => stepCompletedFromProgress(s, resume))
|
|
|
|
|
|
: [];
|
2026-06-12 17:25:30 +08:00
|
|
|
|
let liveCurrentStep: PlanStep | null = null;
|
2026-06-10 14:06:16 +08:00
|
|
|
|
const persist = (stageNow: EcommerceVideoStage) => {
|
|
|
|
|
|
saveEcommerceVideoState({
|
|
|
|
|
|
inputFingerprint,
|
|
|
|
|
|
stage: stageNow,
|
|
|
|
|
|
completedSteps: liveCompletedSteps,
|
|
|
|
|
|
planResult: null,
|
|
|
|
|
|
planProgress: livePlanProgress,
|
|
|
|
|
|
scenes: [],
|
|
|
|
|
|
sourceImageUrls: livePlanProgress.imageUrls || [],
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
try {
|
|
|
|
|
|
const productImageSources = productImageDataUrls.map((url, index) => productImageFiles[index] ?? url);
|
|
|
|
|
|
const result = await runVideoPlan(
|
|
|
|
|
|
productImageSources, requirement, buildConfig(),
|
|
|
|
|
|
{
|
2026-06-12 17:25:30 +08:00
|
|
|
|
onStepStart: (step) => {
|
|
|
|
|
|
liveCurrentStep = step;
|
|
|
|
|
|
setCurrentStep(step);
|
|
|
|
|
|
},
|
2026-06-10 14:06:16 +08:00
|
|
|
|
onStepDone: (step) => {
|
|
|
|
|
|
liveCompletedSteps = [...liveCompletedSteps, step];
|
|
|
|
|
|
setCompletedSteps((prev) => [...prev, step]);
|
|
|
|
|
|
},
|
|
|
|
|
|
onImagesUploaded: (urls) => {
|
|
|
|
|
|
setSourceImageUrls(urls);
|
|
|
|
|
|
livePlanProgress = { ...livePlanProgress, imageUrls: urls };
|
|
|
|
|
|
persist("planning");
|
|
|
|
|
|
},
|
|
|
|
|
|
onUploadRejected: (messages) => {
|
|
|
|
|
|
if (messages.length) showNotice(`已跳过 ${messages.length} 张上传失败的图片`);
|
|
|
|
|
|
},
|
|
|
|
|
|
onPartialProgress: (progress) => {
|
|
|
|
|
|
livePlanProgress = progress;
|
|
|
|
|
|
setPlanProgress(progress);
|
|
|
|
|
|
persist("planning");
|
|
|
|
|
|
},
|
|
|
|
|
|
resumeFrom: resume || undefined,
|
|
|
|
|
|
signal: controller.signal,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
const builtScenes = buildSceneTasks(result);
|
|
|
|
|
|
setPlanResult(result);
|
|
|
|
|
|
setPlanProgress(null);
|
|
|
|
|
|
setScenes(builtScenes);
|
|
|
|
|
|
setStage("planned");
|
|
|
|
|
|
saveEcommerceVideoState({ inputFingerprint, stage: "planned", completedSteps: [...ALL_STEPS], planResult: result, planProgress: null, scenes: builtScenes, sourceImageUrls: result.imageUrls });
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
if ((err as Error).name === "AbortError" && controller.signal.aborted) return;
|
|
|
|
|
|
const message = err instanceof Error ? err.message : "策划失败";
|
|
|
|
|
|
setError(message);
|
|
|
|
|
|
// Mark the step that was in-progress as failed so user can resume
|
2026-06-12 17:25:30 +08:00
|
|
|
|
setFailedStep((prev) => prev || liveCurrentStep);
|
2026-06-10 14:06:16 +08:00
|
|
|
|
setStage("idle");
|
|
|
|
|
|
// Persist partial progress so the user can resume after a page switch
|
|
|
|
|
|
persist("idle");
|
|
|
|
|
|
} finally { setCurrentStep(null); }
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handlePlan = async () => {
|
|
|
|
|
|
if (!isAuthenticated) { onRequestLogin?.(); return; }
|
2026-06-12 17:25:30 +08:00
|
|
|
|
if (!productImageDataUrls.length) {
|
|
|
|
|
|
setError("请先上传商品图片"); return;
|
2026-06-10 14:06:16 +08:00
|
|
|
|
}
|
|
|
|
|
|
await runPlanFlow(null);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleResumePlan = async () => {
|
|
|
|
|
|
if (!isAuthenticated) { onRequestLogin?.(); return; }
|
|
|
|
|
|
if (!planProgress) { void handlePlan(); return; }
|
|
|
|
|
|
await runPlanFlow(planProgress);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// ── Phase 2: Image generation per scene ──────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
const handleGenerateImages = async () => {
|
|
|
|
|
|
if (!planResult || !scenes.length) return;
|
2026-06-12 17:25:30 +08:00
|
|
|
|
if (!planAllowsVideoGeneration(planResult)) {
|
|
|
|
|
|
setError(buildComplianceFailureMessage(planResult.compliance));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-06-10 14:06:16 +08:00
|
|
|
|
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({ inputFingerprint, stage: "imaging", completedSteps, planResult, scenes: next, sourceImageUrls });
|
|
|
|
|
|
};
|
|
|
|
|
|
// Only redo scenes missing imageUrl — preserves successfully generated images on partial retry
|
|
|
|
|
|
const scenesToProcess = currentScenes.filter((s) => !s.imageUrl);
|
2026-06-12 17:25:30 +08:00
|
|
|
|
if (!scenesToProcess.length) {
|
|
|
|
|
|
setStage("imaged");
|
|
|
|
|
|
saveEcommerceVideoState({ inputFingerprint, stage: "imaged", completedSteps, planResult, scenes: currentScenes, sourceImageUrls });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-06-10 14:06:16 +08:00
|
|
|
|
for (const scene of scenesToProcess) {
|
|
|
|
|
|
if (renderAbortRef.current.current) break;
|
|
|
|
|
|
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending", error: undefined } : s));
|
|
|
|
|
|
try {
|
|
|
|
|
|
await renderSceneImage(
|
|
|
|
|
|
{ sceneId: scene.sceneId, prompt: scene.prompt, aspectRatio: ratio, productImageUrls: sourceImageUrls },
|
|
|
|
|
|
{
|
|
|
|
|
|
onSceneImageSubmitted: (id, taskId) => {
|
|
|
|
|
|
persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, imageTaskId: taskId, status: "running" } : s));
|
|
|
|
|
|
const storeId = generation.submitTask({ title: `分镜${id}图片`, type: "image", status: "running", progress: 0, prompt: scene.prompt, sourceView: "ecommerce", taskId, params: { sceneId: id, phase: "imaging" } });
|
|
|
|
|
|
sceneStoreIdMap.current.set(id, storeId);
|
|
|
|
|
|
},
|
|
|
|
|
|
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));
|
|
|
|
|
|
const sid = sceneStoreIdMap.current.get(id);
|
|
|
|
|
|
if (sid) generation.markCompleted(sid, url);
|
|
|
|
|
|
},
|
|
|
|
|
|
onSceneImageFailed: (id, err2) => {
|
|
|
|
|
|
persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "idle", error: err2 } : s));
|
|
|
|
|
|
const sid = sceneStoreIdMap.current.get(id);
|
|
|
|
|
|
if (sid) generation.markFailed(sid, err2);
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
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({ inputFingerprint, stage: finalStage, completedSteps, planResult, scenes: currentScenes, sourceImageUrls });
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// ── Phase 3: Video rendering from generated images ──────────
|
|
|
|
|
|
|
|
|
|
|
|
const handleRenderVideos = async () => {
|
|
|
|
|
|
if (!scenes.length) return;
|
2026-06-12 17:25:30 +08:00
|
|
|
|
if (!planAllowsVideoGeneration(planResult)) {
|
|
|
|
|
|
setError(planResult ? buildComplianceFailureMessage(planResult.compliance) : "合规检查未通过,已停止生成。");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-06-10 14:06:16 +08:00
|
|
|
|
if (!scenes.some((s) => s.imageUrl)) { 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({ inputFingerprint, stage: "rendering", completedSteps, planResult, scenes: next, sourceImageUrls });
|
|
|
|
|
|
};
|
|
|
|
|
|
// Only render scenes that haven't completed yet — preserves successful videos on partial retry
|
|
|
|
|
|
const scenesToProcess = currentScenes.filter((s) => s.imageUrl && s.status !== "completed");
|
2026-06-12 17:25:30 +08:00
|
|
|
|
if (!scenesToProcess.length) {
|
|
|
|
|
|
const finalStage = currentScenes.every((s) => s.status === "completed") ? "completed" as const : "partial_failed" as const;
|
|
|
|
|
|
setStage(finalStage);
|
|
|
|
|
|
saveEcommerceVideoState({ inputFingerprint, stage: finalStage, completedSteps, planResult, scenes: currentScenes, sourceImageUrls });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-06-10 14:06:16 +08:00
|
|
|
|
for (const scene of scenesToProcess) {
|
|
|
|
|
|
if (renderAbortRef.current.current) break;
|
|
|
|
|
|
if (!scene.imageUrl) continue;
|
|
|
|
|
|
persistScenes(currentScenes.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, productImageUrls: sourceImageUrls, aspectRatio, resolution: quality },
|
|
|
|
|
|
{
|
|
|
|
|
|
onSceneSubmitted: (id, taskId) => {
|
|
|
|
|
|
persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, taskId, status: "running" } : s));
|
|
|
|
|
|
const storeId = generation.submitTask({ title: `分镜${id}视频`, type: "video", status: "running", progress: 0, prompt: scene.prompt, sourceView: "ecommerce", taskId, params: { sceneId: id, phase: "rendering" } });
|
|
|
|
|
|
sceneStoreIdMap.current.set(id, storeId);
|
|
|
|
|
|
},
|
|
|
|
|
|
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));
|
|
|
|
|
|
const sid = sceneStoreIdMap.current.get(id);
|
|
|
|
|
|
if (sid) generation.markCompleted(sid, url);
|
|
|
|
|
|
},
|
|
|
|
|
|
onSceneFailed: (id, err2) => {
|
|
|
|
|
|
persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "failed", error: err2 } : s));
|
|
|
|
|
|
const sid = sceneStoreIdMap.current.get(id);
|
|
|
|
|
|
if (sid) generation.markFailed(sid, err2);
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
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({ inputFingerprint, 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!, productImageUrls: sourceImageUrls, 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 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}>
|
2026-06-11 23:56:27 +08:00
|
|
|
|
{/* ── Preview header ─────────────────────────────── */}
|
|
|
|
|
|
<header className="ecom-video-flowbar ecom-video-preview-head" title={flowMeta}>
|
|
|
|
|
|
<div className="ecom-video-preview-copy">
|
|
|
|
|
|
<h1>预览</h1>
|
|
|
|
|
|
<p>上传商品图,AI 即刻生成 <span>符合多电商平台规范</span> 的高转化率短视频素材。</p>
|
|
|
|
|
|
<div className="ecom-video-flowbar__zoom">
|
|
|
|
|
|
<button type="button" onClick={() => setFlowZoom((z) => Math.max(0.25, z - 0.1))} disabled={flowZoom <= 0.25} aria-label="缩小">−</button>
|
|
|
|
|
|
<span>{Math.round(flowZoom * 100)}%</span>
|
|
|
|
|
|
<button type="button" onClick={() => setFlowZoom((z) => Math.min(2, z + 0.1))} disabled={flowZoom >= 2} aria-label="放大">+</button>
|
|
|
|
|
|
</div>
|
2026-06-10 14:06:16 +08:00
|
|
|
|
</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">
|
|
|
|
|
|
{onOpenHistory ? (
|
|
|
|
|
|
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost" onClick={onOpenHistory} title="生成记录">
|
|
|
|
|
|
<HistoryOutlined />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{error ? <span className="ecom-video-flowbar__error" role="alert">{error}</span> : null}
|
|
|
|
|
|
{stage === "idle" && planProgress && (planProgress.summary || planProgress.creatives || planProgress.storyboard) ? (
|
|
|
|
|
|
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost"
|
|
|
|
|
|
onClick={() => void handleResumePlan()} title={`从「${failedStep ? PLAN_STEP_LABELS[failedStep] : "已中断处"}」继续策划`}>
|
|
|
|
|
|
<ReloadOutlined /> 继续
|
|
|
|
|
|
</button>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{stage === "planned" || stage === "imaged" ? (
|
|
|
|
|
|
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost"
|
|
|
|
|
|
onClick={() => void handleGenerateImages()} title={stage === "imaged" ? "重新生成分镜图" : "生成图片"}>
|
|
|
|
|
|
{stage === "imaged" ? <ReloadOutlined /> : <SendOutlined />}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{stage === "imaged" || (stage === "partial_failed" && imagedScenes.length > 0) ? (
|
|
|
|
|
|
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost"
|
|
|
|
|
|
onClick={() => void handleRenderVideos()} title={stage === "partial_failed" ? "重新生成失败的视频" : "生成视频"}>
|
|
|
|
|
|
<SendOutlined />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{stage === "planning" ? (
|
|
|
|
|
|
<span className="ecom-video-flowbar__stage-label"><LoadingOutlined /> {currentStep ? PLAN_STEP_LABELS[currentStep] : "策划中"}</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="视频分镜流程图">
|
|
|
|
|
|
<div style={{ zoom: flowZoom, flexShrink: 0, display: "flex", alignItems: "flex-start", justifyContent: "center", minWidth: "max-content" }}>
|
|
|
|
|
|
{!sourceImage ? (
|
|
|
|
|
|
<div className="ecom-video-empty">
|
|
|
|
|
|
<span>上传商品图并点击"一键策划"开始</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="ecom-video-tree">
|
|
|
|
|
|
{/* Source Node — 附件原图 */}
|
|
|
|
|
|
<div className="ecom-video-tree__source">
|
|
|
|
|
|
<article className="ecom-video-tree-node ecom-video-tree-node--source">
|
|
|
|
|
|
<img src={sourceImage} alt="商品原图" />
|
|
|
|
|
|
</article>
|
|
|
|
|
|
<span className="ecom-video-tree-node__label">附件原图</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Branch Connector — 分支连接线 */}
|
|
|
|
|
|
<div className="ecom-video-tree__trunk" aria-hidden="true">
|
|
|
|
|
|
<div className="ecom-video-tree__trunk-line" />
|
|
|
|
|
|
<div className="ecom-video-tree__branches-line">
|
|
|
|
|
|
{scenes.length > 0 ? scenes.map((s) => (
|
|
|
|
|
|
<div key={`trunk-${s.sceneId}`} className="ecom-video-tree__branch-tap" />
|
|
|
|
|
|
)) : (
|
|
|
|
|
|
<div className="ecom-video-tree__branch-tap" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Branches — 每个场景一条分支 */}
|
|
|
|
|
|
<div className="ecom-video-tree__rows">
|
|
|
|
|
|
{scenes.length > 0 ? scenes.map((scene, idx) => {
|
|
|
|
|
|
const planDone = completedSteps.length >= ALL_STEPS.length;
|
|
|
|
|
|
const imgReady = !!scene.imageUrl;
|
|
|
|
|
|
const imgRunning = stage === "imaging" && (scene.status === "running" || scene.status === "pending") && !scene.imageUrl;
|
|
|
|
|
|
const vidReady = scene.status === "completed" && scene.resultUrl;
|
|
|
|
|
|
const vidRunning = stage === "rendering" && (scene.status === "running" || scene.status === "pending");
|
|
|
|
|
|
const vidFailed = scene.status === "failed";
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div key={scene.sceneId} className="ecom-video-tree__row" style={{ animationDelay: `${idx * 120}ms` }}>
|
|
|
|
|
|
<article className={`ecom-video-tree-node ecom-video-tree-node--text${planDone ? " is-completed" : currentStep ? " is-active" : ""}`}>
|
|
|
|
|
|
<div className="ecom-video-tree-node__inner">
|
|
|
|
|
|
<span className="ecom-video-tree-node__title">分镜文本{scene.sceneId}</span>
|
|
|
|
|
|
<span className="ecom-video-tree-node__desc">
|
|
|
|
|
|
{planDone ? "已完成" : stage === "planning" ? "策划中..." : "等待策划"}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="ecom-video-tree__arrow" aria-hidden="true">
|
|
|
|
|
|
<svg viewBox="0 0 40 20" fill="none"><path d="M0 10 H28 M22 4 L30 10 L22 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<article className={`ecom-video-tree-node ecom-video-tree-node--image${imgReady ? " is-completed" : imgRunning ? " is-active" : ""}`} onClick={imgReady ? () => setPreviewMedia({ url: scene.imageUrl!, type: "image" }) : undefined} style={imgReady ? { cursor: "pointer" } : undefined}>
|
|
|
|
|
|
{imgReady ? (
|
|
|
|
|
|
<img src={scene.imageUrl!} alt={`分镜${scene.sceneId}`} />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="ecom-video-tree-node__placeholder">
|
|
|
|
|
|
{imgRunning ? <LoadingOutlined /> : <span>待生成</span>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{imgRunning ? <span className="ecom-video-tree-node__progress">{scene.progress || 0}%</span> : null}
|
|
|
|
|
|
<span className="ecom-video-tree-node__tag">分镜图{scene.sceneId}</span>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="ecom-video-tree__arrow" aria-hidden="true">
|
|
|
|
|
|
<svg viewBox="0 0 40 20" fill="none"><path d="M0 10 H28 M22 4 L30 10 L22 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<article className={`ecom-video-tree-node ecom-video-tree-node--video${vidReady ? " is-completed" : vidRunning ? " is-active" : vidFailed ? " is-failed" : ""}`} onClick={vidReady ? () => setPreviewMedia({ url: scene.resultUrl!, type: "video" }) : undefined} style={vidReady ? { cursor: "pointer" } : undefined}>
|
|
|
|
|
|
{vidReady ? (
|
|
|
|
|
|
<video src={scene.resultUrl!} muted playsInline loop autoPlay />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="ecom-video-tree-node__placeholder">
|
|
|
|
|
|
{vidRunning ? <LoadingOutlined /> : vidFailed ? <span>失败</span> : <span>待生成</span>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{vidRunning ? <span className="ecom-video-tree-node__progress">{scene.progress || 0}%</span> : null}
|
|
|
|
|
|
<span className="ecom-video-tree-node__tag">分镜视频{scene.sceneId}</span>
|
|
|
|
|
|
{vidFailed ? (
|
|
|
|
|
|
<button type="button" className="ecom-video-tree-node__retry"
|
|
|
|
|
|
onClick={(e) => { e.stopPropagation(); void handleRetryScene(scene); }}
|
|
|
|
|
|
title="重试此镜头">
|
|
|
|
|
|
<ReloadOutlined />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</article>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}) : (
|
|
|
|
|
|
<div className={`ecom-video-tree__row ecom-video-tree__row--empty${stage === "planning" ? " is-planning" : ""}`}>
|
|
|
|
|
|
<article className="ecom-video-tree-node ecom-video-tree-node--text">
|
|
|
|
|
|
<div className="ecom-video-tree-node__inner">
|
|
|
|
|
|
<span className="ecom-video-tree-node__title">分镜策划</span>
|
|
|
|
|
|
<span className="ecom-video-tree-node__desc">{stage === "planning" ? "策划中..." : "点击一键策划开始"}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
<div className="ecom-video-tree__arrow" aria-hidden="true">
|
|
|
|
|
|
<svg viewBox="0 0 40 20" fill="none"><path d="M0 10 H28 M22 4 L30 10 L22 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<article className="ecom-video-tree-node ecom-video-tree-node--image">
|
|
|
|
|
|
<div className="ecom-video-tree-node__placeholder">
|
|
|
|
|
|
{stage === "planning" ? <LoadingOutlined /> : <span>待生成</span>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span className="ecom-video-tree-node__tag">分镜图</span>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
<div className="ecom-video-tree__arrow" aria-hidden="true">
|
|
|
|
|
|
<svg viewBox="0 0 40 20" fill="none"><path d="M0 10 H28 M22 4 L30 10 L22 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<article className="ecom-video-tree-node ecom-video-tree-node--video">
|
|
|
|
|
|
<div className="ecom-video-tree-node__placeholder">
|
|
|
|
|
|
{stage === "planning" ? <LoadingOutlined /> : <span>待生成</span>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span className="ecom-video-tree-node__tag">分镜视频</span>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</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>
|
|
|
|
|
|
|
|
|
|
|
|
{previewMedia ? (
|
|
|
|
|
|
<div className="ecom-video-preview-overlay" onClick={() => setPreviewMedia(null)}>
|
|
|
|
|
|
<button type="button" className="ecom-video-preview-overlay__close" onClick={() => setPreviewMedia(null)}>
|
|
|
|
|
|
<CloseOutlined />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{previewMedia.type === "image" ? (
|
|
|
|
|
|
<img src={previewMedia.url} alt="预览" onClick={(e) => e.stopPropagation()} />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<video src={previewMedia.url} controls autoPlay onClick={(e) => e.stopPropagation()} />
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|