fix(ecommerce): video 400 error — use OSS URLs instead of data URLs for video generation

The renderScene function was passing local data URLs (data:image/png;base64,...)
as imageUrl and referenceUrls to createVideoTask, which the /api/ai/video endpoint
rejects with 400 Bad Request. The planning phase already uploads images to OSS
but the resulting URLs were not returned to the component.

- Add imageUrls field to EcommerceVideoPlanResult
- Return OSS imageUrls from runVideoPlan alongside existing plan data
- Use planResult.imageUrls[0] in handleRender instead of productImageDataUrls[0]
- Use planResult?.imageUrls[0] for sourceImage display fallback

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 19:37:29 +08:00
parent e5e5af5b54
commit 5fcd225825
7 changed files with 82 additions and 52 deletions
+1 -1
View File
@@ -362,7 +362,7 @@ function App() {
}, [clearSessionState, setProjects, setProjectsLoaded, setUsage, clearTasks, setRuntimeNotifications, setServerNotifications, setCanvasWorkflow, setCurrentCanvasProjectId, setWorkspaceExpanded, handleSetView]); }, [clearSessionState, setProjects, setProjectsLoaded, setUsage, clearTasks, setRuntimeNotifications, setServerNotifications, setCanvasWorkflow, setCurrentCanvasProjectId, setWorkspaceExpanded, handleSetView]);
const showSessionReplacedModal = useCallback((message?: string) => { const showSessionReplacedModal = useCallback((message?: string) => {
clearAuthenticatedState(); clearAuthenticatedState({ resetView: true });
showSessionReplaced(message); showSessionReplaced(message);
}, [clearAuthenticatedState, showSessionReplaced]); }, [clearAuthenticatedState, showSessionReplaced]);
+42 -8
View File
@@ -6,6 +6,7 @@ interface PageTransitionProps {
} }
const EXIT_DURATION_MS = 180; const EXIT_DURATION_MS = 180;
const ENTER_DURATION_MS = 220;
const NAV_ORDER: string[] = [ const NAV_ORDER: string[] = [
"home", "home",
@@ -39,8 +40,8 @@ function getNavIndex(key: string): number {
export default function PageTransition({ viewKey, children }: PageTransitionProps) { export default function PageTransition({ viewKey, children }: PageTransitionProps) {
const [displayedChildren, setDisplayedChildren] = useState(children); const [displayedChildren, setDisplayedChildren] = useState(children);
const [phase, setPhase] = useState<"idle" | "exit">("idle"); const [phase, setPhase] = useState<"idle" | "exit" | "enter">("idle");
const [direction, setDirection] = useState<"forward" | "backward" | "neutral">("neutral"); const [exitDirection, setExitDirection] = useState<"forward" | "backward" | "neutral">("neutral");
const prevKeyRef = useRef(viewKey); const prevKeyRef = useRef(viewKey);
const timerRef = useRef<ReturnType<typeof setTimeout>>(); const timerRef = useRef<ReturnType<typeof setTimeout>>();
@@ -49,28 +50,61 @@ export default function PageTransition({ viewKey, children }: PageTransitionProp
setDisplayedChildren(children); setDisplayedChildren(children);
return; return;
} }
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (prefersReducedMotion) {
prevKeyRef.current = viewKey;
setDisplayedChildren(children);
setPhase("idle");
return;
}
const prevIndex = getNavIndex(prevKeyRef.current); const prevIndex = getNavIndex(prevKeyRef.current);
const nextIndex = getNavIndex(viewKey); const nextIndex = getNavIndex(viewKey);
if (prevIndex < nextIndex) { if (prevIndex < nextIndex) {
setDirection("forward"); setExitDirection("forward");
} else if (prevIndex > nextIndex) { } else if (prevIndex > nextIndex) {
setDirection("backward"); setExitDirection("backward");
} else { } else {
setDirection("neutral"); setExitDirection("neutral");
} }
prevKeyRef.current = viewKey; prevKeyRef.current = viewKey;
setPhase("exit"); setPhase("exit");
timerRef.current = setTimeout(() => { timerRef.current = setTimeout(() => {
setDisplayedChildren(children); setDisplayedChildren(children);
setPhase("idle"); setPhase("enter");
}, EXIT_DURATION_MS); }, EXIT_DURATION_MS);
return () => clearTimeout(timerRef.current); return () => clearTimeout(timerRef.current);
}, [viewKey, children]); }, [viewKey, children]);
const dirClass = direction === "forward" ? " is-forward" : direction === "backward" ? " is-backward" : ""; // After enter animation completes, go back to idle
useEffect(() => {
if (phase !== "enter") return;
const timer = setTimeout(() => setPhase("idle"), ENTER_DURATION_MS);
return () => clearTimeout(timer);
}, [phase]);
const dirClass = exitDirection === "forward" ? " is-forward" : exitDirection === "backward" ? " is-backward" : "";
if (phase === "exit") {
return (
<div className={`page-transition-wrap page-motion--exit${dirClass}`}>
{displayedChildren}
</div>
);
}
if (phase === "enter") {
return (
<div className="page-transition-wrap page-motion--enter">
{displayedChildren}
</div>
);
}
return ( return (
<div className={phase === "exit" ? `page-transition-wrap page-motion--exit${dirClass}` : `page-transition-wrap${phase === "idle" && direction !== "neutral" ? ` page-motion--enter${dirClass}` : ""}`}> <div className="page-transition-wrap">
{displayedChildren} {displayedChildren}
</div> </div>
); );
@@ -170,7 +170,7 @@ export default function EcommerceVideoWorkspace({
const handleRender = async () => { const handleRender = async () => {
if (!planResult || !scenes.length) return; if (!planResult || !scenes.length) return;
const imageUrl = productImageDataUrls[0] || ""; const imageUrl = planResult.imageUrls[0] || "";
setStage("rendering"); setStage("rendering");
setError(null); setError(null);
renderAbortRef.current = { current: false }; renderAbortRef.current = { current: false };
@@ -213,7 +213,7 @@ export default function EcommerceVideoWorkspace({
const completedScenes = scenes.filter((s) => s.status === "completed" && s.resultUrl); const completedScenes = scenes.filter((s) => s.status === "completed" && s.resultUrl);
const primaryVideo = completedScenes[0]?.resultUrl; const primaryVideo = completedScenes[0]?.resultUrl;
const canRender = planResult?.compliance.allow_video_generation && stage === "planned"; const canRender = planResult?.compliance.allow_video_generation && stage === "planned";
const sourceImage = productImageDataUrls[0] || ""; const sourceImage = planResult?.imageUrls[0] || productImageDataUrls[0] || "";
const flowHasStarted = stage !== "idle" || completedSteps.length > 0 || scenes.length > 0; const flowHasStarted = stage !== "idle" || completedSteps.length > 0 || scenes.length > 0;
const flowMeta = `${platform} / ${aspectRatio} / ${durationSeconds}s / ${resolution}`; const flowMeta = `${platform} / ${aspectRatio} / ${durationSeconds}s / ${resolution}`;
const planActionLabel = stage === "planning" const planActionLabel = stage === "planning"
@@ -77,7 +77,7 @@ export async function runVideoPlan(
const compliance = await checkCompliance(summary, selling, storyboard, signal); const compliance = await checkCompliance(summary, selling, storyboard, signal);
onStepDone("compliance"); onStepDone("compliance");
return { summary, selling, creatives, storyboard, videoPrompts, compliance }; return { imageUrls, summary, selling, creatives, storyboard, videoPrompts, compliance };
} }
export interface RenderSceneInput { export interface RenderSceneInput {
@@ -31,6 +31,7 @@ export interface EcommerceVideoSceneTask {
} }
export interface EcommerceVideoPlanResult { export interface EcommerceVideoPlanResult {
imageUrls: string[];
summary: ProductSummary; summary: ProductSummary;
selling: SellingPointResult; selling: SellingPointResult;
creatives: CreativeOption[]; creatives: CreativeOption[];
+33 -27
View File
@@ -16,43 +16,29 @@
} }
} }
/* Directional page transitions */ /* Exit: fade + directional slide */
.page-motion--enter.is-forward { .page-motion--exit {
animation: page-slide-in-forward 200ms var(--ease-out-expo, cubic-bezier(0.16, 1, 0.3, 1)) both; animation: page-out 180ms ease forwards;
} pointer-events: none;
.page-motion--enter.is-backward {
animation: page-slide-in-backward 200ms var(--ease-out-expo, cubic-bezier(0.16, 1, 0.3, 1)) both;
} }
.page-motion--exit.is-forward { .page-motion--exit.is-forward {
animation: page-slide-out-forward 180ms ease both; animation: page-slide-out-forward 180ms ease forwards;
pointer-events: none;
} }
.page-motion--exit.is-backward { .page-motion--exit.is-backward {
animation: page-slide-out-backward 180ms ease both; animation: page-slide-out-backward 180ms ease forwards;
pointer-events: none;
} }
@keyframes page-slide-in-forward { /* Cancel child's own entrance animation during exit */
from { .page-motion--exit .page-motion {
opacity: 0; animation: none !important;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
} }
@keyframes page-slide-in-backward { @keyframes page-out {
from { to { opacity: 0; transform: translateY(-6px); }
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
} }
@keyframes page-slide-out-forward { @keyframes page-slide-out-forward {
@@ -68,3 +54,23 @@
transform: translateX(16px); transform: translateX(16px);
} }
} }
/* Enter: explicit wrapper entrance animation overrides child page-motion */
.page-motion--enter {
animation: page-enter-fade 220ms ease both;
}
.page-motion--enter .page-motion {
animation: none !important;
}
@keyframes page-enter-fade {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
+1 -12
View File
@@ -65,18 +65,7 @@
min-height: 0; min-height: 0;
} }
.page-motion--exit { /* page-motion--exit moved to page-transition.css */
animation: page-out 180ms ease both;
pointer-events: none;
}
.page-motion--exit .page-motion {
animation: none;
}
@keyframes page-out {
to { opacity: 0; transform: translateY(-6px); }
}
.page-loading-center { .page-loading-center {
display: flex; display: flex;