diff --git a/.gitea/workflows/deploy-video2slides.yml b/.gitea/workflows/deploy-video2slides.yml new file mode 100644 index 0000000..491e758 --- /dev/null +++ b/.gitea/workflows/deploy-video2slides.yml @@ -0,0 +1,52 @@ +name: deploy video2slides +# video2slides.famzheng.me — 长视频抽帧,逐帧差异比较,拖阈值挑关键画面/幻灯片。 +# host shell runner(fam 用户)。镜像非 scratch:debian-slim + ffmpeg + 静态 musl binary。 + +on: + push: + branches: [master] + paths: + - 'apps/video2slides/**' + - 'crates/cube-core/**' + - 'Cargo.toml' + - 'Cargo.lock' + - '.gitea/workflows/deploy-video2slides.yml' + workflow_dispatch: + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + env: + APP: video2slides + IMAGE: registry.famzheng.me/mochi/video2slides + steps: + - uses: actions/checkout@v4 + + - name: Resolve image tag + id: tag + run: | + echo "sha=$(git rev-parse --short=12 HEAD)" >> "$GITHUB_OUTPUT" + + - name: Build rust (musl static) + run: | + export PATH="$HOME/.cargo/bin:$PATH" + cargo build --release --target x86_64-unknown-linux-musl -p "$APP" + + - name: Build & push image + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + echo "$REGISTRY_TOKEN" | docker login registry.famzheng.me -u mochi --password-stdin + # COPY binary 这层必须 --no-cache,否则 docker layer cache 会套用历史 binary + # (cube 平台踩过的坑)。 + docker build --no-cache -f "apps/$APP/Dockerfile" -t "$IMAGE:${{ steps.tag.outputs.sha }}" . + docker push "$IMAGE:${{ steps.tag.outputs.sha }}" + + - name: Initialize K8s resources + run: | + kubectl apply -f "apps/$APP/k8s/all.yaml" + + - name: Roll out to k3s + run: | + kubectl -n "cube-$APP" set image "deploy/$APP" "$APP=$IMAGE:${{ steps.tag.outputs.sha }}" + kubectl -n "cube-$APP" rollout status "deploy/$APP" --timeout=120s diff --git a/Cargo.lock b/Cargo.lock index 13c893b..f69e4ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,6 +23,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + [[package]] name = "articulate" version = "0.1.0" @@ -48,6 +54,12 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "axum" version = "0.7.9" @@ -134,12 +146,24 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.1" @@ -247,6 +271,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -275,6 +305,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -404,11 +440,24 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -418,15 +467,36 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "hashlink" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" dependencies = [ - "hashbrown", + "hashbrown 0.14.5", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "http" version = "1.4.0" @@ -620,6 +690,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "idna" version = "1.1.0" @@ -641,6 +717,32 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -689,6 +791,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.186" @@ -795,6 +903,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "multer" version = "3.1.0" @@ -857,6 +975,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -922,6 +1049,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -931,6 +1068,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + [[package]] name = "quinn" version = "0.11.9" @@ -1001,6 +1144,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.6" @@ -1215,6 +1364,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -1722,6 +1877,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "untrusted" version = "0.9.0" @@ -1752,6 +1913,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "uuid" +version = "1.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -1770,6 +1942,20 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "video2slides" +version = "0.1.0" +dependencies = [ + "axum", + "cube-core", + "image", + "serde", + "serde_json", + "tokio", + "tracing", + "uuid", +] + [[package]] name = "want" version = "0.3.1" @@ -1791,7 +1977,16 @@ version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", ] [[package]] @@ -1849,6 +2044,28 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + [[package]] name = "wasm-streams" version = "0.4.2" @@ -1862,6 +2079,18 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-sys" version = "0.3.98" @@ -2075,12 +2304,100 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "write" version = "0.1.0" @@ -2216,3 +2533,18 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index af406d8..824c5cc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "apps/llm-proxy", "apps/write", "apps/webgl", + "apps/video2slides", ] [workspace.package] @@ -32,6 +33,8 @@ rusqlite = { version = "0.32", features = ["bundled"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream", "multipart"] } futures = "0.3" tokio-stream = "0.1" +image = { version = "0.25", default-features = false, features = ["jpeg"] } +uuid = { version = "1", features = ["v4"] } [profile.release] opt-level = "z" diff --git a/apps/video2slides/.gitignore b/apps/video2slides/.gitignore new file mode 100644 index 0000000..a7e1884 --- /dev/null +++ b/apps/video2slides/.gitignore @@ -0,0 +1,2 @@ +data/ +target/ diff --git a/apps/video2slides/Cargo.toml b/apps/video2slides/Cargo.toml new file mode 100644 index 0000000..0577649 --- /dev/null +++ b/apps/video2slides/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "video2slides" +version = "0.1.0" +edition.workspace = true +license.workspace = true +authors.workspace = true +description = "video2slides.famzheng.me — 长视频抽帧,逐帧差异比较,拖阈值挑关键画面/幻灯片" + +[dependencies] +cube-core = { path = "../../crates/cube-core" } +tokio = { workspace = true } +axum = { workspace = true, features = ["multipart"] } +serde = { workspace = true } +serde_json = { workspace = true } +tracing = { workspace = true } +image = { workspace = true } +uuid = { workspace = true } diff --git a/apps/video2slides/Dockerfile b/apps/video2slides/Dockerfile new file mode 100644 index 0000000..22d67fc --- /dev/null +++ b/apps/video2slides/Dockerfile @@ -0,0 +1,12 @@ +# video2slides — video2slides.famzheng.me +# 跟其它 cube app 不同:依赖 ffmpeg/ffprobe,所以不能 FROM scratch。 +# 用 debian-slim 装 ffmpeg,再拷静态 musl binary(静态链接,跑在 debian 上没问题)。 +FROM debian:bookworm-slim +RUN apt-get update \ + && apt-get install -y --no-install-recommends ffmpeg ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY target/x86_64-unknown-linux-musl/release/video2slides /video2slides +COPY apps/video2slides/frontend /dist +EXPOSE 8080 +ENV VIDEO2SLIDES_DIST_DIR=/dist VIDEO2SLIDES_DATA_DIR=/data +ENTRYPOINT ["/video2slides"] diff --git a/apps/video2slides/README.md b/apps/video2slides/README.md new file mode 100644 index 0000000..7f2d865 --- /dev/null +++ b/apps/video2slides/README.md @@ -0,0 +1,46 @@ +# video2slides + +把一段长视频(演讲 / 课程录屏)按固定间隔抽帧,逐帧比差异,拖动阈值就能把重复画面变灰、留下关键变化(≈ 幻灯片翻页点)。 + +线上: + +## 架构 + +跟 cube 其它 app 同款:Rust + axum + `cube-core`,前端纯静态单页(零构建)。唯一特殊处——依赖 `ffmpeg`/`ffprobe`,所以镜像不是 `FROM scratch` 而是 `debian-slim` 装 ffmpeg + 拷静态 musl binary。 + +- 后端 `src/`:`core.rs`(存储 / 后台任务 / ffmpeg 抽帧 / image crate 逐帧灰度 MAE 差异)、`handlers.rs`(REST)、`main.rs`(路由)。 +- 前端 `frontend/index.html`:上传(进度) → 分析(进度) → 结果网格;顶部常驻 toolbar(阈值滑块 + 仅显示保留帧 + 保留/总数 + 分页),悬停缩略图浮出彩色大图。 + +## 流程 + +1. **上传** —— 拖拽或选文件,multipart 流式落盘,进度条到 100% 后 ffprobe 校验。 +2. **分析** —— 设「每隔 N 秒抽一帧」(默认 8),`ffmpeg fps=1/N` 抽帧(限宽 1280),逐帧生成缩略图并算相邻帧灰度逐像素 MAE(0–100)。后台线程跑,前端轮询进度。 +3. **挑帧** —— `diff ≥ 阈值` = 关键变化高亮保留;`< 阈值` = 跟上一帧太像变灰。纯前端实时切换,不回后端。 + +## 隔离与存储 + +- 前端在 `localStorage` 存随机 `v2s_client`,所有请求带 `X-Client-Id`,后端按 client 分目录。换浏览器 = 换身份,互相看不到。 +- 每个视频一个目录:`source.*` 原片、`frames/` 抽出的彩色帧(大图预览)、`thumbs/` 缩略图、`meta.json`(含每帧时间戳与 diff)。 +- 线上数据落 hostPath `/var/lib/cube/video2slides`(单节点 k3s,pod 重启不丢)。 + +## 本地跑 + +```bash +# 后端(数据 / 前端目录用 env 指过去,端口 8080) +VIDEO2SLIDES_DATA_DIR=/tmp/v2sdata VIDEO2SLIDES_DIST_DIR=apps/video2slides/frontend \ + cargo run -p video2slides +``` + +打开 。需要本机有 `ffmpeg` / `ffprobe`。 + +## 部署 + +push 到 master 且改了 `apps/video2slides/**` → `.gitea/workflows/deploy-video2slides.yml` 自动 build musl → 打 debian+ffmpeg 镜像(`--no-cache`)→ push registry → `kubectl apply` + rollout。 + +namespace `cube-video2slides`,已手工建好 `registry-creds`(从别的 ns 拷的,不进 git)。换 ns 重建命令: + +```bash +kubectl -n cube-webgl get secret registry-creds -o yaml \ + | sed 's/namespace: cube-webgl/namespace: cube-video2slides/' \ + | kubectl apply -f - +``` diff --git a/apps/video2slides/frontend/index.html b/apps/video2slides/frontend/index.html new file mode 100644 index 0000000..6e75992 --- /dev/null +++ b/apps/video2slides/frontend/index.html @@ -0,0 +1,387 @@ + + + + + +video2slides — 长视频抽帧挑幻灯片 + + + +
+

🎞️ video2slides

+ 长视频 → 抽帧 → 挑关键画面/幻灯片 + + + +
+ +
+ + +
+
+ +
+ + + + diff --git a/apps/video2slides/k8s/all.yaml b/apps/video2slides/k8s/all.yaml new file mode 100644 index 0000000..46170a4 --- /dev/null +++ b/apps/video2slides/k8s/all.yaml @@ -0,0 +1,100 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: cube-video2slides +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: video2slides + namespace: cube-video2slides + labels: + app: video2slides +spec: + replicas: 1 + # 用了 hostPath + 单副本,重建时先杀旧的再起新的,避免两个 pod 抢同一目录 + strategy: + type: Recreate + selector: + matchLabels: + app: video2slides + template: + metadata: + labels: + app: video2slides + spec: + imagePullSecrets: + - name: registry-creds + containers: + - name: video2slides + image: registry.famzheng.me/mochi/video2slides:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 8080 + name: http + env: + - name: VIDEO2SLIDES_DATA_DIR + value: "/data" + - name: VIDEO2SLIDES_DIST_DIR + value: "/dist" + volumeMounts: + - name: data + mountPath: /data + readinessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 1 + periodSeconds: 5 + livenessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 5 + periodSeconds: 15 + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + # ffmpeg 抽帧 + image 解码比较吃 CPU/内存,给足 + cpu: "2" + memory: 1Gi + volumes: + - name: data + hostPath: + # 上传的视频 + 抽出的帧落在节点本地盘,pod 重启不丢(单节点 k3s) + path: /var/lib/cube/video2slides + type: DirectoryOrCreate +--- +apiVersion: v1 +kind: Service +metadata: + name: video2slides + namespace: cube-video2slides +spec: + selector: + app: video2slides + ports: + - name: http + port: 80 + targetPort: 8080 +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: video2slides + namespace: cube-video2slides +spec: + ingressClassName: traefik + rules: + - host: video2slides.famzheng.me + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: video2slides + port: + number: 80 diff --git a/apps/video2slides/src/core.rs b/apps/video2slides/src/core.rs new file mode 100644 index 0000000..c44779d --- /dev/null +++ b/apps/video2slides/src/core.rs @@ -0,0 +1,436 @@ +//! video2slides 核心:存储布局、后台任务进度、ffmpeg 抽帧、逐帧灰度差异。 +//! +//! 存储布局(data 根下):: +//! +//! /// +//! source. 原始上传 +//! frames/000001.jpg 抽出的帧(彩色,限宽,用作悬停大图预览) +//! thumbs/000001.jpg 网格缩略图(小、压得狠) +//! meta.json { name, size, duration, interval, status, frames:[{idx,t,diff}] } + +use std::collections::HashMap; +use std::io::{BufRead, BufReader, Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::{Arc, Mutex}; + +use image::imageops::FilterType; +use serde::{Deserialize, Serialize}; + +pub const PREVIEW_MAX_W: u32 = 1280; // 抽帧时直接限宽,悬停大图用这个 +pub const THUMB_W: u32 = 320; // 网格缩略图宽度 +pub const DIFF_W: u32 = 256; // 算差异时降采样宽度(灰度) +pub const JPEG_Q_FRAME: &str = "4"; // ffmpeg -q:v(越小越清晰) +pub const JPEG_Q_THUMB: u8 = 70; + +/// clientId / videoId 只允许字母数字和 `_` `-`,挡路径穿越。 +pub fn safe_id(s: &str) -> String { + let cleaned: String = s + .chars() + .filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-') + .take(64) + .collect(); + if cleaned.is_empty() { + "anon".into() + } else { + cleaned + } +} + +pub fn client_dir(root: &Path, client: &str) -> PathBuf { + root.join(safe_id(client)) +} + +pub fn video_dir(root: &Path, client: &str, vid: &str) -> PathBuf { + client_dir(root, client).join(safe_id(vid)) +} + +// --------------------------------------------------------------------------- +// meta.json +// --------------------------------------------------------------------------- + +#[derive(Serialize, Deserialize, Clone)] +pub struct FrameMeta { + pub idx: u32, + pub t: f64, + pub diff: Option, +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct Meta { + pub name: String, + pub size: u64, + pub duration: f64, + pub interval: Option, + pub status: String, // uploaded | processing | done | error + #[serde(default)] + pub frames: Vec, +} + +pub fn read_meta(root: &Path, client: &str, vid: &str) -> Option { + let p = video_dir(root, client, vid).join("meta.json"); + let data = std::fs::read(p).ok()?; + serde_json::from_slice(&data).ok() +} + +pub fn write_meta(root: &Path, client: &str, vid: &str, meta: &Meta) -> std::io::Result<()> { + let d = video_dir(root, client, vid); + std::fs::create_dir_all(&d)?; + let data = serde_json::to_vec(meta).expect("serialize meta"); + std::fs::write(d.join("meta.json"), data) +} + +#[derive(Serialize)] +pub struct VideoSummary { + pub video_id: String, + pub name: String, + pub size: u64, + pub duration: f64, + pub interval: Option, + pub status: String, + pub frame_count: usize, +} + +pub fn list_videos(root: &Path, client: &str) -> Vec { + let base = client_dir(root, client); + let Ok(entries) = std::fs::read_dir(&base) else { + return vec![]; + }; + let mut dirs: Vec = entries + .flatten() + .filter(|e| e.path().is_dir()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + dirs.sort(); + dirs.reverse(); // 新的在前(video_id 用 uuid,无时间序,但保证稳定顺序) + dirs.into_iter() + .filter_map(|vid| { + let m = read_meta(root, client, &vid)?; + Some(VideoSummary { + video_id: vid, + name: m.name, + size: m.size, + duration: m.duration, + interval: m.interval, + status: m.status, + frame_count: m.frames.len(), + }) + }) + .collect() +} + +pub fn delete_video(root: &Path, client: &str, vid: &str) -> bool { + let d = video_dir(root, client, vid); + if d.is_dir() { + std::fs::remove_dir_all(&d).is_ok() + } else { + false + } +} + +// --------------------------------------------------------------------------- +// ffprobe 时长 +// --------------------------------------------------------------------------- + +pub fn probe_duration(path: &Path) -> f64 { + let out = Command::new("ffprobe") + .args([ + "-v", + "error", + "-show_entries", + "format=duration", + "-of", + "default=noprint_wrappers=1:nokey=1", + ]) + .arg(path) + .output(); + match out { + Ok(o) => String::from_utf8_lossy(&o.stdout).trim().parse().unwrap_or(0.0), + Err(_) => 0.0, + } +} + +// --------------------------------------------------------------------------- +// 任务进度(内存态,进程级) +// --------------------------------------------------------------------------- + +#[derive(Serialize, Clone)] +pub struct Job { + pub status: String, // queued | extracting | processing | done | error + pub progress: f64, // 0..1 + pub message: String, + pub frame_count: u32, + pub error: String, +} + +impl Default for Job { + fn default() -> Self { + Job { + status: "queued".into(), + progress: 0.0, + message: "排队中…".into(), + frame_count: 0, + error: String::new(), + } + } +} + +pub type JobMap = Arc>>>>; + +fn job_key(client: &str, vid: &str) -> String { + format!("{}/{}", safe_id(client), safe_id(vid)) +} + +pub fn get_job(jobs: &JobMap, client: &str, vid: &str) -> Option { + let map = jobs.lock().ok()?; + let j = map.get(&job_key(client, vid))?; + j.lock().ok().map(|g| g.clone()) +} + +fn upd(job: &Mutex, f: impl FnOnce(&mut Job)) { + if let Ok(mut g) = job.lock() { + f(&mut g); + } +} + +// --------------------------------------------------------------------------- +// 差异计算 +// --------------------------------------------------------------------------- + +fn round2(x: f64) -> f64 { + (x * 100.0).round() / 100.0 +} + +fn mae_0_100(a: &image::GrayImage, b: &image::GrayImage) -> f64 { + let (pa, pb) = (a.as_raw(), b.as_raw()); + let n = pa.len().min(pb.len()); + if n == 0 { + return 0.0; + } + let mut sum: u64 = 0; + for i in 0..n { + sum += (pa[i] as i32 - pb[i] as i32).unsigned_abs() as u64; + } + round2(sum as f64 / n as f64 / 255.0 * 100.0) +} + +// --------------------------------------------------------------------------- +// 抽帧 + 处理(在 spawn_blocking 里跑) +// --------------------------------------------------------------------------- + +fn run_extract( + src: &Path, + frames_dir: &Path, + interval: f64, + duration: f64, + job: &Mutex, +) -> Result<(), String> { + std::fs::create_dir_all(frames_dir).map_err(|e| e.to_string())?; + let vf = format!("fps=1/{interval},scale='min({PREVIEW_MAX_W},iw)':-2"); + let mut child = Command::new("ffmpeg") + .args(["-nostdin", "-y", "-i"]) + .arg(src) + .args(["-vf", &vf, "-q:v", JPEG_Q_FRAME, "-progress", "pipe:1", "-loglevel", "error"]) + .arg(frames_dir.join("%06d.jpg")) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("启动 ffmpeg 失败: {e}"))?; + + let dur_us = (duration.max(0.001)) * 1_000_000.0; + if let Some(stdout) = child.stdout.take() { + for line in BufReader::new(stdout).lines().map_while(Result::ok) { + if let Some(rest) = line.strip_prefix("out_time_us=") { + if let Ok(us) = rest.trim().parse::() { + let p = (us / dur_us).min(1.0); + upd(job, |j| j.progress = p * 0.5); + } + } + } + } + let status = child.wait().map_err(|e| e.to_string())?; + if !status.success() { + let mut err = String::new(); + if let Some(mut s) = child.stderr.take() { + let _ = s.read_to_string(&mut err); + } + return Err(format!("ffmpeg 抽帧失败: {}", err.trim().chars().take(500).collect::())); + } + upd(job, |j| j.progress = 0.5); + Ok(()) +} + +fn frame_index(p: &Path) -> Option { + p.file_stem()?.to_str()?.parse().ok() +} + +fn save_thumb(img: &image::DynamicImage, dst: &Path) -> Result<(), String> { + let (w, h) = (img.width().max(1), img.height().max(1)); + let nh = ((THUMB_W as f64) * h as f64 / w as f64).round().max(1.0) as u32; + let small = img.resize_exact(THUMB_W, nh, FilterType::Triangle).to_rgb8(); + let mut f = std::fs::File::create(dst).map_err(|e| e.to_string())?; + let mut buf = Vec::new(); + let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, JPEG_Q_THUMB); + enc.encode_image(&small).map_err(|e| e.to_string())?; + f.write_all(&buf).map_err(|e| e.to_string()) +} + +fn gray_small(img: &image::DynamicImage) -> image::GrayImage { + let (w, h) = (img.width().max(1), img.height().max(1)); + let nh = ((DIFF_W as f64) * h as f64 / w as f64).round().max(1.0) as u32; + img.resize_exact(DIFF_W, nh, FilterType::Triangle).to_luma8() +} + +#[allow(clippy::too_many_arguments)] +fn process( + root: PathBuf, + client: String, + vid: String, + src: PathBuf, + interval: f64, + duration: f64, + name: String, + size: u64, + job: Arc>, +) { + let vdir = video_dir(&root, &client, &vid); + let frames_dir = vdir.join("frames"); + let thumbs_dir = vdir.join("thumbs"); + + let result = (|| -> Result { + upd(&job, |j| { + j.status = "extracting".into(); + j.message = "抽帧中…".into(); + }); + std::fs::create_dir_all(&thumbs_dir).map_err(|e| e.to_string())?; + run_extract(&src, &frames_dir, interval, duration, &job)?; + + let mut frame_files: Vec = std::fs::read_dir(&frames_dir) + .map_err(|e| e.to_string())? + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().map(|x| x == "jpg").unwrap_or(false)) + .collect(); + frame_files.sort(); + let total = frame_files.len(); + if total == 0 { + return Err("没抽到任何帧,检查视频是否有效。".into()); + } + + upd(&job, |j| { + j.status = "processing".into(); + j.message = "比对差异中…".into(); + }); + + let mut frames_meta: Vec = Vec::with_capacity(total); + let mut prev_gray: Option = None; + for (i, fp) in frame_files.iter().enumerate() { + let idx = frame_index(fp).unwrap_or((i + 1) as u32); + let img = image::open(fp).map_err(|e| format!("解码 {fp:?} 失败: {e}"))?; + save_thumb(&img, &thumbs_dir.join(format!("{idx:06}.jpg")))?; + let gray = gray_small(&img); + let diff = prev_gray.as_ref().map(|p| mae_0_100(p, &gray)); + prev_gray = Some(gray); + frames_meta.push(FrameMeta { + idx, + t: round2((idx.saturating_sub(1)) as f64 * interval), + diff, + }); + let p = 0.5 + 0.5 * (i + 1) as f64 / total as f64; + upd(&job, |j| { + j.progress = p; + j.frame_count = (i + 1) as u32; + }); + } + + let meta = Meta { + name: name.clone(), + size, + duration, + interval: Some(interval), + status: "done".into(), + frames: frames_meta, + }; + write_meta(&root, &client, &vid, &meta).map_err(|e| e.to_string())?; + Ok(total) + })(); + + match result { + Ok(total) => upd(&job, |j| { + j.status = "done".into(); + j.progress = 1.0; + j.message = format!("完成,共 {total} 帧"); + }), + Err(e) => { + upd(&job, |j| { + j.status = "error".into(); + j.error = e.clone(); + j.message = format!("出错: {e}"); + }); + // 把 meta 标成 error,供无任务时回退 + let mut meta = read_meta(&root, &client, &vid).unwrap_or(Meta { + name, + size, + duration, + interval: Some(interval), + status: "error".into(), + frames: vec![], + }); + meta.status = "error".into(); + let _ = write_meta(&root, &client, &vid, &meta); + } + } +} + +/// 启动后台分析。清掉旧 frames/thumbs(保留 source),允许换间隔重跑。 +pub fn start_analysis( + root: PathBuf, + jobs: JobMap, + client: String, + vid: String, + interval: f64, +) -> Result<(), String> { + let vdir = video_dir(&root, &client, &vid); + let src = std::fs::read_dir(&vdir) + .map_err(|_| "源视频不存在".to_string())? + .flatten() + .map(|e| e.path()) + .find(|p| p.file_stem().map(|s| s == "source").unwrap_or(false)) + .ok_or_else(|| "源视频不存在".to_string())?; + + let duration = probe_duration(&src); + let prev = read_meta(&root, &client, &vid); + let name = prev + .as_ref() + .map(|m| m.name.clone()) + .unwrap_or_else(|| src.file_name().unwrap_or_default().to_string_lossy().into_owned()); + let size = std::fs::metadata(&src).map(|m| m.len()).unwrap_or(0); + + let _ = std::fs::remove_dir_all(vdir.join("frames")); + let _ = std::fs::remove_dir_all(vdir.join("thumbs")); + + write_meta( + &root, + &client, + &vid, + &Meta { + name: name.clone(), + size, + duration, + interval: Some(interval), + status: "processing".into(), + frames: vec![], + }, + ) + .map_err(|e| e.to_string())?; + + let job = Arc::new(Mutex::new(Job::default())); + if let Ok(mut map) = jobs.lock() { + map.insert(job_key(&client, &vid), job.clone()); + } + + tokio::task::spawn_blocking(move || { + process(root, client, vid, src, interval, duration, name, size, job); + }); + Ok(()) +} diff --git a/apps/video2slides/src/handlers.rs b/apps/video2slides/src/handlers.rs new file mode 100644 index 0000000..d5e5db4 --- /dev/null +++ b/apps/video2slides/src/handlers.rs @@ -0,0 +1,288 @@ +//! axum 处理器:上传 / 列表 / 删除 / 分析 / 进度 / 帧元数据 / 缩略图 / 大图。 +//! 所有请求靠 `X-Client-Id` 头按浏览器隔离。 + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use axum::extract::{Multipart, Path as AxPath, State}; +use axum::http::{header, HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde::Deserialize; +use serde_json::{json, Value}; +use tokio::io::AsyncWriteExt; + +use crate::core; + +const ALLOWED_EXT: &[&str] = &["mp4", "mov", "mkv", "webm", "avi", "m4v", "flv", "wmv"]; +const UPLOAD_CHUNK_HINT: usize = 1 << 20; + +#[derive(Clone)] +pub struct AppState { + pub root: PathBuf, + pub jobs: core::JobMap, +} + +impl AppState { + pub fn new(root: PathBuf) -> Self { + AppState { + root, + jobs: Arc::new(Mutex::new(HashMap::new())), + } + } +} + +type ApiErr = (StatusCode, String); + +fn err(code: StatusCode, msg: impl Into) -> ApiErr { + (code, msg.into()) +} + +fn client_id(headers: &HeaderMap) -> Result { + headers + .get("x-client-id") + .and_then(|v| v.to_str().ok()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .ok_or_else(|| err(StatusCode::BAD_REQUEST, "缺少 X-Client-Id")) +} + +// --------------------------------------------------------------------------- +// 上传 +// --------------------------------------------------------------------------- + +pub async fn upload_video( + State(st): State, + headers: HeaderMap, + mut mp: Multipart, +) -> Result, ApiErr> { + let client = client_id(&headers)?; + + while let Some(mut field) = mp + .next_field() + .await + .map_err(|e| err(StatusCode::BAD_REQUEST, format!("multipart 解析失败: {e}")))? + { + if field.name() != Some("file") { + continue; + } + let filename = field.file_name().unwrap_or("video").to_string(); + let ext = Path::new(&filename) + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_lowercase()) + .unwrap_or_default(); + if !ALLOWED_EXT.contains(&ext.as_str()) { + return Err(err( + StatusCode::BAD_REQUEST, + format!("不支持的格式 .{ext},支持: {}", ALLOWED_EXT.join(", ")), + )); + } + + let vid: String = uuid::Uuid::new_v4().simple().to_string().chars().take(12).collect(); + let vdir = core::video_dir(&st.root, &client, &vid); + std::fs::create_dir_all(&vdir).map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + let dst = vdir.join(format!("source.{ext}")); + + let mut size: u64 = 0; + let write_res = async { + let mut f = tokio::fs::File::create(&dst).await?; + let mut buf: Vec = Vec::with_capacity(UPLOAD_CHUNK_HINT); + while let Some(chunk) = field + .chunk() + .await + .map_err(|e| std::io::Error::other(e.to_string()))? + { + size += chunk.len() as u64; + buf.extend_from_slice(&chunk); + if buf.len() >= UPLOAD_CHUNK_HINT { + f.write_all(&buf).await?; + buf.clear(); + } + } + if !buf.is_empty() { + f.write_all(&buf).await?; + } + f.flush().await?; + Ok::<(), std::io::Error>(()) + } + .await; + + if let Err(e) = write_res { + let _ = std::fs::remove_dir_all(&vdir); + return Err(err(StatusCode::INTERNAL_SERVER_ERROR, format!("写入失败: {e}"))); + } + + let duration = core::probe_duration(&dst); + if duration <= 0.0 { + let _ = std::fs::remove_dir_all(&vdir); + return Err(err(StatusCode::BAD_REQUEST, "无法解析视频(可能不是有效视频文件)")); + } + + core::write_meta( + &st.root, + &client, + &vid, + &core::Meta { + name: filename.clone(), + size, + duration, + interval: None, + status: "uploaded".into(), + frames: vec![], + }, + ) + .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + return Ok(Json(json!({ + "video_id": vid, "name": filename, "size": size, "duration": duration + }))); + } + + Err(err(StatusCode::BAD_REQUEST, "未收到文件")) +} + +// --------------------------------------------------------------------------- +// 列表 / 删除 +// --------------------------------------------------------------------------- + +pub async fn list_videos( + State(st): State, + headers: HeaderMap, +) -> Result, ApiErr> { + let client = client_id(&headers)?; + let vids = core::list_videos(&st.root, &client); + Ok(Json(serde_json::to_value(vids).unwrap_or(json!([])))) +} + +pub async fn delete_video( + State(st): State, + AxPath(vid): AxPath, + headers: HeaderMap, +) -> Result, ApiErr> { + let client = client_id(&headers)?; + if core::delete_video(&st.root, &client, &vid) { + if let Ok(mut m) = st.jobs.lock() { + m.remove(&format!("{}/{}", core::safe_id(&client), core::safe_id(&vid))); + } + Ok(Json(json!({"ok": true}))) + } else { + Err(err(StatusCode::NOT_FOUND, "不存在")) + } +} + +// --------------------------------------------------------------------------- +// 分析 / 进度 / 帧元数据 +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +pub struct AnalyzeReq { + interval: Option, +} + +pub async fn analyze( + State(st): State, + AxPath(vid): AxPath, + headers: HeaderMap, + Json(req): Json, +) -> Result, ApiErr> { + let client = client_id(&headers)?; + let interval = req.interval.unwrap_or(8.0); + if !(0.2..=600.0).contains(&interval) { + return Err(err(StatusCode::BAD_REQUEST, "interval 需在 0.2 ~ 600 秒之间")); + } + core::start_analysis(st.root.clone(), st.jobs.clone(), client, vid, interval).map_err(|e| { + if e.contains("不存在") { + err(StatusCode::NOT_FOUND, e) + } else { + err(StatusCode::INTERNAL_SERVER_ERROR, e) + } + })?; + Ok(Json(json!({"ok": true, "interval": interval}))) +} + +pub async fn job_status( + State(st): State, + AxPath(vid): AxPath, + headers: HeaderMap, +) -> Result, ApiErr> { + let client = client_id(&headers)?; + if let Some(j) = core::get_job(&st.jobs, &client, &vid) { + return Ok(Json(json!({ + "status": j.status, + "progress": (j.progress * 10000.0).round() / 10000.0, + "message": j.message, + "frame_count": j.frame_count, + "error": j.error, + }))); + } + // 没有活动任务:回退看 meta + if let Some(m) = core::read_meta(&st.root, &client, &vid) { + if m.status == "done" { + return Ok(Json(json!({ + "status": "done", "progress": 1.0, + "message": "已完成", "frame_count": m.frames.len(), + }))); + } + } + Err(err(StatusCode::NOT_FOUND, "无任务")) +} + +pub async fn frames( + State(st): State, + AxPath(vid): AxPath, + headers: HeaderMap, +) -> Result, ApiErr> { + let client = client_id(&headers)?; + let m = core::read_meta(&st.root, &client, &vid).ok_or_else(|| err(StatusCode::NOT_FOUND, "不存在"))?; + Ok(Json(json!({ + "video_id": vid, + "name": m.name, + "interval": m.interval, + "duration": m.duration, + "status": m.status, + "frames": serde_json::to_value(&m.frames).unwrap_or(json!([])), + }))) +} + +// --------------------------------------------------------------------------- +// 图片:缩略图 / 大图 +// --------------------------------------------------------------------------- + +async fn serve_img( + st: &AppState, + client: &str, + vid: &str, + sub: &str, + idx: u32, +) -> Result { + let p = core::video_dir(&st.root, client, vid).join(sub).join(format!("{idx:06}.jpg")); + let bytes = tokio::fs::read(&p).await.map_err(|_| err(StatusCode::NOT_FOUND, "帧不存在"))?; + Ok(( + [ + (header::CONTENT_TYPE, "image/jpeg"), + (header::CACHE_CONTROL, "max-age=86400"), + ], + bytes, + ) + .into_response()) +} + +pub async fn thumb( + State(st): State, + AxPath((vid, idx)): AxPath<(String, u32)>, + headers: HeaderMap, +) -> Result { + let client = client_id(&headers)?; + serve_img(&st, &client, &vid, "thumbs", idx).await +} + +pub async fn frame( + State(st): State, + AxPath((vid, idx)): AxPath<(String, u32)>, + headers: HeaderMap, +) -> Result { + let client = client_id(&headers)?; + serve_img(&st, &client, &vid, "frames", idx).await +} diff --git a/apps/video2slides/src/main.rs b/apps/video2slides/src/main.rs new file mode 100644 index 0000000..239c7d3 --- /dev/null +++ b/apps/video2slides/src/main.rs @@ -0,0 +1,43 @@ +//! video2slides.famzheng.me — 长视频按固定间隔抽帧,逐帧差异比较, +//! 拖动阈值挑出关键画面/幻灯片。 +//! +//! - 静态 SPA + SPA fallback 由 cube-core::base 处理。 +//! - `/api/*` 一组 REST:上传(multipart 流式落盘)→ 分析(后台线程跑 ffmpeg +//! 抽帧 + image crate 逐帧灰度差异,前端轮询进度)→ 取帧元数据 / 缩略图 / 大图。 +//! - 按 `X-Client-Id` 头分目录,每个浏览器只看到自己上传的。 +//! - 存储靠 hostPath 挂进来的目录(默认 /data),pod 重启不丢。 + +mod core; +mod handlers; + +use axum::extract::DefaultBodyLimit; +use axum::routing::{delete, get, post}; +use axum::Router; + +use handlers::AppState; + +#[tokio::main] +async fn main() -> std::io::Result<()> { + cube_core::init_tracing(); + + let dist = std::env::var("VIDEO2SLIDES_DIST_DIR").unwrap_or_else(|_| "/dist".into()); + let data_dir = std::env::var("VIDEO2SLIDES_DATA_DIR").unwrap_or_else(|_| "/data".into()); + std::fs::create_dir_all(&data_dir)?; + tracing::info!(%data_dir, "video2slides data dir"); + + let state = AppState::new(data_dir.into()); + + let api = Router::new() + .route("/videos", post(handlers::upload_video).get(handlers::list_videos)) + .route("/videos/:id", delete(handlers::delete_video)) + .route("/videos/:id/analyze", post(handlers::analyze)) + .route("/videos/:id/job", get(handlers::job_status)) + .route("/videos/:id/frames", get(handlers::frames)) + .route("/videos/:id/thumb/:idx", get(handlers::thumb)) + .route("/videos/:id/frame/:idx", get(handlers::frame)) + .layer(DefaultBodyLimit::disable()) // 视频上传可能很大,关掉默认 2MB 限制 + .with_state(state); + + let app = cube_core::base(dist).nest("/api", api); + cube_core::serve(app, 8080).await +}