feat: 修复视频序列帧工具失败的问题
This commit is contained in:
79
CODEBUDDY.md
79
CODEBUDDY.md
@@ -17,54 +17,67 @@
|
|||||||
- **Framework**: Next.js 15 with App Router, React 19
|
- **Framework**: Next.js 15 with App Router, React 19
|
||||||
- **State**: Zustand for client state
|
- **State**: Zustand for client state
|
||||||
- **Styling**: Tailwind CSS with HSL CSS variables (dark mode by default)
|
- **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
|
- **Animations**: Framer Motion
|
||||||
- **Form Validation**: Zod
|
- **Form Validation**: Zod
|
||||||
- **Media Processing**: Sharp (images), FFmpeg (video/audio)
|
- **Media Processing**: Sharp (images), FFmpeg (video/audio)
|
||||||
|
- **Internationalization**: i18n store + server helpers (cookie/Accept-Language detection)
|
||||||
|
|
||||||
### Directory Structure
|
### Directory Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── app/ # Next.js App Router
|
├── app/ # Next.js App Router
|
||||||
│ ├── (auth)/ # Auth routes group (login, register)
|
│ ├── (auth)/ # Auth routes group
|
||||||
│ ├── (dashboard)/ # Dashboard routes with Sidebar layout
|
│ ├── (dashboard)/ # Dashboard routes with Sidebar layout
|
||||||
│ │ ├── tools/ # Tool pages (image-compress, video-frames, audio-compress)
|
│ │ └── tools/ # Tool pages (image/audio/video/texture-atlas)
|
||||||
│ │ └── layout.tsx # Dashboard layout with Sidebar
|
│ ├── api/ # API routes
|
||||||
│ ├── api/ # API routes
|
│ │ ├── upload/ # File upload endpoint
|
||||||
│ │ ├── upload/ # File upload endpoint
|
│ │ └── process/ # Processing endpoints per tool type
|
||||||
│ │ └── process/ # Processing endpoints per tool type
|
│ ├── globals.css # Global styles with CSS variables
|
||||||
│ ├── globals.css # Global styles with CSS variables
|
│ └── layout.tsx # Root layout (Header + Footer + SEO)
|
||||||
│ └── layout.tsx # Root layout (Header + Footer)
|
|
||||||
├── components/
|
├── components/
|
||||||
│ ├── ui/ # Base UI primitives (button, card, input, etc.)
|
│ ├── layout/ # Header, Footer, Sidebar, LanguageSwitcher
|
||||||
│ ├── tools/ # Tool-specific components (FileUploader, ConfigPanel, ProgressBar, ResultPreview)
|
│ ├── seo/ # StructuredData
|
||||||
│ └── layout/ # Layout components (Header, Footer, Sidebar)
|
│ ├── tools/ # Tool components (FileUploader, ConfigPanel, ResultPreview...)
|
||||||
├── lib/
|
│ │ └── atlas/ # Texture-atlas specific panels
|
||||||
│ ├── api.ts # API client functions
|
│ └── ui/ # Base UI primitives (button, card, dialog, etc.)
|
||||||
│ └── utils.ts # Utility functions (cn, formatFileSize, etc.)
|
├── hooks/ # Custom hooks (e.g., atlas worker bridge)
|
||||||
├── store/
|
├── lib/ # API clients, i18n, atlas algorithms, image processing
|
||||||
│ ├── authStore.ts # Auth state
|
├── locales/ # i18n resources
|
||||||
│ └── uploadStore.ts # File upload and processing state
|
├── store/ # Zustand stores (upload/atlas/auth)
|
||||||
└── types/
|
└── types/ # Shared TypeScript types
|
||||||
└── index.ts # TypeScript types (UploadedFile, ProcessedFile, configs, etc.)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Key Patterns
|
### 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:
|
**Tool Pages Pattern**: Tools (audio-compress, image-compress, video-frames) share a common UI flow:
|
||||||
1. Uses `FileUploader` for drag-drop file input
|
1. `FileUploader` for drag-drop input
|
||||||
2. Uses `ConfigPanel` for tool-specific configuration options
|
2. `ConfigPanel` for tool-specific settings
|
||||||
3. Uses `ProgressBar` to show processing status
|
3. `ProgressBar` for processing status
|
||||||
4. Uses `ResultPreview` to display processed files
|
4. `ResultPreview` for outputs
|
||||||
5. State managed via `useUploadStore` Zustand store
|
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
|
||||||
|
|||||||
@@ -1,260 +1,596 @@
|
|||||||
"use client";
|
'use client';
|
||||||
|
|
||||||
import { useState, useCallback, useEffect } from "react";
|
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
|
||||||
import { motion } from "framer-motion";
|
import { useDropzone } from 'react-dropzone';
|
||||||
import { Video, Settings } from "lucide-react";
|
import JSZip from 'jszip';
|
||||||
import { FileUploader } from "@/components/tools/FileUploader";
|
import { Button } from '@/components/ui/button';
|
||||||
import { ProgressBar } from "@/components/tools/ProgressBar";
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { ResultPreview } from "@/components/tools/ResultPreview";
|
import { Progress } from '@/components/ui/progress';
|
||||||
import { ConfigPanel, type ConfigOption } from "@/components/tools/ConfigPanel";
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from "@/components/ui/button";
|
import {
|
||||||
import { useUploadStore } from "@/store/uploadStore";
|
Film,
|
||||||
import { generateId } from "@/lib/utils";
|
Upload,
|
||||||
import { useTranslation, getServerTranslations } from "@/lib/i18n";
|
Download,
|
||||||
import type { UploadedFile, ProcessedFile, VideoFramesConfig } from "@/types";
|
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 = {
|
type ExtractionStatus = 'idle' | 'uploading' | 'extracting' | 'completed' | 'error';
|
||||||
"video/*": [".mp4", ".mov", ".avi", ".webm", ".mkv"],
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultConfig: VideoFramesConfig = {
|
export default function VideoToFramesPage() {
|
||||||
fps: 30,
|
// 视频相关状态
|
||||||
format: "png",
|
const [videoFile, setVideoFile] = useState<File | null>(null);
|
||||||
quality: 90,
|
const [videoUrl, setVideoUrl] = useState<string>('');
|
||||||
width: undefined,
|
const [metadata, setMetadata] = useState<VideoMetadata | null>(null);
|
||||||
height: undefined,
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
};
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
|
||||||
function useConfigOptions(config: VideoFramesConfig, getT: (key: string) => string): ConfigOption[] {
|
// 设置相关状态
|
||||||
return [
|
const [frameRate, setFrameRate] = useState<number>(15);
|
||||||
{
|
const [format, setFormat] = useState<'image/png' | 'image/jpeg'>('image/png');
|
||||||
id: "fps",
|
const [jpegQuality, setJpegQuality] = useState<number>(0.92);
|
||||||
type: "slider",
|
|
||||||
label: getT("config.videoFrames.fps"),
|
// 提取状态
|
||||||
description: getT("config.videoFrames.fpsDescription"),
|
const [status, setStatus] = useState<ExtractionStatus>('idle');
|
||||||
value: config.fps,
|
const [extractProgress, setExtractProgress] = useState<number>(0);
|
||||||
min: 1,
|
const [error, setError] = useState<string>('');
|
||||||
max: 60,
|
|
||||||
step: 1,
|
// 提取结果
|
||||||
suffix: " fps",
|
const [frames, setFrames] = useState<ExtractedFrame[]>([]);
|
||||||
icon: <Video className="h-4 w-4" />,
|
|
||||||
|
// 下载状态
|
||||||
|
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');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
[videoUrl]
|
||||||
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]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleConfigChange = (id: string, value: string | number | boolean | undefined) => {
|
// 拖拽上传
|
||||||
setConfig((prev) => ({ ...prev, [id]: value }));
|
const onDrop = useCallback(
|
||||||
};
|
(acceptedFiles: File[]) => {
|
||||||
|
if (acceptedFiles.length > 0) {
|
||||||
const handleResetConfig = () => {
|
handleVideoUpload(acceptedFiles[0]);
|
||||||
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 }),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
[handleVideoUpload]
|
||||||
|
);
|
||||||
|
|
||||||
setProcessingStatus({
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||||
status: "processing",
|
onDrop,
|
||||||
progress: 0,
|
accept: SUPPORTED_VIDEO_FORMATS,
|
||||||
message: getT("processing.extractingFrames"),
|
maxFiles: 1,
|
||||||
});
|
multiple: false,
|
||||||
|
});
|
||||||
|
|
||||||
// Simulate processing
|
// 视频播放控制
|
||||||
for (let i = 0; i <= 100; i += 5) {
|
const togglePlay = () => {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
if (videoRef.current) {
|
||||||
setProcessingStatus({
|
if (isPlaying) {
|
||||||
status: "processing",
|
videoRef.current.pause();
|
||||||
progress: i,
|
} else {
|
||||||
message: getT("processing.processProgress", { progress: i }),
|
videoRef.current.play();
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
setIsPlaying(!isPlaying);
|
||||||
// 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"),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div className="p-6">
|
<div className="flex flex-1 flex-col gap-6 p-6 max-w-7xl mx-auto w-full">
|
||||||
<motion.div
|
{/* 页面标题 */}
|
||||||
initial={{ opacity: 0, y: 20 }}
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
animate={{ opacity: 1, y: 0 }}
|
<div className="space-y-1">
|
||||||
>
|
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-3">
|
||||||
{/* Header */}
|
<Film className="w-8 h-8 text-emerald-500" />
|
||||||
<div className="mb-8">
|
视频转序列帧
|
||||||
<div className="flex items-center gap-3">
|
</h1>
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
|
<p className="text-muted-foreground">
|
||||||
<Video className="h-6 w-6 text-primary" />
|
上传视频后自动提取序列帧,支持自定义帧率、帧选择和批量下载
|
||||||
</div>
|
</p>
|
||||||
<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>
|
</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">
|
<div className="h-px bg-border" />
|
||||||
{/* 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"}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ConfigPanel
|
{/* 上传区域 - 未上传视频时显示 */}
|
||||||
title={getT("config.videoFrames.title")}
|
{!videoFile && (
|
||||||
description={getT("config.videoFrames.description")}
|
<div
|
||||||
options={configOptions.map((opt) => ({
|
{...getRootProps()}
|
||||||
...opt,
|
className={`
|
||||||
value: config[opt.id as keyof VideoFramesConfig],
|
border-2 border-dashed rounded-xl p-12 flex flex-col items-center justify-center transition-all cursor-pointer
|
||||||
}))}
|
${isDragActive
|
||||||
onChange={handleConfigChange}
|
? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-950/20'
|
||||||
onReset={handleResetConfig}
|
: '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">
|
{error && (
|
||||||
<Settings className="mr-2 h-4 w-4" />
|
<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">
|
||||||
{getT("tools.videoFrames.processVideo")}
|
{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>
|
</Button>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Column - Progress and Results */}
|
{/* 提取进度条 */}
|
||||||
<div className="space-y-6">
|
{status === 'extracting' && (
|
||||||
{processingStatus.status !== "idle" && (
|
<Progress value={extractProgress} className="h-2" />
|
||||||
<ProgressBar progress={processingStatus} />
|
)}
|
||||||
)}
|
</CardContent>
|
||||||
|
</Card>
|
||||||
{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>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,31 +4,31 @@
|
|||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: 222.2 84% 4.9%;
|
--background: 222 47% 6%;
|
||||||
--foreground: 210 40% 98%;
|
--foreground: 210 40% 98%;
|
||||||
--card: 222.2 84% 4.9%;
|
--card: 222 41% 9%;
|
||||||
--card-foreground: 210 40% 98%;
|
--card-foreground: 210 40% 98%;
|
||||||
--popover: 222.2 84% 4.9%;
|
--popover: 222 41% 9%;
|
||||||
--popover-foreground: 210 40% 98%;
|
--popover-foreground: 210 40% 98%;
|
||||||
--primary: 262.1 83.3% 57.8%;
|
--primary: 164 84% 42%;
|
||||||
--primary-foreground: 210 40% 98%;
|
--primary-foreground: 210 40% 98%;
|
||||||
--secondary: 217.2 32.6% 17.5%;
|
--secondary: 217 24% 16%;
|
||||||
--secondary-foreground: 210 40% 98%;
|
--secondary-foreground: 210 40% 98%;
|
||||||
--muted: 217.2 32.6% 17.5%;
|
--muted: 217 24% 16%;
|
||||||
--muted-foreground: 215 20.2% 65.1%;
|
--muted-foreground: 215 20% 68%;
|
||||||
--accent: 217.2 32.6% 17.5%;
|
--accent: 189 85% 40%;
|
||||||
--accent-foreground: 210 40% 98%;
|
--accent-foreground: 210 40% 98%;
|
||||||
--destructive: 0 62.8% 30.6%;
|
--destructive: 0 62% 35%;
|
||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 210 40% 98%;
|
||||||
--border: 217.2 32.6% 17.5%;
|
--border: 217 22% 20%;
|
||||||
--input: 217.2 32.6% 17.5%;
|
--input: 217 22% 20%;
|
||||||
--ring: 262.1 83.3% 57.8%;
|
--ring: 164 84% 42%;
|
||||||
--chart-1: 220 70% 50%;
|
--chart-1: 164 84% 42%;
|
||||||
--chart-2: 160 60% 45%;
|
--chart-2: 189 85% 40%;
|
||||||
--chart-3: 30 80% 55%;
|
--chart-3: 210 90% 56%;
|
||||||
--chart-4: 280 65% 60%;
|
--chart-4: 280 70% 60%;
|
||||||
--chart-5: 340 75% 55%;
|
--chart-5: 20 85% 55%;
|
||||||
--radius: 0.5rem;
|
--radius: 0.6rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
|
|
||||||
/* Gradient text */
|
/* Gradient text */
|
||||||
.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 */
|
/* Custom scrollbar */
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { motion, useReducedMotion } from "framer-motion";
|
import { motion, useMotionValue, useReducedMotion } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
Video,
|
Video,
|
||||||
Zap,
|
Zap,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { cn } from "@/lib/utils";
|
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 }) {
|
function Hero({ t, reduceMotion }: { t: TFn; reduceMotion: boolean }) {
|
||||||
return (
|
return (
|
||||||
<section className="relative overflow-hidden">
|
<section className="relative overflow-hidden">
|
||||||
@@ -466,14 +533,18 @@ function FinalCTA({ t, reduceMotion }: { t: TFn; reduceMotion: boolean }) {
|
|||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const t = useStableT();
|
const t = useStableT();
|
||||||
const reduceMotion = useReducedMotion() ?? false;
|
const reduceMotion = useReducedMotion() ?? false;
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div ref={containerRef} className="relative">
|
||||||
<Hero t={t} reduceMotion={reduceMotion} />
|
<MouseFollowGlow containerRef={containerRef} reduceMotion={reduceMotion} />
|
||||||
<ToolsShowcase t={t} reduceMotion={reduceMotion} />
|
<div className="relative z-10">
|
||||||
<Workflow t={t} reduceMotion={reduceMotion} />
|
<Hero t={t} reduceMotion={reduceMotion} />
|
||||||
<Quality t={t} reduceMotion={reduceMotion} />
|
<ToolsShowcase t={t} reduceMotion={reduceMotion} />
|
||||||
<FinalCTA t={t} reduceMotion={reduceMotion} />
|
<Workflow t={t} reduceMotion={reduceMotion} />
|
||||||
|
<Quality t={t} reduceMotion={reduceMotion} />
|
||||||
|
<FinalCTA t={t} reduceMotion={reduceMotion} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
388
src/components/frame-preview-player.tsx
Normal file
388
src/components/frame-preview-player.tsx
Normal 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
261
src/lib/frame-extractor.ts
Normal 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-1),PNG 忽略此参数
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证常量
|
||||||
|
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} 秒`;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user