Add video node toolbar interactions
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-26 21:08:19 +08:00
parent 2cad27cdcd
commit c5c2d6eb93

View File

@ -2,9 +2,11 @@
import {
Bell,
Check,
ChevronDown,
Clock3,
Copy,
Download,
FileText,
GitBranch,
Headphones,
@ -15,6 +17,7 @@ import {
Music2,
Plus,
Play,
RotateCcw,
Scissors,
Settings2,
Sparkles,
@ -108,6 +111,7 @@ type ComposerMenu =
| "slash"
| null;
type MemberStoreTab = "vip" | "model" | "credits";
type VideoToolbarMode = "clip" | "analysis" | null;
type CapturedTool = {
type?: string;
@ -341,6 +345,28 @@ type AngleEditorSettings = {
zoom: number;
};
type VideoClipRange = {
start: number;
end: number;
};
type VideoAnalysisRow = {
id: string;
sceneDescription: string;
backgroundMusic: string;
voiceAndSfx: string;
imagePrompt: string;
motionPrompt: string;
keyframeUrl: string;
};
type VideoAnalysisState = {
status: "idle" | "loading" | "ready" | "error";
summary: string;
rows: VideoAnalysisRow[];
error?: string;
};
const capturedTools = ((capturedCanvasModules as any).tools || []) as CapturedTool[];
const capturedCounts = (capturedCanvasModules as any).counts || {};
const capturedWorkflows = ((capturedCanvasModules as any).workflows || []) as WorkflowPreset[];
@ -446,6 +472,7 @@ const OFFICIAL_ZOOM_DOCK_SCALE = 0.65;
const OFFICIAL_DROPDOWN_SCALE = 0.7;
const OFFICIAL_NINE_GRID_WIDTH = 930;
const OFFICIAL_NINE_GRID_HEIGHT = 520;
const VIDEO_CLIP_FRAME_COUNT = 9;
const STORY_BEAT_GRID_FUNCTION_KEY = "story-beat-grid-4";
const MULTI_VIEW_NINE_GRID_FUNCTION_KEY = "multi-view-nine-grid";
const CONTINUOUS_STORYBOARD_GRID_FUNCTION_KEY = "continuous-storyboard-grid-25";
@ -1244,6 +1271,108 @@ function getNodeGridSplitSourceUrl(node: CloneNode | null | undefined) {
return getNodeMediaUrl(node) || getNodeSourceMediaUrl(node);
}
function isVideoMediaNode(node: CloneNode | null | undefined): node is CloneNode {
if (!node || node.kind !== "media") return false;
const mediaType = getDraftString(node, "mediaType") || node.inputType || "";
return mediaType === "video";
}
function getVideoNodePreviewUrl(node: CloneNode | null | undefined) {
return getDraftString(node, "poster") || getNodeSourceMediaUrl(node) || getNodeMediaUrl(node);
}
function getVideoNodeDurationSeconds(node: CloneNode | null | undefined) {
const params = getNodeParams(node);
const paramsSettings =
params.settings && typeof params.settings === "object"
? (params.settings as Record<string, unknown>)
: {};
const numericValue = Number(
params.duration ||
paramsSettings.duration ||
getDraftString(node, "duration") ||
(node?.draftValues?.videoMeta && typeof node.draftValues.videoMeta === "object"
? (node.draftValues.videoMeta as Record<string, unknown>).duration
: 0)
);
if (Number.isFinite(numericValue) && numericValue > 0) return numericValue;
const outputLabel = getDraftString(node, "outputSizeLabel");
const durationMatch = outputLabel.match(/(\d+(?:\.\d+)?)\s*s/i);
if (durationMatch) {
const parsed = Number(durationMatch[1] || "0");
if (Number.isFinite(parsed) && parsed > 0) return parsed;
}
return 5;
}
function formatClipDurationLabel(seconds: number) {
const normalized = Math.max(0.1, seconds);
return `${normalized.toFixed(2)} s`;
}
function normalizeVideoClipRange(value: unknown): VideoClipRange {
if (!value || typeof value !== "object") return { start: 0.18, end: 0.52 };
const candidate = value as Partial<VideoClipRange>;
const start = Number(candidate.start);
const end = Number(candidate.end);
if (!Number.isFinite(start) || !Number.isFinite(end)) {
return { start: 0.18, end: 0.52 };
}
return {
start: Math.max(0, Math.min(start, 0.94)),
end: Math.max(Math.min(start + 0.04, 1), Math.min(end, 1)),
};
}
function buildVideoAnalysisRows(node: CloneNode, responseData?: {
highlights?: string[];
raw?: string;
recommendedNextSteps?: string[];
shotIdeas?: Array<{ title: string; description: string }>;
summary?: string;
}): VideoAnalysisRow[] {
const previewUrl = getVideoNodePreviewUrl(node) || getNodeMediaUrl(node) || "";
const highlights = responseData?.highlights || [];
const shotIdeas = responseData?.shotIdeas?.slice(0, 3) || [];
const summary = responseData?.summary?.trim() || "";
const fallbackPrompt = summary || responseData?.raw?.trim() || `${node.title} 的连续镜头推演`;
const rows =
shotIdeas.length > 0
? shotIdeas.map((idea, index) => ({
id: `${node.id}-analysis-${index}`,
sceneDescription: idea.title || `镜头 ${index + 1}`,
backgroundMusic: highlights[index] || highlights[0] || "氛围音乐待接入",
voiceAndSfx: highlights[index + 1] || "无",
imagePrompt: idea.description || fallbackPrompt,
motionPrompt:
responseData?.recommendedNextSteps?.[index] ||
"保持镜头运动自然平稳,围绕主体动作推进。",
keyframeUrl: previewUrl,
}))
: [
{
id: `${node.id}-analysis-0`,
sceneDescription: `${node.title} 开场镜头`,
backgroundMusic: highlights[0] || "轻氛围背景音乐",
voiceAndSfx: highlights[1] || "无",
imagePrompt: fallbackPrompt,
motionPrompt: "中景起势,镜头跟随主体动作缓慢推进。",
keyframeUrl: previewUrl,
},
{
id: `${node.id}-analysis-1`,
sceneDescription: `${node.title} 情绪推进`,
backgroundMusic: highlights[2] || highlights[0] || "音乐情绪逐渐抬升",
voiceAndSfx: highlights[3] || "环境声增强",
imagePrompt: fallbackPrompt,
motionPrompt: "切换近景,强调表情和动作细节。",
keyframeUrl: previewUrl,
},
];
return rows;
}
function getGridSplitCellLabel(index: number, split: Pick<GridSplitState, "columns">) {
const row = Math.floor(index / split.columns) + 1;
const column = (index % split.columns) + 1;
@ -2391,11 +2520,18 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
const [inspectorNodeId, setInspectorNodeId] = useState<string | null>(null);
const [dockOverviewOpen, setDockOverviewOpen] = useState(false);
const [snapEnabled, setSnapEnabled] = useState(true);
const [activeVideoToolbarMode, setActiveVideoToolbarMode] = useState<VideoToolbarMode>(null);
const [videoClipRanges, setVideoClipRanges] = useState<Record<string, VideoClipRange>>({});
const [videoAnalysisStates, setVideoAnalysisStates] = useState<Record<string, VideoAnalysisState>>({});
const selectedNode = useMemo(
() => nodes.find((node) => node.id === selectedNodeId) || null,
[nodes, selectedNodeId]
);
const selectedVideoMediaNode = useMemo(
() => (isVideoMediaNode(selectedNode) && Boolean(getNodeMediaUrl(selectedNode)) ? selectedNode : null),
[selectedNode]
);
const contextNode = useMemo(
() => nodes.find((node) => node.id === nodeContextMenu?.nodeId) || null,
@ -2416,6 +2552,10 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
[angleEditorNodeId, nodes]
);
useEffect(() => {
setActiveVideoToolbarMode(null);
}, [selectedVideoMediaNode?.id]);
useEffect(() => {
let active = true;
@ -4299,6 +4439,133 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
[edges, nodes, videoCanvasFunctionConfig]
);
const updateVideoClipRange = useCallback((nodeId: string, nextRange: VideoClipRange) => {
setVideoClipRanges((current) => ({
...current,
[nodeId]: {
start: Math.max(0, Math.min(nextRange.start, 0.94)),
end: Math.max(Math.min(nextRange.start + 0.04, 1), Math.min(nextRange.end, 1)),
},
}));
}, []);
const applyVideoClipSelection = useCallback(
(nodeId: string) => {
const node = nodes.find((item) => item.id === nodeId);
if (!node) return;
const existingRange = normalizeVideoClipRange(
videoClipRanges[nodeId] || nodes.find((item) => item.id === nodeId)?.draftValues?.clipRange
);
const normalizedRange =
existingRange.end > existingRange.start
? existingRange
: { start: existingRange.start, end: Math.min(1, existingRange.start + 0.12) };
const durationSeconds = getVideoNodeDurationSeconds(node) * Math.max(0.04, normalizedRange.end - normalizedRange.start);
setNodes((current) =>
current.map((item) =>
item.id === nodeId
? {
...item,
draftValues: {
...(item.draftValues || {}),
clipDurationLabel: formatClipDurationLabel(durationSeconds),
clipRange: normalizedRange,
},
}
: item
)
);
setActiveVideoToolbarMode(null);
toast.success("已记录当前剪辑范围,视频裁剪导出后端待接入。");
},
[nodes, videoClipRanges]
);
const downloadVideoNode = useCallback((node: CloneNode) => {
const mediaUrl = getNodeMediaUrl(node);
if (!mediaUrl) {
toast.error("当前视频没有可下载地址");
return;
}
const link = document.createElement("a");
link.href = mediaUrl;
link.download = `${node.title || "libtv-video"}.mp4`;
link.rel = "noreferrer";
document.body.appendChild(link);
link.click();
link.remove();
toast.success("已开始下载当前视频");
}, []);
const requestVideoAnalysis = useCallback(
async (nodeId: string) => {
const node = nodes.find((item) => item.id === nodeId);
if (!node) return;
const videoUrl = getNodeReferenceMediaUrl(node);
if (!videoUrl) {
toast.error("当前视频暂时没有可解析的素材地址");
return;
}
if (videoAnalysisStates[nodeId]?.status === "loading" || videoAnalysisStates[nodeId]?.status === "ready") {
return;
}
const definition = getConfiguredFunction(videoCanvasFunctionConfig, "story-script");
const prompt = composeFunctionPrompt(
definition,
`请解析当前视频,输出结构化视频故事表。重点补充镜头描述、背景音乐、人声/音效、图像生成提示词、视频运动提示词与关键帧建议。视频标题:${node.title}`
);
setVideoAnalysisStates((current) => ({
...current,
[nodeId]: {
error: undefined,
rows: [],
status: "loading",
summary: "正在解析视频故事...",
},
}));
try {
const response = await workflowAnalysisService.analyze({
mode: "video-analysis",
nodeTitle: node.title,
preferredModelRef: getFunctionModel(videoCanvasFunctionConfig, definition),
prompt,
videoUrls: [videoUrl],
});
if (!response.success) {
throw new Error(response.error || "视频解析失败");
}
const rows = buildVideoAnalysisRows(node, response.data);
const summary =
response.data?.summary?.trim() ||
response.data?.highlights?.join(" · ") ||
"已整理出当前视频的镜头节奏与生成建议。";
setVideoAnalysisStates((current) => ({
...current,
[nodeId]: {
rows,
status: "ready",
summary,
},
}));
} catch (error) {
setVideoAnalysisStates((current) => ({
...current,
[nodeId]: {
error: error instanceof Error ? error.message : "视频解析失败",
rows: buildVideoAnalysisRows(node),
status: "error",
summary: "解析失败,先展示本地占位结构,方便继续编排。",
},
}));
toast.error(error instanceof Error ? error.message : "视频解析失败");
}
},
[nodes, videoAnalysisStates, videoCanvasFunctionConfig]
);
const runPresetComposerSubmit = useCallback(
async (
nodeId: string,
@ -4916,6 +5183,15 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
zoom: viewport.zoom,
};
}, [selectedNode, selectedTargetModuleKind, viewport.x, viewport.y, viewport.zoom]);
const selectedVideoAnchor = useMemo<PresetControlAnchor | null>(() => {
if (!selectedVideoMediaNode) return null;
return {
bottomY: Math.round(viewport.y + (selectedVideoMediaNode.y + selectedVideoMediaNode.height) * viewport.zoom),
centerX: Math.round(viewport.x + (selectedVideoMediaNode.x + selectedVideoMediaNode.width / 2) * viewport.zoom),
topY: Math.round(viewport.y + selectedVideoMediaNode.y * viewport.zoom),
zoom: viewport.zoom,
};
}, [selectedVideoMediaNode, viewport.x, viewport.y, viewport.zoom]);
return (
<div
@ -5055,6 +5331,56 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
/>
) : null}
{selectedVideoMediaNode && selectedVideoAnchor ? (
<div className="pointer-events-none absolute inset-0 z-30" data-canvas-overlay="true">
<VideoMediaToolbar
activeMode={activeVideoToolbarMode}
anchor={selectedVideoAnchor}
onAnalyze={() => {
const nextMode = activeVideoToolbarMode === "analysis" ? null : "analysis";
setActiveVideoToolbarMode(nextMode);
if (nextMode === "analysis") {
void requestVideoAnalysis(selectedVideoMediaNode.id);
}
}}
onDownload={() => downloadVideoNode(selectedVideoMediaNode)}
onHd={() => toast.info("视频高清后端待接入,按钮先保留。")}
onPreview={() => setPreviewNode(selectedVideoMediaNode)}
onToggleClip={() => setActiveVideoToolbarMode((current) => current === "clip" ? null : "clip")}
/>
{activeVideoToolbarMode === "clip" ? (
<VideoClipEditorBar
anchor={selectedVideoAnchor}
clipRange={
normalizeVideoClipRange(
videoClipRanges[selectedVideoMediaNode.id] || selectedVideoMediaNode.draftValues?.clipRange
)
}
durationSeconds={getVideoNodeDurationSeconds(selectedVideoMediaNode)}
previewUrl={getVideoNodePreviewUrl(selectedVideoMediaNode) || getNodeMediaUrl(selectedVideoMediaNode)}
title={selectedVideoMediaNode.title}
onApply={() => applyVideoClipSelection(selectedVideoMediaNode.id)}
onChange={(range) => updateVideoClipRange(selectedVideoMediaNode.id, range)}
onReset={() => updateVideoClipRange(selectedVideoMediaNode.id, { start: 0.18, end: 0.52 })}
/>
) : null}
{activeVideoToolbarMode === "analysis" ? (
<VideoAnalysisFloatingPanel
anchor={selectedVideoAnchor}
analysisState={
videoAnalysisStates[selectedVideoMediaNode.id] || {
rows: [],
status: "idle",
summary: "准备解析当前视频故事...",
}
}
node={selectedVideoMediaNode}
onClose={() => setActiveVideoToolbarMode(null)}
/>
) : null}
</div>
) : null}
{selectedNode && selectedTargetModuleKind === "text" && selectedTargetAnchor ? (
<TextTargetModuleComposer
anchor={selectedTargetAnchor}
@ -5908,6 +6234,375 @@ function GridSplitActionToolbar({
);
}
function VideoMediaToolbar({
activeMode,
anchor,
onAnalyze,
onDownload,
onHd,
onPreview,
onToggleClip,
}: {
activeMode: VideoToolbarMode;
anchor?: PresetControlAnchor;
onAnalyze: () => void;
onDownload: () => void;
onHd: () => void;
onPreview: () => void;
onToggleClip: () => void;
}) {
return (
<div
className="pointer-events-auto absolute flex h-[58px] items-center gap-4 rounded-[14px] border border-white/8 bg-[#262626]/96 px-5 text-[16px] text-white/82 shadow-[0_20px_64px_rgba(0,0,0,0.42)] backdrop-blur-[10px]"
style={
anchor
? {
left: anchor.centerX,
top: anchor.topY - (24 * anchor.zoom + 10),
transform: `translateX(-50%) translateY(-100%) scale(${OFFICIAL_IMAGE_TOOLBAR_SCALE})`,
transformOrigin: "bottom center",
}
: {
left: "50%",
top: 168,
transform: `translateX(-50%) scale(${OFFICIAL_IMAGE_TOOLBAR_SCALE})`,
transformOrigin: "top center",
}
}
>
<VideoToolbarActionButton
active={activeMode === "clip"}
icon={<Scissors className="h-[18px] w-[18px]" strokeWidth={2.1} />}
label="剪辑"
onClick={onToggleClip}
/>
<VideoToolbarActionButton
disabled
icon={<SceneToolIcon kind="hd" />}
label="高清"
onClick={onHd}
/>
<VideoToolbarActionButton
active={activeMode === "analysis"}
icon={<VideoAnalysisGlyph />}
label="解析"
onClick={onAnalyze}
/>
<div className="h-9 w-px bg-white/8" />
<button
className="flex h-10 w-10 items-center justify-center rounded-[8px] text-white/84 transition hover:bg-white/8 hover:text-white"
onClick={onDownload}
type="button"
>
<Download className="h-[18px] w-[18px]" strokeWidth={2.1} />
</button>
<button
className="flex h-10 w-10 items-center justify-center rounded-[8px] text-[24px] leading-none text-white/84 transition hover:bg-white/8 hover:text-white"
onClick={onPreview}
type="button"
>
↗
</button>
</div>
);
}
function VideoToolbarActionButton({
active = false,
disabled = false,
icon,
label,
onClick,
}: {
active?: boolean;
disabled?: boolean;
icon: ReactNode;
label: string;
onClick: () => void;
}) {
return (
<button
aria-disabled={disabled}
className={cn(
"flex h-10 items-center gap-2 rounded-[10px] px-3 transition",
disabled
? "cursor-not-allowed text-white/28"
: active
? "bg-white/10 text-white ring-1 ring-[#76b3ff]"
: "text-white/84 hover:bg-white/8 hover:text-white"
)}
onClick={() => {
if (disabled) {
onClick();
return;
}
onClick();
}}
type="button"
>
<span className="flex h-5 w-5 items-center justify-center">{icon}</span>
<span className="whitespace-nowrap text-[15px]">{label}</span>
</button>
);
}
function VideoAnalysisGlyph() {
return (
<svg aria-hidden="true" className="h-[18px] w-[18px]" fill="none" viewBox="0 0 20 20">
<rect height="16" rx="3" stroke="currentColor" strokeWidth="1.8" width="12" x="4" y="2" />
<path d="M8 5.5h4M8 9h4M8 12.5h4" stroke="currentColor" strokeLinecap="round" strokeWidth="1.8" />
</svg>
);
}
function VideoClipEditorBar({
anchor,
clipRange,
durationSeconds,
onApply,
onChange,
onReset,
previewUrl,
title,
}: {
anchor?: PresetControlAnchor;
clipRange: VideoClipRange;
durationSeconds: number;
onApply: () => void;
onChange: (range: VideoClipRange) => void;
onReset: () => void;
previewUrl: string;
title: string;
}) {
const trackRef = useRef<HTMLDivElement | null>(null);
const [dragHandle, setDragHandle] = useState<"start" | "end" | null>(null);
const frames = useMemo(
() => Array.from({ length: VIDEO_CLIP_FRAME_COUNT }).map((_, index) => ({ id: `${title}-${index}`, url: previewUrl })),
[previewUrl, title]
);
useEffect(() => {
if (!dragHandle) return;
const handlePointerMove = (event: PointerEvent) => {
const rect = trackRef.current?.getBoundingClientRect();
if (!rect || rect.width <= 0) return;
const ratio = Math.max(0, Math.min((event.clientX - rect.left) / rect.width, 1));
if (dragHandle === "start") {
onChange({
end: clipRange.end,
start: Math.min(ratio, clipRange.end - 0.04),
});
return;
}
onChange({
end: Math.max(ratio, clipRange.start + 0.04),
start: clipRange.start,
});
};
const handlePointerUp = () => setDragHandle(null);
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
return () => {
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
};
}, [clipRange.end, clipRange.start, dragHandle, onChange]);
const selectionWidth = Math.max(clipRange.end - clipRange.start, 0.04);
const durationLabel = formatClipDurationLabel(durationSeconds * selectionWidth);
return (
<div
className="pointer-events-auto absolute overflow-visible"
style={
anchor
? {
left: anchor.centerX,
top: anchor.bottomY + 12,
transform: "translateX(-50%)",
width: 920,
}
: {
bottom: 54,
left: "50%",
transform: "translateX(-50%)",
width: 920,
}
}
>
<div className="relative flex h-[68px] items-center gap-4 rounded-[16px] border border-white/8 bg-[#262626]/98 px-5 shadow-[0_20px_64px_rgba(0,0,0,0.42)] backdrop-blur-[10px]">
<div className="min-w-0 flex-1">
<div
className="relative h-[42px] overflow-hidden rounded-[12px] border border-white/8 bg-black/24"
onPointerDown={(event) => event.stopPropagation()}
ref={trackRef}
>
<div className="absolute inset-0 flex">
{frames.map((frame, index) => (
<div className="relative h-full flex-1 border-r border-white/6 last:border-r-0" key={frame.id}>
{frame.url ? <img alt="" className="h-full w-full object-cover opacity-82" draggable={false} src={frame.url} /> : null}
<span className="absolute inset-0 bg-black/18" />
{index !== frames.length - 1 ? <span className="absolute right-0 top-0 h-full w-px bg-white/8" /> : null}
</div>
))}
</div>
<div
className="absolute bottom-[3px] top-[3px] rounded-[10px] border border-white/88 bg-white/12 shadow-[0_0_0_1px_rgba(87,168,255,0.75)]"
style={{ left: `${clipRange.start * 100}%`, width: `${selectionWidth * 100}%` }}
>
<button
className="absolute left-[-8px] top-1/2 h-7 w-4 -translate-y-1/2 rounded-full bg-white shadow-[0_6px_18px_rgba(0,0,0,0.34)]"
onPointerDown={(event) => {
event.stopPropagation();
setDragHandle("start");
}}
type="button"
/>
<button
className="absolute right-[-8px] top-1/2 h-7 w-4 -translate-y-1/2 rounded-full bg-white shadow-[0_6px_18px_rgba(0,0,0,0.34)]"
onPointerDown={(event) => {
event.stopPropagation();
setDragHandle("end");
}}
type="button"
/>
<span className="absolute left-1/2 top-1/2 flex h-7 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-[10px] bg-black/72 px-3 text-[14px] font-semibold text-white">
{durationLabel}
</span>
</div>
</div>
</div>
<button
className="flex h-10 w-10 items-center justify-center rounded-[10px] text-white/44 transition hover:bg-white/8 hover:text-white/76"
onClick={() => toast.info("视频音轨编辑后端待接入。")}
type="button"
>
<Music2 className="h-[18px] w-[18px]" strokeWidth={2} />
</button>
<button
className="flex h-10 w-10 items-center justify-center rounded-[10px] text-white/74 transition hover:bg-white/8 hover:text-white"
onClick={onReset}
type="button"
>
<RotateCcw className="h-[18px] w-[18px]" strokeWidth={2} />
</button>
<button
className="flex h-11 w-11 items-center justify-center rounded-full bg-white text-black shadow-[0_10px_24px_rgba(255,255,255,0.18)] transition hover:bg-white/90"
onClick={onApply}
type="button"
>
<Check className="h-[20px] w-[20px]" strokeWidth={2.5} />
</button>
</div>
</div>
);
}
function VideoAnalysisFloatingPanel({
anchor,
analysisState,
node,
onClose,
}: {
anchor?: PresetControlAnchor;
analysisState: VideoAnalysisState;
node: CloneNode;
onClose: () => void;
}) {
const previewUrl = getVideoNodePreviewUrl(node) || getNodeMediaUrl(node) || "";
const rows = analysisState.rows;
const floatingLeft =
anchor
? Math.max(
24,
Math.min(
typeof window !== "undefined" ? window.innerWidth - 884 : anchor.centerX + 380,
anchor.centerX + 380
)
)
: undefined;
return (
<div
className="pointer-events-auto absolute overflow-hidden rounded-[14px] border border-white/8 bg-[#202020]/98 text-white shadow-[0_24px_84px_rgba(0,0,0,0.48)] backdrop-blur-[12px]"
style={
anchor
? {
height: 378,
left: floatingLeft,
top: anchor.topY + 18,
width: 860,
}
: {
height: 378,
left: "50%",
top: 240,
transform: "translateX(-50%)",
width: 860,
}
}
>
<div className="flex h-11 items-center justify-between border-b border-white/8 px-4">
<div className="flex items-center gap-2 text-[16px] font-semibold text-white/86">
<VideoAnalysisGlyph />
视频故事
</div>
<button className="rounded-[8px] p-1 text-white/38 transition hover:bg-white/8 hover:text-white/78" onClick={onClose} type="button">
<X className="h-4 w-4" />
</button>
</div>
<div className="px-4 py-3 text-[13px] text-white/56">{analysisState.summary}</div>
{analysisState.error ? (
<div className="mx-4 mb-3 rounded-[10px] border border-[#ff8b8b]/14 bg-[#ff8b8b]/8 px-3 py-2 text-[12px] text-[#ffb3b3]">
{analysisState.error}
</div>
) : null}
<div className="h-[calc(100%-84px)] overflow-auto px-3 pb-3">
<div className="min-w-[860px] overflow-hidden rounded-[10px] border border-white/8 bg-[#171717]">
<div className="grid grid-cols-[1.2fr_1fr_0.9fr_1.3fr_1.3fr_108px] border-b border-white/8 bg-white/5 text-[12px] text-white/38">
{["镜头描述", "背景音乐", "人声/音效", "图像生成提示词", "视频运动提示词", "关键帧"].map((item) => (
<div className="border-r border-white/8 px-3 py-2 last:border-r-0" key={item}>{item}</div>
))}
</div>
{analysisState.status === "loading" && rows.length === 0 ? (
Array.from({ length: 2 }).map((_, index) => (
<div className="grid grid-cols-[1.2fr_1fr_0.9fr_1.3fr_1.3fr_108px] border-b border-white/6 last:border-b-0" key={`video-analysis-loading-${index}`}>
{Array.from({ length: 6 }).map((_, cell) => (
<div className="border-r border-white/6 px-3 py-3 last:border-r-0" key={`video-analysis-loading-${index}-${cell}`}>
<div className="h-3 rounded-full bg-white/8" />
<div className="mt-2 h-3 w-4/5 rounded-full bg-white/6" />
</div>
))}
</div>
))
) : rows.length > 0 ? (
rows.map((row) => (
<div className="grid grid-cols-[1.2fr_1fr_0.9fr_1.3fr_1.3fr_108px] border-b border-white/6 last:border-b-0" key={row.id}>
<div className="border-r border-white/6 px-3 py-3 text-[13px] leading-6 text-white/78">{row.sceneDescription}</div>
<div className="border-r border-white/6 px-3 py-3 text-[13px] leading-6 text-white/70">{row.backgroundMusic}</div>
<div className="border-r border-white/6 px-3 py-3 text-[13px] leading-6 text-white/70">{row.voiceAndSfx}</div>
<div className="border-r border-white/6 px-3 py-3 text-[13px] leading-6 text-white/70">{row.imagePrompt}</div>
<div className="border-r border-white/6 px-3 py-3 text-[13px] leading-6 text-white/70">{row.motionPrompt}</div>
<div className="flex items-center justify-center px-3 py-3">
{row.keyframeUrl || previewUrl ? (
<img alt="" className="h-[54px] w-[84px] rounded-[8px] object-cover" src={row.keyframeUrl || previewUrl} />
) : (
<div className="flex h-[54px] w-[84px] items-center justify-center rounded-[8px] bg-white/6 text-[11px] text-white/34">
无预览
</div>
)}
</div>
</div>
))
) : (
<div className="flex h-[220px] items-center justify-center text-[13px] text-white/34">暂无解析结果</div>
)}
</div>
</div>
</div>
);
}
function PresetWorkflowControls({
anchor,
functionConfig,