"use client"; import { useEffect, useRef, useState } from "react"; import { H264AnnexBStreamParser, type FrameThumb, type ParsedH264 } from "@/lib/h264"; import { clearCachedFrameSession, readCachedFrameSession, type CachedFrameRecord, type CachedFrameSession, writeCachedFrameSession, } from "@/lib/frame-store"; import { clearCachedVideoResponse, openCachedVideoResponse } from "@/lib/video-cache"; const DEFAULT_VIDEO_URL = process.env.NEXT_PUBLIC_VIDEO_URL ?? "http://localhost:8080/video.h264"; const TARGET_FPS = 30; const FRAME_EXPORT_WIDTH = 1600; const PRELOAD_AHEAD = 6; const FRAME_STEP_US = Math.round(1_000_000 / TARGET_FPS); type LoadState = "idle" | "loading" | "ready" | "error"; type StreamSummary = Pick; type CacheState = "idle" | "hit" | "miss" | "released"; type StorageRequestState = "idle" | "requesting" | "granted" | "denied" | "unsupported" | "error"; type ProgressState = { bytesRead: number; bytesTotal: number | null; decoded: number; decodedTotal: number | null; }; type StorageInfo = { usageBytes: number | null; quotaBytes: number | null; persisted: boolean | null; }; type QueuedAccessUnit = { type: "key" | "delta"; data: Uint8Array; timestampUs: number; }; type PlaybackAnchor = { index: number; timestampUs: number; startedAt: number; }; export default function VideoFrameViewer() { const [videoUrl, setVideoUrl] = useState(DEFAULT_VIDEO_URL); const [loadState, setLoadState] = useState("idle"); const [videoCacheState, setVideoCacheState] = useState("idle"); const [frameCacheState, setFrameCacheState] = useState("idle"); const [storageInfo, setStorageInfo] = useState({ usageBytes: null, quotaBytes: null, persisted: null, }); const [storageRequestState, setStorageRequestState] = useState("idle"); const [status, setStatus] = useState("等待开始。建议使用 Chromium / Edge 浏览器。"); const [error, setError] = useState(null); const [frames, setFrames] = useState([]); const [streamInfo, setStreamInfo] = useState(null); const [progress, setProgress] = useState({ bytesRead: 0, bytesTotal: null, decoded: 0, decodedTotal: null, }); const [playing, setPlaying] = useState(false); const [playhead, setPlayhead] = useState(0); const [scrubValue, setScrubValue] = useState(0); const abortRef = useRef(null); const playbackCanvasRef = useRef(null); const rafRef = useRef(null); const framesRef = useRef([]); const playheadRef = useRef(0); const imageCacheRef = useRef(new Map>()); const frameObjectUrlsRef = useRef(new Set()); const loadTokenRef = useRef(0); const playbackAnchorRef = useRef({ index: 0, timestampUs: 0, startedAt: 0, }); const codecRef = useRef(null); useEffect(() => { framesRef.current = frames; }, [frames]); useEffect(() => { playheadRef.current = playhead; }, [playhead]); useEffect(() => { setScrubValue(playhead); }, [playhead]); useEffect(() => { return () => { abortRef.current?.abort(); if (rafRef.current !== null) { cancelAnimationFrame(rafRef.current); } imageCacheRef.current.clear(); revokeFrameObjectUrls(); }; }, []); useEffect(() => { const token = ++loadTokenRef.current; let cancelled = false; async function restoreCachedSession() { setStatus("正在检查本地帧缓存..."); const cachedSession = await readCachedFrameSession(videoUrl); if (cancelled || token !== loadTokenRef.current) { return; } if (!cachedSession) { setFrameCacheState("miss"); setStatus("本地帧缓存未命中,可以点击开始切帧。"); return; } applyFrameSession(cachedSession); await clearCachedVideoResponse(videoUrl); setFrameCacheState("hit"); setVideoCacheState("released"); await refreshStorageInfo(); setStatus("已从本地帧缓存恢复,刷新后可以直接播放。"); } void restoreCachedSession(); return () => { cancelled = true; }; }, []); useEffect(() => { void refreshStorageInfo(); }, []); const progressValue = (() => { if (loadState === "loading" && progress.bytesTotal && progress.bytesTotal > 0) { return Math.min(100, (progress.bytesRead / progress.bytesTotal) * 100); } if (progress.decodedTotal && progress.decodedTotal > 0) { return Math.min(100, (progress.decoded / progress.decodedTotal) * 100); } return 0; })(); function resetPlaybackSurface() { if (rafRef.current !== null) { cancelAnimationFrame(rafRef.current); rafRef.current = null; } imageCacheRef.current.clear(); const canvas = playbackCanvasRef.current; const context = canvas?.getContext("2d"); if (canvas && context) { context.clearRect(0, 0, canvas.width, canvas.height); } } function revokeFrameObjectUrls() { for (const objectUrl of frameObjectUrlsRef.current) { URL.revokeObjectURL(objectUrl); } frameObjectUrlsRef.current.clear(); } function registerFrameObjectUrl(objectUrl: string) { frameObjectUrlsRef.current.add(objectUrl); return objectUrl; } function clearDisplayedFrames() { revokeFrameObjectUrls(); framesRef.current = []; setFrames([]); } function clearDisplayedSession() { resetPlaybackSurface(); clearDisplayedFrames(); setStreamInfo(null); setProgress({ bytesRead: 0, bytesTotal: null, decoded: 0, decodedTotal: null, }); setPlaying(false); setPlayhead(0); setScrubValue(0); playheadRef.current = 0; playbackAnchorRef.current = { index: 0, timestampUs: 0, startedAt: 0, }; } function applyFrameSession(session: CachedFrameSession) { resetPlaybackSurface(); clearDisplayedFrames(); const nextFrames = session.frames.map((record) => ({ index: record.index, src: registerFrameObjectUrl(URL.createObjectURL(record.blob)), width: record.width, height: record.height, timestampUs: record.timestampUs, })); framesRef.current = nextFrames; setFrames(nextFrames); setStreamInfo({ codec: session.codec, frameCount: session.frameCount, keyFrameCount: session.keyFrameCount, }); setProgress({ bytesRead: session.sourceSize ?? 0, bytesTotal: session.sourceSize, decoded: session.frameCount, decodedTotal: session.frameCount, }); setPlaying(false); setPlayhead(0); setScrubValue(0); playheadRef.current = 0; playbackAnchorRef.current = { index: 0, timestampUs: 0, startedAt: 0, }; setLoadState("ready"); setError(null); } function describeCacheState(state: CacheState) { if (state === "hit") { return "命中"; } if (state === "miss") { return "未命中"; } if (state === "released") { return "已释放"; } return "待查"; } function describeStorageState() { if (storageInfo.persisted === true) { return "已持久化"; } if (storageInfo.persisted === false) { return "可申请持久化"; } return "等待检测"; } function getStorageUsageText() { if (storageInfo.quotaBytes && storageInfo.quotaBytes > 0) { const used = storageInfo.usageBytes ?? 0; const percent = Math.min(100, Math.round((used / storageInfo.quotaBytes) * 100)); return `${formatBytes(used)} / ${formatBytes(storageInfo.quotaBytes)} · ${percent}%`; } if (storageInfo.usageBytes !== null) { return formatBytes(storageInfo.usageBytes); } return "不可用"; } function jumpToFrame(index: number) { if (framesRef.current.length === 0) { return; } const nextIndex = Math.max(0, Math.min(framesRef.current.length - 1, index)); setPlaying(false); setPlayhead(nextIndex); setScrubValue(nextIndex); playheadRef.current = nextIndex; void drawFrameToCanvas(nextIndex); } function getPersistentButtonLabel() { if (storageRequestState === "requesting") { return "申请中..."; } if (storageInfo.persisted === true) { return "已持久化"; } if (storageRequestState === "unsupported") { return "浏览器不支持"; } return "申请持久化"; } function isPersistentButtonDisabled() { return storageRequestState === "requesting" || storageInfo.persisted === true; } async function clearAllCaches() { abortRef.current?.abort(); loadTokenRef.current += 1; resetPlaybackSurface(); clearDisplayedSession(); setLoadState("idle"); setError(null); setStatus("正在清理缓存..."); setFrameCacheState("idle"); setVideoCacheState("idle"); try { await Promise.all([ clearCachedFrameSession(videoUrl), clearCachedVideoResponse(videoUrl), ]); await refreshStorageInfo(); setStatus("缓存已清空,点击开始切帧会重新请求后端。"); setFrameCacheState("miss"); setVideoCacheState("miss"); } catch (cause) { const message = cause instanceof Error ? cause.message : String(cause); setError(message); setStatus("清理缓存失败。"); } } function startPlaybackFromCurrentFrame() { if (framesRef.current.length === 0) { return; } const nextIndex = Math.max(0, Math.min(framesRef.current.length - 1, playheadRef.current)); const nextTimestampUs = framesRef.current[nextIndex]?.timestampUs ?? 0; playbackAnchorRef.current = { index: nextIndex, timestampUs: nextTimestampUs, startedAt: performance.now(), }; setPlaying(true); } async function refreshStorageInfo() { if (typeof navigator === "undefined" || !navigator.storage) { setStorageInfo({ usageBytes: null, quotaBytes: null, persisted: null, }); return; } try { const [estimate, persisted] = await Promise.all([ navigator.storage.estimate(), navigator.storage.persisted(), ]); setStorageInfo({ usageBytes: typeof estimate.usage === "number" ? estimate.usage : null, quotaBytes: typeof estimate.quota === "number" ? estimate.quota : null, persisted, }); } catch { setStorageInfo({ usageBytes: null, quotaBytes: null, persisted: null, }); } } async function requestPersistentStorage() { if (typeof navigator === "undefined" || !navigator.storage || !navigator.storage.persist) { setStorageRequestState("unsupported"); setStatus("当前浏览器不支持持久化存储申请。"); return; } setStorageRequestState("requesting"); try { const granted = await navigator.storage.persist(); setStorageRequestState(granted ? "granted" : "denied"); await refreshStorageInfo(); setStatus( granted ? "已申请持久化存储,长期缓存更不容易被浏览器回收。" : "浏览器没有授予持久化存储,仍可继续使用当前缓存。", ); } catch { setStorageRequestState("error"); setStatus("持久化存储申请失败。"); } } function preloadFrameWindow(startIndex: number) { const endIndex = Math.min(framesRef.current.length, startIndex + PRELOAD_AHEAD); for (let index = startIndex; index < endIndex; index += 1) { void loadFrameImage(index).catch(() => undefined); } } function loadFrameImage(index: number): Promise { const cached = imageCacheRef.current.get(index); if (cached) { return cached; } const frame = framesRef.current[index]; if (!frame) { return Promise.reject(new Error(`找不到第 ${index + 1} 帧`)); } const promise = new Promise((resolve, reject) => { const image = new Image(); image.decoding = "async"; let settled = false; const settle = (fn: () => void) => { if (settled) { return; } settled = true; fn(); }; image.onload = () => settle(() => resolve(image)); image.onerror = () => settle(() => reject(new Error(`第 ${index + 1} 帧加载失败`))); image.src = frame.src; if (typeof image.decode === "function") { image .decode() .then(() => settle(() => resolve(image))) .catch(() => { // Fallback to onload/onerror. }); } }); imageCacheRef.current.set(index, promise); return promise; } async function drawFrameToCanvas(index: number) { try { const canvas = playbackCanvasRef.current; if (!canvas) { return; } const frame = framesRef.current[index]; if (!frame) { return; } const context = canvas.getContext("2d"); if (!context) { return; } const image = await loadFrameImage(index); if (playheadRef.current !== index) { return; } const nextWidth = Math.max(1, image.naturalWidth || frame.width); const nextHeight = Math.max(1, image.naturalHeight || frame.height); if (canvas.width !== nextWidth || canvas.height !== nextHeight) { canvas.width = nextWidth; canvas.height = nextHeight; } context.clearRect(0, 0, canvas.width, canvas.height); context.drawImage(image, 0, 0, canvas.width, canvas.height); } catch { // Ignore transient frame load failures and keep the previous frame visible. } } async function loadAndDecode() { const token = ++loadTokenRef.current; abortRef.current?.abort(); const controller = new AbortController(); abortRef.current = controller; setError(null); setPlaying(false); setPlayhead(0); setScrubValue(0); playheadRef.current = 0; playbackAnchorRef.current = { index: 0, timestampUs: 0, startedAt: 0, }; setStreamInfo(null); setProgress({ bytesRead: 0, bytesTotal: null, decoded: 0, decodedTotal: null, }); setFrameCacheState("idle"); setVideoCacheState("idle"); codecRef.current = null; setStatus("正在查找本地帧缓存..."); const decoderRef = { current: null as VideoDecoder | null }; const closeDecoder = () => { const decoder = decoderRef.current; if (!decoder) { return; } decoderRef.current = null; if (decoder.state !== "closed") { decoder.close(); } }; try { const cachedSession = await readCachedFrameSession(videoUrl); if (token !== loadTokenRef.current || controller.signal.aborted) { return; } if (cachedSession) { applyFrameSession(cachedSession); await clearCachedVideoResponse(videoUrl); setFrameCacheState("hit"); setVideoCacheState("released"); await refreshStorageInfo(); setStatus("已从本地帧缓存恢复,刷新后可以直接播放。"); return; } setFrameCacheState("miss"); clearDisplayedSession(); setLoadState("loading"); setStatus("本地帧缓存未命中,正在请求后端 video.h264..."); const { response, cacheState: nextCacheState } = await openCachedVideoResponse( videoUrl, controller.signal, ); if (token !== loadTokenRef.current || controller.signal.aborted) { return; } setVideoCacheState(nextCacheState); const contentLength = Number(response.headers.get("content-length") ?? ""); const bytesTotal = Number.isFinite(contentLength) && contentLength > 0 ? contentLength : null; setProgress((current) => ({ ...current, bytesTotal })); if (!response.body) { throw new Error("当前浏览器不支持流式读取响应体。"); } const reader = response.body.getReader(); const parser = new H264AnnexBStreamParser(); const decodedFrames: FrameThumb[] = []; const persistedFrames: CachedFrameRecord[] = []; const pendingFrames = new Map(); const pendingUnits: QueuedAccessUnit[] = []; const materializationTasks: Promise[] = []; let materializationError: Error | null = null; let latestSummary: StreamSummary | null = null; let bytesRead = 0; let decodedCount = 0; let readyFrameCount = 0; let nextFrameToCommit = 0; let timestampCursorUs = 0; let firstFrameSeen = false; const thumbnailCanvas = document.createElement("canvas"); const thumbnailContext = thumbnailCanvas.getContext("2d"); if (!thumbnailContext) { throw new Error("无法创建缩略图 canvas 2D 上下文。"); } const flushUi = (force = false) => { if (force || readyFrameCount <= 4 || readyFrameCount % 8 === 0) { framesRef.current = decodedFrames; setFrames(decodedFrames.slice()); setProgress({ bytesRead, bytesTotal, decoded: readyFrameCount, decodedTotal: null, }); } }; const flushPendingFrames = (force = false) => { let changed = false; while (pendingFrames.has(nextFrameToCommit)) { const entry = pendingFrames.get(nextFrameToCommit); if (!entry) { break; } pendingFrames.delete(nextFrameToCommit); decodedFrames.push(entry.thumb); persistedFrames.push(entry.record); nextFrameToCommit += 1; readyFrameCount += 1; changed = true; if (!firstFrameSeen) { firstFrameSeen = true; setStatus("首帧已到达,页面正在边下边渲染。"); } } if (changed || force) { flushUi(force); } }; const queueFrameMaterialization = (frame: VideoFrame) => { const frameIndex = decodedCount; const width = frame.displayWidth; const height = frame.displayHeight; const timestampUs = typeof frame.timestamp === "number" ? frame.timestamp : frameIndex * FRAME_STEP_US; const scale = Math.min(1, FRAME_EXPORT_WIDTH / width); const exportCanvas = document.createElement("canvas"); exportCanvas.width = Math.max(1, Math.round(width * scale)); exportCanvas.height = Math.max(1, Math.round(height * scale)); const exportContext = exportCanvas.getContext("2d"); if (!exportContext) { frame.close(); materializationError = materializationError ?? new Error("无法创建帧导出上下文。"); return; } exportContext.clearRect(0, 0, exportCanvas.width, exportCanvas.height); exportContext.drawImage(frame, 0, 0, exportCanvas.width, exportCanvas.height); const blobPromise = canvasToWebpBlob(exportCanvas); frame.close(); const task = blobPromise .then((blob) => { if (controller.signal.aborted || token !== loadTokenRef.current) { return; } const objectUrl = URL.createObjectURL(blob); if (controller.signal.aborted || token !== loadTokenRef.current) { URL.revokeObjectURL(objectUrl); return; } frameObjectUrlsRef.current.add(objectUrl); pendingFrames.set(frameIndex, { thumb: { index: frameIndex, src: objectUrl, width, height, timestampUs, }, record: { index: frameIndex, width, height, timestampUs, blob, }, }); flushPendingFrames(); }) .catch((error) => { materializationError = materializationError ?? (error instanceof Error ? error : new Error(String(error))); }); materializationTasks.push(task); }; const decodeQueuedUnit = async (unit: QueuedAccessUnit) => { if (!decoderRef.current) { pendingUnits.push(unit); return; } while (decoderRef.current.decodeQueueSize > 12) { await sleep(8); } decoderRef.current.decode( new EncodedVideoChunk({ type: unit.type, timestamp: unit.timestampUs, data: unit.data, }), ); }; const flushPendingUnits = async () => { while (pendingUnits.length > 0) { const next = pendingUnits.shift(); if (next) { await decodeQueuedUnit(next); } } }; const ensureDecoder = async (config: { codec: string; description: Uint8Array }) => { if (decoderRef.current) { return; } const support = await VideoDecoder.isConfigSupported(config); if (!support.supported || !support.config) { throw new Error("当前浏览器返回了不支持的 H.264 配置。"); } decoderRef.current = new VideoDecoder({ output(frame) { if (controller.signal.aborted || token !== loadTokenRef.current) { frame.close(); return; } queueFrameMaterialization(frame); decodedCount += 1; }, error(error) { materializationError = materializationError ?? (error instanceof Error ? error : new Error(String(error))); }, }); decoderRef.current.configure(support.config); setStatus("解码器已就绪,正在等待更多分片。"); await flushPendingUnits(); }; const processChunk = async (chunk: Uint8Array) => { bytesRead += chunk.length; setProgress((current) => ({ ...current, bytesRead, bytesTotal, })); const result = parser.push(chunk); if (result.config) { codecRef.current = result.config.codec; latestSummary = { codec: result.config.codec, frameCount: result.frameCount, keyFrameCount: result.keyFrameCount, }; setStreamInfo(latestSummary); await ensureDecoder(result.config); } else if (codecRef.current) { latestSummary = { codec: codecRef.current, frameCount: result.frameCount, keyFrameCount: result.keyFrameCount, }; setStreamInfo(latestSummary); } for (const unit of result.accessUnits) { const queued: QueuedAccessUnit = { type: unit.type, data: unit.data, timestampUs: timestampCursorUs, }; timestampCursorUs += FRAME_STEP_US; await decodeQueuedUnit(queued); } flushPendingFrames(); }; setStatus(nextCacheState === "hit" ? "缓存命中,开始流式读取..." : "网络流式下载中..."); while (true) { if (controller.signal.aborted || token !== loadTokenRef.current) { closeDecoder(); return; } const { done, value } = await reader.read(); if (done) { break; } if (value && value.length > 0) { await processChunk(value); } } const finalResult = parser.flush(); if (finalResult.config) { codecRef.current = finalResult.config.codec; latestSummary = { codec: finalResult.config.codec, frameCount: finalResult.frameCount, keyFrameCount: finalResult.keyFrameCount, }; setStreamInfo(latestSummary); await ensureDecoder(finalResult.config); } for (const unit of finalResult.accessUnits) { const queued: QueuedAccessUnit = { type: unit.type, data: unit.data, timestampUs: timestampCursorUs, }; timestampCursorUs += FRAME_STEP_US; await decodeQueuedUnit(queued); } if (!decoderRef.current) { throw new Error("没有拿到可用的 SPS / PPS,无法初始化解码器。"); } await flushPendingUnits(); await decoderRef.current.flush(); await Promise.all(materializationTasks); if (materializationError) { throw materializationError; } flushPendingFrames(true); closeDecoder(); const summary = latestSummary ?? { codec: codecRef.current ?? "avc1.000000", frameCount: decodedFrames.length, keyFrameCount: decodedFrames.length > 0 ? 1 : 0, }; let frameCacheSaved = false; let videoCacheReleased = false; try { await writeCachedFrameSession({ version: 1, url: videoUrl, sourceSize: bytesTotal, codec: summary.codec, frameCount: decodedFrames.length, keyFrameCount: summary.keyFrameCount, frames: persistedFrames.slice(), }); frameCacheSaved = true; setFrameCacheState("hit"); } catch { setFrameCacheState("miss"); } try { await clearCachedVideoResponse(videoUrl); videoCacheReleased = true; setVideoCacheState("released"); await refreshStorageInfo(); } catch { setVideoCacheState(nextCacheState); } setStatus( frameCacheSaved ? `完成,共切出 ${decodedFrames.length} 帧。${videoCacheReleased ? "原始 video.h264 已释放,仅保留帧缓存。" : "帧缓存已保存,但原始视频缓存清理失败。"}` : `完成,共切出 ${decodedFrames.length} 帧,但帧缓存写入失败。`, ); framesRef.current = decodedFrames.slice(); setFrames(decodedFrames.slice()); setProgress({ bytesRead, bytesTotal, decoded: decodedFrames.length, decodedTotal: decodedFrames.length, }); setLoadState("ready"); setPlaying(false); } catch (cause) { closeDecoder(); if (controller.signal.aborted || token !== loadTokenRef.current) { return; } const message = cause instanceof Error ? cause.message : String(cause); setLoadState("error"); setError(message); setStatus("切帧失败。"); } } useEffect(() => { if (!playing || frames.length === 0) { return; } let cancelled = false; const startIndex = Math.min(playbackAnchorRef.current.index, frames.length - 1); const startTimestampUs = playbackAnchorRef.current.timestampUs; const startTime = playbackAnchorRef.current.startedAt || performance.now(); void drawFrameToCanvas(startIndex); preloadFrameWindow(startIndex + 1); const tick = (now: number) => { if (cancelled) { return; } const elapsedUs = (now - startTime) * 1000; const targetTimestampUs = startTimestampUs + elapsedUs; let nextIndex = startIndex; const currentFrames = framesRef.current; while ( nextIndex + 1 < currentFrames.length && currentFrames[nextIndex + 1].timestampUs <= targetTimestampUs ) { nextIndex += 1; } if (nextIndex !== playheadRef.current) { playheadRef.current = nextIndex; setPlayhead(nextIndex); void drawFrameToCanvas(nextIndex); preloadFrameWindow(nextIndex + 1); } if (nextIndex >= currentFrames.length - 1) { setPlaying(false); return; } rafRef.current = requestAnimationFrame(tick); }; rafRef.current = requestAnimationFrame(tick); return () => { cancelled = true; if (rafRef.current !== null) { cancelAnimationFrame(rafRef.current); rafRef.current = null; } }; }, [playing, frames]); const hasFrames = frames.length > 0; const canPlay = loadState === "ready" && frames.length > 0; return (

Rust backend · Next.js frontend · WebCodecs

边下边看,最后再顺滑播放整段帧序列

前端先从 Cache Storage 里找视频,命中就直接流式解码;没命中则边下边解码, 先把前几帧送到页面。全部帧到齐后,可以切到单画布播放模式,避免一堆图片 同时刷新造成的卡顿。

视频地址

{videoUrl}

默认指向 Rust 服务的 `/video.h264`。

缓存

帧 {describeCacheState(frameCacheState)} · 视频 {describeCacheState(videoCacheState)}

帧缓存命中就能直接播放;原始视频会在切帧完成后尽量释放。

进度

{progress.bytesTotal ? `${Math.round(progressValue)}%` : `${progress.decoded} 帧`}

{progress.bytesTotal ? `${formatBytes(progress.bytesRead)} / ${formatBytes(progress.bytesTotal)}` : status}

存储

{getStorageUsageText()}

{storageInfo.persisted === true ? "已启用持久化,长期缓存更不容易被清理。" : storageInfo.persisted === false ? "可申请持久化存储,减少浏览器回收概率。" : "统计的是整个 origin 的占用,包含帧缓存与视频缓存。"}

运行提示
这页会先从本地帧缓存恢复;没有命中时才去请求后端 video.h264 并边下边切帧。 切帧完成后会尽量只保留帧缓存,减少重复占用存储。
{streamInfo ? (
{streamInfo.codec} {streamInfo.frameCount} 帧 {streamInfo.keyFrameCount} 关键帧
) : null}
{error ?
{error}
: null}

播放模式

{canPlay ? `${playhead + 1} / ${frames.length} · 1x` : "等待可播放帧"}
帧拖动条 {canPlay ? `Frame ${scrubValue + 1} / ${frames.length}` : "等待可播放帧"}
setPlaying(false)} onChange={(event) => { const nextIndex = event.currentTarget.valueAsNumber; if (!Number.isFinite(nextIndex)) { return; } jumpToFrame(nextIndex); }} />
{canPlay ? ( ) : (
{loadState === "loading" ? "正在切帧,第一批缩略图出来后会直接显示在下面。" : hasFrames ? "帧已恢复,准备播放。" : "这里会显示单画布播放区。"}
)}
); } function formatBytes(value: number | null): string { if (value === null) { return "--"; } if (value < 1024) { return `${value} B`; } const units = ["KB", "MB", "GB"]; let size = value / 1024; let unit = 0; while (size >= 1024 && unit < units.length - 1) { size /= 1024; unit += 1; } return `${size.toFixed(size >= 10 ? 1 : 2)} ${units[unit]}`; } function sleep(ms: number) { return new Promise((resolve) => { window.setTimeout(resolve, ms); }); } function canvasToWebpBlob(canvas: HTMLCanvasElement): Promise { return new Promise((resolve, reject) => { canvas.toBlob( (blob) => { if (!blob) { reject(new Error("无法生成 WebP 帧。")); return; } resolve(blob); }, "image/webp", 0.92, ); }); }