feat: 修复视频序列帧工具失败的问题

This commit is contained in:
richarjiang
2026-02-02 10:15:16 +08:00
parent 4eb2aea547
commit 6ffa9aa706
6 changed files with 1361 additions and 292 deletions

261
src/lib/frame-extractor.ts Normal file
View File

@@ -0,0 +1,261 @@
/**
* 视频帧提取工具 - 纯前端实现
* 使用 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-1PNG 忽略此参数
}
// 验证常量
export const MAX_DURATION = 5; // 最大视频时长(秒)
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<VideoMetadata> => {
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<ValidationResult> => {
// 检查文件大小
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<ExtractedFrame[]> => {
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<void>((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}`;
};