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

View File

@@ -17,54 +17,67 @@
- **Framework**: Next.js 15 with App Router, React 19
- **State**: Zustand for client state
- **Styling**: Tailwind CSS with HSL CSS variables (dark mode by default)
- **UI Components**: Custom components built on Radix UI primitives
- **UI Components**: Custom components built on Radix UI primitives + class-variance-authority
- **Animations**: Framer Motion
- **Form Validation**: Zod
- **Media Processing**: Sharp (images), FFmpeg (video/audio)
- **Internationalization**: i18n store + server helpers (cookie/Accept-Language detection)
### Directory Structure
```
src/
├── app/ # Next.js App Router
│ ├── (auth)/ # Auth routes group (login, register)
│ ├── (dashboard)/ # Dashboard routes with Sidebar layout
│ │ ── tools/ # Tool pages (image-compress, video-frames, audio-compress)
│ └── layout.tsx # Dashboard layout with Sidebar
│ ├── api/ # API routes
│ │ ── upload/ # File upload endpoint
│ └── process/ # Processing endpoints per tool type
── globals.css # Global styles with CSS variables
│ └── layout.tsx # Root layout (Header + Footer)
├── app/ # Next.js App Router
│ ├── (auth)/ # Auth routes group
│ ├── (dashboard)/ # Dashboard routes with Sidebar layout
│ │ ── tools/ # Tool pages (image/audio/video/texture-atlas)
├── api/ # API routes
│ ├── upload/ # File upload endpoint
│ │ ── process/ # Processing endpoints per tool type
├── globals.css # Global styles with CSS variables
── layout.tsx # Root layout (Header + Footer + SEO)
├── components/
│ ├── ui/ # Base UI primitives (button, card, input, etc.)
│ ├── tools/ # Tool-specific components (FileUploader, ConfigPanel, ProgressBar, ResultPreview)
── layout/ # Layout components (Header, Footer, Sidebar)
├── lib/
── api.ts # API client functions
│ └── utils.ts # Utility functions (cn, formatFileSize, etc.)
├── store/
│ ├── authStore.ts # Auth state
│ └── uploadStore.ts # File upload and processing state
└── types/
└── index.ts # TypeScript types (UploadedFile, ProcessedFile, configs, etc.)
│ ├── layout/ # Header, Footer, Sidebar, LanguageSwitcher
│ ├── seo/ # StructuredData
── tools/ # Tool components (FileUploader, ConfigPanel, ResultPreview...)
│ │ └── atlas/ # Texture-atlas specific panels
── ui/ # Base UI primitives (button, card, dialog, etc.)
├── hooks/ # Custom hooks (e.g., atlas worker bridge)
├── lib/ # API clients, i18n, atlas algorithms, image processing
├── locales/ # i18n resources
├── store/ # Zustand stores (upload/atlas/auth)
└── types/ # Shared TypeScript types
```
### Key Patterns
**Route Groups**: Uses `(auth)` and `(dashboard)` route groups. Dashboard routes share a layout with `Sidebar` component.
**Route Groups**: Uses `(auth)` and `(dashboard)` route groups. Dashboard routes share a layout with `Sidebar`.
**Tool Pages Pattern**: Each tool (image-compress, video-frames, audio-compress) follows the same pattern:
1. Uses `FileUploader` for drag-drop file input
2. Uses `ConfigPanel` for tool-specific configuration options
3. Uses `ProgressBar` to show processing status
4. Uses `ResultPreview` to display processed files
5. State managed via `useUploadStore` Zustand store
**Tool Pages Pattern**: Tools (audio-compress, image-compress, video-frames) share a common UI flow:
1. `FileUploader` for drag-drop input
2. `ConfigPanel` for tool-specific settings
3. `ProgressBar` for processing status
4. `ResultPreview` for outputs
5. State managed via `useUploadStore`
**API Routes**: API routes under `app/api/` use Node.js runtime. Each processing endpoint validates input and returns JSON responses. Currently mock implementations - production would use Sharp/FFmpeg and cloud storage.
**Texture Atlas Tool**: `/tools/texture-atlas` uses a three-panel layout (file list, canvas preview, config panel) with Web Worker processing for packing and preview.
**State Management**: Zustand stores in `store/` directory. `uploadStore` manages file list, processing status and progress. `authStore` manages user authentication state.
**API Routes**: `app/api/` routes run on Node.js. Upload and processing endpoints validate input and return JSON. Production processing is intended to use Sharp/FFmpeg.
**Styling**: Uses `cn()` utility from `lib/utils.ts` for Tailwind class merging. Theme colors defined as CSS variables in `globals.css`. Component styling uses HSL color functions like `hsl(var(--primary))`.
**State Management**: Zustand stores in `store/` directory.
- `uploadStore`: file queue and processing progress
- `atlasStore`: texture-atlas state (sprites, configs, results, preview)
- `authStore`: user state placeholder
**Type Definitions**: All shared types in `types/index.ts`. Includes file types, processing configs, API responses, and user types.
**Internationalization**: `useI18nStore` + `useSafeTranslation` to avoid SSR/CSR hydration mismatch; server uses cookie/Accept-Language to set locale.
**Styling**: Uses `cn()` from `lib/utils.ts` for Tailwind class merging. Theme colors are CSS variables in `globals.css`.
**Type Definitions**: Shared types live in `types/index.ts` (uploads, processing configs, atlas models, API responses).
### Service & Processing Logic
- `lib/api.ts`: API client wrappers (upload/process/download/quota)
- `lib/atlas-packer.ts` + `lib/atlas-worker.ts`: browser-side packing algorithms
- `hooks/useAtlasWorker.ts`: Web Worker bridge for atlas packing
- `lib/image-processor.ts`: Sharp-based image compression pipeline
- `lib/file-storage.ts`: temp storage, validation, cleanup, downloads

View File

@@ -1,260 +1,596 @@
"use client";
'use client';
import { useState, useCallback, useEffect } from "react";
import { motion } from "framer-motion";
import { Video, Settings } from "lucide-react";
import { FileUploader } from "@/components/tools/FileUploader";
import { ProgressBar } from "@/components/tools/ProgressBar";
import { ResultPreview } from "@/components/tools/ResultPreview";
import { ConfigPanel, type ConfigOption } from "@/components/tools/ConfigPanel";
import { Button } from "@/components/ui/button";
import { useUploadStore } from "@/store/uploadStore";
import { generateId } from "@/lib/utils";
import { useTranslation, getServerTranslations } from "@/lib/i18n";
import type { UploadedFile, ProcessedFile, VideoFramesConfig } from "@/types";
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
import { useDropzone } from 'react-dropzone';
import JSZip from 'jszip';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { Badge } from '@/components/ui/badge';
import {
Film,
Upload,
Download,
Loader2,
CheckCircle2,
Play,
Pause,
Settings2,
Trash2,
CheckSquare,
Square,
ImageIcon,
Clock,
Maximize2,
FileVideo,
Eye,
} from 'lucide-react';
import { FramePreviewPlayer } from '@/components/frame-preview-player';
import {
ExtractedFrame,
ExtractionSettings,
VideoMetadata,
validateVideo,
getVideoMetadata,
extractFrames,
calculateEstimatedFrames,
formatFileSize,
formatDuration,
SUPPORTED_VIDEO_FORMATS,
MAX_DURATION,
MAX_FILE_SIZE,
MIN_FRAME_RATE,
MAX_FRAME_RATE,
} from '@/lib/frame-extractor';
const videoAccept = {
"video/*": [".mp4", ".mov", ".avi", ".webm", ".mkv"],
};
type ExtractionStatus = 'idle' | 'uploading' | 'extracting' | 'completed' | 'error';
const defaultConfig: VideoFramesConfig = {
fps: 30,
format: "png",
quality: 90,
width: undefined,
height: undefined,
};
export default function VideoToFramesPage() {
// 视频相关状态
const [videoFile, setVideoFile] = useState<File | null>(null);
const [videoUrl, setVideoUrl] = useState<string>('');
const [metadata, setMetadata] = useState<VideoMetadata | null>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
function useConfigOptions(config: VideoFramesConfig, getT: (key: string) => string): ConfigOption[] {
return [
{
id: "fps",
type: "slider",
label: getT("config.videoFrames.fps"),
description: getT("config.videoFrames.fpsDescription"),
value: config.fps,
min: 1,
max: 60,
step: 1,
suffix: " fps",
icon: <Video className="h-4 w-4" />,
// 设置相关状态
const [frameRate, setFrameRate] = useState<number>(15);
const [format, setFormat] = useState<'image/png' | 'image/jpeg'>('image/png');
const [jpegQuality, setJpegQuality] = useState<number>(0.92);
// 提取状态
const [status, setStatus] = useState<ExtractionStatus>('idle');
const [extractProgress, setExtractProgress] = useState<number>(0);
const [error, setError] = useState<string>('');
// 提取结果
const [frames, setFrames] = useState<ExtractedFrame[]>([]);
// 下载状态
const [isDownloading, setIsDownloading] = useState(false);
// 预览状态
const [showPreview, setShowPreview] = useState(false);
// 派生状态
const estimatedFrames = useMemo(() => {
if (!metadata) return 0;
return calculateEstimatedFrames(metadata.duration, frameRate);
}, [metadata, frameRate]);
const selectedCount = useMemo(() => {
return frames.filter((f) => f.selected).length;
}, [frames]);
useEffect(() => {
return () => {
if (videoUrl) {
URL.revokeObjectURL(videoUrl);
}
};
}, [videoUrl]);
// 处理视频上传
const handleVideoUpload = useCallback(
async (file: File) => {
setError('');
setStatus('uploading');
setFrames([]);
// 验证视频
const validation = await validateVideo(file);
if (!validation.valid) {
setError(validation.error || '视频验证失败');
setStatus('error');
return;
}
// 获取元数据
try {
const meta = await getVideoMetadata(file);
setMetadata(meta);
setVideoFile(file);
// 释放之前的 URL
if (videoUrl) {
URL.revokeObjectURL(videoUrl);
}
const url = URL.createObjectURL(file);
setVideoUrl(url);
setStatus('idle');
} catch {
setError('无法解析视频文件');
setStatus('error');
}
},
{
id: "format",
type: "select",
label: getT("config.videoFrames.format"),
description: getT("config.videoFrames.formatDescription"),
value: config.format,
options: [
{ label: "PNG", value: "png" },
{ label: "JPEG", value: "jpeg" },
{ label: "WebP", value: "webp" },
],
},
{
id: "quality",
type: "slider",
label: getT("config.videoFrames.quality"),
description: getT("config.videoFrames.qualityDescription"),
value: config.quality,
min: 1,
max: 100,
step: 1,
suffix: "%",
},
];
}
export default function VideoFramesPage() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const { t } = useTranslation();
const getT = (key: string, params?: Record<string, string | number>) => {
if (!mounted) return getServerTranslations("en").t(key, params);
return t(key, params);
};
const { files, addFile, removeFile, clearFiles, processingStatus, setProcessingStatus } =
useUploadStore();
const [config, setConfig] = useState<VideoFramesConfig>(defaultConfig);
const [processedFiles, setProcessedFiles] = useState<ProcessedFile[]>([]);
const handleFilesDrop = useCallback(
(acceptedFiles: File[]) => {
const newFiles: UploadedFile[] = acceptedFiles.map((file) => ({
id: generateId(),
file,
name: file.name,
size: file.size,
type: file.type,
uploadedAt: new Date(),
}));
newFiles.forEach((file) => addFile(file));
},
[addFile]
[videoUrl]
);
const handleConfigChange = (id: string, value: string | number | boolean | undefined) => {
setConfig((prev) => ({ ...prev, [id]: value }));
};
const handleResetConfig = () => {
setConfig(defaultConfig);
};
const handleProcess = async () => {
if (files.length === 0) return;
setProcessingStatus({
status: "uploading",
progress: 0,
message: getT("processing.uploadingVideo"),
});
try {
// Simulate upload
for (let i = 0; i <= 100; i += 10) {
await new Promise((resolve) => setTimeout(resolve, 100));
setProcessingStatus({
status: "uploading",
progress: i,
message: getT("processing.uploadProgress", { progress: i }),
});
// 拖拽上传
const onDrop = useCallback(
(acceptedFiles: File[]) => {
if (acceptedFiles.length > 0) {
handleVideoUpload(acceptedFiles[0]);
}
},
[handleVideoUpload]
);
setProcessingStatus({
status: "processing",
progress: 0,
message: getT("processing.extractingFrames"),
});
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: SUPPORTED_VIDEO_FORMATS,
maxFiles: 1,
multiple: false,
});
// Simulate processing
for (let i = 0; i <= 100; i += 5) {
await new Promise((resolve) => setTimeout(resolve, 150));
setProcessingStatus({
status: "processing",
progress: i,
message: getT("processing.processProgress", { progress: i }),
});
// 视频播放控制
const togglePlay = () => {
if (videoRef.current) {
if (isPlaying) {
videoRef.current.pause();
} else {
videoRef.current.play();
}
// Simulate completion
const results: ProcessedFile[] = files.map((file) => ({
id: generateId(),
originalFile: file,
processedUrl: "#",
metadata: {
format: config.format,
quality: config.quality,
fps: config.fps,
frames: Math.floor(10 * config.fps), // Simulated
},
createdAt: new Date(),
}));
setProcessedFiles(results);
clearFiles();
setProcessingStatus({
status: "completed",
progress: 100,
message: getT("processing.processingComplete"),
});
} catch (error) {
setProcessingStatus({
status: "failed",
progress: 0,
message: getT("processing.processingFailed"),
error: error instanceof Error ? error.message : getT("processing.unknownError"),
});
setIsPlaying(!isPlaying);
}
};
const handleDownload = (fileId: string) => {
console.log("Downloading file:", fileId);
// 开始提取帧
const handleExtract = async () => {
if (!videoFile) return;
setStatus('extracting');
setExtractProgress(0);
setFrames([]);
const settings: ExtractionSettings = {
frameRate,
format,
quality: jpegQuality,
};
try {
const extractedFrames = await extractFrames(
videoFile,
settings,
(progress) => setExtractProgress(progress),
(frame) => setFrames((prev) => [...prev, frame])
);
setFrames(extractedFrames);
setStatus('completed');
} catch (e) {
setError(e instanceof Error ? e.message : '提取帧失败');
setStatus('error');
}
};
const canProcess = files.length > 0 && processingStatus.status !== "processing";
const configOptions = useConfigOptions(config, getT);
// 切换帧选中状态
const toggleFrameSelection = (frameId: string) => {
setFrames((prev) => prev.map((f) => (f.id === frameId ? { ...f, selected: !f.selected } : f)));
};
// 全选/取消全选
const selectAll = () => {
setFrames((prev) => prev.map((f) => ({ ...f, selected: true })));
};
const deselectAll = () => {
setFrames((prev) => prev.map((f) => ({ ...f, selected: false })));
};
// 下载选中的帧
const downloadSelectedFrames = async () => {
const selectedFrames = frames.filter((f) => f.selected);
if (selectedFrames.length === 0) return;
setIsDownloading(true);
try {
const zip = new JSZip();
const ext = format === 'image/png' ? 'png' : 'jpg';
for (const frame of selectedFrames) {
// 将 dataUrl 转为 Blob
const response = await fetch(frame.dataUrl);
const blob = await response.blob();
// 添加到 ZIP文件名格式: frame_001.png
const fileName = `frame_${String(frame.index + 1).padStart(3, '0')}.${ext}`;
zip.file(fileName, blob);
}
// 生成 ZIP 并下载
const content = await zip.generateAsync({ type: 'blob' });
const url = URL.createObjectURL(content);
const link = document.createElement('a');
link.href = url;
link.download = `frames_${Date.now()}.zip`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (e) {
console.error('打包下载失败', e);
setError('打包下载失败,请重试');
} finally {
setIsDownloading(false);
}
};
// 重置
const handleReset = () => {
if (videoUrl) {
URL.revokeObjectURL(videoUrl);
}
setVideoFile(null);
setVideoUrl('');
setMetadata(null);
setFrames([]);
setStatus('idle');
setExtractProgress(0);
setError('');
setIsPlaying(false);
setShowPreview(false);
};
return (
<div className="p-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
<Video className="h-6 w-6 text-primary" />
</div>
<div>
<h1 className="text-3xl font-bold">{getT("tools.videoFrames.title")}</h1>
<p className="text-muted-foreground">
{getT("tools.videoFrames.description")}
</p>
</div>
</div>
<div className="flex flex-1 flex-col gap-6 p-6 max-w-7xl mx-auto w-full">
{/* 页面标题 */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="space-y-1">
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-3">
<Film className="w-8 h-8 text-emerald-500" />
</h1>
<p className="text-muted-foreground">
</p>
</div>
<div className="flex items-center gap-3">
{/* 格式选择 */}
<div className="flex items-center gap-2 bg-muted/50 rounded-lg p-1">
<Button
variant={format === 'image/png' ? 'default' : 'ghost'}
size="sm"
onClick={() => setFormat('image/png')}
className={format === 'image/png' ? 'bg-emerald-600 hover:bg-emerald-700' : ''}
>
PNG
</Button>
<Button
variant={format === 'image/jpeg' ? 'default' : 'ghost'}
size="sm"
onClick={() => setFormat('image/jpeg')}
className={format === 'image/jpeg' ? 'bg-emerald-600 hover:bg-emerald-700' : ''}
>
JPG
</Button>
</div>
{videoFile && (
<Button
variant="ghost"
size="sm"
onClick={handleReset}
className="text-red-500 hover:text-red-600 hover:bg-red-50"
>
<Trash2 className="w-4 h-4 mr-2" />
</Button>
)}
</div>
</div>
<div className="grid gap-6 lg:grid-cols-2">
{/* Left Column - Upload and Config */}
<div className="space-y-6">
<FileUploader
files={files}
onFilesDrop={handleFilesDrop}
onRemoveFile={removeFile}
accept={videoAccept}
maxSize={500 * 1024 * 1024} // 500MB
maxFiles={1}
disabled={processingStatus.status === "processing"}
/>
<div className="h-px bg-border" />
<ConfigPanel
title={getT("config.videoFrames.title")}
description={getT("config.videoFrames.description")}
options={configOptions.map((opt) => ({
...opt,
value: config[opt.id as keyof VideoFramesConfig],
}))}
onChange={handleConfigChange}
onReset={handleResetConfig}
/>
{/* 上传区域 - 未上传视频时显示 */}
{!videoFile && (
<div
{...getRootProps()}
className={`
border-2 border-dashed rounded-xl p-12 flex flex-col items-center justify-center transition-all cursor-pointer
${isDragActive
? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-950/20'
: 'border-muted-foreground/25 hover:border-emerald-400 hover:bg-emerald-50/50 dark:hover:bg-emerald-950/10'}
`}
>
<input {...getInputProps()} />
<div
className={`p-4 rounded-full mb-4 transition-colors ${
isDragActive ? 'bg-emerald-100 dark:bg-emerald-900/30' : 'bg-emerald-100/50 dark:bg-emerald-900/20'
}`}
>
<Upload className={`w-8 h-8 ${isDragActive ? 'text-emerald-600' : 'text-emerald-500'}`} />
</div>
<p className="text-xl font-medium"></p>
<p className="text-sm text-muted-foreground mt-2">
MP4, WebM, MOV {MAX_FILE_SIZE / 1024 / 1024}MB{MAX_DURATION}
</p>
</div>
)}
{canProcess && (
<Button onClick={handleProcess} size="lg" className="w-full">
<Settings className="mr-2 h-4 w-4" />
{getT("tools.videoFrames.processVideo")}
{/* 错误提示 */}
{error && (
<div className="bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800 rounded-lg p-4 text-red-600 dark:text-red-400">
{error}
</div>
)}
{/* 视频预览和设置区域 */}
{videoFile && metadata && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* 视频预览 */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<FileVideo className="w-5 h-5 text-emerald-500" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="relative rounded-lg overflow-hidden bg-black aspect-video">
<video
ref={videoRef}
src={videoUrl}
className="w-full h-full object-contain"
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
onEnded={() => setIsPlaying(false)}
/>
<div className="absolute inset-0 flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity bg-black/30">
<Button
variant="ghost"
size="icon"
className="w-16 h-16 rounded-full bg-white/20 hover:bg-white/30 text-white"
onClick={togglePlay}
>
{isPlaying ? <Pause className="w-8 h-8" /> : <Play className="w-8 h-8" />}
</Button>
</div>
</div>
{/* 视频信息 */}
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="flex items-center gap-2 text-muted-foreground">
<Clock className="w-4 h-4" />
<span>{formatDuration(metadata.duration)}</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Maximize2 className="w-4 h-4" />
<span>
{metadata.width} × {metadata.height}
</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<FileVideo className="w-4 h-4" />
<span>{formatFileSize(metadata.size)}</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<ImageIcon className="w-4 h-4" />
<span>{metadata.name}</span>
</div>
</div>
</CardContent>
</Card>
{/* 帧率设置 */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Settings2 className="w-5 h-5 text-emerald-500" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* 帧率滑块 */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<label className="text-sm font-medium"></label>
<span className="text-2xl font-bold text-emerald-600">
{frameRate} <span className="text-sm font-normal text-muted-foreground">/</span>
</span>
</div>
<input
type="range"
min={MIN_FRAME_RATE}
max={MAX_FRAME_RATE}
value={frameRate}
onChange={(e) => setFrameRate(Number(e.target.value))}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-emerald-600"
disabled={status === 'extracting'}
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{MIN_FRAME_RATE} fps</span>
<span>{MAX_FRAME_RATE} fps</span>
</div>
</div>
{/* 预计帧数 */}
<div className="bg-emerald-50 dark:bg-emerald-950/20 rounded-lg p-4 space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground"></span>
<Badge
variant="secondary"
className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300 text-lg px-3 py-1"
>
{estimatedFrames}
</Badge>
</div>
<p className="text-xs text-muted-foreground">
{formatDuration(metadata.duration)} × {frameRate} fps = {estimatedFrames}
</p>
</div>
{/* JPEG 质量设置(仅 JPG 格式时显示) */}
{format === 'image/jpeg' && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">JPG </label>
<span className="text-sm font-medium text-emerald-600">
{Math.round(jpegQuality * 100)}%
</span>
</div>
<input
type="range"
min={50}
max={100}
value={jpegQuality * 100}
onChange={(e) => setJpegQuality(Number(e.target.value) / 100)}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-emerald-600"
disabled={status === 'extracting'}
/>
</div>
)}
{/* 开始提取按钮 */}
<Button
onClick={handleExtract}
disabled={status === 'extracting'}
className="w-full bg-emerald-600 hover:bg-emerald-700 text-white h-12 text-lg"
>
{status === 'extracting' ? (
<>
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
... {extractProgress}%
</>
) : (
<>
<Film className="w-5 h-5 mr-2" />
</>
)}
</Button>
)}
</div>
{/* Right Column - Progress and Results */}
<div className="space-y-6">
{processingStatus.status !== "idle" && (
<ProgressBar progress={processingStatus} />
)}
{processedFiles.length > 0 && (
<ResultPreview results={processedFiles} onDownload={handleDownload} />
)}
{/* Info Card */}
<div className="rounded-lg border border-border/40 bg-card/50 p-6">
<h3 className="mb-3 font-semibold">{getT("tools.videoFrames.howItWorks")}</h3>
<ol className="space-y-2 text-sm text-muted-foreground">
{(getT("tools.videoFrames.steps") as unknown as string[]).map((step, index) => (
<li key={index}>{index + 1}. {step}</li>
))}
</ol>
</div>
</div>
{/* 提取进度条 */}
{status === 'extracting' && (
<Progress value={extractProgress} className="h-2" />
)}
</CardContent>
</Card>
</div>
</motion.div>
)}
{/* 提取结果展示 */}
{frames.length > 0 && (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-3">
<CardTitle className="flex items-center gap-2">
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
{frames.length}
{status === 'completed' && (
<Badge variant="secondary" className="ml-2 bg-emerald-100 text-emerald-700">
</Badge>
)}
</CardTitle>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={selectAll}>
<CheckSquare className="w-4 h-4 mr-1" />
</Button>
<Button variant="outline" size="sm" onClick={deselectAll}>
<Square className="w-4 h-4 mr-1" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowPreview(true)}
className="text-emerald-600 border-emerald-300 hover:bg-emerald-50"
>
<Eye className="w-4 h-4 mr-1" />
</Button>
<Button
onClick={downloadSelectedFrames}
disabled={selectedCount === 0 || isDownloading}
className="bg-emerald-600 hover:bg-emerald-700"
>
{isDownloading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
<>
<Download className="w-4 h-4 mr-2" />
({selectedCount})
</>
)}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* 序列帧预览播放器 */}
{showPreview && <FramePreviewPlayer frames={frames} onClose={() => setShowPreview(false)} />}
{/* 帧网格 */}
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10 gap-2">
{frames.map((frame) => (
<div
key={frame.id}
onClick={() => toggleFrameSelection(frame.id)}
className={`
relative aspect-video rounded-lg overflow-hidden cursor-pointer transition-all
${frame.selected ? 'ring-2 ring-emerald-500 ring-offset-2' : 'opacity-50 hover:opacity-75'}
`}
>
<img
src={frame.dataUrl}
alt={`Frame ${frame.index + 1}`}
className="w-full h-full object-cover"
/>
{/* 帧序号 */}
<div className="absolute bottom-0 left-0 right-0 bg-black/60 text-white text-xs px-1 py-0.5 text-center">
{frame.index + 1}
</div>
{/* 选中指示器 */}
<div
className={`
absolute top-1 right-1 w-5 h-5 rounded-full flex items-center justify-center transition-colors
${frame.selected ? 'bg-emerald-500 text-white' : 'bg-white/80 text-gray-400'}
`}
>
{frame.selected ? <CheckSquare className="w-3 h-3" /> : <Square className="w-3 h-3" />}
</div>
</div>
))}
</div>
{/* 底部统计 */}
<div className="mt-4 pt-4 border-t flex items-center justify-between text-sm text-muted-foreground">
<span>
{selectedCount} / {frames.length}
</span>
<span>
:{' '}
{format === 'image/png' ? 'PNG无损' : `JPG${Math.round(jpegQuality * 100)}% 质量)`}
</span>
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -4,31 +4,31 @@
@layer base {
:root {
--background: 222.2 84% 4.9%;
--background: 222 47% 6%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card: 222 41% 9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover: 222 41% 9%;
--popover-foreground: 210 40% 98%;
--primary: 262.1 83.3% 57.8%;
--primary: 164 84% 42%;
--primary-foreground: 210 40% 98%;
--secondary: 217.2 32.6% 17.5%;
--secondary: 217 24% 16%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--muted: 217 24% 16%;
--muted-foreground: 215 20% 68%;
--accent: 189 85% 40%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive: 0 62% 35%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 262.1 83.3% 57.8%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
--radius: 0.5rem;
--border: 217 22% 20%;
--input: 217 22% 20%;
--ring: 164 84% 42%;
--chart-1: 164 84% 42%;
--chart-2: 189 85% 40%;
--chart-3: 210 90% 56%;
--chart-4: 280 70% 60%;
--chart-5: 20 85% 55%;
--radius: 0.6rem;
}
}
@@ -56,7 +56,7 @@
/* Gradient text */
.gradient-text {
@apply bg-gradient-to-r from-purple-400 via-pink-500 to-blue-500 bg-clip-text text-transparent;
@apply bg-gradient-to-r from-emerald-400 via-teal-400 to-sky-500 bg-clip-text text-transparent;
}
/* Custom scrollbar */

View File

@@ -1,7 +1,7 @@
"use client";
import Link from "next/link";
import { motion, useReducedMotion } from "framer-motion";
import { motion, useMotionValue, useReducedMotion } from "framer-motion";
import {
ArrowRight,
ChevronDown,
@@ -13,7 +13,7 @@ import {
Video,
Zap,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { cn } from "@/lib/utils";
@@ -81,6 +81,73 @@ function BackgroundAuras({ reduceMotion }: { reduceMotion: boolean }) {
);
}
function MouseFollowGlow({
containerRef,
reduceMotion,
}: {
containerRef: React.RefObject<HTMLDivElement | null>;
reduceMotion: boolean;
}) {
const size = 240;
const x = useMotionValue(0);
const y = useMotionValue(0);
useEffect(() => {
const container = containerRef.current;
if (!container || reduceMotion) return;
let rafId: number | null = null;
const centerGlow = () => {
const rect = container.getBoundingClientRect();
x.set(rect.width / 2 - size / 2);
y.set(Math.min(rect.height * 0.3, rect.height / 2) - size / 2);
};
const handleMove = (event: PointerEvent) => {
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
const rect = container.getBoundingClientRect();
const nextX = event.clientX - rect.left - size / 2;
const nextY = event.clientY - rect.top - size / 2;
x.set(nextX);
y.set(nextY);
});
};
const handleLeave = () => {
centerGlow();
};
centerGlow();
container.addEventListener("pointermove", handleMove, { passive: true });
container.addEventListener("pointerleave", handleLeave, { passive: true });
window.addEventListener("resize", centerGlow);
return () => {
if (rafId) cancelAnimationFrame(rafId);
container.removeEventListener("pointermove", handleMove);
container.removeEventListener("pointerleave", handleLeave);
window.removeEventListener("resize", centerGlow);
};
}, [containerRef, x, y, reduceMotion]);
if (reduceMotion) return null;
return (
<div className="pointer-events-none absolute inset-0 -z-10 overflow-hidden">
<motion.div
aria-hidden="true"
style={{ x, y }}
className="absolute left-0 top-0 h-[240px] w-[240px]"
>
<div className="absolute inset-0 rounded-full bg-white/8 backdrop-blur-2xl border border-white/10" />
<div className="absolute -inset-10 rounded-full bg-[radial-gradient(circle_at_35%_35%,rgba(255,255,255,0.28),rgba(20,184,166,0.12),transparent_70%)] blur-2xl" />
</motion.div>
</div>
);
}
function Hero({ t, reduceMotion }: { t: TFn; reduceMotion: boolean }) {
return (
<section className="relative overflow-hidden">
@@ -466,14 +533,18 @@ function FinalCTA({ t, reduceMotion }: { t: TFn; reduceMotion: boolean }) {
export default function HomePage() {
const t = useStableT();
const reduceMotion = useReducedMotion() ?? false;
const containerRef = useRef<HTMLDivElement>(null);
return (
<div className="relative">
<Hero t={t} reduceMotion={reduceMotion} />
<ToolsShowcase t={t} reduceMotion={reduceMotion} />
<Workflow t={t} reduceMotion={reduceMotion} />
<Quality t={t} reduceMotion={reduceMotion} />
<FinalCTA t={t} reduceMotion={reduceMotion} />
<div ref={containerRef} className="relative">
<MouseFollowGlow containerRef={containerRef} reduceMotion={reduceMotion} />
<div className="relative z-10">
<Hero t={t} reduceMotion={reduceMotion} />
<ToolsShowcase t={t} reduceMotion={reduceMotion} />
<Workflow t={t} reduceMotion={reduceMotion} />
<Quality t={t} reduceMotion={reduceMotion} />
<FinalCTA t={t} reduceMotion={reduceMotion} />
</div>
</div>
);
}

View File

@@ -0,0 +1,388 @@
'use client';
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Play, Pause, SkipBack, SkipForward, Eye, X } from 'lucide-react';
import { ExtractedFrame } from '@/lib/frame-extractor';
// 播放速度选项
type PlaybackSpeed = 'slow' | 'normal' | 'fast';
interface SpeedOption {
key: PlaybackSpeed;
label: string;
fps: number; // 播放帧率
}
const SPEED_OPTIONS: SpeedOption[] = [
{ key: 'slow', label: '慢速', fps: 5 },
{ key: 'normal', label: '中速', fps: 15 },
{ key: 'fast', label: '快速', fps: 30 },
];
interface FramePreviewPlayerProps {
frames: ExtractedFrame[];
onClose: () => void;
}
export function FramePreviewPlayer({ frames, onClose }: FramePreviewPlayerProps) {
// 当前帧索引(在选中帧列表中的索引)
const [currentFrameIndex, setCurrentFrameIndex] = useState(0);
// 播放状态
const [isPlaying, setIsPlaying] = useState(false);
// 播放速度
const [speed, setSpeed] = useState<PlaybackSpeed>('normal');
// 是否正在拖动进度条
const [isDragging, setIsDragging] = useState(false);
// 播放定时器引用
const playTimerRef = useRef<NodeJS.Timeout | null>(null);
// 进度条元素引用
const progressBarRef = useRef<HTMLDivElement>(null);
// 实时过滤选中的帧(响应用户选择变化)
const selectedFrames = useMemo(() => {
return frames.filter((f) => f.selected);
}, [frames]);
// 当选中帧变化时,确保当前索引有效
useEffect(() => {
if (selectedFrames.length === 0) {
setCurrentFrameIndex(0);
setIsPlaying(false);
} else if (currentFrameIndex >= selectedFrames.length) {
setCurrentFrameIndex(Math.max(0, selectedFrames.length - 1));
}
}, [selectedFrames.length, currentFrameIndex]);
// 获取当前速度配置
const currentSpeedOption = SPEED_OPTIONS.find((opt) => opt.key === speed) || SPEED_OPTIONS[1];
// 当前帧(从选中帧列表中获取)
const currentFrame = selectedFrames[currentFrameIndex];
// 播放逻辑
useEffect(() => {
if (isPlaying && !isDragging && selectedFrames.length > 0) {
const interval = 1000 / currentSpeedOption.fps;
playTimerRef.current = setInterval(() => {
setCurrentFrameIndex((prev) => {
if (prev >= selectedFrames.length - 1) {
// 播放结束,停止播放
setIsPlaying(false);
return prev;
}
return prev + 1;
});
}, interval);
}
return () => {
if (playTimerRef.current) {
clearInterval(playTimerRef.current);
playTimerRef.current = null;
}
};
}, [isPlaying, isDragging, currentSpeedOption.fps, selectedFrames.length]);
// 播放/暂停切换
const togglePlay = useCallback(() => {
if (selectedFrames.length === 0) return;
if (currentFrameIndex >= selectedFrames.length - 1) {
// 如果已经播放到最后,重新开始
setCurrentFrameIndex(0);
}
setIsPlaying((prev) => !prev);
}, [currentFrameIndex, selectedFrames.length]);
// 跳转到指定帧
const goToFrame = useCallback(
(index: number) => {
const clampedIndex = Math.max(0, Math.min(index, selectedFrames.length - 1));
setCurrentFrameIndex(clampedIndex);
},
[selectedFrames.length]
);
// 上一帧
const goToPrevFrame = useCallback(() => {
goToFrame(currentFrameIndex - 1);
}, [currentFrameIndex, goToFrame]);
// 下一帧
const goToNextFrame = useCallback(() => {
goToFrame(currentFrameIndex + 1);
}, [currentFrameIndex, goToFrame]);
// 处理进度条点击/拖动
const handleProgressBarInteraction = useCallback(
(clientX: number) => {
if (!progressBarRef.current || selectedFrames.length === 0) return;
const rect = progressBarRef.current.getBoundingClientRect();
const x = clientX - rect.left;
const percentage = Math.max(0, Math.min(1, x / rect.width));
const newIndex = Math.round(percentage * (selectedFrames.length - 1));
setCurrentFrameIndex(newIndex);
},
[selectedFrames.length]
);
// 鼠标按下开始拖动
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
setIsDragging(true);
setIsPlaying(false);
handleProgressBarInteraction(e.clientX);
},
[handleProgressBarInteraction]
);
// 鼠标移动时拖动
useEffect(() => {
if (!isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
handleProgressBarInteraction(e.clientX);
};
const handleMouseUp = () => {
setIsDragging(false);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging, handleProgressBarInteraction]);
// 触摸事件支持(移动端)
const handleTouchStart = useCallback(
(e: React.TouchEvent) => {
setIsDragging(true);
setIsPlaying(false);
handleProgressBarInteraction(e.touches[0].clientX);
},
[handleProgressBarInteraction]
);
useEffect(() => {
if (!isDragging) return;
const handleTouchMove = (e: TouchEvent) => {
handleProgressBarInteraction(e.touches[0].clientX);
};
const handleTouchEnd = () => {
setIsDragging(false);
};
document.addEventListener('touchmove', handleTouchMove);
document.addEventListener('touchend', handleTouchEnd);
return () => {
document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleTouchEnd);
};
}, [isDragging, handleProgressBarInteraction]);
// 键盘快捷键支持
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case ' ':
e.preventDefault();
togglePlay();
break;
case 'ArrowLeft':
e.preventDefault();
goToPrevFrame();
break;
case 'ArrowRight':
e.preventDefault();
goToNextFrame();
break;
case 'Escape':
e.preventDefault();
onClose();
break;
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [togglePlay, goToPrevFrame, goToNextFrame, onClose]);
// 计算进度百分比
const progressPercentage =
selectedFrames.length > 1 ? (currentFrameIndex / (selectedFrames.length - 1)) * 100 : 0;
return (
<Card className="w-full">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2 text-lg">
<Eye className="w-5 h-5 text-emerald-500" />
<Badge variant="secondary" className="ml-2 bg-emerald-100 text-emerald-700">
{selectedFrames.length}
</Badge>
</CardTitle>
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="h-8 w-8 text-muted-foreground hover:text-foreground"
>
<X className="w-4 h-4" />
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* 无选中帧提示 */}
{selectedFrames.length === 0 ? (
<div className="bg-muted/50 rounded-lg p-8 text-center">
<p className="text-muted-foreground"></p>
</div>
) : (
<>
{/* 预览区域 */}
<div className="relative bg-black rounded-lg overflow-hidden aspect-video flex items-center justify-center">
{currentFrame && (
<img
src={currentFrame.dataUrl}
alt={`Frame ${currentFrame.index + 1}`}
className="max-w-full max-h-full object-contain"
draggable={false}
/>
)}
{/* 帧信息叠加层 */}
<div className="absolute top-3 left-3 flex items-center gap-2">
<Badge variant="secondary" className="bg-black/60 text-white hover:bg-black/60">
{currentFrameIndex + 1} / {selectedFrames.length}
</Badge>
<Badge
variant="secondary"
className="bg-emerald-600/80 text-white hover:bg-emerald-600/80"
>
#{currentFrame?.index !== undefined ? currentFrame.index + 1 : '-'}
</Badge>
</div>
</div>
{/* 进度条 */}
<div className="space-y-2">
<div
ref={progressBarRef}
className="relative h-3 bg-muted rounded-full cursor-pointer group"
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
>
{/* 进度填充 */}
<div
className="absolute top-0 left-0 h-full bg-emerald-500 rounded-full transition-[width] duration-75"
style={{ width: `${progressPercentage}%` }}
/>
{/* 拖动手柄 */}
<div
className={`
absolute top-1/2 -translate-y-1/2 w-4 h-4 bg-white border-2 border-emerald-500 rounded-full shadow-md
transition-transform group-hover:scale-110
${isDragging ? 'scale-125' : ''}
`}
style={{ left: `calc(${progressPercentage}% - 8px)` }}
/>
</div>
{/* 帧缩略图轨道(仅显示选中的帧) */}
<div className="flex gap-px overflow-hidden rounded-md">
{selectedFrames.map((frame, index) => (
<div
key={frame.id}
className={`
flex-1 h-8 cursor-pointer transition-opacity
${index === currentFrameIndex ? 'ring-2 ring-emerald-500 ring-offset-1 z-10' : 'opacity-60 hover:opacity-100'}
`}
onClick={() => goToFrame(index)}
>
<img
src={frame.dataUrl}
alt={`Thumbnail ${frame.index + 1}`}
className="w-full h-full object-cover"
draggable={false}
/>
</div>
))}
</div>
</div>
{/* 控制栏 */}
<div className="flex items-center justify-between gap-4">
{/* 播放控制 */}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={goToPrevFrame}
disabled={currentFrameIndex === 0}
className="h-9 w-9"
>
<SkipBack className="w-4 h-4" />
</Button>
<Button
onClick={togglePlay}
className="h-10 w-10 rounded-full bg-emerald-600 hover:bg-emerald-700"
size="icon"
>
{isPlaying ? <Pause className="w-5 h-5" /> : <Play className="w-5 h-5 ml-0.5" />}
</Button>
<Button
variant="outline"
size="icon"
onClick={goToNextFrame}
disabled={currentFrameIndex === selectedFrames.length - 1}
className="h-9 w-9"
>
<SkipForward className="w-4 h-4" />
</Button>
</div>
{/* 播放速度选择 */}
<div className="flex items-center gap-1 bg-muted/50 rounded-lg p-1">
{SPEED_OPTIONS.map((option) => (
<Button
key={option.key}
variant={speed === option.key ? 'default' : 'ghost'}
size="sm"
onClick={() => setSpeed(option.key)}
className={`
h-8 px-3 text-xs
${speed === option.key ? 'bg-emerald-600 hover:bg-emerald-700' : ''}
`}
>
{option.label}
</Button>
))}
</div>
{/* 当前速度信息 */}
<Badge variant="outline" className="text-xs">
{currentSpeedOption.fps} FPS
</Badge>
</div>
{/* 快捷键提示 */}
<div className="text-xs text-muted-foreground text-center">
/ | / | ESC
</div>
</>
)}
</CardContent>
</Card>
);
}

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}`;
};