将合图处理从服务端迁移到浏览器端,使用 Web Worker 实现高性能打包算法,新增三栏布局界面和精灵动画预览功能 - 新增 atlasStore 状态管理,实现文件、配置、结果的统一管理 - 新增 atlas-packer 打包算法库(MaxRects/Shelf),支持浏览器端快速合图 - 新增 atlas-worker Web Worker,实现异步打包处理避免阻塞 UI - 新增三栏布局组件:FileListPanel、CanvasPreview、AtlasConfigPanel - 新增 AnimationPreviewDialog 支持精灵动画帧预览和帧率控制 - 优化所有工具页面的响应式布局和交互体验 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
261 lines
7.9 KiB
TypeScript
261 lines
7.9 KiB
TypeScript
"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";
|
|
|
|
const videoAccept = {
|
|
"video/*": [".mp4", ".mov", ".avi", ".webm", ".mkv"],
|
|
};
|
|
|
|
const defaultConfig: VideoFramesConfig = {
|
|
fps: 30,
|
|
format: "png",
|
|
quality: 90,
|
|
width: undefined,
|
|
height: undefined,
|
|
};
|
|
|
|
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" />,
|
|
},
|
|
{
|
|
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 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 }),
|
|
});
|
|
}
|
|
|
|
setProcessingStatus({
|
|
status: "processing",
|
|
progress: 0,
|
|
message: getT("processing.extractingFrames"),
|
|
});
|
|
|
|
// 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 }),
|
|
});
|
|
}
|
|
|
|
// 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 canProcess = files.length > 0 && processingStatus.status !== "processing";
|
|
const configOptions = useConfigOptions(config, getT);
|
|
|
|
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>
|
|
|
|
<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"}
|
|
/>
|
|
|
|
<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}
|
|
/>
|
|
|
|
{canProcess && (
|
|
<Button onClick={handleProcess} size="lg" className="w-full">
|
|
<Settings className="mr-2 h-4 w-4" />
|
|
{getT("tools.videoFrames.processVideo")}
|
|
</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>
|
|
</div>
|
|
</motion.div>
|
|
</div>
|
|
);
|
|
}
|