Gemini-VideoGeneration / web /frontend /src /pages /ToolsVideoFrame.tsx
LehongWu's picture
Upload folder using huggingface_hub
7e26c82 verified
import { useCallback, useEffect, useRef, useState } from "react";
import { DownloadMediaButton } from "../components/DownloadMediaButton";
function formatTime(sec: number): string {
if (!Number.isFinite(sec)) return "0:00";
const s = Math.floor(sec % 60);
const m = Math.floor(sec / 60);
return `${m}:${s.toString().padStart(2, "0")}`;
}
export function ToolsVideoFrame() {
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const [file, setFile] = useState<File | null>(null);
const [url, setUrl] = useState<string | null>(null);
const [duration, setDuration] = useState(0);
const [currentTime, setCurrentTime] = useState(0);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
return () => {
if (url) URL.revokeObjectURL(url);
};
}, [url]);
const drawFrame = useCallback(() => {
const video = videoRef.current;
const canvas = canvasRef.current;
if (!video || !canvas || video.readyState < 2) return;
const w = video.videoWidth;
const h = video.videoHeight;
if (w === 0 || h === 0) return;
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.drawImage(video, 0, 0, w, h);
setPreviewUrl(canvas.toDataURL("image/png"));
}, []);
useEffect(() => {
const v = videoRef.current;
if (!v || !url) return;
const onMeta = () => {
const d = v.duration;
setDuration(Number.isFinite(d) ? d : 0);
setCurrentTime(0);
v.currentTime = 0;
requestAnimationFrame(() => drawFrame());
};
const onSeeked = () => {
setCurrentTime(v.currentTime);
drawFrame();
};
const onTime = () => setCurrentTime(v.currentTime);
v.addEventListener("loadedmetadata", onMeta);
v.addEventListener("seeked", onSeeked);
v.addEventListener("timeupdate", onTime);
return () => {
v.removeEventListener("loadedmetadata", onMeta);
v.removeEventListener("seeked", onSeeked);
v.removeEventListener("timeupdate", onTime);
};
}, [url, drawFrame]);
function onPickVideo(f: File | null) {
setErr(null);
setPreviewUrl(null);
if (url) URL.revokeObjectURL(url);
setUrl(null);
setFile(f);
if (!f) return;
if (!f.type.startsWith("video/")) {
setErr("请选择视频文件。");
return;
}
const u = URL.createObjectURL(f);
setUrl(u);
}
function onScrub(t: number) {
const v = videoRef.current;
if (!v) return;
v.currentTime = t;
}
return (
<div className="max-w-3xl">
<h1 className="font-display text-2xl font-semibold text-ink mb-2">提取视频帧</h1>
<p className="text-mist text-sm mb-6 leading-relaxed">
本地选视频,拖动进度条选时刻,预览后下载 PNG。不上传服务器。
</p>
<div className="space-y-5">
<div>
<label className="block text-sm font-semibold text-ink mb-2">视频(必选)</label>
<input
type="file"
accept="video/*"
className="text-sm text-ink file:mr-3 file:rounded-lg file:border-0 file:bg-slate-200 file:px-3 file:py-1.5 file:text-sm file:text-ink"
onChange={(e) => onPickVideo(e.target.files?.[0] ?? null)}
/>
{file && (
<p className="text-xs text-mist mt-1">
已选:{file.name}({(file.size / (1024 * 1024)).toFixed(2)} MB)
</p>
)}
</div>
{url && (
<>
<div className="rounded-xl overflow-hidden border border-slate-200 bg-black">
<video
ref={videoRef}
src={url}
className="w-full max-h-[360px] object-contain"
playsInline
muted
preload="metadata"
/>
</div>
<div>
<div className="flex justify-between text-xs text-mist mb-1">
<span>{formatTime(currentTime)}</span>
<span>/ {formatTime(duration)}</span>
</div>
<input
type="range"
min={0}
max={duration || 0}
step={0.01}
value={currentTime}
onInput={(e) => onScrub(Number((e.target as HTMLInputElement).value))}
onChange={(e) => onScrub(Number(e.target.value))}
className="w-full accent-ink h-2 rounded-full appearance-none bg-slate-400/90"
/>
<p className="text-xs text-mist mt-1">停稳后显示当前帧预览。</p>
</div>
<canvas ref={canvasRef} className="hidden" aria-hidden />
{previewUrl && (
<div>
<p className="text-sm font-semibold text-ink mb-2">当前帧预览</p>
<img
src={previewUrl}
alt="当前帧"
className="max-w-full max-h-64 rounded-lg border border-slate-200 object-contain bg-slate-100"
/>
<div className="mt-3">
<DownloadMediaButton
dataUrl={previewUrl}
filenameBase="video-frame"
label="下载当前帧 PNG"
className="rounded-lg bg-ink text-paper px-5 py-2 text-sm font-medium hover:bg-slate-800"
/>
</div>
</div>
)}
</>
)}
{err && <p className="text-sm text-red-700">{err}</p>}
</div>
</div>
);
}