diff --git a/components/canvas/LiblibVideoCanvasReplica.tsx b/components/canvas/LiblibVideoCanvasReplica.tsx index 9a7e8f8b5..80340970d 100644 --- a/components/canvas/LiblibVideoCanvasReplica.tsx +++ b/components/canvas/LiblibVideoCanvasReplica.tsx @@ -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) + : {}; + const numericValue = Number( + params.duration || + paramsSettings.duration || + getDraftString(node, "duration") || + (node?.draftValues?.videoMeta && typeof node.draftValues.videoMeta === "object" + ? (node.draftValues.videoMeta as Record).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; + 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) { 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(null); const [dockOverviewOpen, setDockOverviewOpen] = useState(false); const [snapEnabled, setSnapEnabled] = useState(true); + const [activeVideoToolbarMode, setActiveVideoToolbarMode] = useState(null); + const [videoClipRanges, setVideoClipRanges] = useState>({}); + const [videoAnalysisStates, setVideoAnalysisStates] = useState>({}); 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(() => { + 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 (
) : null} + {selectedVideoMediaNode && selectedVideoAnchor ? ( +
+ { + 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" ? ( + applyVideoClipSelection(selectedVideoMediaNode.id)} + onChange={(range) => updateVideoClipRange(selectedVideoMediaNode.id, range)} + onReset={() => updateVideoClipRange(selectedVideoMediaNode.id, { start: 0.18, end: 0.52 })} + /> + ) : null} + {activeVideoToolbarMode === "analysis" ? ( + setActiveVideoToolbarMode(null)} + /> + ) : null} +
+ ) : null} + {selectedNode && selectedTargetModuleKind === "text" && selectedTargetAnchor ? ( void; + onDownload: () => void; + onHd: () => void; + onPreview: () => void; + onToggleClip: () => void; +}) { + return ( +
+ } + label="剪辑" + onClick={onToggleClip} + /> + } + label="高清" + onClick={onHd} + /> + } + label="解析" + onClick={onAnalyze} + /> +
+ + +
+ ); +} + +function VideoToolbarActionButton({ + active = false, + disabled = false, + icon, + label, + onClick, +}: { + active?: boolean; + disabled?: boolean; + icon: ReactNode; + label: string; + onClick: () => void; +}) { + return ( + + ); +} + +function VideoAnalysisGlyph() { + return ( + + ); +} + +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(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 ( +
+
+
+
event.stopPropagation()} + ref={trackRef} + > +
+ {frames.map((frame, index) => ( +
+ {frame.url ? : null} + + {index !== frames.length - 1 ? : null} +
+ ))} +
+
+
+
+
+ + + +
+
+ ); +} + +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 ( +
+
+
+ + 视频故事 +
+ +
+
{analysisState.summary}
+ {analysisState.error ? ( +
+ {analysisState.error} +
+ ) : null} +
+
+
+ {["镜头描述", "背景音乐", "人声/音效", "图像生成提示词", "视频运动提示词", "关键帧"].map((item) => ( +
{item}
+ ))} +
+ {analysisState.status === "loading" && rows.length === 0 ? ( + Array.from({ length: 2 }).map((_, index) => ( +
+ {Array.from({ length: 6 }).map((_, cell) => ( +
+
+
+
+ ))} +
+ )) + ) : rows.length > 0 ? ( + rows.map((row) => ( +
+
{row.sceneDescription}
+
{row.backgroundMusic}
+
{row.voiceAndSfx}
+
{row.imagePrompt}
+
{row.motionPrompt}
+
+ {row.keyframeUrl || previewUrl ? ( + + ) : ( +
+ 无预览 +
+ )} +
+
+ )) + ) : ( +
暂无解析结果
+ )} +
+
+
+ ); +} + function PresetWorkflowControls({ anchor, functionConfig,