Fix/ecommerce video 400 bug #10

Merged
stringadmin merged 3 commits from fix/ecommerce-video-400-bug into master 2026-06-03 12:24:19 +00:00
11 changed files with 373 additions and 120 deletions
Showing only changes of commit 56dabf1f7d - Show all commits
+9 -4
View File
@@ -33,6 +33,7 @@ import {
import { webGenerationGateway, type CreatePreviewTaskInput } from "./api/webGenerationGateway"; import { webGenerationGateway, type CreatePreviewTaskInput } from "./api/webGenerationGateway";
import { translateTaskError } from "./utils/translateTaskError"; import { translateTaskError } from "./utils/translateTaskError";
import AppShell from "./components/AppShell"; import AppShell from "./components/AppShell";
const NotFoundPage = lazy(() => import("./components/NotFoundPage"));
import { cloneWorkflow, createBlankWorkflow } from "./data/workflows"; import { cloneWorkflow, createBlankWorkflow } from "./data/workflows";
const AgentPage = lazy(() => import("./features/agent/AgentPage")); const AgentPage = lazy(() => import("./features/agent/AgentPage"));
const AssetsPage = lazy(() => import("./features/assets/AssetsPage")); const AssetsPage = lazy(() => import("./features/assets/AssetsPage"));
@@ -115,9 +116,10 @@ const VIEW_KEYS = new Set<WebViewKey>([
"communityCaseAdd", "communityCaseAdd",
"report", "report",
"providerHealth", "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 { function normalizeViewKey(rawView: string): WebViewKey {
const normalized = const normalized =
@@ -130,7 +132,7 @@ function normalizeViewKey(rawView: string): WebViewKey {
: rawView === "community-case-add" : rawView === "community-case-add"
? "communityCaseAdd" ? "communityCaseAdd"
: rawView; : 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 { function readViewFromHash(): WebViewKey {
@@ -146,7 +148,8 @@ function isWorkspaceView(view: WebViewKey): boolean {
view !== "ecommerceHub" && view !== "ecommerceHub" &&
view !== "ecommerce" && view !== "ecommerce" &&
view !== "scriptTokens" && view !== "scriptTokens" &&
view !== "login" view !== "login" &&
view !== "not-found"
); );
} }
@@ -1178,7 +1181,6 @@ function App() {
/> />
); );
case "home": case "home":
default:
return ( return (
<HomePage <HomePage
onOpenGenerate={() => handleSetView("workbench")} onOpenGenerate={() => handleSetView("workbench")}
@@ -1190,6 +1192,9 @@ function App() {
onOpenImageTool={handleOpenImageWorkbenchTool} onOpenImageTool={handleOpenImageWorkbenchTool}
/> />
); );
case "not-found":
default:
return <NotFoundPage onGoHome={() => handleSetView("home")} />;
} }
})(); })();
+51 -21
View File
@@ -1,8 +1,7 @@
import { buildApiUrl, buildAuthHeaders } from "./serverConnection"; import { buildApiUrl, buildAuthHeaders } from "./serverConnection";
const TEXT_MODEL = "qwen-max"; const TEXT_MODELS = ["qwen-max", "qwen-plus", "qwen-turbo"];
const VISION_MODEL = "qwen3.7-plus"; const VISION_MODELS = ["qwen3.7-plus", "qwen-vl-plus", "qwen-vl-max"];
const VISION_FALLBACK_MODEL = "qwen-vl-plus";
export interface AdVideoUserConfig { export interface AdVideoUserConfig {
platform: string; platform: string;
@@ -110,27 +109,41 @@ interface ChatMessage {
const MAX_RETRIES = 3; const MAX_RETRIES = 3;
const RETRY_BASE_MS = 2000; 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 { function isTransientError(err: unknown): boolean {
if (!(err instanceof Error)) return false; if (!(err instanceof Error)) return false;
const msg = err.message.toLowerCase(); 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> { async function retryOnTransient<T>(fn: () => Promise<T>, signal?: AbortSignal): Promise<T> {
let lastErr: unknown;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try { try {
return await fn(); return await fn();
} catch (err) { } catch (err) {
lastErr = err;
if (signal?.aborted) throw 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 (attempt === MAX_RETRIES) throw err;
if (!isTransientError(err)) throw err; if (!isTransientError(err)) throw err;
const delay = RETRY_BASE_MS * 2 ** attempt + Math.random() * 1000; const delay = RETRY_BASE_MS * 2 ** attempt + Math.random() * 1000;
await new Promise((r) => setTimeout(r, delay)); await new Promise((r) => setTimeout(r, delay));
} }
} }
throw new Error("unreachable"); throw lastErr instanceof Error ? lastErr : new Error("AI 调用失败:已重试多次");
} }
async function chat( async function chat(
@@ -138,7 +151,12 @@ async function chat(
userContent: string, userContent: string,
options?: { model?: string; signal?: AbortSignal }, options?: { model?: string; signal?: AbortSignal },
): Promise<string> { ): 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[] = [ const messages: ChatMessage[] = [
{ role: "system", content: systemPrompt }, { role: "system", content: systemPrompt },
{ role: "user", content: userContent }, { role: "user", content: userContent },
@@ -150,21 +168,28 @@ async function chat(
const res = await fetch(buildApiUrl("ai/chat"), { const res = await fetch(buildApiUrl("ai/chat"), {
method: "POST", method: "POST",
headers: buildAuthHeaders(), headers: buildAuthHeaders(),
body: JSON.stringify({ body: JSON.stringify({ model, messages, stream: false, temperature: 0.4 }),
model: options?.model ?? TEXT_MODEL,
messages,
stream: false,
temperature: 0.4,
}),
signal: combinedSignal, 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 payload = await res.json();
const content: string = const content: string =
payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? ""; payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? "";
if (!content) throw new Error("模型未返回有效内容"); if (!content) throw new Error("模型未返回有效内容");
return content; return content;
}, options?.signal); }, 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( async function visionChat(
@@ -182,7 +207,8 @@ async function visionChat(
{ role: "user", content }, { 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 timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
const combinedSignal = signal const combinedSignal = signal
? AbortSignal.any([signal, timeoutSignal]) ? AbortSignal.any([signal, timeoutSignal])
@@ -197,8 +223,8 @@ async function visionChat(
}); });
if (!res.ok) { if (!res.ok) {
const errBody = await res.text().catch(() => ""); const errBody = await res.text().catch(() => "");
if (model === VISION_MODEL && errBody.includes("image format")) throw new Error("IMAGE_FORMAT_FALLBACK"); if (errBody.includes("image format")) throw new Error("IMAGE_FORMAT_FALLBACK");
throw new Error(`图片理解调用失败 (${res.status})`); throw new Error(`图片理解调用失败 (${res.status})${errBody ? `: ${errBody.slice(0, 120)}` : ""}`);
} }
const payload = await res.json(); const payload = await res.json();
const result: string = const result: string =
@@ -208,12 +234,16 @@ async function visionChat(
}, signal); }, signal);
return out; return out;
} catch (err) { } catch (err) {
if (err instanceof Error && err.message === "IMAGE_FORMAT_FALLBACK") continue; lastError = err instanceof Error ? err : new Error(String(err));
if (model === VISION_MODEL && err instanceof Error && err.message?.includes("图片理解调用失败")) continue; if (signal?.aborted) throw lastError;
throw err; // 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 = `你是电商产品图片分析专家。请分析用户提供的产品图片,识别产品主体、外观、颜色、材质、形状、尺寸感、品牌标识、关键部件、可视化卖点和适合展示的镜头角度。请用简洁的中文段落描述,不要编造图片中看不到的信息。`; const IMAGE_UNDERSTANDING_PROMPT = `你是电商产品图片分析专家。请分析用户提供的产品图片,识别产品主体、外观、颜色、材质、形状、尺寸感、品牌标识、关键部件、可视化卖点和适合展示的镜头角度。请用简洁的中文段落描述,不要编造图片中看不到的信息。`;
-6
View File
@@ -8,7 +8,6 @@ export interface ScriptEvalResult {
suggestions: string[]; suggestions: string[];
} }
const DASHSCOPE_API_KEY = import.meta.env.VITE_DASHSCOPE_API_KEY || "";
const DASHSCOPE_ENDPOINT = "/dashscope-api/chat/completions"; const DASHSCOPE_ENDPOINT = "/dashscope-api/chat/completions";
const MODEL = "qwen3.7-max"; const MODEL = "qwen3.7-max";
@@ -69,15 +68,10 @@ function extractJson(text: string): unknown {
} }
export async function evaluateScript(script: string, signal?: AbortSignal): Promise<ScriptEvalResult> { 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, { const res = await fetch(DASHSCOPE_ENDPOINT, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${DASHSCOPE_API_KEY}`,
}, },
body: JSON.stringify({ body: JSON.stringify({
model: MODEL, model: MODEL,
+24
View File
@@ -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, PLAN_STEPS_DISPLAY,
type EcommerceVideoStage, type EcommerceVideoStage,
type EcommerceVideoSceneTask, type EcommerceVideoSceneTask,
type EcommerceVideoPlanProgress,
type EcommerceVideoPlanResult, type EcommerceVideoPlanResult,
type PlanStep, type PlanStep,
} from "./ecommerceVideoTypes"; } from "./ecommerceVideoTypes";
@@ -48,6 +49,19 @@ function mapResolutionToQuality(res: string): "720P" | "1080P" {
return res.includes("720") ? "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({ export default function EcommerceVideoWorkspace({
isAuthenticated, isAuthenticated,
productImageDataUrls, productImageDataUrls,
@@ -60,10 +74,12 @@ export default function EcommerceVideoWorkspace({
}: EcommerceVideoWorkspaceProps) { }: EcommerceVideoWorkspaceProps) {
const [stage, setStage] = useState<EcommerceVideoStage>("idle"); const [stage, setStage] = useState<EcommerceVideoStage>("idle");
const [planResult, setPlanResult] = useState<EcommerceVideoPlanResult | null>(null); const [planResult, setPlanResult] = useState<EcommerceVideoPlanResult | null>(null);
const [planProgress, setPlanProgress] = useState<EcommerceVideoPlanProgress | null>(null);
const [scenes, setScenes] = useState<EcommerceVideoSceneTask[]>([]); const [scenes, setScenes] = useState<EcommerceVideoSceneTask[]>([]);
const [completedSteps, setCompletedSteps] = useState<PlanStep[]>([]); const [completedSteps, setCompletedSteps] = useState<PlanStep[]>([]);
const [sourceImageUrls, setSourceImageUrls] = useState<string[]>([]); const [sourceImageUrls, setSourceImageUrls] = useState<string[]>([]);
const [currentStep, setCurrentStep] = useState<PlanStep | null>(null); const [currentStep, setCurrentStep] = useState<PlanStep | null>(null);
const [failedStep, setFailedStep] = useState<PlanStep | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [actionNotice, setActionNotice] = useState<string | null>(null); const [actionNotice, setActionNotice] = useState<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(null); const abortControllerRef = useRef<AbortController | null>(null);
@@ -83,6 +99,7 @@ export default function EcommerceVideoWorkspace({
setStage(saved.stage); setStage(saved.stage);
setCompletedSteps(saved.completedSteps || []); setCompletedSteps(saved.completedSteps || []);
setPlanResult(saved.planResult); setPlanResult(saved.planResult);
setPlanProgress((saved as { planProgress?: EcommerceVideoPlanProgress | null }).planProgress || null);
setScenes(saved.scenes || []); setScenes(saved.scenes || []);
setSourceImageUrls(saved.sourceImageUrls || saved.planResult?.imageUrls || []); setSourceImageUrls(saved.sourceImageUrls || saved.planResult?.imageUrls || []);
}, []); }, []);
@@ -90,8 +107,8 @@ export default function EcommerceVideoWorkspace({
// ── Keep-alive: save state on changes ─────────────────── // ── Keep-alive: save state on changes ───────────────────
useEffect(() => { useEffect(() => {
if (stage === "idle" || stage === "cancelled") return; if (stage === "idle" || stage === "cancelled") return;
saveEcommerceVideoState({ stage, completedSteps, planResult, scenes, sourceImageUrls }); saveEcommerceVideoState({ stage, completedSteps, planResult, planProgress, scenes, sourceImageUrls });
}, [stage, completedSteps, planResult, scenes, sourceImageUrls]); }, [stage, completedSteps, planResult, planProgress, scenes, sourceImageUrls]);
// ── Keep-alive: resume polling for running tasks ────────── // ── Keep-alive: resume polling for running tasks ──────────
useEffect(() => { useEffect(() => {
@@ -253,40 +270,85 @@ export default function EcommerceVideoWorkspace({
// ── Phase 1: Planning ────────────────────────────────────── // ── Phase 1: Planning ──────────────────────────────────────
const handlePlan = async () => { const runPlanFlow = async (resume: EcommerceVideoPlanProgress | null) => {
if (!isAuthenticated) { onRequestLogin?.(); return; }
if (!productImageDataUrls.length && !requirement.trim()) {
setError("请先上传产品图片或填写商品说明"); return;
}
abortControllerRef.current?.abort(); abortControllerRef.current?.abort();
const controller = new AbortController(); const controller = new AbortController();
abortControllerRef.current = controller; abortControllerRef.current = controller;
setStage("planning"); setError(null); setStage("planning"); setError(null); setFailedStep(null);
setCompletedSteps([]); setCurrentStep(null); if (!resume) {
setPlanResult(null); setScenes([]); setSourceImageUrls([]); 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 { try {
const result = await runVideoPlan( const result = await runVideoPlan(
productImageDataUrls, requirement, buildConfig(), productImageDataUrls, requirement, buildConfig(),
{ {
onStepStart: (step) => setCurrentStep(step), onStepStart: (step) => setCurrentStep(step),
onStepDone: (step) => setCompletedSteps((prev) => [...prev, step]), onStepDone: (step) => {
onImagesUploaded: (urls) => { setSourceImageUrls(urls); saveEcommerceVideoState({ stage: "planning", completedSteps: ["upload"], planResult: null, scenes: [], sourceImageUrls: urls }); }, 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, signal: controller.signal,
}, },
); );
const builtScenes = buildSceneTasks(result); const builtScenes = buildSceneTasks(result);
setPlanResult(result); setPlanResult(result);
setPlanProgress(null);
setScenes(builtScenes); setScenes(builtScenes);
setStage("planned"); setStage("planned");
// Persist immediately — component may be unmounted by the time React re-renders saveEcommerceVideoState({ stage: "planned", completedSteps: [...ALL_STEPS], planResult: result, planProgress: null, scenes: builtScenes, sourceImageUrls: result.imageUrls });
saveEcommerceVideoState({ stage: "planned", completedSteps: [...ALL_STEPS], planResult: result, scenes: builtScenes, sourceImageUrls: result.imageUrls });
} catch (err) { } catch (err) {
if ((err as Error).name === "AbortError") return; if ((err as Error).name === "AbortError" && controller.signal.aborted) return;
setError(err instanceof Error ? err.message : "策划失败"); 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"); setStage("idle");
// Persist partial progress so the user can resume after a page switch
persist("idle");
} finally { setCurrentStep(null); } } 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 ────────────────────── // ── Phase 2: Image generation per scene ──────────────────────
const handleGenerateImages = async () => { const handleGenerateImages = async () => {
@@ -302,9 +364,12 @@ export default function EcommerceVideoWorkspace({
setScenes(next); setScenes(next);
saveEcommerceVideoState({ stage: "imaging", completedSteps, planResult, scenes: next, sourceImageUrls }); 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; 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 { try {
await renderSceneImage( await renderSceneImage(
{ sceneId: scene.sceneId, prompt: scene.prompt, aspectRatio: ratio }, { sceneId: scene.sceneId, prompt: scene.prompt, aspectRatio: ratio },
@@ -331,8 +396,7 @@ export default function EcommerceVideoWorkspace({
const handleRenderVideos = async () => { const handleRenderVideos = async () => {
if (!scenes.length) return; if (!scenes.length) return;
const firstImage = scenes[0]?.imageUrl; if (!scenes.some((s) => s.imageUrl)) { setError("请先生成分镜图片"); return; }
if (!firstImage) { setError("请先生成分镜图片"); return; }
setStage("rendering"); setError(null); setStage("rendering"); setError(null);
renderAbortRef.current = { current: false }; renderAbortRef.current = { current: false };
const quality = mapResolutionToQuality(resolution); const quality = mapResolutionToQuality(resolution);
@@ -342,10 +406,13 @@ export default function EcommerceVideoWorkspace({
setScenes(next); setScenes(next);
saveEcommerceVideoState({ stage: "rendering", completedSteps, planResult, scenes: next, sourceImageUrls }); 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 (renderAbortRef.current.current) break;
if (!scene.imageUrl) continue; 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 { try {
await renderScene( await renderScene(
{ sceneId: scene.sceneId, prompt: scene.prompt, durationSeconds: scene.durationSeconds, imageUrl: scene.imageUrl, aspectRatio, resolution: quality }, { 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"> <div className="ecom-video-flowbar__actions">
{error ? <span className="ecom-video-flowbar__error" role="alert">{error}</span> : 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 !== "planning" && stage !== "imaging" && stage !== "rendering" ? ( {stage !== "planning" && stage !== "imaging" && stage !== "rendering" ? (
<button type="button" className="ecom-video-flow-action" <button type="button" className="ecom-video-flow-action"
onClick={() => void handlePlan()} title="一键策划"> onClick={() => void handlePlan()} title={planProgress ? "从头重新策划" : "一键策划"}>
<PlayCircleOutlined /> <PlayCircleOutlined />
</button> </button>
) : null} ) : null}
{stage === "planned" ? ( {stage === "planned" || stage === "imaged" ? (
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost" <button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost"
onClick={() => void handleGenerateImages()} title="生成图片"> onClick={() => void handleGenerateImages()} title={stage === "imaged" ? "重新生成分镜图" : "生成图片"}>
<SendOutlined /> {stage === "imaged" ? <ReloadOutlined /> : <SendOutlined />}
</button> </button>
) : null} ) : null}
{stage === "imaged" ? ( {stage === "imaged" || (stage === "partial_failed" && imagedScenes.length > 0) ? (
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost" <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 /> <SendOutlined />
</button> </button>
) : null} ) : null}
{stage === "planning" ? ( {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} ) : null}
{stage === "imaging" ? ( {stage === "imaging" ? (
<span className="ecom-video-flowbar__stage-label"><LoadingOutlined /> </span> <span className="ecom-video-flowbar__stage-label"><LoadingOutlined /> </span>
@@ -1,6 +1,7 @@
import type { import type {
EcommerceVideoStage, EcommerceVideoStage,
EcommerceVideoSceneTask, EcommerceVideoSceneTask,
EcommerceVideoPlanProgress,
EcommerceVideoPlanResult, EcommerceVideoPlanResult,
PlanStep, PlanStep,
} from "./ecommerceVideoTypes"; } from "./ecommerceVideoTypes";
@@ -11,6 +12,7 @@ interface EcommerceVideoKeepalive {
stage: EcommerceVideoStage; stage: EcommerceVideoStage;
completedSteps: PlanStep[]; completedSteps: PlanStep[];
planResult: EcommerceVideoPlanResult | null; planResult: EcommerceVideoPlanResult | null;
planProgress?: EcommerceVideoPlanProgress | null;
scenes: EcommerceVideoSceneTask[]; scenes: EcommerceVideoSceneTask[];
sourceImageUrls: string[]; sourceImageUrls: string[];
savedAt: number; savedAt: number;
@@ -20,6 +22,7 @@ export function saveEcommerceVideoState(state: {
stage: EcommerceVideoStage; stage: EcommerceVideoStage;
completedSteps: PlanStep[]; completedSteps: PlanStep[];
planResult: EcommerceVideoPlanResult | null; planResult: EcommerceVideoPlanResult | null;
planProgress?: EcommerceVideoPlanProgress | null;
scenes: EcommerceVideoSceneTask[]; scenes: EcommerceVideoSceneTask[];
sourceImageUrls?: string[]; sourceImageUrls?: string[];
}): void { }): void {
+63 -10
View File
@@ -12,6 +12,7 @@ import { aiGenerationClient } from "../../api/aiGenerationClient";
import { waitForTask } from "../../api/taskSubscription"; import { waitForTask } from "../../api/taskSubscription";
import { resolveVideoRequestModel } from "../../utils/resolveVideoModel"; import { resolveVideoRequestModel } from "../../utils/resolveVideoModel";
import type { import type {
EcommerceVideoPlanProgress,
EcommerceVideoPlanResult, EcommerceVideoPlanResult,
EcommerceVideoSceneTask, EcommerceVideoSceneTask,
PlanStep, PlanStep,
@@ -21,17 +22,29 @@ export interface PlanCallbacks {
onStepStart: (step: PlanStep) => void; onStepStart: (step: PlanStep) => void;
onStepDone: (step: PlanStep) => void; onStepDone: (step: PlanStep) => void;
onImagesUploaded?: (urls: string[]) => void; onImagesUploaded?: (urls: string[]) => void;
onPartialProgress?: (progress: EcommerceVideoPlanProgress) => void;
signal?: AbortSignal; 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( export async function runVideoPlan(
imageDataUrls: string[], imageDataUrls: string[],
manualText: string, manualText: string,
config: AdVideoUserConfig, config: AdVideoUserConfig,
callbacks: PlanCallbacks, callbacks: PlanCallbacks,
): Promise<EcommerceVideoPlanResult> { ): 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"); onStepStart("upload");
const imageUrls: string[] = []; const imageUrls: string[] = [];
const SUPPORTED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]); 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("图片上传失败,请检查图片格式或网络后重试"); if (!imageUrls.length) throw new Error("图片上传失败,请检查图片格式或网络后重试");
progress.imageUrls = imageUrls;
onStepDone("upload"); onStepDone("upload");
callbacks.onImagesUploaded?.(imageUrls); callbacks.onImagesUploaded?.(imageUrls);
emit();
}
// ── Step: analyze ─────────────────────────────────────
if (progress.imageDescription === undefined) {
onStepStart("analyze"); onStepStart("analyze");
const imageDesc = await analyzeProductImages(imageUrls, signal); progress.imageDescription = await analyzeProductImages(progress.imageUrls!, signal);
onStepDone("analyze"); onStepDone("analyze");
emit();
}
// ── Step: summary ─────────────────────────────────────
if (!progress.summary) {
onStepStart("summary"); onStepStart("summary");
const summary = await buildProductSummary(imageDesc, manualText, signal); progress.summary = await buildProductSummary(progress.imageDescription || "", manualText, signal);
onStepDone("summary"); onStepDone("summary");
emit();
}
// ── Step: selling ─────────────────────────────────────
if (!progress.selling) {
onStepStart("selling"); onStepStart("selling");
const selling = await extractSellingPoints(summary, signal); progress.selling = await extractSellingPoints(progress.summary, signal);
onStepDone("selling"); onStepDone("selling");
emit();
}
// ── Step: creative ────────────────────────────────────
if (!progress.creatives?.length) {
onStepStart("creative"); onStepStart("creative");
const creatives = await generateCreativeOptions(selling, config, signal); progress.creatives = await generateCreativeOptions(progress.selling, config, signal);
if (!creatives.length) throw new Error("未能生成有效的广告创意"); if (!progress.creatives.length) throw new Error("未能生成有效的广告创意");
onStepDone("creative"); onStepDone("creative");
emit();
}
// ── Step: storyboard ──────────────────────────────────
if (!progress.storyboard) {
onStepStart("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"); onStepDone("storyboard");
emit();
}
// ── Step: prompts ─────────────────────────────────────
if (!progress.videoPrompts) {
onStepStart("prompts"); onStepStart("prompts");
const videoPrompts = await generateVideoPrompts(storyboard, summary, signal); progress.videoPrompts = await generateVideoPrompts(progress.storyboard, progress.summary, signal);
onStepDone("prompts"); onStepDone("prompts");
emit();
}
// ── Step: compliance ──────────────────────────────────
if (!progress.compliance) {
onStepStart("compliance"); onStepStart("compliance");
const compliance = await checkCompliance(summary, selling, storyboard, signal); progress.compliance = await checkCompliance(progress.summary, progress.selling, progress.storyboard, signal);
onStepDone("compliance"); 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 { export interface RenderSceneImageInput {
@@ -36,6 +36,7 @@ export interface EcommerceVideoSceneTask {
export interface EcommerceVideoPlanResult { export interface EcommerceVideoPlanResult {
imageUrls: string[]; imageUrls: string[];
imageDescription?: string;
summary: ProductSummary; summary: ProductSummary;
selling: SellingPointResult; selling: SellingPointResult;
creatives: CreativeOption[]; creatives: CreativeOption[];
@@ -44,6 +45,18 @@ export interface EcommerceVideoPlanResult {
compliance: ComplianceCheck; 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 { export interface EcommerceVideoDelivery {
planResult: EcommerceVideoPlanResult | null; planResult: EcommerceVideoPlanResult | null;
scenes: EcommerceVideoSceneTask[]; scenes: EcommerceVideoSceneTask[];
+1
View File
@@ -29,6 +29,7 @@
@import "./pages/compliance.css"; @import "./pages/compliance.css";
@import "./pages/provider-health.css"; @import "./pages/provider-health.css";
@import "./pages/legacy-pages.css"; @import "./pages/legacy-pages.css";
@import "./pages/not-found.css";
@import "./components/recharge-modal.css"; @import "./components/recharge-modal.css";
@import "./components/dropzone.css"; @import "./components/dropzone.css";
@import "./components/skeleton.css"; @import "./components/skeleton.css";
+56
View File
@@ -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
View File
@@ -26,7 +26,8 @@ export type WebViewKey =
| "communityReview" | "communityReview"
| "communityCaseAdd" | "communityCaseAdd"
| "report" | "report"
| "providerHealth"; | "providerHealth"
| "not-found";
export type WebImageWorkbenchTool = "workbench" | "inpaint" | "camera"; export type WebImageWorkbenchTool = "workbench" | "inpaint" | "camera";