commit 3ffeb1ae62906ae1ded96835a973911af3ff5112 Author: zhangheng Date: Tue May 19 16:19:55 2026 +0800 feat: add frame caching demo documentation diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..22e6be3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# Rust +target/ + +# Frontend dependencies and build output +web/node_modules/ +web/.next/ +web/out/ +web/.turbo/ + +# Generated analysis artifacts +graphify-out/ + +# Local media asset +video.h264 + +# Environment files +.env +.env.local +.env.development.local +.env.test.local +.env.production.local +web/.env.local +web/.env.development.local +web/.env.test.local +web/.env.production.local +!web/.env.local.example + +# Editor / OS noise +.DS_Store +Thumbs.db +.idea/ +.vscode/ +*.swp +*.swo diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..a6f9a3b --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,538 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +dependencies = [ + "bitflags", + "bytes", + "http", + "pin-project-lite", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "video-process-backend" +version = "0.1.0" +dependencies = [ + "axum", + "http", + "tokio", + "tokio-util", + "tower-http", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..6f67d29 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "video-process-backend" +version = "0.1.0" +edition = "2021" + +[dependencies] +axum = "0.8" +http = "1" +tokio = { version = "1", features = ["fs", "macros", "net", "rt-multi-thread"] } +tokio-util = { version = "0.7", features = ["io"] } +tower-http = { version = "0.6", features = ["cors"] } diff --git a/README.md b/README.md new file mode 100644 index 0000000..708a504 --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# Video Process Demo + +Rust 后端原样提供 `video.h264`,Next.js 前端用 WebCodecs 在浏览器里完成流式切帧、本地帧缓存、单画布播放和帧拖动定位。刷新后优先恢复本地帧,不需要重新拉完整视频再解码。 + +## 启动后端 + +```bash +cargo run +``` + +后端默认监听 `http://localhost:8080`,视频地址是 `http://localhost:8080/video.h264`。 +仓库里不包含这个大体积示例视频,请把 `video.h264` 放到项目根目录,或通过 `NEXT_PUBLIC_VIDEO_URL` 指向你自己的 H.264 地址。 + +## 启动前端 + +```bash +cd web +npm install +NEXT_PUBLIC_VIDEO_URL=http://localhost:8080/video.h264 npm run dev +``` + +打开 `http://localhost:3000` 即可。 + +## 前端架构 + +前端分成四层: + +- 传输缓存:`web/lib/video-cache.ts` 用 `Cache Storage` 保存原始 `video.h264` 响应。 +- 解析切帧:`web/lib/h264.ts` 把 Annex B 字节流拆成 access unit,识别 SPS/PPS 和帧边界。 +- 帧持久化:`web/lib/frame-store.ts` 用 `IndexedDB` 保存 `CachedFrameSession`,每帧以 `Blob` 形式落盘,刷新后可直接恢复。 +- 播放控制:`web/components/VideoFrameViewer.tsx` 负责切帧、恢复、单画布播放、拖动条跳帧和缓存清理。 + +## 运行流程 + +1. 页面先查本地帧会话,命中则直接把 `Blob` 恢复成 `objectURL`,无需重新请求后端。 +2. 未命中时再去请求 `video.h264`,并边下载边解析 SPS/PPS 和 access unit。 +3. 每个 `VideoFrame` 会先画到离屏 canvas,再导出成 WebP `Blob`,同时生成缩略图对象 URL。 +4. 全部帧完成后,会把会话写入 `IndexedDB`,然后尽量清掉原始 `video.h264` 的缓存,减少重复占用。 +5. 播放区只用一个 canvas,通过 `requestAnimationFrame` 按时间戳切帧;拖动条则直接 `jumpToFrame()` 到指定帧。 + +## 缓存策略 + +- 原始视频缓存放在 `Cache Storage`,适合保存完整的 fetch 响应。 +- 切好的帧和元数据放在 `IndexedDB`,避免把大体积帧塞进一个 JSON。 +- 页面提供“申请持久化”按钮,会调用 `navigator.storage.persist()`,降低浏览器回收缓存的概率。 +- “清空缓存”会同时删除帧会话和原始视频缓存;下次打开会重新请求后端并重新切帧。 +- 当前实现还保留了旧版 `Cache Storage` 帧清单的迁移逻辑,便于升级已有缓存。 + +## 关键文件 + +- `web/components/VideoFrameViewer.tsx`:主界面、播放、拖动条、缓存状态。 +- `web/lib/h264.ts`:H.264 Annex B 解析、SPS/PPS、WebCodecs 配置。 +- `web/lib/frame-store.ts`:帧会话读写、校验、迁移。 +- `web/lib/video-cache.ts`:原始视频响应缓存。 + +## 备注 + +前端依赖浏览器的 WebCodecs、Cache Storage 和 IndexedDB 能力,建议使用 Chromium / Edge。 diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..8d7246b --- /dev/null +++ b/src/main.rs @@ -0,0 +1,90 @@ +use axum::{ + body::Body, + http::{ + header::{CACHE_CONTROL, CONTENT_LENGTH, CONTENT_TYPE}, + HeaderValue, StatusCode, + }, + response::Response, + routing::get, + Router, +}; +use std::io::ErrorKind; +use tokio::fs::File; +use tokio_util::io::ReaderStream; +use tower_http::cors::CorsLayer; + +const VIDEO_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/video.h264"); + +#[tokio::main] +async fn main() -> Result<(), std::io::Error> { + let port = std::env::var("PORT") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(8080); + + let app = Router::new() + .route("/", get(index)) + .route("/health", get(health)) + .route("/video.h264", get(video)) + .layer(CorsLayer::permissive()); + + let addr = format!("0.0.0.0:{port}"); + let listener = tokio::net::TcpListener::bind(&addr).await?; + println!("Rust video server listening on http://{addr}"); + + axum::serve(listener, app).await +} + +async fn index() -> &'static str { + "Rust video server is running. GET /video.h264 for the raw H.264 stream." +} + +async fn health() -> &'static str { + "ok" +} + +async fn video() -> Result { + let file = open_video_file().await?; + let file_len = file + .metadata() + .await + .map_err(|err| (map_io_status(&err), format!("failed to read metadata: {err}")))? + .len(); + + let stream = ReaderStream::new(file); + let body = Body::from_stream(stream); + + let mut response = Response::new(body); + let headers = response.headers_mut(); + headers.insert(CONTENT_TYPE, HeaderValue::from_static("video/h264")); + headers.insert(CONTENT_LENGTH, header_value(file_len)?); + headers.insert( + CACHE_CONTROL, + HeaderValue::from_static("public, max-age=86400, stale-while-revalidate=604800"), + ); + + Ok(response) +} + +async fn open_video_file() -> Result { + File::open(VIDEO_PATH) + .await + .map_err(|err| (map_io_status(&err), format!("failed to open {VIDEO_PATH}: {err}"))) +} + +fn header_value(len: u64) -> Result { + HeaderValue::from_str(&len.to_string()).map_err(|err| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("failed to build content-length header: {err}"), + ) + }) +} + +fn map_io_status(error: &std::io::Error) -> StatusCode { + match error.kind() { + ErrorKind::NotFound => StatusCode::NOT_FOUND, + ErrorKind::PermissionDenied => StatusCode::FORBIDDEN, + _ => StatusCode::INTERNAL_SERVER_ERROR, + } +} diff --git a/web/.env.local.example b/web/.env.local.example new file mode 100644 index 0000000..8c98dc5 --- /dev/null +++ b/web/.env.local.example @@ -0,0 +1 @@ +NEXT_PUBLIC_VIDEO_URL=http://localhost:8080/video.h264 diff --git a/web/app/globals.css b/web/app/globals.css new file mode 100644 index 0000000..d5c793c --- /dev/null +++ b/web/app/globals.css @@ -0,0 +1,461 @@ +:root { + color-scheme: dark; + --bg: #07101d; + --bg-soft: #0d1828; + --panel: rgba(12, 19, 33, 0.82); + --panel-strong: rgba(16, 25, 41, 0.94); + --border: rgba(255, 255, 255, 0.1); + --text: #eef4ff; + --muted: #a9bbd5; + --accent: #70d6ff; + --accent-strong: #35b8ff; + --accent-warm: #f4b860; + --shadow: 0 24px 80px rgba(0, 0, 0, 0.45); +} + +* { + box-sizing: border-box; +} + +html, +body { + min-height: 100%; + margin: 0; +} + +body { + color: var(--text); + background: + radial-gradient(circle at top left, rgba(112, 214, 255, 0.18), transparent 32%), + radial-gradient(circle at 80% 18%, rgba(244, 184, 96, 0.12), transparent 28%), + linear-gradient(180deg, #07101d 0%, #040912 100%); + font-family: var(--font-body), "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; +} + +button, +input { + font: inherit; +} + +a { + color: inherit; +} + +.viewer-shell { + position: relative; + min-height: 100vh; + padding: 32px; + overflow: hidden; +} + +.viewer-shell::before, +.viewer-shell::after { + content: ""; + position: absolute; + inset: auto; + width: 420px; + height: 420px; + border-radius: 999px; + filter: blur(72px); + opacity: 0.32; + pointer-events: none; +} + +.viewer-shell::before { + top: -100px; + right: -120px; + background: rgba(112, 214, 255, 0.24); +} + +.viewer-shell::after { + bottom: -120px; + left: -120px; + background: rgba(244, 184, 96, 0.18); +} + +.viewer-inner { + position: relative; + z-index: 1; + max-width: 1440px; + margin: 0 auto; + display: grid; + gap: 20px; +} + +.hero-card, +.panel-card { + border: 1px solid var(--border); + background: var(--panel); + box-shadow: var(--shadow); + backdrop-filter: blur(16px); +} + +.hero-card { + display: grid; + grid-template-columns: minmax(0, 1.5fr) minmax(280px, 0.85fr); + gap: 20px; + padding: 28px; + border-radius: 28px; +} + +.eyebrow { + margin: 0 0 12px; + color: var(--accent); + font-family: var(--font-mono), monospace; + font-size: 12px; + letter-spacing: 0.18em; + text-transform: uppercase; +} + +.hero-title { + margin: 0; + font-family: var(--font-display), var(--font-body), sans-serif; + font-size: clamp(2rem, 4vw, 4rem); + line-height: 1.02; +} + +.hero-copy p { + margin: 14px 0 0; + max-width: 68ch; + color: var(--muted); + line-height: 1.7; +} + +.hero-metrics { + display: grid; + gap: 12px; + align-content: start; +} + +.metric { + padding: 16px 18px; + border-radius: 20px; + background: var(--panel-strong); + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.metric-label { + margin: 0 0 8px; + color: var(--muted); + font-size: 12px; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.metric-value { + margin: 0; + font-family: var(--font-mono), monospace; + font-size: 16px; + line-height: 1.45; + overflow-wrap: anywhere; + word-break: break-word; +} + +.metric-note { + margin: 8px 0 0; + color: var(--muted); + font-size: 13px; + line-height: 1.6; +} + +.panel-card { + padding: 22px; + border-radius: 24px; +} + +.toolbar { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 14px; + align-items: end; +} + +.toolbar-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + justify-content: flex-end; + align-items: center; +} + +.field { + display: grid; + gap: 10px; +} + +.field-label, +.status-label { + color: var(--muted); + font-size: 12px; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.field-input { + width: 100%; + padding: 14px 16px; + border-radius: 16px; + border: 1px solid rgba(255, 255, 255, 0.12); + color: var(--text); + background: rgba(3, 8, 18, 0.72); + outline: none; + transition: border-color 0.18s ease, box-shadow 0.18s ease; +} + +.field-input:focus { + border-color: rgba(112, 214, 255, 0.55); + box-shadow: 0 0 0 4px rgba(112, 214, 255, 0.12); +} + +.primary-button, +.ghost-button { + border: 0; + border-radius: 16px; + padding: 14px 18px; + cursor: pointer; + transition: transform 0.18s ease, opacity 0.18s ease, background 0.18s ease; +} + +.primary-button { + background: linear-gradient(135deg, var(--accent-strong), #7f8cff); + color: #04111b; + font-weight: 700; + min-width: 132px; +} + +.ghost-button { + color: var(--text); + background: rgba(255, 255, 255, 0.06); +} + +.primary-button:hover, +.ghost-button:hover { + transform: translateY(-1px); +} + +.primary-button:disabled, +.ghost-button:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +.toolbar-actions .ghost-button { + min-width: 132px; +} + +.status-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + margin-top: 18px; + align-items: center; +} + +.status-copy { + color: var(--muted); + line-height: 1.6; +} + +.progress-track { + margin-top: 18px; + height: 10px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + overflow: hidden; +} + +.progress-bar { + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, var(--accent), var(--accent-warm)); + width: 0%; + transition: width 0.2s ease; +} + +.gallery-header { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; + margin: 20px 0 14px; +} + +.gallery-title { + margin: 0; + font-family: var(--font-display), var(--font-body), sans-serif; + font-size: 18px; +} + +.gallery-meta { + color: var(--muted); + font-family: var(--font-mono), monospace; + font-size: 13px; +} + +.player-card { + margin-top: 18px; + padding: 18px; + border-radius: 22px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.scrubber { + margin-top: 16px; + padding: 14px 16px; + border-radius: 18px; + background: rgba(2, 5, 11, 0.42); + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.scrubber-header { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; + margin-bottom: 10px; +} + +.scrubber-label { + color: var(--muted); + font-size: 12px; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.scrubber-value { + color: var(--text); + font-family: var(--font-mono), monospace; + font-size: 13px; +} + +.scrubber-input { + width: 100%; + height: 28px; + margin: 0; + background: transparent; + cursor: pointer; + accent-color: var(--accent-strong); +} + +.scrubber-input:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.scrubber-input::-webkit-slider-runnable-track { + height: 8px; + border-radius: 999px; + background: linear-gradient(90deg, rgba(112, 214, 255, 0.9), rgba(244, 184, 96, 0.85)); + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.scrubber-input::-webkit-slider-thumb { + appearance: none; + width: 20px; + height: 20px; + margin-top: -7px; + border-radius: 999px; + border: 2px solid rgba(5, 9, 18, 0.92); + background: #eef4ff; + box-shadow: 0 8px 18px rgba(0, 0, 0, 0.35); +} + +.scrubber-input::-moz-range-track { + height: 8px; + border-radius: 999px; + background: linear-gradient(90deg, rgba(112, 214, 255, 0.9), rgba(244, 184, 96, 0.85)); + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.scrubber-input::-moz-range-thumb { + width: 20px; + height: 20px; + border-radius: 999px; + border: 2px solid rgba(5, 9, 18, 0.92); + background: #eef4ff; + box-shadow: 0 8px 18px rgba(0, 0, 0, 0.35); +} + +.player-surface { + min-height: 240px; +} + +.playback-canvas { + display: block; + width: 100%; + max-height: 72vh; + border-radius: 18px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: #02050b; +} + +.player-placeholder { + min-height: 240px; + display: grid; + place-items: center; + padding: 24px; + border-radius: 18px; + border: 1px dashed rgba(255, 255, 255, 0.14); + color: var(--muted); + text-align: center; + background: rgba(2, 5, 11, 0.72); +} + +.pager { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.pager button { + appearance: none; + min-width: 44px; + padding: 10px 12px; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + color: var(--text); + background: rgba(255, 255, 255, 0.05); + cursor: pointer; + transition: transform 0.18s ease, background 0.18s ease, border-color 0.18s ease; +} + +.pager button.active { + border-color: rgba(112, 214, 255, 0.5); + background: rgba(112, 214, 255, 0.14); +} + +.pager button:hover:not(:disabled) { + transform: translateY(-1px); + border-color: rgba(112, 214, 255, 0.35); + background: rgba(255, 255, 255, 0.08); +} + +.empty-state, +.error-state, +.loading-state { + padding: 28px 18px; + border-radius: 18px; + border: 1px dashed rgba(255, 255, 255, 0.14); + color: var(--muted); + text-align: center; +} + +.error-state { + color: #ffd2cf; + border-color: rgba(255, 134, 120, 0.3); + background: rgba(255, 87, 64, 0.08); +} + +@media (max-width: 920px) { + .viewer-shell { + padding: 18px; + } + + .hero-card, + .toolbar, + .status-row { + grid-template-columns: 1fr; + } +} diff --git a/web/app/layout.tsx b/web/app/layout.tsx new file mode 100644 index 0000000..786be51 --- /dev/null +++ b/web/app/layout.tsx @@ -0,0 +1,42 @@ +import type { Metadata } from "next"; +import { IBM_Plex_Mono, Noto_Sans_SC, Space_Grotesk } from "next/font/google"; +import "./globals.css"; + +const display = Space_Grotesk({ + subsets: ["latin"], + variable: "--font-display", + display: "swap", +}); + +const body = Noto_Sans_SC({ + subsets: ["latin"], + weight: ["400", "500", "700"], + variable: "--font-body", + display: "swap", +}); + +const mono = IBM_Plex_Mono({ + subsets: ["latin"], + weight: ["400", "500"], + variable: "--font-mono", + display: "swap", +}); + +export const metadata: Metadata = { + title: "H.264 Frame Viewer", + description: "Rust backend + Next.js frontend for H.264 frame extraction", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/web/app/page.tsx b/web/app/page.tsx new file mode 100644 index 0000000..69ca59f --- /dev/null +++ b/web/app/page.tsx @@ -0,0 +1,5 @@ +import VideoFrameViewer from "@/components/VideoFrameViewer"; + +export default function Page() { + return ; +} diff --git a/web/components/VideoFrameViewer.tsx b/web/components/VideoFrameViewer.tsx new file mode 100644 index 0000000..525ec59 --- /dev/null +++ b/web/components/VideoFrameViewer.tsx @@ -0,0 +1,1235 @@ +"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, + ); + }); +} diff --git a/web/lib/frame-store.ts b/web/lib/frame-store.ts new file mode 100644 index 0000000..fb9d7a4 --- /dev/null +++ b/web/lib/frame-store.ts @@ -0,0 +1,400 @@ +const DB_NAME = "video-process-frame-store-v1"; +const DB_VERSION = 1; +const STORE_NAME = "sessions"; +const LEGACY_CACHE_NAME = "video-process-frame-manifests-v1"; + +export interface CachedFrameRecord { + index: number; + width: number; + height: number; + timestampUs: number; + blob: Blob; +} + +export interface CachedFrameSession { + version: 1; + url: string; + sourceSize: number | null; + codec: string; + frameCount: number; + keyFrameCount: number; + frames: CachedFrameRecord[]; +} + +interface LegacyFrameThumb { + index: number; + src: string; + width: number; + height: number; + timestampUs: number; +} + +interface LegacyFrameManifest { + version: 1; + url: string; + sourceSize: number | null; + codec: string; + frameCount: number; + keyFrameCount: number; + frames: LegacyFrameThumb[]; +} + +export async function readCachedFrameSession(url: string): Promise { + const stored = await readSessionFromIndexedDb(url); + if (stored) { + return stored; + } + + const legacy = await readLegacyFrameManifest(url); + if (!legacy) { + return null; + } + + try { + await writeCachedFrameSession(legacy); + await deleteLegacyFrameManifest(url); + } catch { + // Migration is best-effort. + } + + return legacy; +} + +export async function writeCachedFrameSession(session: CachedFrameSession): Promise { + const normalized = normalizeCachedFrameSession(session); + if (!normalized) { + throw new Error("无效的帧缓存会话。"); + } + + if (typeof indexedDB === "undefined") { + return; + } + + await runStoreOperation("readwrite", (store) => store.put(normalized)); +} + +export async function clearCachedFrameSession(url: string): Promise { + if (typeof indexedDB !== "undefined") { + try { + await runStoreOperation("readwrite", (store) => store.delete(url)); + } catch { + // IndexedDB is best-effort. + } + } + + await deleteLegacyFrameManifest(url); +} + +function normalizeCachedFrameSession(value: unknown, expectedUrl?: string): CachedFrameSession | null { + if (!isRecord(value)) { + return null; + } + + if (value.version !== 1 || typeof value.url !== "string") { + return null; + } + + if (expectedUrl && value.url !== expectedUrl) { + return null; + } + + if ( + !isNullableNumber(value.sourceSize) || + typeof value.codec !== "string" || + !isNonNegativeInteger(value.frameCount) || + !isNonNegativeInteger(value.keyFrameCount) || + !Array.isArray(value.frames) + ) { + return null; + } + + const frames = value.frames.map(normalizeCachedFrameRecord); + if (frames.some((frame) => frame === null)) { + return null; + } + + const normalizedFrames = frames as CachedFrameRecord[]; + if (normalizedFrames.length !== value.frameCount) { + return null; + } + + normalizedFrames.sort((left, right) => left.index - right.index); + + return { + version: 1, + url: value.url, + sourceSize: value.sourceSize, + codec: value.codec, + frameCount: value.frameCount, + keyFrameCount: value.keyFrameCount, + frames: normalizedFrames, + }; +} + +function normalizeCachedFrameRecord(value: unknown): CachedFrameRecord | null { + if (!isRecord(value)) { + return null; + } + + if ( + !isNonNegativeInteger(value.index) || + !isPositiveInteger(value.width) || + !isPositiveInteger(value.height) || + !isNonNegativeInteger(value.timestampUs) || + !(value.blob instanceof Blob) + ) { + return null; + } + + return { + index: value.index, + width: value.width, + height: value.height, + timestampUs: value.timestampUs, + blob: value.blob, + }; +} + +async function readSessionFromIndexedDb(url: string): Promise { + if (typeof indexedDB === "undefined") { + return null; + } + + try { + const record = await runStoreOperation("readonly", (store) => store.get(url)); + return normalizeCachedFrameSession(record, url); + } catch { + return null; + } +} + +async function readLegacyFrameManifest(url: string): Promise { + if (typeof caches === "undefined") { + return null; + } + + try { + const cache = await caches.open(LEGACY_CACHE_NAME); + const cached = await cache.match(url); + if (!cached) { + return null; + } + + const parsed = (await cached.json()) as unknown; + const legacy = normalizeLegacyFrameManifest(parsed, url); + if (!legacy) { + return null; + } + + const frames = await Promise.all( + legacy.frames.map(async (frame) => ({ + index: frame.index, + width: frame.width, + height: frame.height, + timestampUs: frame.timestampUs, + blob: dataUrlToBlob(frame.src), + })), + ); + + frames.sort((left, right) => left.index - right.index); + + return { + version: 1, + url: legacy.url, + sourceSize: legacy.sourceSize, + codec: legacy.codec, + frameCount: legacy.frameCount, + keyFrameCount: legacy.keyFrameCount, + frames, + }; + } catch { + return null; + } +} + +async function deleteLegacyFrameManifest(url: string): Promise { + if (typeof caches === "undefined") { + return; + } + + try { + const cache = await caches.open(LEGACY_CACHE_NAME); + await cache.delete(url); + } catch { + // Cache Storage is best-effort. + } +} + +function normalizeLegacyFrameManifest(value: unknown, expectedUrl?: string): LegacyFrameManifest | null { + if (!isRecord(value)) { + return null; + } + + if (value.version !== 1 || typeof value.url !== "string") { + return null; + } + + if (expectedUrl && value.url !== expectedUrl) { + return null; + } + + if ( + !isNullableNumber(value.sourceSize) || + typeof value.codec !== "string" || + !isNonNegativeInteger(value.frameCount) || + !isNonNegativeInteger(value.keyFrameCount) || + !Array.isArray(value.frames) + ) { + return null; + } + + const frames = value.frames.map((frame) => { + if (!isRecord(frame)) { + return null; + } + + if ( + !isNonNegativeInteger(frame.index) || + typeof frame.src !== "string" || + !isPositiveInteger(frame.width) || + !isPositiveInteger(frame.height) || + !isNonNegativeInteger(frame.timestampUs) + ) { + return null; + } + + return { + index: frame.index, + src: frame.src, + width: frame.width, + height: frame.height, + timestampUs: frame.timestampUs, + }; + }); + + if (frames.some((frame) => frame === null)) { + return null; + } + + const normalizedFrames = frames as LegacyFrameThumb[]; + if (normalizedFrames.length !== value.frameCount) { + return null; + } + + normalizedFrames.sort((left, right) => left.index - right.index); + + return { + version: 1, + url: value.url, + sourceSize: value.sourceSize, + codec: value.codec, + frameCount: value.frameCount, + keyFrameCount: value.keyFrameCount, + frames: normalizedFrames, + }; +} + +function dataUrlToBlob(dataUrl: string): Blob { + const separatorIndex = dataUrl.indexOf(","); + if (!dataUrl.startsWith("data:") || separatorIndex === -1) { + throw new Error("无效的数据 URL。"); + } + + const header = dataUrl.slice(5, separatorIndex); + const payload = dataUrl.slice(separatorIndex + 1); + const isBase64 = header.endsWith(";base64"); + const mimeType = isBase64 ? header.slice(0, -7) || "application/octet-stream" : header || "application/octet-stream"; + + if (!isBase64) { + return new Blob([decodeURIComponent(payload)], { type: mimeType }); + } + + const binary = atob(payload); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + + return new Blob([bytes], { type: mimeType }); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isNullableNumber(value: unknown): value is number | null { + return value === null || typeof value === "number"; +} + +function isNonNegativeInteger(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && value >= 0; +} + +function isPositiveInteger(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && value > 0; +} + +function openDatabase(): Promise { + if (typeof indexedDB === "undefined") { + return Promise.reject(new Error("当前浏览器不支持 IndexedDB。")); + } + + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME, { keyPath: "url" }); + } + }; + + request.onerror = () => { + reject(request.error ?? new Error("打开 IndexedDB 失败。")); + }; + + request.onsuccess = () => { + const db = request.result; + db.onversionchange = () => db.close(); + resolve(db); + }; + }); +} + +function runStoreOperation( + mode: IDBTransactionMode, + operation: (store: IDBObjectStore) => IDBRequest, +): Promise { + return openDatabase().then( + (db) => + new Promise((resolve, reject) => { + const transaction = db.transaction(STORE_NAME, mode); + const store = transaction.objectStore(STORE_NAME); + let result: T | undefined; + let requestError: Error | null = null; + + const request = operation(store); + request.onsuccess = () => { + result = request.result; + }; + request.onerror = () => { + requestError = request.error ?? new Error("IndexedDB 请求失败。"); + transaction.abort(); + }; + + transaction.oncomplete = () => { + db.close(); + resolve(result as T); + }; + + transaction.onabort = () => { + db.close(); + reject(requestError ?? transaction.error ?? new Error("IndexedDB 事务已中止。")); + }; + + transaction.onerror = () => { + requestError = requestError ?? transaction.error ?? request.error ?? new Error("IndexedDB 事务失败。"); + }; + }), + ); +} diff --git a/web/lib/h264.ts b/web/lib/h264.ts new file mode 100644 index 0000000..d67b8f4 --- /dev/null +++ b/web/lib/h264.ts @@ -0,0 +1,514 @@ +export interface H264AccessUnit { + type: "key" | "delta"; + data: Uint8Array; +} + +export interface FrameThumb { + index: number; + src: string; + width: number; + height: number; + timestampUs: number; +} + +export interface ParsedH264 { + codec: string; + description: Uint8Array; + accessUnits: H264AccessUnit[]; + frameCount: number; + keyFrameCount: number; +} + +export interface H264DecoderConfig { + codec: string; + description: Uint8Array; +} + +export interface H264StreamChunk { + accessUnits: H264AccessUnit[]; + config?: H264DecoderConfig; + frameCount: number; + keyFrameCount: number; + bufferedBytes: number; + done: boolean; +} + +interface RawNalUnit { + type: number; + data: Uint8Array; +} + +export function parseH264AnnexB(bytes: Uint8Array): ParsedH264 { + const parser = new H264AnnexBStreamParser(); + const first = parser.push(bytes); + const last = parser.flush(); + const accessUnits = [...first.accessUnits, ...last.accessUnits]; + const config = first.config ?? last.config; + + if (!config) { + throw new Error("没有找到 SPS / PPS,无法配置 WebCodecs 解码器。"); + } + + const codec = config.codec; + const description = config.description; + const keyFrameCount = accessUnits.filter((unit) => unit.type === "key").length; + + return { + codec, + description, + accessUnits, + frameCount: accessUnits.length, + keyFrameCount, + }; +} + +export class H264AnnexBStreamParser { + private buffer: Uint8Array = new Uint8Array(0); + private spsUnits: Uint8Array[] = []; + private ppsUnits: Uint8Array[] = []; + private currentSlices: Uint8Array[] = []; + private currentIsKey = false; + private currentHasVcl = false; + private frameCount = 0; + private keyFrameCount = 0; + private config: H264DecoderConfig | null = null; + private configSignature = ""; + + push(chunk: Uint8Array): H264StreamChunk { + this.append(chunk); + return this.drain(false); + } + + flush(): H264StreamChunk { + return this.drain(true); + } + + private append(chunk: Uint8Array) { + if (chunk.length === 0) { + return; + } + + const merged = new Uint8Array(this.buffer.length + chunk.length); + merged.set(this.buffer, 0); + merged.set(chunk, this.buffer.length); + this.buffer = merged; + } + + private drain(final: boolean): H264StreamChunk { + const accessUnits: H264AccessUnit[] = []; + + while (true) { + const starts = findStartCodes(this.buffer); + + if (starts.length === 0) { + this.buffer = final ? new Uint8Array(0) : keepTail(this.buffer); + break; + } + + if (starts[0].index > 0) { + this.buffer = this.buffer.slice(starts[0].index); + continue; + } + + const processCount = final ? starts.length : starts.length - 1; + if (processCount <= 0) { + break; + } + + for (let index = 0; index < processCount; index += 1) { + const start = starts[index]; + const next = starts[index + 1]; + const nalStart = start.index + start.length; + const nalEnd = next ? next.index : this.buffer.length; + + if (nalEnd <= nalStart) { + continue; + } + + const nal = { + type: this.buffer[nalStart] & 0x1f, + data: copyBytes(this.buffer.subarray(nalStart, nalEnd)), + }; + + const flushed = this.processNal(nal); + if (flushed) { + accessUnits.push(flushed); + } + } + + if (final) { + this.buffer = new Uint8Array(0); + break; + } + + this.buffer = this.buffer.slice(starts[processCount].index); + } + + const finalAccessUnit = final ? this.flushCurrentAccessUnit() : null; + if (finalAccessUnit) { + accessUnits.push(finalAccessUnit); + } + + const config = this.ensureConfig(); + + return { + accessUnits, + config: config ?? undefined, + frameCount: this.frameCount, + keyFrameCount: this.keyFrameCount, + bufferedBytes: this.buffer.length, + done: final && this.buffer.length === 0, + }; + } + + private processNal(nal: RawNalUnit): H264AccessUnit | null { + if (nal.type === 7) { + if (!containsBytes(this.spsUnits, nal.data)) { + this.spsUnits.push(nal.data); + this.configSignature = ""; + this.config = null; + } + return null; + } + + if (nal.type === 8) { + if (!containsBytes(this.ppsUnits, nal.data)) { + this.ppsUnits.push(nal.data); + this.configSignature = ""; + this.config = null; + } + return null; + } + + if (nal.type !== 1 && nal.type !== 5) { + return null; + } + + const firstMbInSlice = readFirstMbInSlice(nal.data); + if (firstMbInSlice === 0 && this.currentHasVcl) { + const flushed = this.flushCurrentAccessUnit(); + this.currentSlices = [nal.data]; + this.currentHasVcl = true; + this.currentIsKey = nal.type === 5; + return flushed; + } + + this.currentSlices.push(nal.data); + this.currentHasVcl = true; + this.currentIsKey = this.currentIsKey || nal.type === 5; + return null; + } + + private flushCurrentAccessUnit(): H264AccessUnit | null { + if (this.currentSlices.length === 0) { + this.currentHasVcl = false; + this.currentIsKey = false; + return null; + } + + const accessUnit: H264AccessUnit = { + type: this.currentIsKey ? "key" : "delta", + data: concatNalPayloads(this.currentSlices), + }; + + this.frameCount += 1; + if (accessUnit.type === "key") { + this.keyFrameCount += 1; + } + + this.currentSlices = []; + this.currentHasVcl = false; + this.currentIsKey = false; + return accessUnit; + } + + private ensureConfig(): H264DecoderConfig | null { + if (this.spsUnits.length === 0 || this.ppsUnits.length === 0) { + return null; + } + + const signature = `${this.spsUnits.map(bytesToHex).join("|")}::${this.ppsUnits + .map(bytesToHex) + .join("|")}`; + if (this.config && this.configSignature === signature) { + return this.config; + } + + this.config = { + codec: codecStringFromSps(this.spsUnits[0]), + description: buildAvcDecoderConfigRecord(this.spsUnits, this.ppsUnits), + }; + this.configSignature = signature; + return this.config; + } +} + +function findStartCodes(bytes: Uint8Array): Array<{ index: number; length: number }> { + const starts: Array<{ index: number; length: number }> = []; + + for (let index = 0; index < bytes.length - 3; index += 1) { + if (bytes[index] !== 0 || bytes[index + 1] !== 0) { + continue; + } + + if (bytes[index + 2] === 1) { + starts.push({ index, length: 3 }); + index += 2; + continue; + } + + if (index < bytes.length - 4 && bytes[index + 2] === 0 && bytes[index + 3] === 1) { + starts.push({ index, length: 4 }); + index += 3; + } + } + + return starts; +} + +function keepTail(bytes: Uint8Array): Uint8Array { + if (bytes.length <= 3) { + return bytes.slice(); + } + + return bytes.slice(bytes.length - 3); +} + +function extractNalUnits(bytes: Uint8Array): RawNalUnit[] { + const starts: Array<{ index: number; length: number }> = []; + + for (let index = 0; index < bytes.length - 3; index += 1) { + if (bytes[index] !== 0 || bytes[index + 1] !== 0) { + continue; + } + + if (bytes[index + 2] === 1) { + starts.push({ index, length: 3 }); + index += 2; + continue; + } + + if (index < bytes.length - 4 && bytes[index + 2] === 0 && bytes[index + 3] === 1) { + starts.push({ index, length: 4 }); + index += 3; + } + } + + if (starts.length === 0) { + throw new Error("没有找到 Annex-B start code。"); + } + + const nalUnits: RawNalUnit[] = []; + + for (let i = 0; i < starts.length; i += 1) { + const start = starts[i]; + const next = starts[i + 1]; + const nalStart = start.index + start.length; + const nalEnd = next ? next.index : bytes.length; + + if (nalEnd <= nalStart) { + continue; + } + + const data = bytes.subarray(nalStart, nalEnd); + nalUnits.push({ + type: data[0] & 0x1f, + data, + }); + } + + return nalUnits; +} + +function readFirstMbInSlice(nalData: Uint8Array): number { + if (nalData.length < 2) { + return 0; + } + + const rbsp = removeEmulationPreventionBytes(nalData.subarray(1)); + const reader = new BitReader(rbsp); + return reader.readUE(); +} + +function codecStringFromSps(sps: Uint8Array): string { + if (sps.length < 4) { + throw new Error("SPS 长度不足,无法生成 codec 字符串。"); + } + + const profile = sps[1].toString(16).padStart(2, "0"); + const compatibility = sps[2].toString(16).padStart(2, "0"); + const level = sps[3].toString(16).padStart(2, "0"); + + return `avc1.${profile}${compatibility}${level}`; +} + +function buildAvcDecoderConfigRecord( + spsUnits: Uint8Array[], + ppsUnits: Uint8Array[], +): Uint8Array { + const spsList = uniqueByContent(spsUnits); + const ppsList = uniqueByContent(ppsUnits); + + let size = 7; + for (const sps of spsList) { + size += 2 + sps.length; + } + size += 1; + for (const pps of ppsList) { + size += 2 + pps.length; + } + + const record = new Uint8Array(size); + let offset = 0; + + record[offset++] = 1; + record[offset++] = spsList[0][1]; + record[offset++] = spsList[0][2]; + record[offset++] = spsList[0][3]; + record[offset++] = 0xff; + record[offset++] = 0xe0 | spsList.length; + + for (const sps of spsList) { + writeUint16(record, offset, sps.length); + offset += 2; + record.set(sps, offset); + offset += sps.length; + } + + record[offset++] = ppsList.length; + + for (const pps of ppsList) { + writeUint16(record, offset, pps.length); + offset += 2; + record.set(pps, offset); + offset += pps.length; + } + + return record; +} + +function concatNalPayloads(units: Uint8Array[]): Uint8Array { + let size = 0; + for (const unit of units) { + size += 4 + unit.length; + } + + const output = new Uint8Array(size); + let offset = 0; + + for (const unit of units) { + writeUint32(output, offset, unit.length); + offset += 4; + output.set(unit, offset); + offset += unit.length; + } + + return output; +} + +function removeEmulationPreventionBytes(data: Uint8Array): Uint8Array { + const output: number[] = []; + let zeroCount = 0; + + for (const byte of data) { + if (zeroCount >= 2 && byte === 0x03) { + zeroCount = 0; + continue; + } + + output.push(byte); + zeroCount = byte === 0x00 ? zeroCount + 1 : 0; + } + + return Uint8Array.from(output); +} + +function uniqueByContent(units: Uint8Array[]): Uint8Array[] { + const seen = new Set(); + const result: Uint8Array[] = []; + + for (const unit of units) { + const key = bytesToHex(unit); + if (seen.has(key)) { + continue; + } + + seen.add(key); + result.push(unit); + } + + return result; +} + +function containsBytes(units: Uint8Array[], candidate: Uint8Array): boolean { + const candidateKey = bytesToHex(candidate); + return units.some((unit) => bytesToHex(unit) === candidateKey); +} + +function copyBytes(bytes: Uint8Array): Uint8Array { + return bytes.slice(); +} + +function bytesToHex(bytes: Uint8Array): string { + let output = ""; + for (const byte of bytes) { + output += byte.toString(16).padStart(2, "0"); + } + return output; +} + +function writeUint16(target: Uint8Array, offset: number, value: number) { + target[offset] = (value >> 8) & 0xff; + target[offset + 1] = value & 0xff; +} + +function writeUint32(target: Uint8Array, offset: number, value: number) { + target[offset] = (value >>> 24) & 0xff; + target[offset + 1] = (value >>> 16) & 0xff; + target[offset + 2] = (value >>> 8) & 0xff; + target[offset + 3] = value & 0xff; +} + +class BitReader { + private readonly data: Uint8Array; + private byteIndex = 0; + private bitIndex = 7; + + constructor(data: Uint8Array) { + this.data = data; + } + + readBit(): number { + if (this.byteIndex >= this.data.length) { + return 0; + } + + const bit = (this.data[this.byteIndex] >> this.bitIndex) & 1; + this.bitIndex -= 1; + + if (this.bitIndex < 0) { + this.bitIndex = 7; + this.byteIndex += 1; + } + + return bit; + } + + readBits(count: number): number { + let value = 0; + for (let index = 0; index < count; index += 1) { + value = (value << 1) | this.readBit(); + } + return value; + } + + readUE(): number { + let zeroCount = 0; + while (this.readBit() === 0 && zeroCount < 31) { + zeroCount += 1; + } + + const suffix = zeroCount > 0 ? this.readBits(zeroCount) : 0; + return (1 << zeroCount) - 1 + suffix; + } +} diff --git a/web/lib/video-cache.ts b/web/lib/video-cache.ts new file mode 100644 index 0000000..43bb73f --- /dev/null +++ b/web/lib/video-cache.ts @@ -0,0 +1,45 @@ +const CACHE_NAME = "video-process-h264-v1"; + +export async function openCachedVideoResponse( + url: string, + signal: AbortSignal, +): Promise<{ response: Response; cacheState: "hit" | "miss" }> { + if (typeof caches !== "undefined") { + const cache = await caches.open(CACHE_NAME); + const cached = await cache.match(url); + if (cached) { + return { response: cached, cacheState: "hit" }; + } + } + + const response = await fetch(url, { + signal, + cache: "no-store", + }); + + if (!response.ok) { + throw new Error(`请求失败:${response.status} ${response.statusText}`); + } + + if (typeof caches !== "undefined") { + const cache = await caches.open(CACHE_NAME); + cache.put(url, response.clone()).catch(() => { + // Cache Storage is best-effort. + }); + } + + return { response, cacheState: "miss" }; +} + +export async function clearCachedVideoResponse(url: string): Promise { + if (typeof caches === "undefined") { + return; + } + + try { + const cache = await caches.open(CACHE_NAME); + await cache.delete(url); + } catch { + // Cache Storage is best-effort. + } +} diff --git a/web/next-env.d.ts b/web/next-env.d.ts new file mode 100644 index 0000000..40c3d68 --- /dev/null +++ b/web/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/web/next.config.mjs b/web/next.config.mjs new file mode 100644 index 0000000..d5456a1 --- /dev/null +++ b/web/next.config.mjs @@ -0,0 +1,6 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, +}; + +export default nextConfig; diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..cae5eb5 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,496 @@ +{ + "name": "video-frame-viewer", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "video-frame-viewer", + "dependencies": { + "next": "^14.2.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "typescript": "^5.5.4" + } + }, + "node_modules/@next/env": { + "version": "14.2.35", + "resolved": "https://registry.npmmirror.com/@next/env/-/env-14.2.35.tgz", + "integrity": "sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "14.2.33", + "resolved": "https://registry.npmmirror.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.33.tgz", + "integrity": "sha512-HqYnb6pxlsshoSTubdXKu15g3iivcbsMXg4bYpjL2iS/V6aQot+iyF4BUc2qA/J/n55YtvE4PHMKWBKGCF/+wA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "14.2.33", + "resolved": "https://registry.npmmirror.com/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.33.tgz", + "integrity": "sha512-8HGBeAE5rX3jzKvF593XTTFg3gxeU4f+UWnswa6JPhzaR6+zblO5+fjltJWIZc4aUalqTclvN2QtTC37LxvZAA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "14.2.33", + "resolved": "https://registry.npmmirror.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.33.tgz", + "integrity": "sha512-JXMBka6lNNmqbkvcTtaX8Gu5by9547bukHQvPoLe9VRBx1gHwzf5tdt4AaezW85HAB3pikcvyqBToRTDA4DeLw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "14.2.33", + "resolved": "https://registry.npmmirror.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.33.tgz", + "integrity": "sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "14.2.33", + "resolved": "https://registry.npmmirror.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.33.tgz", + "integrity": "sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "14.2.33", + "resolved": "https://registry.npmmirror.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.33.tgz", + "integrity": "sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "14.2.33", + "resolved": "https://registry.npmmirror.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.33.tgz", + "integrity": "sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-ia32-msvc": { + "version": "14.2.33", + "resolved": "https://registry.npmmirror.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.33.tgz", + "integrity": "sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "14.2.33", + "resolved": "https://registry.npmmirror.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.33.tgz", + "integrity": "sha512-nOjfZMy8B94MdisuzZo9/57xuFVLHJaDj5e/xrduJp9CV2/HrfxTRH2fbyLe+K9QT41WBLUd4iXX3R7jBp0EUg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmmirror.com/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "license": "Apache-2.0" + }, + "node_modules/@swc/helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmmirror.com/@swc/helpers/-/helpers-0.5.5.tgz", + "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==", + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@types/node": { + "version": "20.19.41", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-20.19.41.tgz", + "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmmirror.com/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmmirror.com/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmmirror.com/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmmirror.com/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "14.2.35", + "resolved": "https://registry.npmmirror.com/next/-/next-14.2.35.tgz", + "integrity": "sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==", + "license": "MIT", + "dependencies": { + "@next/env": "14.2.35", + "@swc/helpers": "0.5.5", + "busboy": "1.6.0", + "caniuse-lite": "^1.0.30001579", + "graceful-fs": "^4.2.11", + "postcss": "8.4.31", + "styled-jsx": "5.1.1" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=18.17.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "14.2.33", + "@next/swc-darwin-x64": "14.2.33", + "@next/swc-linux-arm64-gnu": "14.2.33", + "@next/swc-linux-arm64-musl": "14.2.33", + "@next/swc-linux-x64-gnu": "14.2.33", + "@next/swc-linux-x64-musl": "14.2.33", + "@next/swc-win32-arm64-msvc": "14.2.33", + "@next/swc-win32-ia32-msvc": "14.2.33", + "@next/swc-win32-x64-msvc": "14.2.33" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.41.2", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmmirror.com/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/styled-jsx/-/styled-jsx-5.1.1.tgz", + "integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..cea3883 --- /dev/null +++ b/web/package.json @@ -0,0 +1,20 @@ +{ + "name": "video-frame-viewer", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "next": "^14.2.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "typescript": "^5.5.4" + } +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..a702d52 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,41 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": false, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "baseUrl": ".", + "paths": { + "@/*": [ + "./*" + ] + }, + "plugins": [ + { + "name": "next" + } + ] + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +}