/** * 视频帧提取工具 - 纯前端实现 * 使用 Canvas + Video API 从视频中提取序列帧 */ export interface VideoMetadata { duration: number; // 视频时长(秒) width: number; // 视频宽度 height: number; // 视频高度 size: number; // 文件大小(字节) name: string; // 文件名 } export interface ExtractedFrame { id: string; // 唯一标识 index: number; // 帧序号(从 0 开始) timestamp: number; // 时间戳(秒) dataUrl: string; // Base64 图片数据 selected: boolean; // 是否选中 } export interface ExtractionSettings { frameRate: number; // 帧率(5-30) format: "image/png" | "image/jpeg"; // 输出格式 quality: number; // JPEG 质量(0-1),PNG 忽略此参数 } // 验证常量 export const MAX_DURATION = 10; // 最大视频时长(秒) export const MAX_FILE_SIZE = 20 * 1024 * 1024; // 最大文件大小(20MB) export const MIN_FRAME_RATE = 5; // 最小帧率 export const MAX_FRAME_RATE = 30; // 最大帧率 // 支持的视频格式 export const SUPPORTED_VIDEO_FORMATS = { "video/mp4": [".mp4"], "video/webm": [".webm"], "video/quicktime": [".mov"], }; /** * 从本地文件获取视频元数据 */ export const getVideoMetadata = (file: File): Promise => { return new Promise((resolve, reject) => { const video = document.createElement("video"); video.preload = "metadata"; const cleanup = () => { URL.revokeObjectURL(video.src); video.remove(); }; video.onloadedmetadata = () => { const metadata: VideoMetadata = { duration: video.duration, width: video.videoWidth, height: video.videoHeight, size: file.size, name: file.name, }; cleanup(); resolve(metadata); }; video.onerror = () => { cleanup(); reject(new Error("无法解析视频文件,请确保文件格式正确")); }; video.src = URL.createObjectURL(file); }); }; /** * 验证视频文件 */ export interface ValidationResult { valid: boolean; error?: string; } export const validateVideo = async (file: File): Promise => { // 检查文件大小 if (file.size > MAX_FILE_SIZE) { return { valid: false, error: `文件大小超过限制,最大允许 ${MAX_FILE_SIZE / 1024 / 1024}MB`, }; } // 检查文件类型 const validTypes = Object.keys(SUPPORTED_VIDEO_FORMATS); if (!validTypes.includes(file.type) && !file.name.match(/\.(mp4|webm|mov)$/i)) { return { valid: false, error: "不支持的视频格式,请上传 MP4、WebM 或 MOV 格式", }; } // 检查视频时长 try { const metadata = await getVideoMetadata(file); if (metadata.duration > MAX_DURATION) { return { valid: false, error: `视频时长超过限制,最大允许 ${MAX_DURATION} 秒,当前视频 ${metadata.duration.toFixed(1)} 秒`, }; } } catch { return { valid: false, error: "无法解析视频文件", }; } return { valid: true }; }; /** * 计算预计提取的帧数 */ export const calculateEstimatedFrames = (duration: number, frameRate: number): number => { return Math.floor(duration * frameRate); }; /** * 从视频中提取序列帧 * @param videoFile 视频文件 * @param settings 提取设置 * @param onProgress 进度回调 (0-100) * @param onFrameExtracted 单帧提取完成回调(用于渐进式显示) */ export const extractFrames = async ( videoFile: File, settings: ExtractionSettings, onProgress?: (progress: number) => void, onFrameExtracted?: (frame: ExtractedFrame) => void ): Promise => { const { frameRate, format, quality } = settings; return new Promise((resolve, reject) => { const video = document.createElement("video"); video.preload = "auto"; video.muted = true; const cleanup = () => { URL.revokeObjectURL(video.src); video.remove(); }; video.onloadeddata = async () => { try { const duration = video.duration; const totalFrames = Math.floor(duration * frameRate); const interval = 1 / frameRate; // 创建 Canvas const canvas = document.createElement("canvas"); canvas.width = video.videoWidth; canvas.height = video.videoHeight; const ctx = canvas.getContext("2d"); if (!ctx) { cleanup(); reject(new Error("无法创建 Canvas 上下文")); return; } const frames: ExtractedFrame[] = []; for (let i = 0; i < totalFrames; i++) { const timestamp = i * interval; // 设置视频当前时间 video.currentTime = timestamp; // 等待视频 seek 完成 await new Promise((seekResolve) => { const onSeeked = () => { video.removeEventListener("seeked", onSeeked); seekResolve(); }; video.addEventListener("seeked", onSeeked); }); // 绘制当前帧到 Canvas ctx.drawImage(video, 0, 0); // 导出为图片 const dataUrl = canvas.toDataURL(format, format === "image/jpeg" ? quality : undefined); const frame: ExtractedFrame = { id: `frame-${i}-${Date.now()}`, index: i, timestamp, dataUrl, selected: true, }; frames.push(frame); // 回调单帧提取完成 onFrameExtracted?.(frame); // 更新进度 const progress = Math.round(((i + 1) / totalFrames) * 100); onProgress?.(progress); } cleanup(); resolve(frames); } catch (error) { cleanup(); reject(error); } }; video.onerror = () => { cleanup(); reject(new Error("视频加载失败")); }; video.src = URL.createObjectURL(videoFile); video.load(); }); }; /** * 格式化时间戳为显示字符串 */ export const formatTimestamp = (seconds: number): string => { const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); const ms = Math.floor((seconds % 1) * 100); return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}.${ms.toString().padStart(2, "0")}`; }; /** * 格式化文件大小 */ export const formatFileSize = (bytes: number): string => { if (bytes === 0) return "0 B"; const k = 1024; const sizes = ["B", "KB", "MB", "GB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; }; /** * 格式化视频时长 */ export const formatDuration = (seconds: number): string => { const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); const ms = Math.floor((seconds % 1) * 10); if (mins > 0) { return `${mins}:${secs.toString().padStart(2, "0")}.${ms}`; } return `${secs}.${ms} 秒`; };