使用 HTML +JavaScript 从零构建视频帧提取器

在视频编辑、内容分析和多媒体处理领域,常常需要从视频中提取关键帧。手动截取不仅效率低下,还容易遗漏重要画面。本文介绍的视频帧提取工具通过 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>
相关推荐
_r0bin_14 分钟前
前端面试准备-7
开发语言·前端·javascript·fetch·跨域·class
IT瘾君15 分钟前
JavaWeb:前端工程化-Vue
前端·javascript·vue.js
zhang988000015 分钟前
JavaScript 核心原理深度解析-不停留于表面的VUE等的使用!
开发语言·javascript·vue.js
站在风口的猪11081 小时前
《前端面试题:CSS预处理器(Sass、Less等)》
前端·css·html·less·css3·sass·html5
Hygge-star2 小时前
【Flask】:轻量级Python Web框架详解
css·flask·html·学习方法·web app
拉不动的猪4 小时前
都25年啦,还有谁分不清双向绑定原理,响应式原理、v-model实现原理
前端·javascript·vue.js
狂炫一碗大米饭4 小时前
一文打通TypeScript 泛型
前端·javascript·typescript
武子康4 小时前
AI炼丹日志-28 - Audiblez 将你的电子书epub转换为音频mp3 做有声书
人工智能·爬虫·gpt·算法·机器学习·ai·音视频
二十雨辰5 小时前
[HTML5]快速掌握canvas
前端·html
棉花糖超人6 小时前
【从0-1的HTML】第2篇:HTML标签
前端·html