262 lines
6.9 KiB
TypeScript
262 lines
6.9 KiB
TypeScript
/**
|
||
* 视频帧提取工具 - 纯前端实现
|
||
* 使用 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<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} 秒`;
|
||
};
|