import { useEffect, useMemo, useRef, useState } from "react"; import { App, Button, Input, InputNumber, Modal, Segmented } from "antd"; import { AudioLines, Check, ListVideo, Plus, Scissors, SkipBack, SkipForward, Trash2 } from "lucide-react"; import { nanoid } from "nanoid"; import { ModelPicker } from "@/components/model-picker"; import { canvasThemes } from "@/lib/canvas-theme"; import { buildTimelineImportSegments, type CanvasTimelineSegmentItem } from "@/lib/canvas/canvas-video-timeline-segments"; import { listVideoReferenceModels } from "@/lib/canvas/canvas-video-regeneration"; import { modelCapabilityConfigFor } from "@/lib/model-capabilities"; import { modelRequestOptions, resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection"; import { navigateToSettings } from "@/lib/settings-navigation"; import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store"; import { resolveMediaUrl } from "@/services/file-storage"; import { modelDisplayName, type AiConfig } from "@/stores/use-config-store"; import { type CanvasConnection, type CanvasNodeData, type CanvasVideoEditOperation } from "@/types/canvas"; import type { TimelineProject } from "@/types/timeline"; import { Select } from "@/components/ui/base/select"; export type CanvasVideoSegmentItem = CanvasTimelineSegmentItem; export type CanvasVideoSegmentParams = { mode: "audio" | "video"; action?: "extract" | "create-generation-nodes"; startMs: number; endMs: number; prompt?: string; segments?: CanvasVideoSegmentItem[]; model?: string; operation?: CanvasVideoEditOperation; }; type CanvasVideoSegmentDialogProps = { node: CanvasNodeData; nodes: CanvasNodeData[]; connections: CanvasConnection[]; open: boolean; mode: "audio" | "video"; config: AiConfig; timeline?: TimelineProject | null; onClose: () => void; onConfirm: (params: CanvasVideoSegmentParams) => void; }; const MIN_SEGMENT_MS = 100; export function CanvasVideoSegmentDialog({ node, nodes, connections, open, mode, config, timeline, onClose, onConfirm }: CanvasVideoSegmentDialogProps) { const { message } = App.useApp(); const theme = canvasThemes[useActiveTheme()]; const videoRef = useRef(null); const segmentsSeededRef = useRef(false); const [videoUrl, setVideoUrl] = useState(""); const [videoError, setVideoError] = useState(false); const [durationMs, setDurationMs] = useState(0); const [startSec, setStartSec] = useState(0); const [endSec, setEndSec] = useState(0); const [prompt, setPrompt] = useState(""); const [segments, setSegments] = useState([]); const [model, setModel] = useState(""); const [operation, setOperation] = useState("extend"); const [videoAction, setVideoAction] = useState<"extract" | "create-generation-nodes">("extract"); const eligibleModels = useMemo(() => listVideoReferenceModels(config), [config]); // 打开弹窗时解析视频地址,读取时长并初始化起止时间。 useEffect(() => { if (!open) return; let cancelled = false; setVideoUrl(""); setVideoError(false); setDurationMs(0); setStartSec(0); setEndSec(0); setPrompt(""); setSegments([]); setVideoAction("extract"); segmentsSeededRef.current = false; const defaultModel = config.videoModel || config.model || ""; const initialModel = eligibleModels.includes(defaultModel) ? defaultModel : eligibleModels[0] || defaultModel; setModel(initialModel); const profile = initialModel ? modelCapabilityConfigFor(config, initialModel).video : undefined; setOperation(profile?.operations.includes("extend") ? "extend" : (profile?.operations[0] as CanvasVideoEditOperation | undefined) || "extend"); const storageKey = node.metadata?.storageKey || ""; const fallback = node.metadata?.content || ""; void resolveMediaUrl(storageKey, fallback).then((url) => { if (!cancelled) setVideoUrl(url); }); return () => { cancelled = true; }; }, [config, eligibleModels, node, open]); // 视频模式等待时长元数据就绪后,默认放入一个完整时长的片段。 useEffect(() => { if (!open || mode !== "video" || segmentsSeededRef.current || !durationMs) return; segmentsSeededRef.current = true; setSegments([{ id: nanoid(), startMs: 0, endMs: durationMs, sourceNodeId: node.id }]); }, [durationMs, mode, open]); const isVideoMode = mode === "video"; const createsGenerationNodes = videoAction === "create-generation-nodes"; const durationSec = durationMs > 0 ? durationMs / 1000 : 0; const hasTimelineVideoClips = Boolean(timeline?.clips.some((clip) => clip.kind === "video")); const hasPrompt = Boolean(prompt.trim()); const modelRequirements = useMemo( () => ({ capability: "video", input: { textCount: createsGenerationNodes && hasPrompt ? 1 : 0, imageCount: 0, videoCount: createsGenerationNodes ? 1 : 0, audioCount: 0, characterCount: 0 }, videoOperation: operation, videoSeconds: config.videoSeconds, options: modelRequestOptions(config, "video"), }), [config.videoSeconds, createsGenerationNodes, hasPrompt, operation], ); const resolvedModel = resolveCompatibleModel(config, model, modelRequirements) || model; const videoProfile = useMemo(() => (resolvedModel ? modelCapabilityConfigFor(config, resolvedModel).video : undefined), [config, resolvedModel]); const defaultModel = config.videoModel || config.model || ""; const defaultModelSupported = eligibleModels.includes(defaultModel); const hasEligibleModels = eligibleModels.length > 0; const firstEligibleModel = eligibleModels[0] || ""; // 切换模型后保持所选模式仍然有效,优先回退到“视频续写”。 useEffect(() => { if (!videoProfile?.operations.length || videoProfile.operations.includes(operation)) return; setOperation(videoProfile.operations.includes("extend") ? "extend" : (videoProfile.operations[0] as CanvasVideoEditOperation)); }, [operation, videoProfile]); const operationOptions = (videoProfile?.operations || []).map((value) => { const operation = value as CanvasVideoEditOperation; return { value: operation, label: videoOperationLabel(operation) }; }); const rangeMs = useMemo(() => { const start = Math.max(0, Math.round((startSec || 0) * 1000)); const end = durationMs > 0 ? Math.min(durationMs, Math.round((endSec || 0) * 1000)) : Math.round((endSec || 0) * 1000); return { startMs: start, endMs: Math.max(start, end) }; }, [durationMs, endSec, startSec]); const addManualSegment = () => { const last = segments[segments.length - 1]; const maxEndMs = durationMs || last?.endMs || 0; const startMs = last ? Math.min(maxEndMs, last.endMs) : 0; const endMs = Math.max(startMs + MIN_SEGMENT_MS, maxEndMs); setSegments((current) => [...current, { id: nanoid(), startMs, endMs, sourceNodeId: node.id }]); }; const importTimelineSegments = () => { try { const result = buildTimelineImportSegments(node, nodes, connections, timeline, durationMs); if (!result.ok) { message.warning(result.error); return; } setSegments(result.segments); setPrompt((current) => current || `按时间线截取的 ${result.segments.length} 段视频,保持画面主体与镜头,重新生成每一段`); message.success(`已从时间线导入 ${result.segments.length} 个片段`); } catch (error) { console.warn("时间线片段导入失败", error); message.warning("读取时间线片段失败,请刷新后重试"); } }; const updateSegment = (id: string, patch: Partial>) => { setSegments((current) => current.map((item) => (item.id === id ? { ...item, ...patch } : item))); }; const removeSegment = (id: string) => { setSegments((current) => current.filter((item) => item.id !== id)); }; const handleConfirm = () => { if (isVideoMode) { if (createsGenerationNodes && !model) { message.warning("请选择视频模型"); return; } if (!segments.length) { message.warning("请至少添加一个截取片段"); return; } for (const segment of segments) { if (segment.endMs - segment.startMs < MIN_SEGMENT_MS) { message.warning("片段时长至少 0.1 秒"); return; } } onConfirm({ mode: "video", action: videoAction, startMs: 0, endMs: 0, segments: segments.map(({ id, startMs, endMs, sourceNodeId, sourceStorageKey, sourceUrl }) => ({ id, startMs, endMs, sourceNodeId, sourceStorageKey, sourceUrl })), model: createsGenerationNodes ? resolvedModel : undefined, operation: createsGenerationNodes ? operation : undefined, prompt: createsGenerationNodes ? prompt.trim() : undefined, }); return; } if (!durationMs) { message.warning("视频时长未就绪,请稍候再试"); return; } if (rangeMs.endMs - rangeMs.startMs < MIN_SEGMENT_MS) { message.warning("片段时长至少 0.1 秒"); return; } onConfirm({ mode: "audio", startMs: rangeMs.startMs, endMs: rangeMs.endMs }); }; const title = (
{isVideoMode ? : }
{isVideoMode ? "截取视频片段" : "从视频提取声音"}
{node.title || "视频节点"}
); return (
{videoUrl ? (
{isVideoMode ? (
{createsGenerationNodes && !defaultModelSupported ? (
{hasEligibleModels ? `当前默认模型${defaultModel ? `「${modelDisplayName(config, defaultModel)}」` : ""}不支持参考视频,已自动切换到「${modelDisplayName(config, firstEligibleModel)}」。` : "当前配置中没有支持参考视频的视频模型,请先到设置里配置 Seedance / Agent Plan / NewAPI 渠道。"} {!hasEligibleModels ? ( ) : null}
) : null}
时长 {durationSec ? formatSegmentTime(durationSec) : "未知"} 已选 {segments.length} 段
{segments.length ? (
{segments.map((segment, index) => (
片段 {index + 1}
起点 updateSegment(segment.id, { startMs: Math.round((value ?? 0) * 1000) })} className="w-full" aria-label={`片段 ${index + 1} 起点(秒)`} />
终点 updateSegment(segment.id, { endMs: Math.round((value ?? 0) * 1000) })} className="w-full" aria-label={`片段 ${index + 1} 终点(秒)`} />
))}
) : (
还没有片段:手动添加起点/终点,或从时间线导入该节点的已有片段。
)}
截取后
默认只创建片段,生成由你稍后决定。
setVideoAction(value as "extract" | "create-generation-nodes")} />
{createsGenerationNodes ? ( <>