Initial commit: OmniAI Web Frontend
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,323 @@
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
CheckCircleOutlined,
|
||||
DeleteOutlined,
|
||||
PlayCircleOutlined,
|
||||
SearchOutlined,
|
||||
ShoppingOutlined,
|
||||
TagsOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
||||
import type { WebProjectSummary } from "../../types";
|
||||
import { useDebounce } from "../../hooks/useDebounce";
|
||||
import { templateCarouselCases, templateCases, templateCategories, type TemplateCase } from "./ecommerceTemplates";
|
||||
|
||||
interface EcommerceTemplatesPageProps {
|
||||
projects: WebProjectSummary[];
|
||||
onOpenMore?: () => void;
|
||||
onOpenEcommerce?: () => void;
|
||||
onSelectTemplate?: (template: TemplateCase) => void;
|
||||
onStartCreate?: () => void;
|
||||
onOpenProject: (project: WebProjectSummary) => void;
|
||||
onDeleteProject?: (project: WebProjectSummary) => void;
|
||||
}
|
||||
|
||||
const APPLE_CAROUSEL_SLOTS = [-4, -3, -2, -1, 0, 1, 2, 3, 4];
|
||||
const APPLE_CAROUSEL_TRANSITION_MS = 760;
|
||||
|
||||
interface AppleCarouselMotion {
|
||||
direction: number;
|
||||
progress: 0 | 1;
|
||||
}
|
||||
|
||||
function getPositiveModulo(value: number, length: number) {
|
||||
return ((value % length) + length) % length;
|
||||
}
|
||||
|
||||
function getAppleCarouselCardStyle(offset: number): CSSProperties {
|
||||
const depth = Math.abs(offset);
|
||||
const direction = Math.sign(offset);
|
||||
const isActive = depth === 0;
|
||||
const xByDepth = [0, 286, 456, 610, 735, 840];
|
||||
const yByDepth = [8, -2, -8, -13, -18, -24];
|
||||
const rotateByDepth = [0, 0, 0, 0, 0];
|
||||
const scaleByDepth = [1, 0.98, 0.94, 0.91, 0.88, 0.84];
|
||||
const x = direction * (xByDepth[depth] ?? xByDepth[xByDepth.length - 1]!);
|
||||
const y = yByDepth[depth] ?? yByDepth[yByDepth.length - 1]!;
|
||||
const z = isActive ? 90 : 28 - depth;
|
||||
const rotateY = 0;
|
||||
const rotateZ = direction * (rotateByDepth[depth] ?? rotateByDepth[rotateByDepth.length - 1]!);
|
||||
const scale = scaleByDepth[depth] ?? scaleByDepth[scaleByDepth.length - 1]!;
|
||||
|
||||
return {
|
||||
"--apple-card-offset": offset,
|
||||
"--apple-card-depth": depth,
|
||||
"--apple-card-z": 80 - depth,
|
||||
"--apple-card-x": `${x}px`,
|
||||
"--apple-card-y": `${y}px`,
|
||||
"--apple-card-z-offset": `${z}px`,
|
||||
"--apple-card-rotate-y": `${rotateY}deg`,
|
||||
"--apple-card-rotate-z": `${rotateZ}deg`,
|
||||
"--apple-card-scale": String(scale),
|
||||
"--apple-card-opacity": String(depth > 4 ? 0 : 1),
|
||||
} as CSSProperties;
|
||||
}
|
||||
|
||||
function EcommerceTemplatesPage({
|
||||
projects,
|
||||
onOpenMore,
|
||||
onOpenEcommerce,
|
||||
onSelectTemplate,
|
||||
onStartCreate,
|
||||
onOpenProject,
|
||||
onDeleteProject,
|
||||
}: EcommerceTemplatesPageProps) {
|
||||
const [activeTemplateCategory, setActiveTemplateCategory] = useState("全部");
|
||||
const [templateSearch, setTemplateSearch] = useState("");
|
||||
const debouncedSearch = useDebounce(templateSearch, 300);
|
||||
const [carouselIndex, setCarouselIndex] = useState(0);
|
||||
const [carouselMotion, setCarouselMotion] = useState<AppleCarouselMotion | null>(null);
|
||||
const [carouselIsResetting, setCarouselIsResetting] = useState(false);
|
||||
const carouselFrameRef = useRef<number | null>(null);
|
||||
const carouselResetFrameRef = useRef<number | null>(null);
|
||||
const carouselTimerRef = useRef<number | null>(null);
|
||||
|
||||
const filteredTemplates = useMemo(() => {
|
||||
const keyword = debouncedSearch.trim();
|
||||
return templateCases.filter((item) => {
|
||||
const categoryMatches = activeTemplateCategory === "全部" || item.category === activeTemplateCategory;
|
||||
const keywordMatches =
|
||||
!keyword || item.title.includes(keyword) || item.summary.includes(keyword) || item.category.includes(keyword);
|
||||
return categoryMatches && keywordMatches;
|
||||
});
|
||||
}, [activeTemplateCategory, debouncedSearch]);
|
||||
const carouselItems = useMemo(() => templateCarouselCases.slice(0, 5), []);
|
||||
const carouselSlotOffsets = useMemo(() => {
|
||||
const direction = carouselMotion?.direction ?? 0;
|
||||
const minSlot = APPLE_CAROUSEL_SLOTS[0]! + Math.min(direction, 0);
|
||||
const maxSlot = APPLE_CAROUSEL_SLOTS[APPLE_CAROUSEL_SLOTS.length - 1]! + Math.max(direction, 0);
|
||||
return Array.from({ length: maxSlot - minSlot + 1 }, (_, index) => minSlot + index);
|
||||
}, [carouselMotion?.direction]);
|
||||
|
||||
const startCarouselShift = useCallback(
|
||||
(rawDirection: number) => {
|
||||
const direction = Math.sign(rawDirection);
|
||||
if (!direction || carouselItems.length <= 1 || carouselMotion) return;
|
||||
|
||||
if (carouselFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(carouselFrameRef.current);
|
||||
}
|
||||
if (carouselTimerRef.current !== null) {
|
||||
window.clearTimeout(carouselTimerRef.current);
|
||||
}
|
||||
if (carouselResetFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(carouselResetFrameRef.current);
|
||||
}
|
||||
|
||||
setCarouselIsResetting(false);
|
||||
setCarouselMotion({ direction, progress: 0 });
|
||||
carouselFrameRef.current = window.requestAnimationFrame(() => {
|
||||
carouselFrameRef.current = window.requestAnimationFrame(() => {
|
||||
setCarouselMotion((current) => (current?.direction === direction ? { direction, progress: 1 } : current));
|
||||
});
|
||||
});
|
||||
carouselTimerRef.current = window.setTimeout(() => {
|
||||
setCarouselIsResetting(true);
|
||||
setCarouselIndex((current) => getPositiveModulo(current + direction, carouselItems.length));
|
||||
setCarouselMotion(null);
|
||||
carouselResetFrameRef.current = window.requestAnimationFrame(() => {
|
||||
carouselResetFrameRef.current = window.requestAnimationFrame(() => {
|
||||
setCarouselIsResetting(false);
|
||||
});
|
||||
});
|
||||
}, APPLE_CAROUSEL_TRANSITION_MS);
|
||||
},
|
||||
[carouselItems.length, carouselMotion],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (carouselItems.length <= 1) return undefined;
|
||||
const intervalId = window.setInterval(() => {
|
||||
startCarouselShift(-1);
|
||||
}, 2200);
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [carouselItems.length, startCarouselShift]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (carouselFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(carouselFrameRef.current);
|
||||
}
|
||||
if (carouselTimerRef.current !== null) {
|
||||
window.clearTimeout(carouselTimerRef.current);
|
||||
}
|
||||
if (carouselResetFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(carouselResetFrameRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="image-workbench-page ecommerce-template-page" aria-label="示例模板">
|
||||
<header className="image-workbench-topbar">
|
||||
<button type="button" className="image-workbench-back-to-more" onClick={onOpenMore}>
|
||||
工具盒
|
||||
</button>
|
||||
<div className="image-workbench-tool-strip" aria-label="电商工具入口">
|
||||
<button type="button" onClick={onOpenEcommerce}>
|
||||
<ShoppingOutlined />
|
||||
电商生成
|
||||
</button>
|
||||
<button type="button" className="is-active">
|
||||
<TagsOutlined />
|
||||
示例模板
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="image-workbench-subbar">
|
||||
<button type="button" className="image-workbench-icon-btn" aria-label="返回工具盒" onClick={onOpenMore}>
|
||||
<ArrowLeftOutlined />
|
||||
</button>
|
||||
<strong>示例模板</strong>
|
||||
<div className="image-workbench-camera-summary" aria-label="模板数量">
|
||||
<strong>{filteredTemplates.length} 个模板</strong>
|
||||
<span>最近项目 {projects.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="ecommerce-template-page__scroll omni-commerce-page">
|
||||
<section className={`ecommerce-template-apple-carousel${carouselIsResetting ? " is-resetting" : ""}`} aria-label="苹果卡片式模板轮播">
|
||||
<div className="ecommerce-template-apple-carousel__stage" aria-roledescription="carousel">
|
||||
<div className="ecommerce-template-apple-carousel__deck">
|
||||
{carouselSlotOffsets.map((slotOffset) => {
|
||||
const itemIndex = getPositiveModulo(carouselIndex + slotOffset, carouselItems.length);
|
||||
const item = carouselItems[itemIndex];
|
||||
const visualOffset = slotOffset - (carouselMotion?.direction ?? 0) * (carouselMotion?.progress ?? 0);
|
||||
const isActive = visualOffset === 0;
|
||||
|
||||
if (!item) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={slotOffset}
|
||||
type="button"
|
||||
className={`ecommerce-template-apple-card${isActive ? " is-active" : ""}`}
|
||||
style={getAppleCarouselCardStyle(visualOffset)}
|
||||
aria-label={`套用模板:${item.title}`}
|
||||
aria-pressed={isActive}
|
||||
onClick={() => {
|
||||
if (isActive) {
|
||||
onSelectTemplate?.(item);
|
||||
return;
|
||||
}
|
||||
startCarouselShift(slotOffset);
|
||||
}}
|
||||
>
|
||||
<img src={item.imageUrl} alt="" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="omni-commerce-band ecommerce-template-page__band" aria-labelledby="commerce-template-title">
|
||||
<div className="omni-commerce-section-head">
|
||||
<div>
|
||||
<span>
|
||||
<TagsOutlined />
|
||||
示例模板
|
||||
</span>
|
||||
<h2 id="commerce-template-title">常用电商场景</h2>
|
||||
</div>
|
||||
<label className="omni-commerce-search">
|
||||
<SearchOutlined />
|
||||
<input value={templateSearch} placeholder="搜索模板" onChange={(event) => setTemplateSearch(event.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="omni-commerce-filter" role="tablist" aria-label="模板分类">
|
||||
{templateCategories.map((category) => (
|
||||
<button
|
||||
key={category}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTemplateCategory === category}
|
||||
className={activeTemplateCategory === category ? "is-active" : ""}
|
||||
onClick={() => setActiveTemplateCategory(category)}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="omni-commerce-template-grid motion-stagger">
|
||||
{filteredTemplates.map((item) => (
|
||||
<article key={item.title} className="omni-commerce-template">
|
||||
<button type="button" className="omni-commerce-template__select" onClick={() => onSelectTemplate?.(item)}>
|
||||
<img src={item.imageUrl} alt={item.title} />
|
||||
<div>
|
||||
<span>{item.category}</span>
|
||||
<strong>{item.title}</strong>
|
||||
<p>{item.summary}</p>
|
||||
<em>选择此场景</em>
|
||||
</div>
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="omni-commerce-band ecommerce-template-page__band omni-commerce-band--projects" aria-labelledby="commerce-project-title">
|
||||
<div className="omni-commerce-section-head">
|
||||
<div>
|
||||
<span>
|
||||
<CheckCircleOutlined />
|
||||
最近处理
|
||||
</span>
|
||||
<h2 id="commerce-project-title">最近项目</h2>
|
||||
</div>
|
||||
<button type="button" className="omni-commerce-text-button" onClick={onStartCreate}>
|
||||
新建项目
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{projects.length ? (
|
||||
<div className="omni-commerce-project-grid motion-stagger">
|
||||
{projects.slice(0, 4).map((project, index) => (
|
||||
<article key={project.id} className="omni-commerce-project">
|
||||
<button type="button" onClick={() => onOpenProject(project)}>
|
||||
<img src={project.thumbnailUrl || templateCases[index % templateCases.length]!.imageUrl} alt="" />
|
||||
<span>{project.description || "最近更新的电商项目"}</span>
|
||||
<strong>{project.name || `预览项目 ${index + 1}`}</strong>
|
||||
</button>
|
||||
{onDeleteProject ? (
|
||||
<button type="button" className="omni-commerce-project__delete" aria-label={`删除项目 ${project.name}`} onClick={() => onDeleteProject(project)}>
|
||||
<DeleteOutlined />
|
||||
</button>
|
||||
) : null}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="omni-commerce-empty">
|
||||
<PlayCircleOutlined />
|
||||
<strong>还没有最近项目</strong>
|
||||
<span>完成一次生成后,这里会显示最近处理的商品内容。</span>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer className="image-workbench-status">
|
||||
<span>模板</span>
|
||||
<p>选择模板后会回到电商生成并自动套用场景。</p>
|
||||
<em>{activeTemplateCategory}</em>
|
||||
</footer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default EcommerceTemplatesPage;
|
||||
@@ -0,0 +1,347 @@
|
||||
import { Fragment, useCallback, useRef, useState } from "react";
|
||||
import {
|
||||
CopyOutlined,
|
||||
DownloadOutlined,
|
||||
FolderAddOutlined,
|
||||
LoadingOutlined,
|
||||
PlayCircleOutlined,
|
||||
ReloadOutlined,
|
||||
SendOutlined,
|
||||
StopOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { runVideoPlan, renderScene, buildSceneTasks } from "./ecommerceVideoService";
|
||||
import {
|
||||
PLAN_STEP_LABELS,
|
||||
type EcommerceVideoStage,
|
||||
type EcommerceVideoSceneTask,
|
||||
type EcommerceVideoPlanResult,
|
||||
type PlanStep,
|
||||
} from "./ecommerceVideoTypes";
|
||||
import type { AdVideoUserConfig } from "../../api/adVideoPlanClient";
|
||||
import { saveToolResultToLocal, addToolResultToAssetLibrary } from "../workbench/toolResultActions";
|
||||
import { useAppStore } from "../../stores";
|
||||
|
||||
interface EcommerceVideoWorkspaceProps {
|
||||
isAuthenticated: boolean;
|
||||
productImageDataUrls: string[];
|
||||
requirement: string;
|
||||
platform: string;
|
||||
aspectRatio: string;
|
||||
durationSeconds: number;
|
||||
resolution: string;
|
||||
onRequestLogin?: () => void;
|
||||
}
|
||||
|
||||
const ALL_STEPS: PlanStep[] = [
|
||||
"upload", "analyze", "summary", "selling",
|
||||
"creative", "storyboard", "prompts", "compliance",
|
||||
];
|
||||
|
||||
function mapResolutionToQuality(res: string): "720P" | "1080P" {
|
||||
return res.includes("720") ? "720P" : "1080P";
|
||||
}
|
||||
|
||||
export default function EcommerceVideoWorkspace({
|
||||
isAuthenticated,
|
||||
productImageDataUrls,
|
||||
requirement,
|
||||
platform,
|
||||
aspectRatio,
|
||||
durationSeconds,
|
||||
resolution,
|
||||
onRequestLogin,
|
||||
}: EcommerceVideoWorkspaceProps) {
|
||||
const [stage, setStage] = useState<EcommerceVideoStage>("idle");
|
||||
const [planResult, setPlanResult] = useState<EcommerceVideoPlanResult | null>(null);
|
||||
const [scenes, setScenes] = useState<EcommerceVideoSceneTask[]>([]);
|
||||
const [completedSteps, setCompletedSteps] = useState<PlanStep[]>([]);
|
||||
const [currentStep, setCurrentStep] = useState<PlanStep | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [actionNotice, setActionNotice] = useState<string | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const renderAbortRef = useRef({ current: false });
|
||||
const setView = useAppStore((s) => s.setView);
|
||||
|
||||
const showNotice = (msg: string) => {
|
||||
setActionNotice(msg);
|
||||
setTimeout(() => setActionNotice(null), 3000);
|
||||
};
|
||||
|
||||
const handleDownload = async (url: string) => {
|
||||
try {
|
||||
await saveToolResultToLocal({
|
||||
url,
|
||||
name: `ecommerce-video-${Date.now()}`,
|
||||
type: "video",
|
||||
isVideo: true,
|
||||
tags: ["电商", "短视频", "生成视频"],
|
||||
});
|
||||
showNotice("下载完成");
|
||||
} catch {
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "ecommerce-video.mp4";
|
||||
a.click();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAsset = async (url: string) => {
|
||||
try {
|
||||
const result = await addToolResultToAssetLibrary({
|
||||
url,
|
||||
name: `电商短视频-${Date.now()}.mp4`,
|
||||
description: "电商广告视频生成结果",
|
||||
type: "video",
|
||||
isVideo: true,
|
||||
tags: ["电商", "短视频", "广告视频"],
|
||||
metadata: { source: "ecommerce-video", platform },
|
||||
});
|
||||
showNotice(result === "server" ? "已保存到资产库" : "已保存到本地资产库");
|
||||
} catch {
|
||||
showNotice("保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportToCanvas = async (url: string) => {
|
||||
try {
|
||||
await addToolResultToAssetLibrary({
|
||||
url,
|
||||
name: `电商短视频-${Date.now()}.mp4`,
|
||||
description: "电商广告视频 - 导入画布",
|
||||
type: "video",
|
||||
isVideo: true,
|
||||
tags: ["电商", "短视频", "画布导入"],
|
||||
metadata: { source: "ecommerce-video", platform },
|
||||
});
|
||||
setView("canvas");
|
||||
showNotice("已保存资产并跳转画布");
|
||||
} catch {
|
||||
showNotice("导入失败");
|
||||
}
|
||||
};
|
||||
|
||||
const buildConfig = useCallback((): AdVideoUserConfig => ({
|
||||
platform,
|
||||
aspectRatio,
|
||||
durationSeconds,
|
||||
style: "痛点解决",
|
||||
language: "中文",
|
||||
market: "中国",
|
||||
needVoiceover: true,
|
||||
needSubtitle: true,
|
||||
conversionFocus: "conversion",
|
||||
}), [platform, aspectRatio, durationSeconds]);
|
||||
|
||||
const handlePlan = async () => {
|
||||
if (!isAuthenticated) { onRequestLogin?.(); return; }
|
||||
if (!productImageDataUrls.length && !requirement.trim()) {
|
||||
setError("请先上传产品图片或填写商品说明");
|
||||
return;
|
||||
}
|
||||
abortControllerRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortControllerRef.current = controller;
|
||||
setStage("planning");
|
||||
setError(null);
|
||||
setCompletedSteps([]);
|
||||
setCurrentStep(null);
|
||||
setPlanResult(null);
|
||||
setScenes([]);
|
||||
try {
|
||||
const result = await runVideoPlan(
|
||||
productImageDataUrls, requirement, buildConfig(),
|
||||
{
|
||||
onStepStart: (step) => setCurrentStep(step),
|
||||
onStepDone: (step) => setCompletedSteps((prev) => [...prev, step]),
|
||||
signal: controller.signal,
|
||||
},
|
||||
);
|
||||
setPlanResult(result);
|
||||
setScenes(buildSceneTasks(result));
|
||||
setStage("planned");
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") return;
|
||||
setError(err instanceof Error ? err.message : "策划失败");
|
||||
setStage("idle");
|
||||
} finally {
|
||||
setCurrentStep(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRender = async () => {
|
||||
if (!planResult || !scenes.length) return;
|
||||
const imageUrl = productImageDataUrls[0] || "";
|
||||
setStage("rendering");
|
||||
setError(null);
|
||||
renderAbortRef.current = { current: false };
|
||||
const quality = mapResolutionToQuality(resolution);
|
||||
|
||||
for (const scene of scenes) {
|
||||
if (renderAbortRef.current.current) break;
|
||||
setScenes((prev) => prev.map((s) =>
|
||||
s.sceneId === scene.sceneId ? { ...s, status: "pending" } : s));
|
||||
try {
|
||||
await renderScene(
|
||||
{ sceneId: scene.sceneId, prompt: scene.prompt, durationSeconds: scene.durationSeconds, imageUrl, aspectRatio, resolution: quality },
|
||||
{
|
||||
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 instanceof Error ? err.message : "生成失败" } : 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;
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
abortControllerRef.current?.abort();
|
||||
renderAbortRef.current.current = true;
|
||||
setStage("cancelled");
|
||||
};
|
||||
|
||||
const completedScenes = scenes.filter((s) => s.status === "completed" && s.resultUrl);
|
||||
const primaryVideo = completedScenes[0]?.resultUrl;
|
||||
const canRender = planResult?.compliance.allow_video_generation && stage === "planned";
|
||||
const sourceImage = productImageDataUrls[0] || "";
|
||||
const flowHasStarted = stage !== "idle" || completedSteps.length > 0 || scenes.length > 0;
|
||||
const flowMeta = `${platform} / ${aspectRatio} / ${durationSeconds}s / ${resolution}`;
|
||||
const planActionLabel = stage === "planning"
|
||||
? "策划中"
|
||||
: (stage === "planned" || stage === "completed" || stage === "partial_failed") ? "重新策划" : "一键策划";
|
||||
const renderActionLabel = stage === "rendering" ? "生成中" : "确认生成";
|
||||
|
||||
return (
|
||||
<div className="ecom-video-workspace" data-stage={stage}>
|
||||
<header className="ecom-video-flowbar">
|
||||
<div className="ecom-video-flowbar__title" aria-label={`短视频分镜流,${flowMeta}`} title={flowMeta}>
|
||||
<span className={`ecom-video-flowbar__pulse${flowHasStarted ? " is-active" : ""}`} aria-hidden="true" />
|
||||
<span className="ecom-video-flowbar__wave" aria-hidden="true"><i /><i /><i /></span>
|
||||
</div>
|
||||
|
||||
<div className="ecom-video-step-dots" aria-label="策划进度">
|
||||
{ALL_STEPS.map((step) => {
|
||||
const isDone = completedSteps.includes(step);
|
||||
const isActive = currentStep === step;
|
||||
const cls = isDone ? "is-done" : isActive ? "is-active" : "";
|
||||
return (
|
||||
<span
|
||||
key={step}
|
||||
className={`ecom-video-step-dot ${cls}`}
|
||||
title={PLAN_STEP_LABELS[step]}
|
||||
aria-label={PLAN_STEP_LABELS[step]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="ecom-video-flowbar__actions">
|
||||
{error ? <span className="ecom-video-flowbar__error" role="alert">{error}</span> : null}
|
||||
<button
|
||||
type="button"
|
||||
className="ecom-video-flow-action"
|
||||
disabled={stage === "planning" || stage === "rendering"}
|
||||
onClick={() => void handlePlan()}
|
||||
aria-label={planActionLabel}
|
||||
title={planActionLabel}
|
||||
>
|
||||
{stage === "planning" ? <LoadingOutlined /> : (stage === "planned" || stage === "completed" || stage === "partial_failed") ? <ReloadOutlined /> : <PlayCircleOutlined />}
|
||||
</button>
|
||||
{(stage === "rendering" || stage === "planned") ? (
|
||||
<button
|
||||
type="button"
|
||||
className="ecom-video-flow-action ecom-video-flow-action--ghost"
|
||||
disabled={!canRender}
|
||||
onClick={() => void handleRender()}
|
||||
aria-label={renderActionLabel}
|
||||
title={renderActionLabel}
|
||||
>
|
||||
{stage === "rendering" ? <LoadingOutlined /> : <SendOutlined />}
|
||||
</button>
|
||||
) : null}
|
||||
{stage === "rendering" ? (
|
||||
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--danger" onClick={handleCancel} aria-label="取消生成" title="取消生成">
|
||||
<StopOutlined />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="ecom-video-flow-canvas" aria-label="视频分镜流程图">
|
||||
{completedScenes.length === 0 && !sourceImage ? (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", color: "#697486", fontSize: 13 }}>
|
||||
<span>上传商品图并点击"一键策划"开始</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="ecom-video-flow-map">
|
||||
{sourceImage ? (
|
||||
<article className="ecom-video-flow-node ecom-video-flow-node--source is-ready" aria-label="商品图节点">
|
||||
<div className="ecom-video-flow-node__media">
|
||||
<img src={sourceImage} alt="商品图" />
|
||||
</div>
|
||||
<span className="ecom-video-flow-node__status-orb" aria-hidden="true" />
|
||||
</article>
|
||||
) : null}
|
||||
|
||||
{sourceImage && completedScenes.length > 0 ? (
|
||||
<div className="ecom-video-flow-connector is-active" aria-hidden="true"><i /></div>
|
||||
) : null}
|
||||
|
||||
<div className="ecom-video-scene-strip" aria-label="已完成分镜节点">
|
||||
{completedScenes.map((scene, index) => (
|
||||
<Fragment key={scene.sceneId}>
|
||||
<article
|
||||
className="ecom-video-flow-node ecom-video-flow-node--scene is-completed"
|
||||
aria-label={`镜头 ${scene.sceneId},完成`}
|
||||
title={`镜头 ${scene.sceneId}`}
|
||||
>
|
||||
<div className="ecom-video-flow-node__media">
|
||||
<video src={scene.resultUrl!} muted playsInline loop autoPlay />
|
||||
</div>
|
||||
<span className="ecom-video-flow-node__status-orb" aria-hidden="true" />
|
||||
</article>
|
||||
{index < completedScenes.length - 1 ? (
|
||||
<div className="ecom-video-scene-link is-active" aria-hidden="true"><i /></div>
|
||||
) : null}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{completedScenes.length > 0 && primaryVideo ? (
|
||||
<div className="ecom-video-flow-connector is-active" aria-hidden="true"><i /></div>
|
||||
) : null}
|
||||
|
||||
{primaryVideo ? (
|
||||
<article className="ecom-video-flow-node ecom-video-flow-node--final is-completed" aria-label="成片节点,已完成">
|
||||
<div className="ecom-video-flow-node__media">
|
||||
<video src={primaryVideo} muted playsInline loop autoPlay />
|
||||
</div>
|
||||
<span className="ecom-video-flow-node__status-orb" aria-hidden="true" />
|
||||
</article>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{primaryVideo ? (
|
||||
<div className="ecom-video-flow-dock" aria-label="视频交付操作">
|
||||
<button type="button" aria-label="下载当前视频" title="下载当前视频" onClick={() => void handleDownload(primaryVideo)}><DownloadOutlined /></button>
|
||||
<button type="button" aria-label="保存到资产库" title="保存到资产库" onClick={() => void handleSaveAsset(primaryVideo)}><FolderAddOutlined /></button>
|
||||
<button type="button" aria-label="导入画布" title="导入画布" onClick={() => void handleImportToCanvas(primaryVideo)}><SendOutlined /></button>
|
||||
<button type="button" aria-label="复制视频链接" title="复制视频链接" onClick={() => void navigator.clipboard.writeText(primaryVideo)}><CopyOutlined /></button>
|
||||
</div>
|
||||
) : null}
|
||||
{actionNotice ? <div className="ecom-video-flow-notice">{actionNotice}</div> : null}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
export interface MentionImageOption {
|
||||
id: string;
|
||||
src: string;
|
||||
name: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function getImageMentionQuery(value: string, selectionStart: number | null | undefined): string | null {
|
||||
if (selectionStart === null || selectionStart === undefined) return null;
|
||||
const beforeCursor = value.slice(0, selectionStart);
|
||||
const match = beforeCursor.match(/(?:^|\s)@([^\s@]*)$/);
|
||||
return match ? match[1] ?? "" : null;
|
||||
}
|
||||
|
||||
export function insertImageMentionValue(
|
||||
value: string,
|
||||
selectionStart: number,
|
||||
imageName: string,
|
||||
maxLength: number,
|
||||
): { value: string; selectionStart: number } {
|
||||
const beforeCursor = value.slice(0, selectionStart);
|
||||
const afterCursor = value.slice(selectionStart);
|
||||
const match = beforeCursor.match(/(?:^|\s)@([^\s@]*)$/);
|
||||
const mentionStart = match ? beforeCursor.length - match[0].length + (match[0].startsWith("@") ? 0 : 1) : selectionStart;
|
||||
const mentionText = `@${imageName.trim() || "uploaded-image"} `;
|
||||
const nextValue = `${value.slice(0, mentionStart)}${mentionText}${afterCursor}`.slice(0, maxLength);
|
||||
const nextSelectionStart = Math.min(mentionStart + mentionText.length, nextValue.length);
|
||||
return { value: nextValue, selectionStart: nextSelectionStart };
|
||||
}
|
||||
|
||||
interface ImageMentionMenuProps {
|
||||
images: MentionImageOption[];
|
||||
query?: string;
|
||||
onSelect: (image: MentionImageOption) => void;
|
||||
}
|
||||
|
||||
function ImageMentionMenu({ images, query = "", onSelect }: ImageMentionMenuProps) {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const visibleImages = normalizedQuery
|
||||
? images.filter((image) => `${image.label ?? ""} ${image.name}`.toLowerCase().includes(normalizedQuery))
|
||||
: images;
|
||||
|
||||
return (
|
||||
<div className="image-mention-menu" role="listbox" aria-label="选择上传图片">
|
||||
{visibleImages.length ? (
|
||||
visibleImages.map((image) => (
|
||||
<button
|
||||
key={image.id}
|
||||
type="button"
|
||||
className="image-mention-menu__item"
|
||||
role="option"
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
onSelect(image);
|
||||
}}
|
||||
>
|
||||
<img src={image.src} alt="" />
|
||||
<span>
|
||||
<strong>{image.label ?? image.name}</strong>
|
||||
<em>{image.name}</em>
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<span className="image-mention-menu__empty">没有匹配的上传图片</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImageMentionMenu;
|
||||
@@ -0,0 +1,129 @@
|
||||
import ecommerceCarouselGenerated from "../../assets/ecommerce-carousel-generated.png";
|
||||
import moreTemplateSlide1 from "../../assets/more-template-carousel/slide-1.jpg";
|
||||
import moreTemplateSlide2 from "../../assets/more-template-carousel/slide-2.jpg";
|
||||
import moreTemplateSlide3 from "../../assets/more-template-carousel/slide-3.jpg";
|
||||
import moreTemplateSlide4 from "../../assets/more-template-carousel/slide-4.png";
|
||||
import moreTemplateSlide5 from "../../assets/more-template-carousel/slide-5.gif";
|
||||
import ecommerceHeroSlide1 from "../../assets/ecommerce-hero-carousel/slide-1.webp";
|
||||
import ecommerceHeroSlide2 from "../../assets/ecommerce-hero-carousel/slide-2.webp";
|
||||
import ecommerceHeroSlide3 from "../../assets/ecommerce-hero-carousel/slide-3.webp";
|
||||
import ecommerceHeroSlide4 from "../../assets/ecommerce-hero-carousel/slide-4.webp";
|
||||
import ecommerceHeroSlide5 from "../../assets/ecommerce-hero-carousel/slide-5.webp";
|
||||
import ecommerceCarouselImage1 from "../../../tu/微信图片_20260514125332_8_2.png";
|
||||
import ecommerceCarouselImage2 from "../../../tu/微信图片_20260514125332_9_2.png";
|
||||
import ecommerceCarouselImage3 from "../../../tu/微信图片_20260514125332_7_2.png";
|
||||
import ecommerceCarouselImage4 from "../../../tu/微信图片_20260514125332_12_2.png";
|
||||
|
||||
export interface TemplateCase {
|
||||
title: string;
|
||||
category: string;
|
||||
summary: string;
|
||||
imageUrl: string;
|
||||
}
|
||||
|
||||
export const templateCategories = ["全部", "详情图", "主图", "模特图", "短视频", "营销图"];
|
||||
|
||||
export const templateCarouselCases: TemplateCase[] = [
|
||||
{
|
||||
title: "霓虹水花动态猫",
|
||||
category: "短视频",
|
||||
summary: "适合展示高动势、强色彩和奇幻质感的视觉参考。",
|
||||
imageUrl: moreTemplateSlide1,
|
||||
},
|
||||
{
|
||||
title: "古风人物光影",
|
||||
category: "模特图",
|
||||
summary: "适合古装人物、柔光棚拍和氛围大片参考。",
|
||||
imageUrl: moreTemplateSlide2,
|
||||
},
|
||||
{
|
||||
title: "绘本郊游场景",
|
||||
category: "营销图",
|
||||
summary: "适合童趣插画、故事场景和清新户外主题。",
|
||||
imageUrl: moreTemplateSlide3,
|
||||
},
|
||||
{
|
||||
title: "动画乐队片段",
|
||||
category: "短视频",
|
||||
summary: "适合复古动画、角色表演和音乐节奏参考。",
|
||||
imageUrl: moreTemplateSlide4,
|
||||
},
|
||||
{
|
||||
title: "猫咪动态贴图",
|
||||
category: "主图",
|
||||
summary: "适合轻量动图、角色素材和趣味展示参考。",
|
||||
imageUrl: moreTemplateSlide5,
|
||||
},
|
||||
];
|
||||
|
||||
export const ecommerceHeroCarouselCases: TemplateCase[] = [
|
||||
{
|
||||
title: "生成电商图 1",
|
||||
category: "电商轮播",
|
||||
summary: "电商页面轮播素材",
|
||||
imageUrl: ecommerceHeroSlide1,
|
||||
},
|
||||
{
|
||||
title: "生成电商图 2",
|
||||
category: "电商轮播",
|
||||
summary: "电商页面轮播素材",
|
||||
imageUrl: ecommerceHeroSlide2,
|
||||
},
|
||||
{
|
||||
title: "生成电商图 3",
|
||||
category: "电商轮播",
|
||||
summary: "电商页面轮播素材",
|
||||
imageUrl: ecommerceHeroSlide3,
|
||||
},
|
||||
{
|
||||
title: "生成电商图 4",
|
||||
category: "电商轮播",
|
||||
summary: "电商页面轮播素材",
|
||||
imageUrl: ecommerceHeroSlide4,
|
||||
},
|
||||
{
|
||||
title: "生成电商图 5",
|
||||
category: "电商轮播",
|
||||
summary: "电商页面轮播素材",
|
||||
imageUrl: ecommerceHeroSlide5,
|
||||
},
|
||||
];
|
||||
|
||||
export const templateCases: TemplateCase[] = [
|
||||
{
|
||||
title: "香氛详情页场景图",
|
||||
category: "详情图",
|
||||
summary: "适合家居香氛、礼盒、美妆类目,突出氛围和质感。",
|
||||
imageUrl: ecommerceCarouselImage1,
|
||||
},
|
||||
{
|
||||
title: "护肤套装主图",
|
||||
category: "主图",
|
||||
summary: "白底构图清晰,适合天猫、淘宝、京东主图位。",
|
||||
imageUrl: ecommerceCarouselImage3,
|
||||
},
|
||||
{
|
||||
title: "美妆营销海报",
|
||||
category: "营销图",
|
||||
summary: "强化利益点和视觉冲击,适合活动页和投放素材。",
|
||||
imageUrl: ecommerceCarouselGenerated,
|
||||
},
|
||||
{
|
||||
title: "岩境香氛短视频",
|
||||
category: "短视频",
|
||||
summary: "首帧质感强,适合慢展示和品牌调性视频。",
|
||||
imageUrl: ecommerceCarouselImage4,
|
||||
},
|
||||
{
|
||||
title: "花漾香氛模特手持图",
|
||||
category: "模特图",
|
||||
summary: "增加真实使用感,适合礼品和生活方式类商品。",
|
||||
imageUrl: ecommerceCarouselImage2,
|
||||
},
|
||||
{
|
||||
title: "促销卖点组合图",
|
||||
category: "详情图",
|
||||
summary: "把成分、规格、卖点拆成清晰的详情页模块。",
|
||||
imageUrl: "https://picsum.photos/id/1080/900/620",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,144 @@
|
||||
import {
|
||||
analyzeProductImages,
|
||||
buildProductSummary,
|
||||
extractSellingPoints,
|
||||
generateCreativeOptions,
|
||||
generateStoryboard,
|
||||
generateVideoPrompts,
|
||||
checkCompliance,
|
||||
type AdVideoUserConfig,
|
||||
} from "../../api/adVideoPlanClient";
|
||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||
import { uploadAssetWithProgress } from "../../api/uploadWithProgress";
|
||||
import { waitForTask } from "../../api/taskSubscription";
|
||||
import { resolveVideoRequestModel } from "../../utils/resolveVideoModel";
|
||||
import type {
|
||||
EcommerceVideoPlanResult,
|
||||
EcommerceVideoSceneTask,
|
||||
PlanStep,
|
||||
} from "./ecommerceVideoTypes";
|
||||
|
||||
export interface PlanCallbacks {
|
||||
onStepStart: (step: PlanStep) => void;
|
||||
onStepDone: (step: PlanStep) => void;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export async function runVideoPlan(
|
||||
imageDataUrls: string[],
|
||||
manualText: string,
|
||||
config: AdVideoUserConfig,
|
||||
callbacks: PlanCallbacks,
|
||||
): Promise<EcommerceVideoPlanResult> {
|
||||
const { onStepStart, onStepDone, signal } = callbacks;
|
||||
|
||||
onStepStart("upload");
|
||||
const imageUrls: string[] = [];
|
||||
for (const dataUrl of imageDataUrls) {
|
||||
const result = await uploadAssetWithProgress(
|
||||
{ dataUrl, scope: "ecommerce-product", mimeType: "image/png" },
|
||||
{ signal },
|
||||
);
|
||||
imageUrls.push(result.url);
|
||||
}
|
||||
onStepDone("upload");
|
||||
|
||||
onStepStart("analyze");
|
||||
const imageDesc = await analyzeProductImages(imageUrls, signal);
|
||||
onStepDone("analyze");
|
||||
|
||||
onStepStart("summary");
|
||||
const summary = await buildProductSummary(imageDesc, manualText, signal);
|
||||
onStepDone("summary");
|
||||
|
||||
onStepStart("selling");
|
||||
const selling = await extractSellingPoints(summary, signal);
|
||||
onStepDone("selling");
|
||||
|
||||
onStepStart("creative");
|
||||
const creatives = await generateCreativeOptions(selling, config, signal);
|
||||
if (!creatives.length) throw new Error("未能生成有效的广告创意");
|
||||
onStepDone("creative");
|
||||
|
||||
onStepStart("storyboard");
|
||||
const storyboard = await generateStoryboard(creatives[0], summary, config, signal);
|
||||
onStepDone("storyboard");
|
||||
|
||||
onStepStart("prompts");
|
||||
const videoPrompts = await generateVideoPrompts(storyboard, summary, signal);
|
||||
onStepDone("prompts");
|
||||
|
||||
onStepStart("compliance");
|
||||
const compliance = await checkCompliance(summary, selling, storyboard, signal);
|
||||
onStepDone("compliance");
|
||||
|
||||
return { summary, selling, creatives, storyboard, videoPrompts, compliance };
|
||||
}
|
||||
|
||||
export interface RenderSceneInput {
|
||||
sceneId: number;
|
||||
prompt: string;
|
||||
durationSeconds: number;
|
||||
imageUrl: string;
|
||||
aspectRatio: string;
|
||||
resolution: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface RenderCallbacks {
|
||||
onSceneSubmitted: (sceneId: number, taskId: string) => void;
|
||||
onSceneProgress: (sceneId: number, progress: number) => void;
|
||||
onSceneCompleted: (sceneId: number, resultUrl: string) => void;
|
||||
onSceneFailed: (sceneId: number, error: string) => void;
|
||||
}
|
||||
|
||||
export async function renderScene(
|
||||
input: RenderSceneInput,
|
||||
callbacks: RenderCallbacks,
|
||||
abortRef: { current: boolean },
|
||||
): Promise<void> {
|
||||
const model = resolveVideoRequestModel({
|
||||
model: input.model || "happyhorse-1.0",
|
||||
referenceUrls: [input.imageUrl],
|
||||
});
|
||||
|
||||
const { taskId } = await aiGenerationClient.createVideoTask({
|
||||
model,
|
||||
prompt: input.prompt,
|
||||
ratio: input.aspectRatio,
|
||||
duration: input.durationSeconds,
|
||||
quality: input.resolution,
|
||||
resolution: input.resolution,
|
||||
imageUrl: input.imageUrl,
|
||||
referenceUrls: [input.imageUrl],
|
||||
hasReferenceVideo: false,
|
||||
});
|
||||
|
||||
callbacks.onSceneSubmitted(input.sceneId, taskId);
|
||||
|
||||
const resultUrl = await waitForTask(taskId, {
|
||||
abortRef,
|
||||
onProgress: (e) => callbacks.onSceneProgress(input.sceneId, e.progress),
|
||||
});
|
||||
|
||||
if (resultUrl) {
|
||||
callbacks.onSceneCompleted(input.sceneId, resultUrl);
|
||||
} else {
|
||||
callbacks.onSceneFailed(input.sceneId, "任务未返回结果");
|
||||
}
|
||||
}
|
||||
|
||||
export function buildSceneTasks(
|
||||
plan: EcommerceVideoPlanResult,
|
||||
): EcommerceVideoSceneTask[] {
|
||||
return plan.storyboard.scenes.map((scene) => {
|
||||
const prompt = plan.videoPrompts.find((p) => p.scene_id === scene.scene_id);
|
||||
return {
|
||||
sceneId: scene.scene_id,
|
||||
prompt: prompt?.positive_prompt || scene.visual_description,
|
||||
durationSeconds: Number.parseInt(scene.duration, 10) || 5,
|
||||
status: "idle",
|
||||
progress: 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type {
|
||||
ProductSummary,
|
||||
SellingPointResult,
|
||||
CreativeOption,
|
||||
Storyboard,
|
||||
VideoPrompt,
|
||||
ComplianceCheck,
|
||||
} from "../../api/adVideoPlanClient";
|
||||
|
||||
export type EcommerceVideoStage =
|
||||
| "idle"
|
||||
| "uploading"
|
||||
| "planning"
|
||||
| "planned"
|
||||
| "rendering"
|
||||
| "partial_failed"
|
||||
| "completed"
|
||||
| "cancelled";
|
||||
|
||||
export type SceneTaskStatus = "idle" | "pending" | "running" | "completed" | "failed" | "cancelled";
|
||||
|
||||
export interface EcommerceVideoSceneTask {
|
||||
sceneId: number;
|
||||
taskId?: string;
|
||||
prompt: string;
|
||||
durationSeconds: number;
|
||||
status: SceneTaskStatus;
|
||||
progress: number;
|
||||
resultUrl?: string | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface EcommerceVideoPlanResult {
|
||||
summary: ProductSummary;
|
||||
selling: SellingPointResult;
|
||||
creatives: CreativeOption[];
|
||||
storyboard: Storyboard;
|
||||
videoPrompts: VideoPrompt[];
|
||||
compliance: ComplianceCheck;
|
||||
}
|
||||
|
||||
export interface EcommerceVideoDelivery {
|
||||
planResult: EcommerceVideoPlanResult | null;
|
||||
scenes: EcommerceVideoSceneTask[];
|
||||
completedCount: number;
|
||||
failedCount: number;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export type PlanStep =
|
||||
| "upload"
|
||||
| "analyze"
|
||||
| "summary"
|
||||
| "selling"
|
||||
| "creative"
|
||||
| "storyboard"
|
||||
| "prompts"
|
||||
| "compliance";
|
||||
|
||||
export const PLAN_STEP_LABELS: Record<PlanStep, string> = {
|
||||
upload: "上传商品图",
|
||||
analyze: "分析商品图",
|
||||
summary: "生成商品理解",
|
||||
selling: "提炼卖点",
|
||||
creative: "生成广告创意",
|
||||
storyboard: "生成视频分镜",
|
||||
prompts: "生成镜头提示词",
|
||||
compliance: "合规检查",
|
||||
};
|
||||
Reference in New Issue
Block a user