Files
video-process/web/components/VideoFrameViewer.tsx
2026-05-19 16:19:55 +08:00

1236 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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<ParsedH264, "codec" | "frameCount" | "keyFrameCount">;
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<LoadState>("idle");
const [videoCacheState, setVideoCacheState] = useState<CacheState>("idle");
const [frameCacheState, setFrameCacheState] = useState<CacheState>("idle");
const [storageInfo, setStorageInfo] = useState<StorageInfo>({
usageBytes: null,
quotaBytes: null,
persisted: null,
});
const [storageRequestState, setStorageRequestState] = useState<StorageRequestState>("idle");
const [status, setStatus] = useState("等待开始。建议使用 Chromium / Edge 浏览器。");
const [error, setError] = useState<string | null>(null);
const [frames, setFrames] = useState<FrameThumb[]>([]);
const [streamInfo, setStreamInfo] = useState<StreamSummary | null>(null);
const [progress, setProgress] = useState<ProgressState>({
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<AbortController | null>(null);
const playbackCanvasRef = useRef<HTMLCanvasElement | null>(null);
const rafRef = useRef<number | null>(null);
const framesRef = useRef<FrameThumb[]>([]);
const playheadRef = useRef(0);
const imageCacheRef = useRef(new Map<number, Promise<HTMLImageElement>>());
const frameObjectUrlsRef = useRef(new Set<string>());
const loadTokenRef = useRef(0);
const playbackAnchorRef = useRef<PlaybackAnchor>({
index: 0,
timestampUs: 0,
startedAt: 0,
});
const codecRef = useRef<string | null>(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<HTMLImageElement> {
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<HTMLImageElement>((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<number, { thumb: FrameThumb; record: CachedFrameRecord }>();
const pendingUnits: QueuedAccessUnit[] = [];
const materializationTasks: Promise<void>[] = [];
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 (
<main className="viewer-shell">
<div className="viewer-inner">
<section className="hero-card">
<div className="hero-copy">
<p className="eyebrow">Rust backend · Next.js frontend · WebCodecs</p>
<h1 className="hero-title"></h1>
<p>
Cache Storage
</p>
</div>
<div className="hero-metrics">
<div className="metric">
<p className="metric-label"></p>
<p className="metric-value">{videoUrl}</p>
<p className="metric-note"> Rust `/video.h264`</p>
</div>
<div className="metric">
<p className="metric-label"></p>
<p className="metric-value">
{describeCacheState(frameCacheState)} · {describeCacheState(videoCacheState)}
</p>
<p className="metric-note">
</p>
</div>
<div className="metric">
<p className="metric-label"></p>
<p className="metric-value">
{progress.bytesTotal ? `${Math.round(progressValue)}%` : `${progress.decoded}`}
</p>
<p className="metric-note">
{progress.bytesTotal
? `${formatBytes(progress.bytesRead)} / ${formatBytes(progress.bytesTotal)}`
: status}
</p>
</div>
<div className="metric">
<p className="metric-label"></p>
<p className="metric-value">{getStorageUsageText()}</p>
<p className="metric-note">
{storageInfo.persisted === true
? "已启用持久化,长期缓存更不容易被清理。"
: storageInfo.persisted === false
? "可申请持久化存储,减少浏览器回收概率。"
: "统计的是整个 origin 的占用,包含帧缓存与视频缓存。"}
</p>
</div>
</div>
</section>
<section className="panel-card">
<div className="toolbar">
<label className="field">
<span className="field-label">Video URL</span>
<input
className="field-input"
value={videoUrl}
onChange={(event) => setVideoUrl(event.target.value)}
spellCheck={false}
/>
</label>
<div className="toolbar-actions">
<button
className="primary-button"
onClick={loadAndDecode}
disabled={loadState === "loading"}
>
{loadState === "loading" ? "解码中..." : "开始切帧"}
</button>
<button
className="ghost-button"
onClick={requestPersistentStorage}
disabled={isPersistentButtonDisabled()}
>
{getPersistentButtonLabel()}
</button>
<button
className="ghost-button"
onClick={clearAllCaches}
disabled={loadState === "loading"}
>
</button>
</div>
</div>
<div className="status-row">
<div>
<div className="status-label"></div>
<div className="status-copy">
video.h264
</div>
</div>
{streamInfo ? (
<div className="pager">
<span className="ghost-button" style={{ cursor: "default" }}>
{streamInfo.codec}
</span>
<span className="ghost-button" style={{ cursor: "default" }}>
{streamInfo.frameCount}
</span>
<span className="ghost-button" style={{ cursor: "default" }}>
{streamInfo.keyFrameCount}
</span>
</div>
) : null}
</div>
<div className="progress-track" aria-label="decode progress">
<div className="progress-bar" style={{ width: `${progressValue}%` }} />
</div>
{error ? <div className="error-state">{error}</div> : null}
<div className="player-card">
<div className="gallery-header">
<div>
<h2 className="gallery-title"></h2>
<div className="gallery-meta">
{canPlay ? `${playhead + 1} / ${frames.length} · 1x` : "等待可播放帧"}
</div>
</div>
<div className="pager">
<button
onClick={() => {
if (playing) {
setPlaying(false);
return;
}
startPlaybackFromCurrentFrame();
}}
disabled={!canPlay}
>
{playing ? "暂停" : "播放"}
</button>
<button
onClick={() => {
setPlaying(false);
setPlayhead(0);
setScrubValue(0);
playheadRef.current = 0;
void drawFrameToCanvas(0);
}}
disabled={!canPlay}
>
</button>
</div>
</div>
<div className="scrubber">
<div className="scrubber-header">
<span className="scrubber-label"></span>
<span className="scrubber-value">
{canPlay ? `Frame ${scrubValue + 1} / ${frames.length}` : "等待可播放帧"}
</span>
</div>
<input
className="scrubber-input"
type="range"
min={0}
max={Math.max(0, frames.length - 1)}
value={Math.min(scrubValue, Math.max(0, frames.length - 1))}
disabled={!canPlay}
aria-label="拖动到指定帧"
onPointerDown={() => setPlaying(false)}
onChange={(event) => {
const nextIndex = event.currentTarget.valueAsNumber;
if (!Number.isFinite(nextIndex)) {
return;
}
jumpToFrame(nextIndex);
}}
/>
</div>
<div className="player-surface">
{canPlay ? (
<canvas ref={playbackCanvasRef} className="playback-canvas" />
) : (
<div className="player-placeholder">
{loadState === "loading"
? "正在切帧,第一批缩略图出来后会直接显示在下面。"
: hasFrames
? "帧已恢复,准备播放。"
: "这里会显示单画布播放区。"}
</div>
)}
</div>
</div>
</section>
</div>
</main>
);
}
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<void>((resolve) => {
window.setTimeout(resolve, ms);
});
}
function canvasToWebpBlob(canvas: HTMLCanvasElement): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (!blob) {
reject(new Error("无法生成 WebP 帧。"));
return;
}
resolve(blob);
},
"image/webp",
0.92,
);
});
}