在视频编辑、内容分析和多媒体处理领域,常常需要从视频中提取关键帧。手动截取不仅效率低下,还容易遗漏重要画面。本文介绍的视频帧提取工具通过 HTML5 技术栈实现了一个完整的浏览器端解决方案,用户可以轻松选择视频文件并进行手动或自动帧捕获。
效果演示


核心功能
手动帧捕获
用户可以通过点击"捕获帧"按钮,在视频播放过程中随时抓取当前帧。捕获的画面会实时显示在预览区域,并生成可下载的 PNG 图像。
自动帧捕获
支持设定时间间隔(如每秒一张)自动捕获帧的功能,适用于批量提取视频中的关键画面。进度条实时反映当前处理进度,增强用户体验。
帧管理
- 缩略图展示:所有捕获的帧以网格形式展示,鼠标悬停时显示操作按钮。
- 下载与删除:每个帧都支持独立下载和删除,方便用户整理和导出所需内容。
- 预览切换:点击缩略图即可在主画布上查看高清版本。
空状态提示
当没有任何帧被捕获时,提供友好的空状态提示,提升交互体验。
页面结构
视频上传与播放区域
用户选择本地视频文件,上传后在 video 中播放。
html
<div class="file-input-wrapper">
<button class="file-input-button" id="uploadButton">
<svg class="icon" viewBox="0 0 24 24">
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
</svg>
选择视频文件
</button>
<input type="file" id="videoUpload" class="file-input" accept="video/*">
</div>
<div class="video-container">
<video id="videoElement" controls></video>
</div>
操作控制区域
【捕获帧】按钮用于手动截取当前视频画面;【自动捕获】按钮开启定时连续提取帧的功能;【停止】按钮用于终止自动捕获过程;下方的输入框用于设置自动捕获时每帧之间的时间间隔(单位为秒)。 整体提供了用户与视频帧提取功能交互的主要控件。
html
<div class="controls">
<button id="captureBtn" class="btn btn-primary" disabled>
<svg class="icon" viewBox="0 0 24 24">
<path d="M4 8h4V4h12v16H8v-4H4V8zm12 6v2h2v-2h-2zm-4-3v5h2v-5h-2zm-4-3v8h2V8h-2z"/>
</svg>
捕获帧
</button>
<button id="autoCaptureBtn" class="btn btn-primary" disabled>
<svg class="icon" viewBox="0 0 24 24">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm.5-13H11v6l5.2 3.2.8-1.3-4.5-2.7V7z"/>
</svg>
自动捕获
</button>
<button id="stopAutoCaptureBtn" class="btn btn-danger" disabled>
<svg class="icon" viewBox="0 0 24 24">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zM8 8h8v8H8z"/>
</svg>
停止
</button>
<div class="input-group">
<input type="number" id="frameInterval" class="number-input" value="1" min="0.1" step="0.1">
<span class="input-label">秒/帧</span>
</div>
</div>
帧预览与导出区域
显示当前捕获帧的预览区域,用户可以查看具体画面;提供"下载当前帧"按钮,支持将当前预览帧保存为图片文件;展示已捕获帧的缩略图列表,方便浏览和管理。
html
<div class="panel">
<h2 class="panel-title">
<svg class="icon" viewBox="0 0 24 24">
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14z"/>
<path d="M8.5 15H10V9H7v1.5h1.5zM13.5 12.75L15.25 15H17l-2.25-3L17 9h-1.75l-1.75 2.25V9H12v6h1.5z"/>
</svg>
帧预览与导出
</h2>
<div class="preview-container">
<div class="canvas-wrapper">
<canvas id="canvasElement"></canvas>
</div>
<button id="downloadBtn" class="btn btn-primary" disabled>
<svg class="icon" viewBox="0 0 24 24">
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
</svg>
下载当前帧
</button>
</div>
<h3 class="panel-title" style="margin-top: 20px; font-size: 16px;">
<svg class="icon" viewBox="0 0 24 24">
<path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z"/>
</svg>
已捕获的帧 (共<span id="frameCount">0</span>张)
</h3>
<div class="thumbnails-container" id="thumbnails">
<div class="empty-state" id="emptyState">
<svg class="icon" viewBox="0 0 24 24">
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>
</svg>
<p>尚未捕获任何帧</p>
</div>
</div>
</div>
核心功能实现
初始化与事件绑定
使用 FrameExtractor
类封装所有逻辑,构造函数中初始化 DOM 元素和状态变量:
js
this.elements = {
videoUpload: document.getElementById('videoUpload'),
uploadButton: document.getElementById('uploadButton'),
videoElement: document.getElementById('videoElement'),
canvasElement: document.getElementById('canvasElement'),
captureBtn: document.getElementById('captureBtn'),
autoCaptureBtn: document.getElementById('autoCaptureBtn'),
stopAutoCaptureBtn: document.getElementById('stopAutoCaptureBtn'),
downloadBtn: document.getElementById('downloadBtn'),
frameInterval: document.getElementById('frameInterval'),
thumbnailsContainer: document.getElementById('thumbnails'),
emptyState: document.getElementById('emptyState'),
frameCount: document.getElementById('frameCount'),
progressBar: document.getElementById('progressBar'),
progress: document.getElementById('progress')
};
js
this.state = {
autoCaptureInterval: null,
capturedFrames: [],
isAutoCapturing: false,
captureStartTime: 0
};
js
this.elements.videoUpload.addEventListener('change', (e) => this.handleVideoUpload(e));
this.elements.uploadButton.addEventListener('click', () => this.elements.videoUpload.click());
// 按钮事件
this.elements.captureBtn.addEventListener('click', () => this.captureFrame());
this.elements.autoCaptureBtn.addEventListener('click', () => this.toggleAutoCapture());
this.elements.stopAutoCaptureBtn.addEventListener('click', () => this.stopAutoCapture());
this.elements.downloadBtn.addEventListener('click', () => this.downloadCurrentFrame());
视频上传与播放
用户选择视频后,通过 URL.createObjectURL
创建临时链接加载视频。
js
handleVideoUpload(e) {
const file = e.target.files[0];
if (file) {
const videoURL = URL.createObjectURL(file);
this.elements.videoElement.src = videoURL;
// 启用按钮
this.elements.captureBtn.disabled = false;
this.elements.autoCaptureBtn.disabled = false;
// 重置状态
this.resetCaptureState();
}
}
手动帧捕获
将当前视频帧绘制到 canvas 上,供用户查看和下载。
js
captureFrame() {
if (this.elements.videoElement.readyState === 0) return;
const ctx = this.elements.canvasElement.getContext('2d');
// 设置canvas尺寸与视频帧相同
this.elements.canvasElement.width = this.elements.videoElement.videoWidth;
this.elements.canvasElement.height = this.elements.videoElement.videoHeight;
// 绘制视频帧到canvas
ctx.drawImage(this.elements.videoElement, 0, 0,
this.elements.canvasElement.width, this.elements.canvasElement.height);
// 启用下载按钮
this.elements.downloadBtn.disabled = false;
// 创建缩略图
this.createThumbnail(this.elements.canvasElement.toDataURL('image/jpeg', 0.8));
// 更新进度条(自动捕获时)
if (this.state.isAutoCapturing) {
const currentTime = this.elements.videoElement.currentTime;
const duration = this.elements.videoElement.duration;
const progress = (currentTime / duration) * 100;
this.elements.progress.style.width = `${progress}%`;
}
}
自动帧捕获
根据用户设置的时间间隔启动定时任务:
js
startAutoCapture() {
const interval = parseFloat(this.elements.frameInterval.value) * 1000;
if (interval > 0) {
this.state.isAutoCapturing = true;
this.state.captureStartTime = this.elements.videoElement.currentTime;
this.elements.stopAutoCaptureBtn.disabled = false;
this.elements.autoCaptureBtn.textContent = '暂停捕获';
this.elements.autoCaptureBtn.classList.add('btn-danger');
// 显示进度条
this.elements.progressBar.style.display = 'block';
this.elements.progress.style.width = '0%';
// 先捕获一帧
this.captureFrame();
// 设置定时器
this.state.autoCaptureInterval = setInterval(() => {
this.captureFrame();
// 检查是否到达视频末尾
if (this.elements.videoElement.currentTime >= this.elements.videoElement.duration - 0.1) {
this.stopAutoCapture();
}
}, interval);
}
}
缩略图与交互
每次捕获的帧都会生成缩略图,并添加下载和删除功能:
js
createThumbnail(dataURL) {
const thumbnailDiv = document.createElement('div');
thumbnailDiv.className = 'thumbnail';
// ...
downloadBtn.addEventListener('click', (e) => {
e.stopPropagation();
const link = document.createElement('a');
link.download = `frame_${frameId}.png`;
link.href = dataURL;
link.click();
});
// ...
this.elements.thumbnailsContainer.appendChild(thumbnailDiv);
}
技术亮点
- Canvas 操作:利用 HTML5 Canvas 实现图像捕获与动态渲染。
- 对象 URL :通过
URL.createObjectURL
高效加载本地视频资源。 - 响应式设计:使用 CSS Grid 和 Flexbox 构建灵活布局,适配不同屏幕尺寸。
- 模块化结构:将功能封装在类中,提高代码组织清晰度和可维护性。
- 性能优化:自动捕获时限制帧率,避免过度消耗资源。
扩展建议
-
支持多视频格式:目前仅支持
video/*
,未来可扩展支持更多格式如.mkv
、.avi
,并通过 FFmpeg WASM 解码。 -
添加帧过滤功能:允许用户对已捕获帧进行筛选,例如按时间范围、相似度去重等。
-
导出为 ZIP 文件:集成 JSZip 库,一键打包所有帧为 ZIP 文件,便于批量下载。
-
云端存储:集成云存储 API(如 Firebase 或阿里云 OSS),实现帧图片的持久化保存与分享。
-
AI 关键帧识别:引入机器学习模型(如 TensorFlow.js),自动识别视频中的关键帧进行智能提取。
完整代码
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>视频帧提取工具</title>
<style>
:root {
--primary-color: #4361ee;
--secondary-color: #3f37c9;
--accent-color: #4895ef;
--danger-color: #f72585;
--light-color: #f8f9fa;
--dark-color: #212529;
--border-radius: 8px;
--box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
--transition: all 0.3s ease;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
line-height: 1.6;
color: var(--dark-color);
background-color: #f5f7fa;
padding: 20px;
}
.app-container {
max-width: 1200px;
margin: 0 auto;
background-color: white;
border-radius: var(--border-radius);
box-shadow: var(--box-shadow);
overflow: hidden;
}
.app-header {
background-color: var(--primary-color);
color: white;
padding: 20px;
text-align: center;
}
.app-header h1 {
margin-bottom: 10px;
font-weight: 600;
}
.app-header p {
opacity: 0.9;
}
.main-content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
padding: 20px;
}
@media (max-width: 768px) {
.main-content {
grid-template-columns: 1fr;
}
}
.panel {
background-color: white;
border-radius: var(--border-radius);
box-shadow: var(--box-shadow);
padding: 20px;
display: flex;
flex-direction: column;
}
.panel-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 15px;
color: var(--secondary-color);
display: flex;
align-items: center;
gap: 10px;
padding-bottom: 10px;
border-bottom: 1px solid #eee;
}
.panel-title svg {
width: 20px;
height: 20px;
}
.video-container {
position: relative;
width: 100%;
background-color: black;
border-radius: var(--border-radius);
overflow: hidden;
margin-bottom: 15px;
aspect-ratio: 16/9;
}
video {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.file-input-wrapper {
margin-bottom: 15px;
}
.file-input-button {
width: 100%;
padding: 12px;
background-color: var(--primary-color);
color: white;
border: none;
border-radius: var(--border-radius);
cursor: pointer;
transition: var(--transition);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-weight: 500;
}
.file-input-button:hover {
background-color: var(--secondary-color);
}
.file-input {
display: none;
}
.controls {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 10px;
}
.btn {
padding: 10px;
border: none;
border-radius: var(--border-radius);
cursor: pointer;
transition: var(--transition);
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.btn-primary {
background-color: var(--primary-color);
color: white;
}
.btn-primary:hover {
background-color: var(--secondary-color);
}
.btn-danger {
background-color: var(--danger-color);
color: white;
}
.btn-danger:hover {
opacity: 0.9;
}
.btn:disabled {
background-color: #ddd;
color: #999;
cursor: not-allowed;
}
.input-group {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
}
.input-label {
font-size: 14px;
color: #555;
white-space: nowrap;
}
.number-input {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: var(--border-radius);
width: 100%;
text-align: center;
}
.preview-container {
flex-grow: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.canvas-wrapper {
flex-grow: 1;
display: flex;
align-items: center;
justify-content: center;
background-color: #f0f0f0;
border-radius: var(--border-radius);
overflow: hidden;
margin-bottom: 15px;
position: relative;
}
canvas {
max-width: 100%;
max-height: 100%;
display: block;
background-color: white;
}
.thumbnails-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 10px;
margin-top: 15px;
max-height: 300px;
overflow-y: auto;
padding: 5px;
}
.thumbnail {
position: relative;
border-radius: var(--border-radius);
overflow: hidden;
box-shadow: var(--box-shadow);
transition: var(--transition);
aspect-ratio: 16/9;
}
.thumbnail:hover {
transform: translateY(-3px);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15);
}
.thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.thumbnail-actions {
position: absolute;
top: 5px;
right: 5px;
display: flex;
gap: 5px;
opacity: 0;
transition: var(--transition);
}
.thumbnail:hover .thumbnail-actions {
opacity: 1;
}
.thumbnail-btn {
width: 28px;
height: 28px;
border-radius: 50%;
border: none;
background-color: rgba(0, 0, 0, 0.7);
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: var(--transition);
}
.thumbnail-btn:hover {
background-color: rgba(0, 0, 0, 0.9);
transform: scale(1.1);
}
.thumbnail-btn.download {
background-color: rgba(67, 97, 238, 0.7);
}
.thumbnail-btn.download:hover {
background-color: rgba(67, 97, 238, 0.9);
}
.thumbnail-btn.delete {
background-color: rgba(247, 37, 133, 0.7);
}
.thumbnail-btn.delete:hover {
background-color: rgba(247, 37, 133, 0.9);
}
.empty-state {
text-align: center;
padding: 20px;
color: #999;
grid-column: 1 / -1;
}
.empty-state svg {
width: 50px;
height: 50px;
margin-bottom: 10px;
opacity: 0.5;
}
.progress-bar {
width: 100%;
height: 6px;
background-color: #eee;
border-radius: 3px;
margin-top: 10px;
overflow: hidden;
display: none;
}
.progress {
height: 100%;
background-color: var(--primary-color);
width: 0%;
transition: width 0.3s ease;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: #ccc;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #aaa;
}
.icon {
width: 18px;
height: 18px;
vertical-align: middle;
fill: currentColor;
}
</style>
</head>
<body>
<div class="app-container">
<header class="app-header">
<h1>视频帧提取工具</h1>
<p>轻松从视频中提取关键帧并保存为图片</p>
</header>
<div class="main-content">
<div class="panel">
<h2 class="panel-title">
<svg class="icon" viewBox="0 0 24 24">
<path d="M18 3v2h-2V3H8v2H6V3H4v18h2v-2h2v2h8v-2h2v2h2V3h-2zM8 17H6v-2h2v2zm0-4H6v-2h2v2zm0-4H6V7h2v2zm6 10h-4V5h4v14zm4-2h-2v-2h2v2zm0-4h-2v-2h2v2zm0-4h-2V7h2v2z"/>
</svg>
视频控制
</h2>
<div class="file-input-wrapper">
<button class="file-input-button" id="uploadButton">
<svg class="icon" viewBox="0 0 24 24">
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
</svg>
选择视频文件
</button>
<input type="file" id="videoUpload" class="file-input" accept="video/*">
</div>
<div class="video-container">
<video id="videoElement" controls></video>
</div>
<div class="controls">
<button id="captureBtn" class="btn btn-primary" disabled>
<svg class="icon" viewBox="0 0 24 24">
<path d="M4 8h4V4h12v16H8v-4H4V8zm12 6v2h2v-2h-2zm-4-3v5h2v-5h-2zm-4-3v8h2V8h-2z"/>
</svg>
捕获帧
</button>
<button id="autoCaptureBtn" class="btn btn-primary" disabled>
<svg class="icon" viewBox="0 0 24 24">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm.5-13H11v6l5.2 3.2.8-1.3-4.5-2.7V7z"/>
</svg>
自动捕获
</button>
<button id="stopAutoCaptureBtn" class="btn btn-danger" disabled>
<svg class="icon" viewBox="0 0 24 24">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zM8 8h8v8H8z"/>
</svg>
停止
</button>
<div class="input-group">
<input type="number" id="frameInterval" class="number-input" value="1" min="0.1" step="0.1">
<span class="input-label">秒/帧</span>
</div>
</div>
<div class="progress-bar" id="progressBar">
<div class="progress" id="progress"></div>
</div>
</div>
<div class="panel">
<h2 class="panel-title">
<svg class="icon" viewBox="0 0 24 24">
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14z"/>
<path d="M8.5 15H10V9H7v1.5h1.5zM13.5 12.75L15.25 15H17l-2.25-3L17 9h-1.75l-1.75 2.25V9H12v6h1.5z"/>
</svg>
帧预览与导出
</h2>
<div class="preview-container">
<div class="canvas-wrapper">
<canvas id="canvasElement"></canvas>
</div>
<button id="downloadBtn" class="btn btn-primary" disabled>
<svg class="icon" viewBox="0 0 24 24">
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
</svg>
下载当前帧
</button>
</div>
<h3 class="panel-title" style="margin-top: 20px; font-size: 16px;">
<svg class="icon" viewBox="0 0 24 24">
<path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z"/>
</svg>
已捕获的帧 (共<span id="frameCount">0</span>张)
</h3>
<div class="thumbnails-container" id="thumbnails">
<div class="empty-state" id="emptyState">
<svg class="icon" viewBox="0 0 24 24">
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>
</svg>
<p>尚未捕获任何帧</p>
</div>
</div>
</div>
</div>
</div>
<script>
class FrameExtractor {
constructor() {
// 初始化DOM元素
this.elements = {
videoUpload: document.getElementById('videoUpload'),
uploadButton: document.getElementById('uploadButton'),
videoElement: document.getElementById('videoElement'),
canvasElement: document.getElementById('canvasElement'),
captureBtn: document.getElementById('captureBtn'),
autoCaptureBtn: document.getElementById('autoCaptureBtn'),
stopAutoCaptureBtn: document.getElementById('stopAutoCaptureBtn'),
downloadBtn: document.getElementById('downloadBtn'),
frameInterval: document.getElementById('frameInterval'),
thumbnailsContainer: document.getElementById('thumbnails'),
emptyState: document.getElementById('emptyState'),
frameCount: document.getElementById('frameCount'),
progressBar: document.getElementById('progressBar'),
progress: document.getElementById('progress')
};
// 状态变量
this.state = {
autoCaptureInterval: null,
capturedFrames: [],
isAutoCapturing: false,
captureStartTime: 0
};
// 初始化事件监听
this.initEventListeners();
}
initEventListeners() {
// 视频上传处理
this.elements.videoUpload.addEventListener('change', (e) => this.handleVideoUpload(e));
this.elements.uploadButton.addEventListener('click', () => this.elements.videoUpload.click());
// 按钮事件
this.elements.captureBtn.addEventListener('click', () => this.captureFrame());
this.elements.autoCaptureBtn.addEventListener('click', () => this.toggleAutoCapture());
this.elements.stopAutoCaptureBtn.addEventListener('click', () => this.stopAutoCapture());
this.elements.downloadBtn.addEventListener('click', () => this.downloadCurrentFrame());
}
handleVideoUpload(e) {
const file = e.target.files[0];
if (file) {
const videoURL = URL.createObjectURL(file);
this.elements.videoElement.src = videoURL;
// 启用按钮
this.elements.captureBtn.disabled = false;
this.elements.autoCaptureBtn.disabled = false;
// 重置状态
this.resetCaptureState();
// 监听视频元数据加载
// this.elements.videoElement.onloadedmetadata = () => {
// this.elements.videoElement.play().catch(e => console.log("自动播放被阻止:", e));
// };
}
}
captureFrame() {
if (this.elements.videoElement.readyState === 0) return;
const ctx = this.elements.canvasElement.getContext('2d');
// 设置canvas尺寸与视频帧相同
this.elements.canvasElement.width = this.elements.videoElement.videoWidth;
this.elements.canvasElement.height = this.elements.videoElement.videoHeight;
// 绘制视频帧到canvas
ctx.drawImage(this.elements.videoElement, 0, 0,
this.elements.canvasElement.width, this.elements.canvasElement.height);
// 启用下载按钮
this.elements.downloadBtn.disabled = false;
// 创建缩略图
this.createThumbnail(this.elements.canvasElement.toDataURL('image/jpeg', 0.8));
// 更新进度条(自动捕获时)
if (this.state.isAutoCapturing) {
const currentTime = this.elements.videoElement.currentTime;
const duration = this.elements.videoElement.duration;
const progress = (currentTime / duration) * 100;
this.elements.progress.style.width = `${progress}%`;
}
}
toggleAutoCapture() {
if (this.state.isAutoCapturing) {
this.stopAutoCapture();
} else {
this.startAutoCapture();
}
}
startAutoCapture() {
const interval = parseFloat(this.elements.frameInterval.value) * 1000;
if (interval > 0) {
this.state.isAutoCapturing = true;
this.state.captureStartTime = this.elements.videoElement.currentTime;
this.elements.stopAutoCaptureBtn.disabled = false;
this.elements.autoCaptureBtn.textContent = '暂停捕获';
this.elements.autoCaptureBtn.classList.add('btn-danger');
// 显示进度条
this.elements.progressBar.style.display = 'block';
this.elements.progress.style.width = '0%';
// 先捕获一帧
this.captureFrame();
// 设置定时器
this.state.autoCaptureInterval = setInterval(() => {
this.captureFrame();
// 检查是否到达视频末尾
if (this.elements.videoElement.currentTime >= this.elements.videoElement.duration - 0.1) {
this.stopAutoCapture();
}
}, interval);
}
}
stopAutoCapture() {
if (this.state.autoCaptureInterval) {
clearInterval(this.state.autoCaptureInterval);
this.state.autoCaptureInterval = null;
}
this.state.isAutoCapturing = false;
this.elements.stopAutoCaptureBtn.disabled = true;
this.elements.autoCaptureBtn.textContent = '自动捕获';
this.elements.autoCaptureBtn.classList.remove('btn-danger');
// 隐藏进度条
this.elements.progressBar.style.display = 'none';
}
downloadCurrentFrame() {
if (this.elements.canvasElement.width > 0) {
const link = document.createElement('a');
link.download = `frame_${new Date().getTime()}.png`;
link.href = this.elements.canvasElement.toDataURL('image/png');
link.click();
}
}
createThumbnail(dataURL) {
// 隐藏空状态
if (this.elements.emptyState) {
this.elements.emptyState.style.display = 'none';
}
const frameId = Date.now();
this.state.capturedFrames.push({id: frameId, dataURL});
// 更新帧计数
this.elements.frameCount.textContent = this.state.capturedFrames.length;
const thumbnailDiv = document.createElement('div');
thumbnailDiv.className = 'thumbnail';
thumbnailDiv.dataset.id = frameId;
const img = document.createElement('img');
img.src = dataURL;
img.alt = `Captured frame ${frameId}`;
const actionsDiv = document.createElement('div');
actionsDiv.className = 'thumbnail-actions';
const downloadBtn = document.createElement('button');
downloadBtn.className = 'thumbnail-btn download';
downloadBtn.title = '下载此帧';
downloadBtn.innerHTML = '<svg class="icon" viewBox="0 0 24 24"><path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/></svg>';
downloadBtn.addEventListener('click', (e) => {
e.stopPropagation();
const link = document.createElement('a');
link.download = `frame_${frameId}.png`;
link.href = dataURL;
link.click();
});
const deleteBtn = document.createElement('button');
deleteBtn.className = 'thumbnail-btn delete';
deleteBtn.title = '删除此帧';
deleteBtn.innerHTML = '<svg class="icon" viewBox="0 0 24 24"><path d="M19 4h-3.5l-1-1h-5l-1 1H5v2h14zM6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12z"/></svg>';
deleteBtn.addEventListener('click', (e) => {
e.stopPropagation();
thumbnailDiv.remove();
this.state.capturedFrames = this.state.capturedFrames.filter(frame => frame.id !== frameId);
this.elements.frameCount.textContent = this.state.capturedFrames.length;
// 如果没有帧了,显示空状态
if (this.state.capturedFrames.length === 0 && this.elements.emptyState) {
this.elements.emptyState.style.display = 'block';
}
});
// 点击缩略图预览大图
thumbnailDiv.addEventListener('click', () => {
this.elements.canvasElement.width = 0;
this.elements.canvasElement.height = 0;
const img = new Image();
img.onload = () => {
this.elements.canvasElement.width = img.width;
this.elements.canvasElement.height = img.height;
const ctx = this.elements.canvasElement.getContext('2d');
ctx.drawImage(img, 0, 0);
this.elements.downloadBtn.disabled = false;
};
img.src = dataURL;
});
actionsDiv.appendChild(downloadBtn);
actionsDiv.appendChild(deleteBtn);
thumbnailDiv.appendChild(img);
thumbnailDiv.appendChild(actionsDiv);
this.elements.thumbnailsContainer.appendChild(thumbnailDiv);
// 滚动到底部
this.elements.thumbnailsContainer.scrollTop = this.elements.thumbnailsContainer.scrollHeight;
}
resetCaptureState() {
// 停止自动捕获
this.stopAutoCapture();
// 清除画布
const ctx = this.elements.canvasElement.getContext('2d');
ctx.clearRect(0, 0, this.elements.canvasElement.width, this.elements.canvasElement.height);
this.elements.canvasElement.width = 0;
this.elements.canvasElement.height = 0;
// 禁用下载按钮
this.elements.downloadBtn.disabled = true;
// 清除所有缩略图
this.elements.thumbnailsContainer.innerHTML = '';
this.state.capturedFrames = [];
this.elements.frameCount.textContent = '0';
// 显示空状态
if (this.elements.emptyState) {
this.elements.emptyState.style.display = 'block';
}
// 隐藏进度条
this.elements.progressBar.style.display = 'none';
}
}
// 初始化应用
document.addEventListener('DOMContentLoaded', () => {
new FrameExtractor();
});
</script>
</body>
</html>