Spaces:
Runtime error
Runtime error
| 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> | |
| ); | |
| } | |