1236 lines
36 KiB
TypeScript
1236 lines
36 KiB
TypeScript
"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,
|
||
);
|
||
});
|
||
}
|