import { useEffect, useRef, useState, type CSSProperties } from "react"; import { App, Button, InputNumber, Modal } from "antd"; import { Check, Image as ImageIcon, SkipBack, SkipForward, Trash2 } from "lucide-react"; import { nanoid } from "nanoid"; import { canvasThemes } from "@/lib/canvas-theme"; import { formatVideoFrameTime, normalizeVideoFrameTimes } from "@/lib/canvas/canvas-video-frame"; import { resourceIdFromStorageKey } from "@/services/api/resources"; import { resolveMediaUrl } from "@/services/file-storage"; import { cacheResourceObjectUrl } from "@/services/resource-blob-cache"; import { useThemeStore } from "@/stores/use-theme-store"; import type { CanvasNodeData } from "@/types/canvas"; type SelectedVideoFrame = { id: string; timeMs: number; }; export type CanvasVideoFrameParams = { timesMs: number[]; }; type CanvasVideoFrameDialogProps = { node: CanvasNodeData; open: boolean; onClose: () => void; onConfirm: (params: CanvasVideoFrameParams) => void; }; const MAX_SELECTED_FRAMES = 30; export function CanvasVideoFrameDialog({ node, open, onClose, onConfirm }: CanvasVideoFrameDialogProps) { const { message } = App.useApp(); const theme = canvasThemes[useThemeStore((state) => state.theme)]; const videoRef = useRef(null); const [videoUrl, setVideoUrl] = useState(""); const [videoError, setVideoError] = useState(false); const [durationMs, setDurationMs] = useState(0); const [currentTimeMs, setCurrentTimeMs] = useState(0); const [frames, setFrames] = useState([]); useEffect(() => { if (!open) return; let cancelled = false; setVideoUrl(""); setVideoError(false); setDurationMs(0); setCurrentTimeMs(0); setFrames([]); const storageKey = node.metadata?.storageKey || ""; const fallback = node.metadata?.content || ""; const applyUrl = (url: string) => { if (!cancelled) setVideoUrl(url); }; if (resourceIdFromStorageKey(storageKey)) { void cacheResourceObjectUrl(storageKey) .then((cached) => { if (cancelled) return; if (cached) setVideoUrl(cached); else void resolveMediaUrl(storageKey, fallback).then(applyUrl); }) .catch(() => { if (!cancelled) void resolveMediaUrl(storageKey, fallback).then(applyUrl); }); } else { void resolveMediaUrl(storageKey, fallback).then(applyUrl); } return () => { cancelled = true; }; }, [node.id, node.metadata?.content, node.metadata?.storageKey, open]); const seekTo = (timeMs: number) => { const normalized = normalizeVideoFrameTimes([timeMs], durationMs)[0]; if (normalized === undefined) return; setCurrentTimeMs(normalized); if (videoRef.current) videoRef.current.currentTime = normalized / 1000; }; const addFrame = (timeMs: number) => { const normalized = normalizeVideoFrameTimes([timeMs], durationMs)[0]; if (normalized === undefined) { message.warning("视频时长未就绪,请稍候再试"); return; } if (frames.some((frame) => frame.timeMs === normalized)) { message.info("该时间点已经添加"); return; } if (frames.length >= MAX_SELECTED_FRAMES) { message.warning(`单次最多提取 ${MAX_SELECTED_FRAMES} 帧,请先完成当前批次`); return; } setFrames((current) => [...current, { id: nanoid(), timeMs: normalized }].sort((left, right) => left.timeMs - right.timeMs)); }; const removeFrame = (id: string) => setFrames((current) => current.filter((frame) => frame.id !== id)); const lastFrameMs = Math.max(0, durationMs - 1); const title = (
提取视频画面
{node.title || "视频节点"}
); return (
{videoUrl ? (
当前时间 seekTo(Math.round((value || 0) * 1000))} /> / {durationMs ? formatVideoFrameTime(durationMs) : "--:--.---"}
已选画面
{frames.length}/{MAX_SELECTED_FRAMES}
{frames.length ? (
{frames.map((frame, index) => (
))}
) : (
播放或拖动到需要的位置,然后添加当前画面。可以连续添加多个时间点。
)}
提取后只创建图片节点,不会自动发起生成任务。
); }