Files
mini-game-ai/src/lib/frame-extractor.ts
2026-02-02 15:53:21 +08:00

262 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 视频帧提取工具 - 纯前端实现
* 使用 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 = 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}`;
};