fix: 电商视频生成链路稳定性 — AI超时/重试/断点续传 + 404页面 + DashScope Key移除
- adVideoPlanClient: 模型级联降级(qwen-max→plus→turbo), 5xx/网络错误可重试, 超时延长至180s, 错误信息包含上游响应体 - 服务端ai/chat: 超时60s→120s, AbortError返回504(非500), PM2已热重载 - EcommerceVideoWorkspace: 策划失败后支持从断点继续(保留已完成步骤的中间产物), 分镜图/视频生成仅重做失败场景 - scriptEvalClient: 移除客户端DASHSCOPE_API_KEY引用(Nginx代理注入) - NotFoundPage: 未知路由显示404页面(替代兜底跳首页) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+9
-4
@@ -33,6 +33,7 @@ import {
|
||||
import { webGenerationGateway, type CreatePreviewTaskInput } from "./api/webGenerationGateway";
|
||||
import { translateTaskError } from "./utils/translateTaskError";
|
||||
import AppShell from "./components/AppShell";
|
||||
const NotFoundPage = lazy(() => import("./components/NotFoundPage"));
|
||||
import { cloneWorkflow, createBlankWorkflow } from "./data/workflows";
|
||||
const AgentPage = lazy(() => import("./features/agent/AgentPage"));
|
||||
const AssetsPage = lazy(() => import("./features/assets/AssetsPage"));
|
||||
@@ -115,9 +116,10 @@ const VIEW_KEYS = new Set<WebViewKey>([
|
||||
"communityCaseAdd",
|
||||
"report",
|
||||
"providerHealth",
|
||||
"not-found",
|
||||
]);
|
||||
|
||||
const PUBLIC_VIEW_SET = new Set<WebViewKey>(["home", "login", "community", "more"]);
|
||||
const PUBLIC_VIEW_SET = new Set<WebViewKey>(["home", "login", "community", "more", "not-found"]);
|
||||
|
||||
function normalizeViewKey(rawView: string): WebViewKey {
|
||||
const normalized =
|
||||
@@ -130,7 +132,7 @@ function normalizeViewKey(rawView: string): WebViewKey {
|
||||
: rawView === "community-case-add"
|
||||
? "communityCaseAdd"
|
||||
: rawView;
|
||||
return VIEW_KEYS.has(normalized as WebViewKey) ? (normalized as WebViewKey) : "home";
|
||||
return VIEW_KEYS.has(normalized as WebViewKey) ? (normalized as WebViewKey) : "not-found";
|
||||
}
|
||||
|
||||
function readViewFromHash(): WebViewKey {
|
||||
@@ -146,7 +148,8 @@ function isWorkspaceView(view: WebViewKey): boolean {
|
||||
view !== "ecommerceHub" &&
|
||||
view !== "ecommerce" &&
|
||||
view !== "scriptTokens" &&
|
||||
view !== "login"
|
||||
view !== "login" &&
|
||||
view !== "not-found"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1178,7 +1181,6 @@ function App() {
|
||||
/>
|
||||
);
|
||||
case "home":
|
||||
default:
|
||||
return (
|
||||
<HomePage
|
||||
onOpenGenerate={() => handleSetView("workbench")}
|
||||
@@ -1190,6 +1192,9 @@ function App() {
|
||||
onOpenImageTool={handleOpenImageWorkbenchTool}
|
||||
/>
|
||||
);
|
||||
case "not-found":
|
||||
default:
|
||||
return <NotFoundPage onGoHome={() => handleSetView("home")} />;
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { buildApiUrl, buildAuthHeaders } from "./serverConnection";
|
||||
|
||||
const TEXT_MODEL = "qwen-max";
|
||||
const VISION_MODEL = "qwen3.7-plus";
|
||||
const VISION_FALLBACK_MODEL = "qwen-vl-plus";
|
||||
const TEXT_MODELS = ["qwen-max", "qwen-plus", "qwen-turbo"];
|
||||
const VISION_MODELS = ["qwen3.7-plus", "qwen-vl-plus", "qwen-vl-max"];
|
||||
|
||||
export interface AdVideoUserConfig {
|
||||
platform: string;
|
||||
@@ -110,27 +109,41 @@ interface ChatMessage {
|
||||
|
||||
const MAX_RETRIES = 3;
|
||||
const RETRY_BASE_MS = 2000;
|
||||
const CHAT_TIMEOUT_MS = 120_000; // 2 minutes per AI call
|
||||
const CHAT_TIMEOUT_MS = 180_000; // 3 minutes per AI call (server times out at 120s + network slack)
|
||||
|
||||
// 5xx, 429, network failures, timeouts, and AbortError-from-timeout are all retryable
|
||||
function isTransientError(err: unknown): boolean {
|
||||
if (!(err instanceof Error)) return false;
|
||||
const msg = err.message.toLowerCase();
|
||||
return /\b429\b/.test(msg) || msg.includes("signal timed out") || msg.includes("aborted") || msg.includes("timeout");
|
||||
if (/\b(429|500|502|503|504|520|521|522|524)\b/.test(msg)) return true;
|
||||
if (msg.includes("signal timed out") || msg.includes("timeout")) return true;
|
||||
if (msg.includes("failed to fetch") || msg.includes("networkerror") || msg.includes("network error")) return true;
|
||||
if (msg.includes("ai 调用失败") || msg.includes("图片理解调用失败")) return true; // generic upstream failures
|
||||
return false;
|
||||
}
|
||||
|
||||
async function retryOnTransient<T>(fn: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||
let lastErr: unknown;
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
if (signal?.aborted) throw err;
|
||||
// External AbortError caused by our timeoutSignal — retryable
|
||||
if (err instanceof Error && err.name === "AbortError" && !signal?.aborted) {
|
||||
if (attempt === MAX_RETRIES) throw err;
|
||||
const delay = RETRY_BASE_MS * 2 ** attempt + Math.random() * 1000;
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
continue;
|
||||
}
|
||||
if (attempt === MAX_RETRIES) throw err;
|
||||
if (!isTransientError(err)) throw err;
|
||||
const delay = RETRY_BASE_MS * 2 ** attempt + Math.random() * 1000;
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
}
|
||||
}
|
||||
throw new Error("unreachable");
|
||||
throw lastErr instanceof Error ? lastErr : new Error("AI 调用失败:已重试多次");
|
||||
}
|
||||
|
||||
async function chat(
|
||||
@@ -138,7 +151,12 @@ async function chat(
|
||||
userContent: string,
|
||||
options?: { model?: string; signal?: AbortSignal },
|
||||
): Promise<string> {
|
||||
return retryOnTransient(async () => {
|
||||
const candidateModels = options?.model ? [options.model] : TEXT_MODELS;
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (const model of candidateModels) {
|
||||
try {
|
||||
return await retryOnTransient(async () => {
|
||||
const messages: ChatMessage[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: userContent },
|
||||
@@ -150,21 +168,28 @@ async function chat(
|
||||
const res = await fetch(buildApiUrl("ai/chat"), {
|
||||
method: "POST",
|
||||
headers: buildAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
model: options?.model ?? TEXT_MODEL,
|
||||
messages,
|
||||
stream: false,
|
||||
temperature: 0.4,
|
||||
}),
|
||||
body: JSON.stringify({ model, messages, stream: false, temperature: 0.4 }),
|
||||
signal: combinedSignal,
|
||||
});
|
||||
if (!res.ok) throw new Error(`AI 调用失败 (${res.status})`);
|
||||
if (!res.ok) {
|
||||
const errBody = await res.text().catch(() => "");
|
||||
throw new Error(`AI 调用失败 (${res.status})${errBody ? `: ${errBody.slice(0, 120)}` : ""}`);
|
||||
}
|
||||
const payload = await res.json();
|
||||
const content: string =
|
||||
payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? "";
|
||||
if (!content) throw new Error("模型未返回有效内容");
|
||||
return content;
|
||||
}, options?.signal);
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err : new Error(String(err));
|
||||
if (options?.signal?.aborted) throw lastError;
|
||||
// If user pinned a specific model, don't fall back to others
|
||||
if (options?.model) throw lastError;
|
||||
// Try next model in fallback chain
|
||||
}
|
||||
}
|
||||
throw lastError ?? new Error("所有候选模型均不可用");
|
||||
}
|
||||
|
||||
async function visionChat(
|
||||
@@ -182,7 +207,8 @@ async function visionChat(
|
||||
{ role: "user", content },
|
||||
];
|
||||
|
||||
for (const model of [VISION_MODEL, VISION_FALLBACK_MODEL]) {
|
||||
let lastError: Error | null = null;
|
||||
for (const model of VISION_MODELS) {
|
||||
const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
|
||||
const combinedSignal = signal
|
||||
? AbortSignal.any([signal, timeoutSignal])
|
||||
@@ -197,8 +223,8 @@ async function visionChat(
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errBody = await res.text().catch(() => "");
|
||||
if (model === VISION_MODEL && errBody.includes("image format")) throw new Error("IMAGE_FORMAT_FALLBACK");
|
||||
throw new Error(`图片理解调用失败 (${res.status})`);
|
||||
if (errBody.includes("image format")) throw new Error("IMAGE_FORMAT_FALLBACK");
|
||||
throw new Error(`图片理解调用失败 (${res.status})${errBody ? `: ${errBody.slice(0, 120)}` : ""}`);
|
||||
}
|
||||
const payload = await res.json();
|
||||
const result: string =
|
||||
@@ -208,12 +234,16 @@ async function visionChat(
|
||||
}, signal);
|
||||
return out;
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message === "IMAGE_FORMAT_FALLBACK") continue;
|
||||
if (model === VISION_MODEL && err instanceof Error && err.message?.includes("图片理解调用失败")) continue;
|
||||
throw err;
|
||||
lastError = err instanceof Error ? err : new Error(String(err));
|
||||
if (signal?.aborted) throw lastError;
|
||||
// Continue trying next vision model on transient failures, image format errors, or upstream errors
|
||||
if (lastError.message === "IMAGE_FORMAT_FALLBACK") continue;
|
||||
if (lastError.message.includes("图片理解调用失败")) continue;
|
||||
if (isTransientError(lastError)) continue;
|
||||
throw lastError;
|
||||
}
|
||||
}
|
||||
throw new Error("图片理解调用失败,所有模型均不可用");
|
||||
throw lastError ?? new Error("图片理解调用失败,所有模型均不可用");
|
||||
}
|
||||
|
||||
const IMAGE_UNDERSTANDING_PROMPT = `你是电商产品图片分析专家。请分析用户提供的产品图片,识别产品主体、外观、颜色、材质、形状、尺寸感、品牌标识、关键部件、可视化卖点和适合展示的镜头角度。请用简洁的中文段落描述,不要编造图片中看不到的信息。`;
|
||||
|
||||
@@ -8,7 +8,6 @@ export interface ScriptEvalResult {
|
||||
suggestions: string[];
|
||||
}
|
||||
|
||||
const DASHSCOPE_API_KEY = import.meta.env.VITE_DASHSCOPE_API_KEY || "";
|
||||
const DASHSCOPE_ENDPOINT = "/dashscope-api/chat/completions";
|
||||
const MODEL = "qwen3.7-max";
|
||||
|
||||
@@ -69,15 +68,10 @@ function extractJson(text: string): unknown {
|
||||
}
|
||||
|
||||
export async function evaluateScript(script: string, signal?: AbortSignal): Promise<ScriptEvalResult> {
|
||||
if (!DASHSCOPE_API_KEY) {
|
||||
throw new Error("DashScope API key 未配置,请在 .env.local 中设置 VITE_DASHSCOPE_API_KEY");
|
||||
}
|
||||
|
||||
const res = await fetch(DASHSCOPE_ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${DASHSCOPE_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: MODEL,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { HomeOutlined } from "@ant-design/icons";
|
||||
import { useCallback } from "react";
|
||||
|
||||
interface NotFoundPageProps {
|
||||
onGoHome: () => void;
|
||||
}
|
||||
|
||||
function NotFoundPage({ onGoHome }: NotFoundPageProps) {
|
||||
return (
|
||||
<section className="not-found-page page-motion">
|
||||
<div className="not-found-page__content">
|
||||
<div className="not-found-page__code">404</div>
|
||||
<h1>页面未找到</h1>
|
||||
<p>您访问的页面不存在或已被移除。</p>
|
||||
<button type="button" className="not-found-page__button" onClick={onGoHome}>
|
||||
<HomeOutlined />
|
||||
返回首页
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default NotFoundPage;
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
PLAN_STEPS_DISPLAY,
|
||||
type EcommerceVideoStage,
|
||||
type EcommerceVideoSceneTask,
|
||||
type EcommerceVideoPlanProgress,
|
||||
type EcommerceVideoPlanResult,
|
||||
type PlanStep,
|
||||
} from "./ecommerceVideoTypes";
|
||||
@@ -48,6 +49,19 @@ 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,
|
||||
@@ -60,10 +74,12 @@ export default function EcommerceVideoWorkspace({
|
||||
}: 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 abortControllerRef = useRef<AbortController | null>(null);
|
||||
@@ -83,6 +99,7 @@ export default function EcommerceVideoWorkspace({
|
||||
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 || []);
|
||||
}, []);
|
||||
@@ -90,8 +107,8 @@ export default function EcommerceVideoWorkspace({
|
||||
// ── Keep-alive: save state on changes ───────────────────
|
||||
useEffect(() => {
|
||||
if (stage === "idle" || stage === "cancelled") return;
|
||||
saveEcommerceVideoState({ stage, completedSteps, planResult, scenes, sourceImageUrls });
|
||||
}, [stage, completedSteps, planResult, scenes, sourceImageUrls]);
|
||||
saveEcommerceVideoState({ stage, completedSteps, planResult, planProgress, scenes, sourceImageUrls });
|
||||
}, [stage, completedSteps, planResult, planProgress, scenes, sourceImageUrls]);
|
||||
|
||||
// ── Keep-alive: resume polling for running tasks ──────────
|
||||
useEffect(() => {
|
||||
@@ -253,40 +270,85 @@ export default function EcommerceVideoWorkspace({
|
||||
|
||||
// ── Phase 1: Planning ──────────────────────────────────────
|
||||
|
||||
const handlePlan = async () => {
|
||||
if (!isAuthenticated) { onRequestLogin?.(); return; }
|
||||
if (!productImageDataUrls.length && !requirement.trim()) {
|
||||
setError("请先上传产品图片或填写商品说明"); return;
|
||||
}
|
||||
const runPlanFlow = async (resume: EcommerceVideoPlanProgress | null) => {
|
||||
abortControllerRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortControllerRef.current = controller;
|
||||
setStage("planning"); setError(null);
|
||||
setCompletedSteps([]); setCurrentStep(null);
|
||||
setPlanResult(null); setScenes([]); setSourceImageUrls([]);
|
||||
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))
|
||||
: [];
|
||||
const persist = (stageNow: EcommerceVideoStage) => {
|
||||
saveEcommerceVideoState({
|
||||
stage: stageNow,
|
||||
completedSteps: liveCompletedSteps,
|
||||
planResult: null,
|
||||
planProgress: livePlanProgress,
|
||||
scenes: [],
|
||||
sourceImageUrls: livePlanProgress.imageUrls || [],
|
||||
});
|
||||
};
|
||||
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 }); },
|
||||
onStepDone: (step) => {
|
||||
liveCompletedSteps = [...liveCompletedSteps, step];
|
||||
setCompletedSteps((prev) => [...prev, step]);
|
||||
},
|
||||
onImagesUploaded: (urls) => {
|
||||
setSourceImageUrls(urls);
|
||||
livePlanProgress = { ...livePlanProgress, imageUrls: urls };
|
||||
persist("planning");
|
||||
},
|
||||
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");
|
||||
// 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 });
|
||||
saveEcommerceVideoState({ stage: "planned", completedSteps: [...ALL_STEPS], planResult: result, planProgress: null, scenes: builtScenes, sourceImageUrls: result.imageUrls });
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") return;
|
||||
setError(err instanceof Error ? err.message : "策划失败");
|
||||
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
|
||||
setFailedStep((prev) => prev || currentStep);
|
||||
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; }
|
||||
if (!productImageDataUrls.length && !requirement.trim()) {
|
||||
setError("请先上传产品图片或填写商品说明"); return;
|
||||
}
|
||||
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 () => {
|
||||
@@ -302,9 +364,12 @@ export default function EcommerceVideoWorkspace({
|
||||
setScenes(next);
|
||||
saveEcommerceVideoState({ stage: "imaging", completedSteps, planResult, scenes: next, sourceImageUrls });
|
||||
};
|
||||
for (const scene of currentScenes) {
|
||||
// Only redo scenes missing imageUrl — preserves successfully generated images on partial retry
|
||||
const scenesToProcess = currentScenes.filter((s) => !s.imageUrl);
|
||||
if (!scenesToProcess.length) { setStage("imaged"); return; }
|
||||
for (const scene of scenesToProcess) {
|
||||
if (renderAbortRef.current.current) break;
|
||||
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending" } : s));
|
||||
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 },
|
||||
@@ -331,8 +396,7 @@ export default function EcommerceVideoWorkspace({
|
||||
|
||||
const handleRenderVideos = async () => {
|
||||
if (!scenes.length) return;
|
||||
const firstImage = scenes[0]?.imageUrl;
|
||||
if (!firstImage) { setError("请先生成分镜图片"); return; }
|
||||
if (!scenes.some((s) => s.imageUrl)) { setError("请先生成分镜图片"); return; }
|
||||
setStage("rendering"); setError(null);
|
||||
renderAbortRef.current = { current: false };
|
||||
const quality = mapResolutionToQuality(resolution);
|
||||
@@ -342,10 +406,13 @@ export default function EcommerceVideoWorkspace({
|
||||
setScenes(next);
|
||||
saveEcommerceVideoState({ stage: "rendering", completedSteps, planResult, scenes: next, sourceImageUrls });
|
||||
};
|
||||
for (const scene of currentScenes) {
|
||||
// Only render scenes that haven't completed yet — preserves successful videos on partial retry
|
||||
const scenesToProcess = currentScenes.filter((s) => s.imageUrl && s.status !== "completed");
|
||||
if (!scenesToProcess.length) { setStage(currentScenes.every((s) => s.status === "completed") ? "completed" : "partial_failed"); return; }
|
||||
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" } : s));
|
||||
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, aspectRatio, resolution: quality },
|
||||
@@ -424,26 +491,32 @@ export default function EcommerceVideoWorkspace({
|
||||
|
||||
<div className="ecom-video-flowbar__actions">
|
||||
{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 !== "planning" && stage !== "imaging" && stage !== "rendering" ? (
|
||||
<button type="button" className="ecom-video-flow-action"
|
||||
onClick={() => void handlePlan()} title="一键策划">
|
||||
onClick={() => void handlePlan()} title={planProgress ? "从头重新策划" : "一键策划"}>
|
||||
<PlayCircleOutlined />
|
||||
</button>
|
||||
) : null}
|
||||
{stage === "planned" ? (
|
||||
{stage === "planned" || stage === "imaged" ? (
|
||||
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost"
|
||||
onClick={() => void handleGenerateImages()} title="生成图片">
|
||||
<SendOutlined />
|
||||
onClick={() => void handleGenerateImages()} title={stage === "imaged" ? "重新生成分镜图" : "生成图片"}>
|
||||
{stage === "imaged" ? <ReloadOutlined /> : <SendOutlined />}
|
||||
</button>
|
||||
) : null}
|
||||
{stage === "imaged" ? (
|
||||
{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="生成视频">
|
||||
onClick={() => void handleRenderVideos()} title={stage === "partial_failed" ? "重新生成失败的视频" : "生成视频"}>
|
||||
<SendOutlined />
|
||||
</button>
|
||||
) : null}
|
||||
{stage === "planning" ? (
|
||||
<span className="ecom-video-flowbar__stage-label"><LoadingOutlined /> 策划中</span>
|
||||
<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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
EcommerceVideoStage,
|
||||
EcommerceVideoSceneTask,
|
||||
EcommerceVideoPlanProgress,
|
||||
EcommerceVideoPlanResult,
|
||||
PlanStep,
|
||||
} from "./ecommerceVideoTypes";
|
||||
@@ -11,6 +12,7 @@ interface EcommerceVideoKeepalive {
|
||||
stage: EcommerceVideoStage;
|
||||
completedSteps: PlanStep[];
|
||||
planResult: EcommerceVideoPlanResult | null;
|
||||
planProgress?: EcommerceVideoPlanProgress | null;
|
||||
scenes: EcommerceVideoSceneTask[];
|
||||
sourceImageUrls: string[];
|
||||
savedAt: number;
|
||||
@@ -20,6 +22,7 @@ export function saveEcommerceVideoState(state: {
|
||||
stage: EcommerceVideoStage;
|
||||
completedSteps: PlanStep[];
|
||||
planResult: EcommerceVideoPlanResult | null;
|
||||
planProgress?: EcommerceVideoPlanProgress | null;
|
||||
scenes: EcommerceVideoSceneTask[];
|
||||
sourceImageUrls?: string[];
|
||||
}): void {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||
import { waitForTask } from "../../api/taskSubscription";
|
||||
import { resolveVideoRequestModel } from "../../utils/resolveVideoModel";
|
||||
import type {
|
||||
EcommerceVideoPlanProgress,
|
||||
EcommerceVideoPlanResult,
|
||||
EcommerceVideoSceneTask,
|
||||
PlanStep,
|
||||
@@ -21,17 +22,29 @@ export interface PlanCallbacks {
|
||||
onStepStart: (step: PlanStep) => void;
|
||||
onStepDone: (step: PlanStep) => void;
|
||||
onImagesUploaded?: (urls: string[]) => void;
|
||||
onPartialProgress?: (progress: EcommerceVideoPlanProgress) => void;
|
||||
signal?: AbortSignal;
|
||||
/** Partial state from a previous run; steps with existing data are skipped. */
|
||||
resumeFrom?: EcommerceVideoPlanProgress;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full ad video planning pipeline.
|
||||
* Supports resumption: if `resumeFrom` contains data for a step, that step is skipped.
|
||||
* After each step, `onPartialProgress` fires so callers can persist intermediate state.
|
||||
*/
|
||||
export async function runVideoPlan(
|
||||
imageDataUrls: string[],
|
||||
manualText: string,
|
||||
config: AdVideoUserConfig,
|
||||
callbacks: PlanCallbacks,
|
||||
): Promise<EcommerceVideoPlanResult> {
|
||||
const { onStepStart, onStepDone, signal } = callbacks;
|
||||
const { onStepStart, onStepDone, signal, resumeFrom = {} } = callbacks;
|
||||
const progress: EcommerceVideoPlanProgress = { ...resumeFrom };
|
||||
const emit = () => callbacks.onPartialProgress?.({ ...progress });
|
||||
|
||||
// ── Step: upload ──────────────────────────────────────
|
||||
if (!progress.imageUrls?.length) {
|
||||
onStepStart("upload");
|
||||
const imageUrls: string[] = [];
|
||||
const SUPPORTED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
|
||||
@@ -48,39 +61,79 @@ export async function runVideoPlan(
|
||||
}
|
||||
}
|
||||
if (!imageUrls.length) throw new Error("图片上传失败,请检查图片格式或网络后重试");
|
||||
progress.imageUrls = imageUrls;
|
||||
onStepDone("upload");
|
||||
callbacks.onImagesUploaded?.(imageUrls);
|
||||
emit();
|
||||
}
|
||||
|
||||
// ── Step: analyze ─────────────────────────────────────
|
||||
if (progress.imageDescription === undefined) {
|
||||
onStepStart("analyze");
|
||||
const imageDesc = await analyzeProductImages(imageUrls, signal);
|
||||
progress.imageDescription = await analyzeProductImages(progress.imageUrls!, signal);
|
||||
onStepDone("analyze");
|
||||
emit();
|
||||
}
|
||||
|
||||
// ── Step: summary ─────────────────────────────────────
|
||||
if (!progress.summary) {
|
||||
onStepStart("summary");
|
||||
const summary = await buildProductSummary(imageDesc, manualText, signal);
|
||||
progress.summary = await buildProductSummary(progress.imageDescription || "", manualText, signal);
|
||||
onStepDone("summary");
|
||||
emit();
|
||||
}
|
||||
|
||||
// ── Step: selling ─────────────────────────────────────
|
||||
if (!progress.selling) {
|
||||
onStepStart("selling");
|
||||
const selling = await extractSellingPoints(summary, signal);
|
||||
progress.selling = await extractSellingPoints(progress.summary, signal);
|
||||
onStepDone("selling");
|
||||
emit();
|
||||
}
|
||||
|
||||
// ── Step: creative ────────────────────────────────────
|
||||
if (!progress.creatives?.length) {
|
||||
onStepStart("creative");
|
||||
const creatives = await generateCreativeOptions(selling, config, signal);
|
||||
if (!creatives.length) throw new Error("未能生成有效的广告创意");
|
||||
progress.creatives = await generateCreativeOptions(progress.selling, config, signal);
|
||||
if (!progress.creatives.length) throw new Error("未能生成有效的广告创意");
|
||||
onStepDone("creative");
|
||||
emit();
|
||||
}
|
||||
|
||||
// ── Step: storyboard ──────────────────────────────────
|
||||
if (!progress.storyboard) {
|
||||
onStepStart("storyboard");
|
||||
const storyboard = await generateStoryboard(creatives[0], summary, config, signal);
|
||||
progress.storyboard = await generateStoryboard(progress.creatives[0], progress.summary, config, signal);
|
||||
onStepDone("storyboard");
|
||||
emit();
|
||||
}
|
||||
|
||||
// ── Step: prompts ─────────────────────────────────────
|
||||
if (!progress.videoPrompts) {
|
||||
onStepStart("prompts");
|
||||
const videoPrompts = await generateVideoPrompts(storyboard, summary, signal);
|
||||
progress.videoPrompts = await generateVideoPrompts(progress.storyboard, progress.summary, signal);
|
||||
onStepDone("prompts");
|
||||
emit();
|
||||
}
|
||||
|
||||
// ── Step: compliance ──────────────────────────────────
|
||||
if (!progress.compliance) {
|
||||
onStepStart("compliance");
|
||||
const compliance = await checkCompliance(summary, selling, storyboard, signal);
|
||||
progress.compliance = await checkCompliance(progress.summary, progress.selling, progress.storyboard, signal);
|
||||
onStepDone("compliance");
|
||||
emit();
|
||||
}
|
||||
|
||||
return { imageUrls, summary, selling, creatives, storyboard, videoPrompts, compliance };
|
||||
return {
|
||||
imageUrls: progress.imageUrls!,
|
||||
imageDescription: progress.imageDescription,
|
||||
summary: progress.summary!,
|
||||
selling: progress.selling!,
|
||||
creatives: progress.creatives!,
|
||||
storyboard: progress.storyboard!,
|
||||
videoPrompts: progress.videoPrompts!,
|
||||
compliance: progress.compliance!,
|
||||
};
|
||||
}
|
||||
|
||||
export interface RenderSceneImageInput {
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface EcommerceVideoSceneTask {
|
||||
|
||||
export interface EcommerceVideoPlanResult {
|
||||
imageUrls: string[];
|
||||
imageDescription?: string;
|
||||
summary: ProductSummary;
|
||||
selling: SellingPointResult;
|
||||
creatives: CreativeOption[];
|
||||
@@ -44,6 +45,18 @@ export interface EcommerceVideoPlanResult {
|
||||
compliance: ComplianceCheck;
|
||||
}
|
||||
|
||||
/** Partial plan state — used as resume input when an earlier run failed mid-flow. */
|
||||
export interface EcommerceVideoPlanProgress {
|
||||
imageUrls?: string[];
|
||||
imageDescription?: string;
|
||||
summary?: ProductSummary;
|
||||
selling?: SellingPointResult;
|
||||
creatives?: CreativeOption[];
|
||||
storyboard?: Storyboard;
|
||||
videoPrompts?: VideoPrompt[];
|
||||
compliance?: ComplianceCheck;
|
||||
}
|
||||
|
||||
export interface EcommerceVideoDelivery {
|
||||
planResult: EcommerceVideoPlanResult | null;
|
||||
scenes: EcommerceVideoSceneTask[];
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
@import "./pages/compliance.css";
|
||||
@import "./pages/provider-health.css";
|
||||
@import "./pages/legacy-pages.css";
|
||||
@import "./pages/not-found.css";
|
||||
@import "./components/recharge-modal.css";
|
||||
@import "./components/dropzone.css";
|
||||
@import "./components/skeleton.css";
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
.not-found-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: calc(100vh - 60px);
|
||||
padding: 48px 24px;
|
||||
background: var(--app-bg, #0b0b0f);
|
||||
}
|
||||
|
||||
.not-found-page__content {
|
||||
text-align: center;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.not-found-page__code {
|
||||
font-size: 96px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--accent-teal, #2dd4bf);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.not-found-page h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #f1f5f9);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.not-found-page p {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary, #94a3b8);
|
||||
margin: 0 0 28px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.not-found-page__button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 24px;
|
||||
border: 1px solid var(--border-default, #334155);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-elevated, #1e293b);
|
||||
color: var(--text-primary, #f1f5f9);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.not-found-page__button:hover {
|
||||
background: var(--surface-hover, #334155);
|
||||
border-color: var(--accent-teal, #2dd4bf);
|
||||
}
|
||||
+2
-1
@@ -26,7 +26,8 @@ export type WebViewKey =
|
||||
| "communityReview"
|
||||
| "communityCaseAdd"
|
||||
| "report"
|
||||
| "providerHealth";
|
||||
| "providerHealth"
|
||||
| "not-found";
|
||||
|
||||
export type WebImageWorkbenchTool = "workbench" | "inpaint" | "camera";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user