video2slides: 新 app — 长视频抽帧 + 逐帧差异 + 拖阈值挑幻灯片
deploy articulate / build-and-deploy (push) Successful in 1m1s
deploy cube / build-and-deploy (push) Successful in 1m22s
deploy karaoke / build-and-deploy (push) Successful in 56s
deploy llm-proxy / build-and-deploy (push) Successful in 1m47s
deploy music / build-and-deploy (push) Successful in 2m14s
deploy notes / build-and-deploy (push) Successful in 1m41s
deploy simpleasm / build-and-deploy (push) Successful in 1m15s
deploy webgl / build-and-deploy (push) Successful in 1m5s
deploy video2slides / build-and-deploy (push) Successful in 2m3s
deploy werewolf / build-and-deploy (push) Successful in 1m8s
deploy write / build-and-deploy (push) Successful in 1m25s
deploy articulate / build-and-deploy (push) Successful in 1m1s
deploy cube / build-and-deploy (push) Successful in 1m22s
deploy karaoke / build-and-deploy (push) Successful in 56s
deploy llm-proxy / build-and-deploy (push) Successful in 1m47s
deploy music / build-and-deploy (push) Successful in 2m14s
deploy notes / build-and-deploy (push) Successful in 1m41s
deploy simpleasm / build-and-deploy (push) Successful in 1m15s
deploy webgl / build-and-deploy (push) Successful in 1m5s
deploy video2slides / build-and-deploy (push) Successful in 2m3s
deploy werewolf / build-and-deploy (push) Successful in 1m8s
deploy write / build-and-deploy (push) Successful in 1m25s
Rust + axum + cube-core,前端纯静态单页。ffmpeg 每 N 秒抽一帧(限宽 1280),image crate 逐帧灰度 MAE 算差异;前端拖阈值实时把相近帧变灰、 关键变化高亮,悬停出彩色大图,分页 + toolbar 常驻。按 X-Client-Id 分目录 隔离,存储走 hostPath。镜像非 scratch:debian-slim + ffmpeg + musl binary。
This commit is contained in:
@@ -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
|
||||||
Generated
+335
-3
@@ -23,6 +23,12 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anyhow"
|
||||||
|
version = "1.0.102"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "articulate"
|
name = "articulate"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -48,6 +54,12 @@ version = "1.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "autocfg"
|
||||||
|
version = "1.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "axum"
|
name = "axum"
|
||||||
version = "0.7.9"
|
version = "0.7.9"
|
||||||
@@ -134,12 +146,24 @@ version = "3.20.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bytemuck"
|
||||||
|
version = "1.25.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "byteorder"
|
name = "byteorder"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "byteorder-lite"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.11.1"
|
version = "1.11.1"
|
||||||
@@ -247,6 +271,12 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "equivalent"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "errno"
|
name = "errno"
|
||||||
version = "0.3.14"
|
version = "0.3.14"
|
||||||
@@ -275,6 +305,12 @@ version = "0.1.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foldhash"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "form_urlencoded"
|
name = "form_urlencoded"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
@@ -404,11 +440,24 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi",
|
"r-efi 5.3.0",
|
||||||
"wasip2",
|
"wasip2",
|
||||||
"wasm-bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.14.5"
|
version = "0.14.5"
|
||||||
@@ -418,15 +467,36 @@ dependencies = [
|
|||||||
"ahash",
|
"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]]
|
[[package]]
|
||||||
name = "hashlink"
|
name = "hashlink"
|
||||||
version = "0.9.1"
|
version = "0.9.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
|
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
|
||||||
dependencies = [
|
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]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "1.4.0"
|
version = "1.4.0"
|
||||||
@@ -620,6 +690,12 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "id-arena"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@@ -641,6 +717,32 @@ dependencies = [
|
|||||||
"icu_properties",
|
"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]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.12.0"
|
version = "2.12.0"
|
||||||
@@ -689,6 +791,12 @@ version = "1.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "leb128fmt"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.186"
|
version = "0.2.186"
|
||||||
@@ -795,6 +903,16 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "multer"
|
name = "multer"
|
||||||
version = "3.1.0"
|
version = "3.1.0"
|
||||||
@@ -857,6 +975,15 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.4"
|
version = "1.21.4"
|
||||||
@@ -922,6 +1049,16 @@ dependencies = [
|
|||||||
"zerocopy",
|
"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]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.106"
|
version = "1.0.106"
|
||||||
@@ -931,6 +1068,12 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pxfm"
|
||||||
|
version = "0.1.29"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quinn"
|
name = "quinn"
|
||||||
version = "0.11.9"
|
version = "0.11.9"
|
||||||
@@ -1001,6 +1144,12 @@ version = "5.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "r-efi"
|
||||||
|
version = "6.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand"
|
name = "rand"
|
||||||
version = "0.8.6"
|
version = "0.8.6"
|
||||||
@@ -1215,6 +1364,12 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "semver"
|
||||||
|
version = "1.0.28"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde"
|
name = "serde"
|
||||||
version = "1.0.228"
|
version = "1.0.228"
|
||||||
@@ -1722,6 +1877,12 @@ version = "1.0.24"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-xid"
|
||||||
|
version = "0.2.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "untrusted"
|
name = "untrusted"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -1752,6 +1913,17 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
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]]
|
[[package]]
|
||||||
name = "valuable"
|
name = "valuable"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -1770,6 +1942,20 @@ version = "0.9.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "video2slides"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"axum",
|
||||||
|
"cube-core",
|
||||||
|
"image",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "want"
|
name = "want"
|
||||||
version = "0.3.1"
|
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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
|
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
|
||||||
dependencies = [
|
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]]
|
[[package]]
|
||||||
@@ -1849,6 +2044,28 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"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]]
|
[[package]]
|
||||||
name = "wasm-streams"
|
name = "wasm-streams"
|
||||||
version = "0.4.2"
|
version = "0.4.2"
|
||||||
@@ -1862,6 +2079,18 @@ dependencies = [
|
|||||||
"web-sys",
|
"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]]
|
[[package]]
|
||||||
name = "web-sys"
|
name = "web-sys"
|
||||||
version = "0.3.98"
|
version = "0.3.98"
|
||||||
@@ -2075,12 +2304,100 @@ version = "0.53.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
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]]
|
[[package]]
|
||||||
name = "wit-bindgen"
|
name = "wit-bindgen"
|
||||||
version = "0.57.1"
|
version = "0.57.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
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]]
|
[[package]]
|
||||||
name = "write"
|
name = "write"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -2216,3 +2533,18 @@ name = "zmij"
|
|||||||
version = "1.0.21"
|
version = "1.0.21"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
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",
|
||||||
|
]
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ members = [
|
|||||||
"apps/llm-proxy",
|
"apps/llm-proxy",
|
||||||
"apps/write",
|
"apps/write",
|
||||||
"apps/webgl",
|
"apps/webgl",
|
||||||
|
"apps/video2slides",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[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"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream", "multipart"] }
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
tokio-stream = "0.1"
|
tokio-stream = "0.1"
|
||||||
|
image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = "z"
|
opt-level = "z"
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
data/
|
||||||
|
target/
|
||||||
@@ -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 }
|
||||||
@@ -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"]
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# video2slides
|
||||||
|
|
||||||
|
把一段长视频(演讲 / 课程录屏)按固定间隔抽帧,逐帧比差异,拖动阈值就能把重复画面变灰、留下关键变化(≈ 幻灯片翻页点)。
|
||||||
|
|
||||||
|
线上:<https://video2slides.famzheng.me>
|
||||||
|
|
||||||
|
## 架构
|
||||||
|
|
||||||
|
跟 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
|
||||||
|
```
|
||||||
|
|
||||||
|
打开 <http://localhost:8080>。需要本机有 `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 -
|
||||||
|
```
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>video2slides — 长视频抽帧挑幻灯片</title>
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--bg:#0f1115; --panel:#171a21; --panel2:#1f242e; --line:#2a303c;
|
||||||
|
--fg:#e8ebf0; --mut:#8b94a3; --accent:#f2b50c; --accent2:#3b82f6;
|
||||||
|
--keep:#f2b50c; --ok:#22c55e; --err:#ef4444;
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
html,body{margin:0;height:100%}
|
||||||
|
body{background:var(--bg);color:var(--fg);font:14px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;display:flex;flex-direction:column;height:100vh;overflow:hidden}
|
||||||
|
|
||||||
|
header.appbar{display:flex;align-items:center;gap:12px;padding:10px 16px;background:var(--panel);border-bottom:1px solid var(--line);flex:0 0 auto;z-index:50}
|
||||||
|
header.appbar h1{font-size:15px;margin:0;font-weight:600;letter-spacing:.3px}
|
||||||
|
header.appbar .tag{color:var(--mut);font-size:12px}
|
||||||
|
header.appbar .spacer{flex:1}
|
||||||
|
|
||||||
|
.layout{display:flex;flex:1;min-height:0}
|
||||||
|
aside{width:240px;flex:0 0 240px;background:var(--panel);border-right:1px solid var(--line);display:flex;flex-direction:column;min-height:0}
|
||||||
|
aside .side-head{padding:10px 12px;border-bottom:1px solid var(--line);display:flex;gap:8px;align-items:center}
|
||||||
|
aside .vlist{overflow:auto;flex:1;padding:6px}
|
||||||
|
.vitem{padding:8px 10px;border-radius:8px;cursor:pointer;margin-bottom:4px;border:1px solid transparent}
|
||||||
|
.vitem:hover{background:var(--panel2)}
|
||||||
|
.vitem.active{background:var(--panel2);border-color:var(--accent)}
|
||||||
|
.vitem .vname{font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.vitem .vmeta{font-size:11px;color:var(--mut);display:flex;gap:6px;align-items:center;margin-top:2px}
|
||||||
|
.badge{font-size:10px;padding:1px 6px;border-radius:99px;background:#333}
|
||||||
|
.badge.done{background:rgba(34,197,94,.18);color:var(--ok)}
|
||||||
|
.badge.processing,.badge.extracting,.badge.queued{background:rgba(59,130,246,.18);color:var(--accent2)}
|
||||||
|
.badge.uploaded{background:rgba(139,148,163,.18);color:var(--mut)}
|
||||||
|
.badge.error{background:rgba(239,68,68,.18);color:var(--err)}
|
||||||
|
.vdel{margin-left:auto;color:var(--mut);cursor:pointer;font-size:14px;opacity:0;padding:0 4px}
|
||||||
|
.vitem:hover .vdel{opacity:1}
|
||||||
|
.vdel:hover{color:var(--err)}
|
||||||
|
|
||||||
|
main{flex:1;display:flex;flex-direction:column;min-width:0;min-height:0;position:relative}
|
||||||
|
|
||||||
|
/* sticky toolbar 常驻顶部 */
|
||||||
|
.toolbar{position:sticky;top:0;z-index:40;background:var(--panel2);border-bottom:1px solid var(--line);padding:10px 16px;display:flex;align-items:center;gap:18px;flex-wrap:wrap;box-shadow:0 2px 8px rgba(0,0,0,.3)}
|
||||||
|
.toolbar .grp{display:flex;align-items:center;gap:8px}
|
||||||
|
.toolbar label{color:var(--mut);font-size:12px;white-space:nowrap}
|
||||||
|
.toolbar input[type=range]{width:200px;accent-color:var(--accent)}
|
||||||
|
.thval{font-variant-numeric:tabular-nums;min-width:42px;text-align:right;color:var(--accent);font-weight:600}
|
||||||
|
.stat{font-size:12px;color:var(--mut)}
|
||||||
|
.stat b{color:var(--keep)}
|
||||||
|
|
||||||
|
.content{flex:1;overflow:auto;padding:16px;min-height:0}
|
||||||
|
|
||||||
|
/* 网格 */
|
||||||
|
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px}
|
||||||
|
.card{background:var(--panel);border:2px solid transparent;border-radius:10px;overflow:hidden;transition:opacity .15s,border-color .15s,filter .15s;cursor:pointer}
|
||||||
|
.card img{width:100%;aspect-ratio:16/9;object-fit:cover;display:block;background:#000}
|
||||||
|
.card .cap{display:flex;justify-content:space-between;padding:5px 8px;font-size:11px;color:var(--mut)}
|
||||||
|
.card .cap .d{font-variant-numeric:tabular-nums}
|
||||||
|
.card.keep{border-color:var(--keep)}
|
||||||
|
.card.dim{opacity:.4;filter:grayscale(1)}
|
||||||
|
.card.dim:hover{opacity:.85;filter:grayscale(0)}
|
||||||
|
|
||||||
|
/* 悬停大图预览 */
|
||||||
|
#preview{position:fixed;z-index:100;pointer-events:none;display:none;border:2px solid var(--accent);border-radius:10px;overflow:hidden;box-shadow:0 12px 40px rgba(0,0,0,.6);background:#000}
|
||||||
|
#preview img{display:block;max-width:560px;max-height:360px}
|
||||||
|
#preview .pcap{position:absolute;left:0;bottom:0;right:0;background:linear-gradient(transparent,rgba(0,0,0,.8));color:#fff;font-size:12px;padding:14px 10px 6px}
|
||||||
|
|
||||||
|
/* 状态屏 */
|
||||||
|
.pane{flex:1;display:flex;align-items:center;justify-content:center;padding:24px}
|
||||||
|
.pane .box{max-width:520px;width:100%;background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:28px}
|
||||||
|
.pane h2{margin:0 0 6px;font-size:18px}
|
||||||
|
.pane p{color:var(--mut);margin:0 0 18px}
|
||||||
|
.drop{border:2px dashed var(--line);border-radius:12px;padding:34px;text-align:center;color:var(--mut);cursor:pointer;transition:.15s}
|
||||||
|
.drop.over{border-color:var(--accent);background:rgba(242,181,12,.06);color:var(--fg)}
|
||||||
|
|
||||||
|
button{font:inherit;border:0;border-radius:8px;padding:8px 14px;background:var(--accent);color:#1a1500;font-weight:600;cursor:pointer}
|
||||||
|
button.ghost{background:var(--panel2);color:var(--fg);border:1px solid var(--line);font-weight:500}
|
||||||
|
button:disabled{opacity:.5;cursor:not-allowed}
|
||||||
|
button.sm{padding:5px 10px;font-size:12px}
|
||||||
|
input[type=number]{font:inherit;background:var(--bg);border:1px solid var(--line);color:var(--fg);border-radius:8px;padding:7px 10px;width:90px}
|
||||||
|
|
||||||
|
.progress{height:10px;background:var(--bg);border-radius:99px;overflow:hidden;border:1px solid var(--line)}
|
||||||
|
.progress > i{display:block;height:100%;background:linear-gradient(90deg,var(--accent2),var(--accent));width:0;transition:width .3s}
|
||||||
|
.pmsg{font-size:12px;color:var(--mut);margin-top:8px;text-align:center}
|
||||||
|
|
||||||
|
.row{display:flex;gap:10px;align-items:center}
|
||||||
|
.err{color:var(--err);font-size:12px}
|
||||||
|
.pager{display:flex;gap:6px;align-items:center}
|
||||||
|
.pager .pg{padding:4px 9px;background:var(--panel);border:1px solid var(--line);border-radius:6px;cursor:pointer;font-size:12px}
|
||||||
|
.pager .pg.cur{background:var(--accent);color:#1a1500;border-color:var(--accent)}
|
||||||
|
.switch{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--mut);cursor:pointer;user-select:none}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="appbar">
|
||||||
|
<h1>🎞️ video2slides</h1>
|
||||||
|
<span class="tag">长视频 → 抽帧 → 挑关键画面/幻灯片</span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button id="btnUpload" class="sm">+ 上传视频</button>
|
||||||
|
<input type="file" id="fileInput" accept="video/*" hidden />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="layout">
|
||||||
|
<aside>
|
||||||
|
<div class="side-head"><b style="font-size:13px">我的视频</b><span class="tag" style="margin-left:auto;font-size:11px" id="clientTag"></span></div>
|
||||||
|
<div class="vlist" id="vlist"></div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main id="main"><!-- 动态 --></main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="preview"><img id="previewImg" alt="" /><div class="pcap" id="previewCap"></div></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const $ = (s,r=document)=>r.querySelector(s);
|
||||||
|
const el = (t,c)=>{const e=document.createElement(t);if(c)e.className=c;return e;};
|
||||||
|
|
||||||
|
// ---- 每浏览器身份:localStorage ----
|
||||||
|
const CK='v2s_client';
|
||||||
|
let CLIENT=localStorage.getItem(CK);
|
||||||
|
if(!CLIENT){CLIENT=(crypto.randomUUID?crypto.randomUUID():'c'+Date.now()+Math.random().toString(16).slice(2)).replace(/-/g,'');localStorage.setItem(CK,CLIENT);}
|
||||||
|
$('#clientTag').textContent=CLIENT.slice(0,6);
|
||||||
|
|
||||||
|
const H={'X-Client-Id':CLIENT};
|
||||||
|
async function api(path,opts={}){
|
||||||
|
const r=await fetch(path,{...opts,headers:{...H,...(opts.headers||{})}});
|
||||||
|
if(!r.ok){let m='';try{m=(await r.json()).detail}catch{}throw new Error(m||r.status);}
|
||||||
|
const ct=r.headers.get('content-type')||'';
|
||||||
|
return ct.includes('json')?r.json():r;
|
||||||
|
}
|
||||||
|
function imgUrl(vid,kind,idx){return `/api/videos/${vid}/${kind}/${idx}`;}
|
||||||
|
const fmtDur=s=>{s=Math.round(s||0);const m=Math.floor(s/60),x=s%60;return `${m}:${String(x).padStart(2,'0')}`;};
|
||||||
|
const fmtSize=b=>b>1e9?(b/1e9).toFixed(1)+'G':b>1e6?(b/1e6).toFixed(1)+'M':(b/1e3|0)+'K';
|
||||||
|
const fmtT=s=>{s=Math.round(s);const h=Math.floor(s/3600),m=Math.floor(s%3600/60),x=s%60;const p=n=>String(n).padStart(2,'0');return h?`${h}:${p(m)}:${p(x)}`:`${m}:${p(x)}`;};
|
||||||
|
|
||||||
|
// ---- 全局状态 ----
|
||||||
|
let videos=[];
|
||||||
|
let cur=null; // 当前打开的 video 对象 (来自 frames 接口)
|
||||||
|
let curId=null;
|
||||||
|
let threshold=10, page=0, pageSize=48, onlyKept=false;
|
||||||
|
let pollTimer=null;
|
||||||
|
|
||||||
|
// ====================== 侧栏 ======================
|
||||||
|
async function loadVideos(){
|
||||||
|
videos=await api('/api/videos');
|
||||||
|
renderSidebar();
|
||||||
|
}
|
||||||
|
function renderSidebar(){
|
||||||
|
const box=$('#vlist');box.innerHTML='';
|
||||||
|
if(!videos.length){const e=el('div');e.style.cssText='color:var(--mut);font-size:12px;padding:14px;text-align:center';e.textContent='还没有视频,点右上角上传';box.appendChild(e);return;}
|
||||||
|
for(const v of videos){
|
||||||
|
const it=el('div','vitem'+(v.video_id===curId?' active':''));
|
||||||
|
const nm=el('div','vname');nm.textContent=v.name;nm.title=v.name;
|
||||||
|
const mt=el('div','vmeta');
|
||||||
|
const bd=el('span','badge '+v.status);bd.textContent=({done:'已分析',processing:'分析中',extracting:'抽帧中',queued:'排队',uploaded:'待分析',error:'出错'})[v.status]||v.status;
|
||||||
|
mt.appendChild(bd);
|
||||||
|
const info=el('span');info.textContent=fmtDur(v.duration)+(v.frame_count?` · ${v.frame_count}帧`:'');mt.appendChild(info);
|
||||||
|
const del=el('span','vdel');del.textContent='✕';del.title='删除';
|
||||||
|
del.onclick=async(ev)=>{ev.stopPropagation();if(!confirm('删除「'+v.name+'」?'))return;await api('/api/videos/'+v.video_id,{method:'DELETE'});if(curId===v.video_id){curId=null;cur=null;}await loadVideos();render();};
|
||||||
|
it.append(nm,mt,del);
|
||||||
|
it.onclick=()=>openVideo(v.video_id);
|
||||||
|
box.appendChild(it);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====================== 打开视频 / 路由渲染 ======================
|
||||||
|
async function openVideo(vid){
|
||||||
|
stopPoll();
|
||||||
|
curId=vid;page=0;
|
||||||
|
const v=videos.find(x=>x.video_id===vid);
|
||||||
|
renderSidebar();
|
||||||
|
if(v && (v.status==='processing'||v.status==='extracting'||v.status==='queued')){
|
||||||
|
cur=await api(`/api/videos/${vid}/frames`).catch(()=>null);
|
||||||
|
render();startPoll();return;
|
||||||
|
}
|
||||||
|
cur=await api(`/api/videos/${vid}/frames`);
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(){
|
||||||
|
const m=$('#main');m.innerHTML='';
|
||||||
|
if(!curId||!cur){m.appendChild(emptyPane());return;}
|
||||||
|
if(cur.status==='uploaded'||cur.status==='error') {m.appendChild(setupPane());return;}
|
||||||
|
if(cur.status!=='done'){m.appendChild(progressPane());return;}
|
||||||
|
// done
|
||||||
|
m.appendChild(buildToolbar());
|
||||||
|
const content=el('div','content');content.id='content';m.appendChild(content);
|
||||||
|
renderGrid();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 空状态 ----
|
||||||
|
function emptyPane(){
|
||||||
|
const p=el('div','pane'),b=el('div','box');
|
||||||
|
b.innerHTML='<h2>从演讲视频里挑幻灯片</h2><p>上传一个长视频,每隔 N 秒抽一帧,逐帧比差异,拖动阈值就能把重复画面变灰、留下关键变化。</p>';
|
||||||
|
const drop=el('div','drop');drop.innerHTML='把视频拖到这里,或 <b>点击选择</b>';
|
||||||
|
wireDrop(drop);b.appendChild(drop);p.appendChild(b);return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 上传中 / 分析参数设置 ----
|
||||||
|
function setupPane(){
|
||||||
|
const p=el('div','pane'),b=el('div','box');
|
||||||
|
const err=cur.status==='error';
|
||||||
|
b.innerHTML=`<h2>${err?'上次分析出错了':'设置分析参数'}</h2>
|
||||||
|
<p>${cur.name||''} · 时长 ${fmtDur(cur.duration)}</p>`;
|
||||||
|
const row=el('div','row');
|
||||||
|
row.innerHTML=`<label style="color:var(--mut)">每隔</label>`;
|
||||||
|
const inp=el('input');inp.type='number';inp.min='0.2';inp.max='600';inp.step='0.5';inp.value=cur.interval||8;
|
||||||
|
const unit=el('span');unit.textContent='秒抽一帧';unit.style.color='var(--mut)';
|
||||||
|
const go=el('button');go.textContent=err?'重新分析':'开始分析';
|
||||||
|
go.onclick=()=>startAnalyze(curId,parseFloat(inp.value));
|
||||||
|
row.append(inp,unit,go);b.appendChild(row);
|
||||||
|
const hint=el('p');hint.style.cssText='margin-top:14px;font-size:12px';hint.textContent='间隔越小帧越多、越慢但越细。演讲翻页慢,8 秒通常够。';
|
||||||
|
b.appendChild(hint);
|
||||||
|
p.appendChild(b);return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 分析进度 ----
|
||||||
|
function progressPane(){
|
||||||
|
const p=el('div','pane'),b=el('div','box');
|
||||||
|
b.innerHTML=`<h2>正在分析…</h2><p>${cur.name||''}</p>
|
||||||
|
<div class="progress"><i id="pbar"></i></div><div class="pmsg" id="pmsg">准备中…</div>`;
|
||||||
|
p.appendChild(b);return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====================== Toolbar + 网格 ======================
|
||||||
|
function keptOf(f){return f.diff===null||f.diff===undefined||f.diff>=threshold;}
|
||||||
|
function shown(){return onlyKept?cur.frames.filter(keptOf):cur.frames;}
|
||||||
|
|
||||||
|
function buildToolbar(){
|
||||||
|
const t=el('div','toolbar');
|
||||||
|
// 阈值
|
||||||
|
const g1=el('div','grp');
|
||||||
|
g1.innerHTML=`<label>差异阈值</label>`;
|
||||||
|
const sl=el('input');sl.type='range';sl.min='0';sl.max='100';sl.step='0.5';sl.value=threshold;
|
||||||
|
const val=el('span','thval');val.textContent=threshold;
|
||||||
|
sl.oninput=()=>{threshold=parseFloat(sl.value);val.textContent=threshold;updateStats();applyClasses();if(onlyKept){page=0;renderGrid();}};
|
||||||
|
g1.append(sl,val);
|
||||||
|
// 仅保留
|
||||||
|
const g2=el('label','switch');
|
||||||
|
const cb=el('input');cb.type='checkbox';cb.checked=onlyKept;
|
||||||
|
cb.onchange=()=>{onlyKept=cb.checked;page=0;renderGrid();};
|
||||||
|
g2.append(cb,document.createTextNode('仅显示保留帧'));
|
||||||
|
// 统计
|
||||||
|
const g3=el('div','grp');g3.innerHTML=`<span class="stat" id="stat"></span>`;
|
||||||
|
// 间隔 + 重分析
|
||||||
|
const g4=el('div','grp');g4.style.marginLeft='auto';
|
||||||
|
g4.innerHTML=`<span class="stat">间隔 ${cur.interval}s</span>`;
|
||||||
|
const re=el('button','ghost sm');re.textContent='重新分析';re.onclick=()=>{cur.status='uploaded';render();};
|
||||||
|
g4.appendChild(re);
|
||||||
|
// 分页
|
||||||
|
const g5=el('div','grp pager');g5.id='pager';
|
||||||
|
t.append(g1,g2,g3,g4,g5);
|
||||||
|
setTimeout(()=>{updateStats();renderPager();},0);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStats(){
|
||||||
|
const total=cur.frames.length;
|
||||||
|
const kept=cur.frames.filter(keptOf).length;
|
||||||
|
const s=$('#stat');if(s)s.innerHTML=`保留 <b>${kept}</b> / 共 ${total} 帧`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGrid(){
|
||||||
|
const c=$('#content');if(!c)return;c.innerHTML='';
|
||||||
|
const list=shown();
|
||||||
|
const pages=Math.max(1,Math.ceil(list.length/pageSize));
|
||||||
|
if(page>=pages)page=pages-1;
|
||||||
|
const slice=list.slice(page*pageSize,(page+1)*pageSize);
|
||||||
|
const grid=el('div','grid');
|
||||||
|
for(const f of slice){
|
||||||
|
const card=el('div','card'+(keptOf(f)?' keep':' dim'));
|
||||||
|
card.dataset.idx=f.idx;
|
||||||
|
const img=el('img');img.loading='lazy';img.src=imgUrl(curId,'thumb',f.idx);img.alt='';
|
||||||
|
const cap=el('div','cap');
|
||||||
|
cap.innerHTML=`<span>${fmtT(f.t)}</span><span class="d">${f.diff===null||f.diff===undefined?'—':'Δ'+f.diff}</span>`;
|
||||||
|
card.append(img,cap);
|
||||||
|
bindHover(card,f);
|
||||||
|
grid.appendChild(card);
|
||||||
|
}
|
||||||
|
c.appendChild(grid);
|
||||||
|
if(!slice.length){const e=el('div');e.style.cssText='color:var(--mut);text-align:center;padding:40px';e.textContent='没有符合的帧';c.appendChild(e);}
|
||||||
|
renderPager();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPager(){
|
||||||
|
const box=$('#pager');if(!box)return;box.innerHTML='';
|
||||||
|
const list=shown();const pages=Math.max(1,Math.ceil(list.length/pageSize));
|
||||||
|
if(pages<=1)return;
|
||||||
|
const mk=(label,p,cls='')=>{const b=el('span','pg '+cls);b.textContent=label;b.onclick=()=>{page=p;renderGrid();$('#content').scrollTop=0;};return b;};
|
||||||
|
box.appendChild(mk('‹',Math.max(0,page-1)));
|
||||||
|
const win=[];for(let i=0;i<pages;i++){if(i<2||i>=pages-2||Math.abs(i-page)<=1)win.push(i);}
|
||||||
|
let last=-1;
|
||||||
|
for(const i of win){if(i-last>1){const d=el('span');d.textContent='…';d.style.color='var(--mut)';box.appendChild(d);}box.appendChild(mk(i+1,i,i===page?'cur':''));last=i;}
|
||||||
|
box.appendChild(mk('›',Math.min(pages-1,page+1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 阈值变化时只切 class,不重建 DOM(性能)
|
||||||
|
function applyClasses(){
|
||||||
|
document.querySelectorAll('.card').forEach(card=>{
|
||||||
|
const idx=+card.dataset.idx;const f=cur.frames.find(x=>x.idx===idx);if(!f)return;
|
||||||
|
const k=keptOf(f);card.classList.toggle('keep',k);card.classList.toggle('dim',!k);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====================== 悬停大图 ======================
|
||||||
|
const pv=$('#preview'),pvImg=$('#previewImg'),pvCap=$('#previewCap');
|
||||||
|
let hoverIdx=null;
|
||||||
|
function bindHover(card,f){
|
||||||
|
card.onmouseenter=()=>{
|
||||||
|
hoverIdx=f.idx;
|
||||||
|
pvImg.onload=()=>{if(hoverIdx!==f.idx)return;positionPreview(card);pv.style.display='block';};
|
||||||
|
pvImg.src=imgUrl(curId,'frame',f.idx);
|
||||||
|
pvCap.textContent=`${fmtT(f.t)} · Δ${f.diff===null||f.diff===undefined?'起始帧':f.diff}`;
|
||||||
|
if(pvImg.complete&&pvImg.naturalWidth){positionPreview(card);pv.style.display='block';}
|
||||||
|
};
|
||||||
|
card.onmouseleave=()=>{hoverIdx=null;pv.style.display='none';};
|
||||||
|
}
|
||||||
|
function positionPreview(card){
|
||||||
|
const r=card.getBoundingClientRect();
|
||||||
|
const pw=pv.offsetWidth||560,ph=pv.offsetHeight||360;
|
||||||
|
let x=r.right+12;if(x+pw>innerWidth)x=r.left-pw-12;if(x<8)x=Math.max(8,(innerWidth-pw)/2);
|
||||||
|
let y=r.top+r.height/2-ph/2;y=Math.min(Math.max(8,y),innerHeight-ph-8);
|
||||||
|
pv.style.left=x+'px';pv.style.top=y+'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====================== 上传 ======================
|
||||||
|
function wireDrop(drop){
|
||||||
|
drop.onclick=()=>$('#fileInput').click();
|
||||||
|
drop.ondragover=e=>{e.preventDefault();drop.classList.add('over');};
|
||||||
|
drop.ondragleave=()=>drop.classList.remove('over');
|
||||||
|
drop.ondrop=e=>{e.preventDefault();drop.classList.remove('over');if(e.dataTransfer.files[0])upload(e.dataTransfer.files[0]);};
|
||||||
|
}
|
||||||
|
$('#btnUpload').onclick=()=>$('#fileInput').click();
|
||||||
|
$('#fileInput').onchange=e=>{if(e.target.files[0])upload(e.target.files[0]);e.target.value='';};
|
||||||
|
|
||||||
|
function upload(file){
|
||||||
|
const m=$('#main');m.innerHTML='';
|
||||||
|
const p=el('div','pane'),b=el('div','box');
|
||||||
|
b.innerHTML=`<h2>上传中…</h2><p>${file.name} · ${fmtSize(file.size)}</p><div class="progress"><i id="ubar"></i></div><div class="pmsg" id="umsg">0%</div>`;
|
||||||
|
p.appendChild(b);m.appendChild(p);curId=null;cur=null;renderSidebar();
|
||||||
|
|
||||||
|
const fd=new FormData();fd.append('file',file);
|
||||||
|
const xhr=new XMLHttpRequest();
|
||||||
|
xhr.open('POST','/api/videos');
|
||||||
|
xhr.setRequestHeader('X-Client-Id',CLIENT);
|
||||||
|
xhr.upload.onprogress=e=>{if(e.lengthComputable){const pc=Math.round(e.loaded/e.total*100);$('#ubar').style.width=pc+'%';$('#umsg').textContent=pc+'%'+(pc>=100?' · 服务器解析中…':'');}};
|
||||||
|
xhr.onload=async()=>{
|
||||||
|
if(xhr.status>=200&&xhr.status<300){
|
||||||
|
const v=JSON.parse(xhr.responseText);
|
||||||
|
await loadVideos();await openVideo(v.video_id);
|
||||||
|
}else{let d='上传失败';try{d=JSON.parse(xhr.responseText).detail}catch{}
|
||||||
|
b.innerHTML=`<h2>上传失败</h2><p class="err">${d}</p>`;
|
||||||
|
const again=el('button');again.textContent='重试';again.onclick=()=>$('#fileInput').click();b.appendChild(again);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.onerror=()=>{b.innerHTML='<h2>上传失败</h2><p class="err">网络错误</p>';};
|
||||||
|
xhr.send(fd);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====================== 分析 + 轮询 ======================
|
||||||
|
async function startAnalyze(vid,interval){
|
||||||
|
if(!(interval>=0.2&&interval<=600)){alert('间隔需在 0.2~600 秒');return;}
|
||||||
|
await api(`/api/videos/${vid}/analyze`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({interval})});
|
||||||
|
cur.status='processing';cur.interval=interval;render();
|
||||||
|
await loadVideos();renderSidebar();
|
||||||
|
startPoll();
|
||||||
|
}
|
||||||
|
function startPoll(){
|
||||||
|
stopPoll();
|
||||||
|
pollTimer=setInterval(async()=>{
|
||||||
|
let j;try{j=await api(`/api/videos/${curId}/job`);}catch{return;}
|
||||||
|
const bar=$('#pbar'),msg=$('#pmsg');
|
||||||
|
if(bar)bar.style.width=Math.round((j.progress||0)*100)+'%';
|
||||||
|
if(msg)msg.textContent=j.message||j.status;
|
||||||
|
if(j.status==='done'){stopPoll();cur=await api(`/api/videos/${curId}/frames`);await loadVideos();render();}
|
||||||
|
else if(j.status==='error'){stopPoll();cur.status='error';await loadVideos();render();}
|
||||||
|
},600);
|
||||||
|
}
|
||||||
|
function stopPoll(){if(pollTimer){clearInterval(pollTimer);pollTimer=null;}}
|
||||||
|
|
||||||
|
window.addEventListener('resize',()=>{if(hoverIdx!=null)pv.style.display='none';});
|
||||||
|
|
||||||
|
// ====================== 启动 ======================
|
||||||
|
(async()=>{await loadVideos();render();})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
//! video2slides 核心:存储布局、后台任务进度、ffmpeg 抽帧、逐帧灰度差异。
|
||||||
|
//!
|
||||||
|
//! 存储布局(data 根下)::
|
||||||
|
//!
|
||||||
|
//! <root>/<client_id>/<video_id>/
|
||||||
|
//! source.<ext> 原始上传
|
||||||
|
//! 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<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
|
pub struct Meta {
|
||||||
|
pub name: String,
|
||||||
|
pub size: u64,
|
||||||
|
pub duration: f64,
|
||||||
|
pub interval: Option<f64>,
|
||||||
|
pub status: String, // uploaded | processing | done | error
|
||||||
|
#[serde(default)]
|
||||||
|
pub frames: Vec<FrameMeta>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_meta(root: &Path, client: &str, vid: &str) -> Option<Meta> {
|
||||||
|
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<f64>,
|
||||||
|
pub status: String,
|
||||||
|
pub frame_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_videos(root: &Path, client: &str) -> Vec<VideoSummary> {
|
||||||
|
let base = client_dir(root, client);
|
||||||
|
let Ok(entries) = std::fs::read_dir(&base) else {
|
||||||
|
return vec![];
|
||||||
|
};
|
||||||
|
let mut dirs: Vec<String> = 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<Mutex<HashMap<String, Arc<Mutex<Job>>>>>;
|
||||||
|
|
||||||
|
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<Job> {
|
||||||
|
let map = jobs.lock().ok()?;
|
||||||
|
let j = map.get(&job_key(client, vid))?;
|
||||||
|
j.lock().ok().map(|g| g.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn upd(job: &Mutex<Job>, 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<Job>,
|
||||||
|
) -> 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::<f64>() {
|
||||||
|
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::<String>()));
|
||||||
|
}
|
||||||
|
upd(job, |j| j.progress = 0.5);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn frame_index(p: &Path) -> Option<u32> {
|
||||||
|
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<Mutex<Job>>,
|
||||||
|
) {
|
||||||
|
let vdir = video_dir(&root, &client, &vid);
|
||||||
|
let frames_dir = vdir.join("frames");
|
||||||
|
let thumbs_dir = vdir.join("thumbs");
|
||||||
|
|
||||||
|
let result = (|| -> Result<usize, String> {
|
||||||
|
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<PathBuf> = 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<FrameMeta> = Vec::with_capacity(total);
|
||||||
|
let mut prev_gray: Option<image::GrayImage> = 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(())
|
||||||
|
}
|
||||||
@@ -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<String>) -> ApiErr {
|
||||||
|
(code, msg.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client_id(headers: &HeaderMap) -> Result<String, ApiErr> {
|
||||||
|
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<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
mut mp: Multipart,
|
||||||
|
) -> Result<Json<Value>, 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<u8> = 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<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Json<Value>, 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<AppState>,
|
||||||
|
AxPath(vid): AxPath<String>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Json<Value>, 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<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn analyze(
|
||||||
|
State(st): State<AppState>,
|
||||||
|
AxPath(vid): AxPath<String>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(req): Json<AnalyzeReq>,
|
||||||
|
) -> Result<Json<Value>, 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<AppState>,
|
||||||
|
AxPath(vid): AxPath<String>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Json<Value>, 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<AppState>,
|
||||||
|
AxPath(vid): AxPath<String>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Json<Value>, 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<Response, ApiErr> {
|
||||||
|
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<AppState>,
|
||||||
|
AxPath((vid, idx)): AxPath<(String, u32)>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Response, ApiErr> {
|
||||||
|
let client = client_id(&headers)?;
|
||||||
|
serve_img(&st, &client, &vid, "thumbs", idx).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn frame(
|
||||||
|
State(st): State<AppState>,
|
||||||
|
AxPath((vid, idx)): AxPath<(String, u32)>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Response, ApiErr> {
|
||||||
|
let client = client_id(&headers)?;
|
||||||
|
serve_img(&st, &client, &vid, "frames", idx).await
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user