video2slides: 重构为纯客户端 app(浏览器抽帧 + IndexedDB),后端归零
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 55s
deploy llm-proxy / build-and-deploy (push) Successful in 1m44s
deploy music / build-and-deploy (push) Successful in 2m14s
deploy notes / build-and-deploy (push) Successful in 1m40s
deploy simpleasm / build-and-deploy (push) Successful in 1m17s
deploy video2slides / build-and-deploy (push) Successful in 39s
deploy werewolf / build-and-deploy (push) Successful in 1m7s
deploy webgl / build-and-deploy (push) Successful in 1m17s
deploy write / build-and-deploy (push) Successful in 1m13s
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 55s
deploy llm-proxy / build-and-deploy (push) Successful in 1m44s
deploy music / build-and-deploy (push) Successful in 2m14s
deploy notes / build-and-deploy (push) Successful in 1m40s
deploy simpleasm / build-and-deploy (push) Successful in 1m17s
deploy video2slides / build-and-deploy (push) Successful in 39s
deploy werewolf / build-and-deploy (push) Successful in 1m7s
deploy webgl / build-and-deploy (push) Successful in 1m17s
deploy write / build-and-deploy (push) Successful in 1m13s
- 不再上传视频:<video>+canvas 原生解码按时间戳 seek 抽帧,逐帧 256px 灰度 MAE 算差异,缩略图(320)+大图(1280) 随抽随写 IndexedDB,带进度条+ETA - 阈值/手动 保留弃用/缩放偏好 持久化到 IndexedDB,刷新仍在 - PDF 导出回到客户端 jsPDF,保留帧逐张 base64 嵌入、单帧处理防 OOM - 后端删光业务逻辑(core.rs/handlers.rs),main.rs 缩成 cube_core::base 静态服务 - 不再需要 ffmpeg → Dockerfile 回归 FROM scratch;k8s 去掉 hostPath 卷、降资源 - 真浏览器(Playwright)验证:抽帧/差异/阈值/持久化/导出 全通过
This commit is contained in:
Generated
+3
-328
@@ -23,12 +23,6 @@ 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"
|
||||
@@ -54,12 +48,6 @@ 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"
|
||||
@@ -146,24 +134,12 @@ 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"
|
||||
@@ -271,12 +247,6 @@ 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"
|
||||
@@ -305,12 +275,6 @@ 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"
|
||||
@@ -440,24 +404,11 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 5.3.0",
|
||||
"r-efi",
|
||||
"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"
|
||||
@@ -467,36 +418,15 @@ 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 0.14.5",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[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"
|
||||
@@ -690,12 +620,6 @@ 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"
|
||||
@@ -717,32 +641,6 @@ 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"
|
||||
@@ -791,12 +689,6 @@ 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"
|
||||
@@ -903,16 +795,6 @@ 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"
|
||||
@@ -975,15 +857,6 @@ 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"
|
||||
@@ -1049,16 +922,6 @@ 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"
|
||||
@@ -1068,12 +931,6 @@ 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"
|
||||
@@ -1144,12 +1001,6 @@ 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"
|
||||
@@ -1364,12 +1215,6 @@ 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"
|
||||
@@ -1877,12 +1722,6 @@ 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"
|
||||
@@ -1913,17 +1752,6 @@ 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"
|
||||
@@ -1946,15 +1774,8 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
name = "video2slides"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"cube-core",
|
||||
"image",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1978,16 +1799,7 @@ version = "1.0.3+wasi-0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
|
||||
dependencies = [
|
||||
"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",
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2045,28 +1857,6 @@ 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"
|
||||
@@ -2080,18 +1870,6 @@ 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"
|
||||
@@ -2305,100 +2083,12 @@ 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"
|
||||
@@ -2534,18 +2224,3 @@ 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",
|
||||
]
|
||||
|
||||
@@ -4,15 +4,8 @@ version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
description = "video2slides.famzheng.me — 长视频抽帧,逐帧差异比较,拖阈值挑关键画面/幻灯片"
|
||||
description = "video2slides.famzheng.me — 纯客户端:浏览器抽帧+逐帧差异+拖阈值挑幻灯片,IndexedDB 持久化"
|
||||
|
||||
[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 }
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
# 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/*
|
||||
# 纯客户端 app,后端只发静态文件 → 回归 cube 惯例:FROM scratch + 静态 musl binary。
|
||||
FROM scratch
|
||||
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
|
||||
ENV VIDEO2SLIDES_DIST_DIR=/dist
|
||||
ENTRYPOINT ["/video2slides"]
|
||||
|
||||
+14
-24
@@ -4,43 +4,33 @@
|
||||
|
||||
线上:<https://video2slides.famzheng.me>
|
||||
|
||||
## 架构
|
||||
## 纯客户端,无后端
|
||||
|
||||
跟 cube 其它 app 同款:Rust + axum + `cube-core`,前端纯静态单页(零构建)。唯一特殊处——依赖 `ffmpeg`/`ffprobe`,所以镜像不是 `FROM scratch` 而是 `debian-slim` 装 ffmpeg + 拷静态 musl binary。
|
||||
视频**不上传**——浏览器用 `<video>` + canvas 原生解码、按时间戳 seek 抽帧,逐帧差异、缩略图、PDF 导出全在前端。结果(缩略图 + 大图 + 元数据)存 **IndexedDB**,每个浏览器各自持久化、刷新还在,换设备/清缓存即清空。
|
||||
|
||||
- 后端 `src/`:`core.rs`(存储 / 后台任务 / ffmpeg 抽帧 / image crate 逐帧灰度 MAE 差异)、`handlers.rs`(REST)、`main.rs`(路由)。
|
||||
- 前端 `frontend/index.html`:上传(进度) → 分析(进度) → 结果网格;顶部常驻 toolbar(阈值滑块 + 仅显示保留帧 + 保留/总数 + 分页),悬停缩略图浮出彩色大图。
|
||||
因此后端没有任何业务逻辑/存储,这个 Rust 进程只是个**静态文件服务器**(`cube_core::base`),把前端发出去而已。也因此镜像回归 cube 惯例:`FROM scratch` + 静态 musl binary,不再需要 ffmpeg / hostPath。
|
||||
|
||||
为什么不上传 + 不用服务端 ffmpeg:大视频(1~5GB)上传慢且易失败,服务端还得存。纯客户端零上传、无限扩展,且内存安全——抽帧逐帧即弃、只在内存留一张 256px 灰度算 diff;缩略图/大图随抽随写 IndexedDB。
|
||||
|
||||
## 流程
|
||||
|
||||
1. **上传** —— 拖拽或选文件,multipart 流式落盘,进度条到 100% 后 ffprobe 校验。
|
||||
2. **分析** —— 设「每隔 N 秒抽一帧」(默认 8),`ffmpeg fps=1/N` 抽帧(限宽 1280),逐帧生成缩略图并算相邻帧灰度逐像素 MAE(0–100)。后台线程跑,前端轮询进度。
|
||||
3. **挑帧** —— `diff ≥ 阈值` = 关键变化高亮保留;`< 阈值` = 跟上一帧太像变灰。纯前端实时切换,不回后端。
|
||||
1. **选视频** —— 拖拽或选本地文件(不上传)。
|
||||
2. **处理** —— 设「每隔 N 秒抽一帧」(默认 8)→ `<video>` 逐点 seek、canvas 抓帧 → 生成缩略图(320px)+大图(1280px) 写 IndexedDB,相邻帧 256px 灰度逐像素 MAE(0–100) 算差异。带进度条 + 预计剩余(ETA)。
|
||||
3. **挑帧** —— toolbar 常驻:差异阈值滑块(`diff ≥ 阈值`=关键变化高亮保留,`<`=变灰)、缩略图缩放、仅显示保留、保留/总数、分页;**点击卡片手动切换 保留/弃用**(覆盖阈值,带角标 + 重置);悬停缩略图浮彩色大图。阈值/手动选择都存回 IndexedDB。
|
||||
4. **导出 PDF** —— 客户端 jsPDF,把保留帧逐张(大图 JPEG 以 base64 直接嵌入、单帧处理避免 OOM)打成 PDF 下载。
|
||||
|
||||
## 隔离与存储
|
||||
## 格式支持
|
||||
|
||||
- 前端在 `localStorage` 存随机 `v2s_client`,所有请求带 `X-Client-Id`,后端按 client 分目录。换浏览器 = 换身份,互相看不到。
|
||||
- 每个视频一个目录:`source.*` 原片、`frames/` 抽出的彩色帧(大图预览)、`thumbs/` 缩略图、`meta.json`(含每帧时间戳与 diff)。
|
||||
- 线上数据落 hostPath `/var/lib/cube/video2slides`(单节点 k3s,pod 重启不丢)。
|
||||
走浏览器原生解码,支持浏览器能播的格式(mp4/H.264、webm/VP9/AV1 等,演讲视频基本都是 mp4)。mkv/avi/冷门编码可能读不了——浏览器播不了就处理不了。
|
||||
|
||||
## 本地跑
|
||||
|
||||
```bash
|
||||
# 后端(数据 / 前端目录用 env 指过去,端口 8080)
|
||||
VIDEO2SLIDES_DATA_DIR=/tmp/v2sdata VIDEO2SLIDES_DIST_DIR=apps/video2slides/frontend \
|
||||
cargo run -p video2slides
|
||||
VIDEO2SLIDES_DIST_DIR=apps/video2slides/frontend cargo run -p video2slides
|
||||
```
|
||||
|
||||
打开 <http://localhost:8080>。需要本机有 `ffmpeg` / `ffprobe`。
|
||||
打开 <http://localhost:8080>。后端纯静态,不需要 ffmpeg。
|
||||
|
||||
## 部署
|
||||
|
||||
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 -
|
||||
```
|
||||
push 到 master 改 `apps/video2slides/**` → CI build musl → `FROM scratch` 镜像 → push registry → `kubectl apply` + rollout。namespace `cube-video2slides`,`registry-creds` 已手工建好(不进 git)。
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
:root{
|
||||
--bg:#0f1115; --panel:#171a21; --panel2:#1f242e; --line:#2a303c;
|
||||
--fg:#e8ebf0; --mut:#8b94a3; --accent:#f2b50c; --accent2:#3b82f6;
|
||||
--keep:#f2b50c; --ok:#22c55e; --err:#ef4444;
|
||||
--keep:#f2b50c; --ok:#22c55e; --err:#ef4444; --thumbw:180px;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;height:100%}
|
||||
@@ -28,29 +28,23 @@
|
||||
.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)}
|
||||
.badge{font-size:10px;padding:1px 6px;border-radius:99px;background:rgba(34,197,94,.18);color:var(--ok)}
|
||||
.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)}
|
||||
.toolbar input[type=range]{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(var(--thumbw,180px),1fr));gap:12px}
|
||||
.card{position:relative;background:var(--panel);border:2px solid transparent;border-radius:10px;overflow:hidden;transition:opacity .15s,border-color .15s;cursor:pointer}
|
||||
.card img{width:100%;aspect-ratio:16/9;object-fit:cover;display:block;background:#000;transition:filter .15s}
|
||||
@@ -61,21 +55,18 @@
|
||||
.card.dim img{filter:grayscale(1)}
|
||||
.card.dim:hover{opacity:.92}
|
||||
.card.dim:hover img{filter:grayscale(0)}
|
||||
/* 手动 use/discard 标记 —— 放图片上层、保持彩色(不被 dim 灰化),不挡点击 */
|
||||
.card .pin{position:absolute;top:6px;left:6px;font-size:10px;line-height:1;padding:3px 6px;border-radius:6px;font-weight:700;display:none;z-index:2;pointer-events:none;box-shadow:0 1px 4px rgba(0,0,0,.4)}
|
||||
.card.pinned .pin{display:block}
|
||||
.card .pin.use{background:var(--keep);color:#1a1500}
|
||||
.card .pin.discard{background:var(--err);color:#fff}
|
||||
.card.pinned{box-shadow:0 0 0 2px rgba(255,255,255,.12) inset}
|
||||
|
||||
/* 悬停大图预览 */
|
||||
#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 .box{max-width:540px;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}
|
||||
@@ -88,36 +79,36 @@
|
||||
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}
|
||||
.progress > i{display:block;height:100%;background:linear-gradient(90deg,var(--accent2),var(--accent));width:0;transition:width .2s}
|
||||
.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}
|
||||
|
||||
#expmask{position:fixed;inset:0;z-index:200;background:rgba(0,0,0,.65);display:none;align-items:center;justify-content:center}
|
||||
#expmask.on{display:flex}
|
||||
#expmask .box2{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:24px 30px;text-align:center}
|
||||
#ebar{display:block;height:100%;background:linear-gradient(90deg,var(--accent2),var(--accent));width:0;transition:width .15s}
|
||||
.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="tag">长视频 → 抽帧 → 挑关键画面/幻灯片 · 全在本地浏览器跑,不上传</span>
|
||||
<span class="spacer"></span>
|
||||
<button id="btnUpload" class="sm">+ 上传视频</button>
|
||||
<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="side-head"><b style="font-size:13px">我的视频</b><span class="tag" style="margin-left:auto;font-size:11px">本机</span></div>
|
||||
<div class="vlist" id="vlist"></div>
|
||||
</aside>
|
||||
|
||||
<main id="main"><!-- 动态 --></main>
|
||||
</div>
|
||||
|
||||
@@ -128,201 +119,262 @@
|
||||
<div class="pmsg" id="emsg">导出中…</div>
|
||||
</div></div>
|
||||
|
||||
<script src="jspdf.umd.min.js"></script>
|
||||
<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}?c=${encodeURIComponent(CLIENT)}`;}
|
||||
const genId = ()=>(crypto.randomUUID?crypto.randomUUID():'v'+Date.now()+Math.random().toString(16).slice(2)).replace(/-/g,'').slice(0,16);
|
||||
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)}`;};
|
||||
const fmtEta=s=>{if(!isFinite(s)||s<0)return '…';s=Math.round(s);return s>=60?`${Math.floor(s/60)}分${s%60}秒`:`${s}秒`;};
|
||||
|
||||
// ---- 全局状态 ----
|
||||
// ====================== IndexedDB ======================
|
||||
// videos: {id,name,size,duration,interval,frames:[{idx,t,diff,pw,ph}],threshold,overrides,createdAt}
|
||||
// frames: {key:`${id}|${idx}|thumb|preview`, vid:id, blob}
|
||||
let _db;
|
||||
function idb(){return _db?Promise.resolve(_db):new Promise((res,rej)=>{
|
||||
const r=indexedDB.open('video2slides',1);
|
||||
r.onupgradeneeded=()=>{const db=r.result;
|
||||
if(!db.objectStoreNames.contains('videos'))db.createObjectStore('videos',{keyPath:'id'});
|
||||
if(!db.objectStoreNames.contains('frames')){const s=db.createObjectStore('frames',{keyPath:'key'});s.createIndex('vid','vid');}
|
||||
};
|
||||
r.onsuccess=()=>{_db=r.result;res(_db);};
|
||||
r.onerror=()=>rej(r.error);
|
||||
});}
|
||||
const store=(name,mode)=>idb().then(db=>db.transaction(name,mode).objectStore(name));
|
||||
const req2p=r=>new Promise((res,rej)=>{r.onsuccess=()=>res(r.result);r.onerror=()=>rej(r.error);});
|
||||
const idbPut=(name,val)=>store(name,'readwrite').then(s=>req2p(s.put(val)));
|
||||
const idbGet=(name,key)=>store(name,'readonly').then(s=>req2p(s.get(key)));
|
||||
const idbGetAll=name=>store(name,'readonly').then(s=>req2p(s.getAll()));
|
||||
async function idbDeleteVideo(id){
|
||||
await store('videos','readwrite').then(s=>req2p(s.delete(id)));
|
||||
const s=await store('frames','readwrite');
|
||||
await new Promise((res,rej)=>{
|
||||
const c=s.index('vid').openKeyCursor(IDBKeyRange.only(id));
|
||||
c.onsuccess=()=>{const cur=c.result;if(cur){s.delete(cur.primaryKey);cur.continue();}else res();};
|
||||
c.onerror=()=>rej(c.error);
|
||||
});
|
||||
}
|
||||
|
||||
// ====================== 全局状态 ======================
|
||||
let videos=[];
|
||||
let cur=null; // 当前打开的 video 对象 (来自 frames 接口)
|
||||
let curId=null;
|
||||
let threshold=10, page=0, pageSize=48, onlyKept=false;
|
||||
let overrides={}; // idx -> true(手动保留) / false(手动弃用),覆盖阈值判定
|
||||
let thumbW=+(localStorage.getItem('v2s_thumbw')||180); // 缩略图列宽(px)
|
||||
let pollTimer=null;
|
||||
let cur=null, curId=null;
|
||||
let threshold=10, page=0, pageSize=48, onlyKept=false, overrides={};
|
||||
let thumbW=+(localStorage.getItem('v2s_thumbw')||180);
|
||||
let pendingFile=null;
|
||||
let gridUrls=[]; // 当前网格的 object URL,重渲染时回收
|
||||
function applyThumbW(){document.documentElement.style.setProperty('--thumbw',thumbW+'px');}
|
||||
applyThumbW();
|
||||
|
||||
// ====================== 侧栏 ======================
|
||||
async function loadVideos(){
|
||||
videos=await api('/api/videos');
|
||||
videos=(await idbGetAll('videos')).sort((a,b)=>b.createdAt-a.createdAt);
|
||||
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;}
|
||||
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 it=el('div','vitem'+(v.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 bd=el('span','badge');bd.textContent=`${v.frames.length}帧`;mt.appendChild(bd);
|
||||
const info=el('span');info.textContent=`${fmtDur(v.duration)} · 每${v.interval}s`;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();};
|
||||
del.onclick=async(ev)=>{ev.stopPropagation();if(!confirm('删除「'+v.name+'」?(仅删本机缓存)'))return;await idbDeleteVideo(v.id);if(curId===v.id){curId=null;cur=null;}await loadVideos();render();};
|
||||
it.append(nm,mt,del);
|
||||
it.onclick=()=>openVideo(v.video_id);
|
||||
it.onclick=()=>openVideo(v.id);
|
||||
box.appendChild(it);
|
||||
}
|
||||
}
|
||||
|
||||
// ====================== 打开视频 / 路由渲染 ======================
|
||||
async function openVideo(vid){
|
||||
stopPoll();
|
||||
curId=vid;page=0;overrides={};
|
||||
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;
|
||||
// ====================== 打开 / 渲染 ======================
|
||||
async function openVideo(id){
|
||||
curId=id;page=0;
|
||||
cur=videos.find(v=>v.id===id)||await idbGet('videos',id);
|
||||
threshold=cur.threshold??10; overrides=cur.overrides||{};
|
||||
renderSidebar();render();
|
||||
}
|
||||
cur=await api(`/api/videos/${vid}/frames`);
|
||||
render();
|
||||
}
|
||||
|
||||
function render(){
|
||||
const m=$('#main');m.innerHTML='';
|
||||
const m=$('#main');revokeGridUrls();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>';
|
||||
b.innerHTML='<h2>从演讲视频里挑幻灯片</h2><p>选一个本地视频,每隔 N 秒抽一帧,逐帧比差异,拖动阈值就能把重复画面变灰、留下关键变化。<br><br>视频<b>不会上传</b>,全程在你浏览器里跑;结果缓存在本机(IndexedDB),换设备/清缓存即清空。</p>';
|
||||
const drop=el('div','drop');drop.innerHTML='把视频拖到这里,或 <b>点击选择</b>';
|
||||
wireDrop(drop);b.appendChild(drop);p.appendChild(b);return p;
|
||||
}
|
||||
|
||||
// ---- 上传中 / 分析参数设置 ----
|
||||
function setupPane(){
|
||||
// ====================== 选择文件 → 设置 → 抽帧 ======================
|
||||
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])pickFile(e.dataTransfer.files[0]);};
|
||||
}
|
||||
$('#btnUpload').onclick=()=>$('#fileInput').click();
|
||||
$('#fileInput').onchange=e=>{if(e.target.files[0])pickFile(e.target.files[0]);e.target.value='';};
|
||||
|
||||
function pickFile(file){
|
||||
pendingFile=file;curId=null;cur=null;renderSidebar();
|
||||
const m=$('#main');revokeGridUrls();m.innerHTML='';
|
||||
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>`;
|
||||
b.innerHTML=`<h2>设置抽帧参数</h2><p>${file.name} · ${fmtSize(file.size)}</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 inp=el('input');inp.type='number';inp.min='0.2';inp.max='600';inp.step='0.5';inp.value=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));
|
||||
const go=el('button');go.textContent='开始处理';
|
||||
go.onclick=()=>extract(file,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;
|
||||
const hint=el('p');hint.style.cssText='margin-top:14px;font-size:12px';hint.textContent='间隔越小帧越多、越慢但越细。演讲翻页慢,8 秒通常够。长视频抽帧需要点时间,会显示进度和预计剩余。';
|
||||
b.appendChild(hint);p.appendChild(b);m.appendChild(p);
|
||||
}
|
||||
|
||||
// ---- 分析进度 ----
|
||||
function progressPane(){
|
||||
function seekTo(video,t){return new Promise((res)=>{
|
||||
let done=false;
|
||||
const ok=()=>{if(done)return;done=true;video.removeEventListener('seeked',ok);res();};
|
||||
video.addEventListener('seeked',ok);
|
||||
try{video.currentTime=t;}catch(e){ok();return;}
|
||||
setTimeout(ok,8000); // 容错:个别 seek 不触发就跳过
|
||||
});}
|
||||
const toBlob=(canvas,q)=>new Promise(r=>canvas.toBlob(b=>r(b),'image/jpeg',q));
|
||||
function toGray(d){const a=d.data,n=a.length>>2,g=new Uint8Array(n);for(let i=0,j=0;j<n;i+=4,j++)g[j]=(a[i]*299+a[i+1]*587+a[i+2]*114)/1000|0;return g;}
|
||||
function mae(a,b){const n=Math.min(a.length,b.length);if(!n)return 0;let s=0;for(let i=0;i<n;i++)s+=Math.abs(a[i]-b[i]);return Math.round(s/n/255*10000)/100;}
|
||||
|
||||
async function extract(file,interval){
|
||||
if(!(interval>=0.2&&interval<=600)){alert('间隔需在 0.2~600 秒');return;}
|
||||
// 进度屏
|
||||
const m=$('#main');m.innerHTML='';
|
||||
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;
|
||||
b.innerHTML=`<h2>正在处理…</h2><p id="exname">${file.name}</p><div class="progress"><i id="pbar"></i></div><div class="pmsg" id="pmsg">读取视频…</div>`;
|
||||
p.appendChild(b);m.appendChild(p);
|
||||
const setP=(frac,msg)=>{const bar=$('#pbar'),t=$('#pmsg');if(bar)bar.style.width=Math.round(frac*100)+'%';if(t)t.textContent=msg;};
|
||||
|
||||
const video=document.createElement('video');
|
||||
video.muted=true;video.playsInline=true;video.preload='auto';
|
||||
const url=URL.createObjectURL(file);video.src=url;
|
||||
try{
|
||||
await new Promise((res,rej)=>{video.onloadedmetadata=()=>res();video.onerror=()=>rej(new Error('浏览器无法读取该视频(格式/编码可能不支持,建议用 mp4/H.264 或 webm)'));});
|
||||
const dur=video.duration,W=video.videoWidth,H=video.videoHeight;
|
||||
if(!isFinite(dur)||dur<=0)throw new Error('无法获取视频时长');
|
||||
if(!W||!H)throw new Error('无法获取视频画面尺寸');
|
||||
const pw=Math.min(1280,W),ph=Math.round(H*Math.min(1280,W)/W);
|
||||
const tw=Math.min(320,W),th=Math.round(H*Math.min(320,W)/W);
|
||||
const dw=256,dh=Math.max(1,Math.round(H*256/W));
|
||||
const pc=el('canvas');pc.width=pw;pc.height=ph;const pctx=pc.getContext('2d');
|
||||
const tc=el('canvas');tc.width=tw;tc.height=th;const tctx=tc.getContext('2d');
|
||||
const dc=el('canvas');dc.width=dw;dc.height=dh;const dctx=dc.getContext('2d',{willReadFrequently:true});
|
||||
|
||||
const id=genId(),total=Math.max(1,Math.ceil(dur/interval)),frames=[];
|
||||
let prev=null;const t0=performance.now();
|
||||
for(let i=0;i<total;i++){
|
||||
const t=Math.min(i*interval,Math.max(0,dur-0.04));
|
||||
await seekTo(video,t);
|
||||
pctx.drawImage(video,0,0,pw,ph);
|
||||
tctx.drawImage(video,0,0,tw,th);
|
||||
dctx.drawImage(video,0,0,dw,dh);
|
||||
const g=toGray(dctx.getImageData(0,0,dw,dh));
|
||||
const diff=prev?mae(prev,g):null;prev=g;
|
||||
const idx=i+1;
|
||||
const [pb,tb]=await Promise.all([toBlob(pc,0.82),toBlob(tc,0.72)]);
|
||||
await idbPut('frames',{key:`${id}|${idx}|preview`,vid:id,blob:pb});
|
||||
await idbPut('frames',{key:`${id}|${idx}|thumb`,vid:id,blob:tb});
|
||||
frames.push({idx,t:Math.round(t*10)/10,diff,pw,ph});
|
||||
const done=i+1,elapsed=(performance.now()-t0)/1000,eta=done?elapsed/done*(total-done):0;
|
||||
setP(done/total,`抽帧 ${done}/${total} · 剩约 ${fmtEta(eta)}`);
|
||||
}
|
||||
const meta={id,name:file.name,size:file.size,duration:dur,interval,frames,threshold:10,overrides:{},createdAt:Date.now()};
|
||||
await idbPut('videos',meta);
|
||||
URL.revokeObjectURL(url);
|
||||
pendingFile=null;
|
||||
await loadVideos();await openVideo(id);
|
||||
}catch(e){
|
||||
URL.revokeObjectURL(url);
|
||||
const box=$('.box');if(box){box.innerHTML=`<h2>处理失败</h2><p class="err">${e.message||e}</p>`;const again=el('button');again.textContent='重新选择';again.onclick=()=>$('#fileInput').click();box.appendChild(again);}
|
||||
}
|
||||
}
|
||||
|
||||
// ====================== Toolbar + 网格 ======================
|
||||
// 阈值给的是默认判定;overrides 是手动例外(点击切换),手动优先。
|
||||
// ====================== 保留判定 / toolbar ======================
|
||||
function autoKeep(f){return f.diff===null||f.diff===undefined||f.diff>=threshold;}
|
||||
function keptOf(f){return (f.idx in overrides)?overrides[f.idx]:autoKeep(f);}
|
||||
function shown(){return onlyKept?cur.frames.filter(keptOf):cur.frames;}
|
||||
|
||||
// 点击卡片:切换 保留/弃用。若切回与阈值默认一致,则清掉该例外(回归自动)。
|
||||
let saveTimer=null;
|
||||
function persistState(immediate){ // 把 threshold/overrides 存回 IDB
|
||||
if(!cur)return;cur.threshold=threshold;cur.overrides=overrides;
|
||||
clearTimeout(saveTimer);
|
||||
if(immediate)idbPut('videos',cur).catch(()=>{}); // 离散动作(点选/重置)立即存
|
||||
else saveTimer=setTimeout(()=>idbPut('videos',cur).catch(()=>{}),400); // 拖阈值防抖
|
||||
}
|
||||
function toggleFrame(f){
|
||||
const next=!keptOf(f);
|
||||
if(next===autoKeep(f))delete overrides[f.idx];else overrides[f.idx]=next;
|
||||
updateStats();
|
||||
if(onlyKept){renderGrid();} // 弃用后该帧应从「仅保留」视图消失
|
||||
updateStats();persistState(true);
|
||||
if(onlyKept)renderGrid();
|
||||
else{const card=document.querySelector(`.card[data-idx="${f.idx}"]`);if(card)paintCard(card,f);}
|
||||
}
|
||||
|
||||
// 统一刷新一张卡片的 保留/弃用/手动标记 外观
|
||||
function paintCard(card,f){
|
||||
const k=keptOf(f),pinned=(f.idx in overrides);
|
||||
card.classList.toggle('keep',k);
|
||||
card.classList.toggle('dim',!k);
|
||||
card.classList.toggle('pinned',pinned);
|
||||
let pin=card.querySelector('.pin');
|
||||
if(pin){pin.className='pin '+(k?'use':'discard');pin.textContent=k?'✓ 手动保留':'✕ 手动弃用';}
|
||||
card.classList.toggle('keep',k);card.classList.toggle('dim',!k);card.classList.toggle('pinned',pinned);
|
||||
const pin=card.querySelector('.pin');if(pin){pin.className='pin '+(k?'use':'discard');pin.textContent=k?'✓ 手动保留':'✕ 手动弃用';}
|
||||
}
|
||||
|
||||
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 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;sl.style.width='200px';
|
||||
const val=el('span','thval');val.textContent=threshold;
|
||||
sl.oninput=()=>{threshold=parseFloat(sl.value);val.textContent=threshold;updateStats();applyClasses();if(onlyKept){page=0;renderGrid();}};
|
||||
sl.oninput=()=>{threshold=parseFloat(sl.value);val.textContent=threshold;updateStats();applyClasses();persistState();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 gz=el('div','grp');gz.innerHTML=`<label>缩略图</label>`;
|
||||
const zs=el('input');zs.type='range';zs.min='110';zs.max='420';zs.step='10';zs.value=thumbW;zs.style.width='120px';zs.title='缩放缩略图';
|
||||
zs.oninput=()=>{thumbW=+zs.value;localStorage.setItem('v2s_thumbw',thumbW);applyThumbW();};
|
||||
gz.append(zs);
|
||||
// 统计
|
||||
|
||||
const g3=el('div','grp');g3.innerHTML=`<span class="stat" id="stat"></span>`;
|
||||
// 导出 / 重置手动 / 间隔 / 重分析
|
||||
|
||||
const g4=el('div','grp');g4.style.marginLeft='auto';
|
||||
const exp=el('button','sm');exp.textContent='⬇ 导出 PDF';exp.onclick=exportPdf;
|
||||
const rs=el('button','ghost sm');rs.textContent='重置手动';rs.title='清除所有手动 保留/弃用,回到纯阈值判定';
|
||||
rs.onclick=()=>{overrides={};updateStats();onlyKept?renderGrid():applyClasses();};
|
||||
g4.append(exp,rs,el('span','stat'));g4.lastChild.textContent=`间隔 ${cur.interval}s`;
|
||||
const re=el('button','ghost sm');re.textContent='重新分析';re.onclick=()=>{cur.status='uploaded';render();};
|
||||
g4.appendChild(re);
|
||||
// 分页
|
||||
const rs=el('button','ghost sm');rs.textContent='重置手动';rs.title='清除所有手动 保留/弃用';
|
||||
rs.onclick=()=>{overrides={};updateStats();persistState(true);onlyKept?renderGrid():applyClasses();};
|
||||
const ist=el('span','stat');ist.textContent=`间隔 ${cur.interval}s`;
|
||||
g4.append(exp,rs,ist);
|
||||
|
||||
const g5=el('div','grp pager');g5.id='pager';
|
||||
t.append(g1,g2,gz,g3,g4,g5);
|
||||
setTimeout(()=>{updateStats();renderPager();},0);
|
||||
return t;
|
||||
}
|
||||
|
||||
function updateStats(){
|
||||
const total=cur.frames.length;
|
||||
const kept=cur.frames.filter(keptOf).length;
|
||||
const man=Object.keys(overrides).length;
|
||||
const total=cur.frames.length,kept=cur.frames.filter(keptOf).length,man=Object.keys(overrides).length;
|
||||
const s=$('#stat');if(s)s.innerHTML=`保留 <b>${kept}</b> / 共 ${total} 帧`+(man?` <span style="opacity:.7">· 手动 ${man}</span>`:'');
|
||||
}
|
||||
|
||||
// ====================== 网格 ======================
|
||||
function revokeGridUrls(){gridUrls.forEach(u=>URL.revokeObjectURL(u));gridUrls=[];}
|
||||
function renderGrid(){
|
||||
const c=$('#content');if(!c)return;c.innerHTML='';
|
||||
const list=shown();
|
||||
const pages=Math.max(1,Math.ceil(list.length/pageSize));
|
||||
const c=$('#content');if(!c)return;revokeGridUrls();c.innerHTML='';
|
||||
const list=shown(),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');
|
||||
card.dataset.idx=f.idx;card.title='点击切换 保留/弃用';
|
||||
const card=el('div','card');card.dataset.idx=f.idx;card.title='点击切换 保留/弃用';
|
||||
const pin=el('span','pin');
|
||||
const img=el('img');img.loading='lazy';img.src=imgUrl(curId,'thumb',f.idx);img.alt='';
|
||||
const img=el('img');img.alt='';img.loading='lazy';
|
||||
idbGet('frames',`${curId}|${f.idx}|thumb`).then(rec=>{if(rec&&rec.blob){const u=URL.createObjectURL(rec.blob);gridUrls.push(u);img.src=u;}}).catch(()=>{});
|
||||
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(pin,img,cap);
|
||||
@@ -335,10 +387,9 @@ function renderGrid(){
|
||||
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));
|
||||
const list=shown(),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)));
|
||||
@@ -347,127 +398,68 @@ function renderPager(){
|
||||
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;
|
||||
paintCard(card,f);
|
||||
});
|
||||
}
|
||||
function applyClasses(){document.querySelectorAll('.card').forEach(card=>{const f=cur.frames.find(x=>x.idx===+card.dataset.idx);if(f)paintCard(card,f);});}
|
||||
|
||||
// ====================== 悬停大图 ======================
|
||||
const pv=$('#preview'),pvImg=$('#previewImg'),pvCap=$('#previewCap');
|
||||
let hoverIdx=null;
|
||||
let hoverIdx=null,hoverUrl=null;
|
||||
function clearHoverUrl(){if(hoverUrl){URL.revokeObjectURL(hoverUrl);hoverUrl=null;}}
|
||||
function bindHover(card,f){
|
||||
card.onmouseenter=()=>{
|
||||
card.onmouseenter=async()=>{
|
||||
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';}
|
||||
const rec=await idbGet('frames',`${curId}|${f.idx}|preview`).catch(()=>null);
|
||||
if(!rec||hoverIdx!==f.idx)return;
|
||||
clearHoverUrl();hoverUrl=URL.createObjectURL(rec.blob);
|
||||
pvImg.onload=()=>{if(hoverIdx!==f.idx)return;positionPreview(card);pv.style.display='block';};
|
||||
pvImg.src=hoverUrl;
|
||||
};
|
||||
card.onmouseleave=()=>{hoverIdx=null;pv.style.display='none';};
|
||||
card.onmouseleave=()=>{hoverIdx=null;pv.style.display='none';clearHoverUrl();};
|
||||
}
|
||||
function positionPreview(card){
|
||||
const r=card.getBoundingClientRect();
|
||||
const pw=pv.offsetWidth||560,ph=pv.offsetHeight||360;
|
||||
const r=card.getBoundingClientRect(),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';
|
||||
}
|
||||
window.addEventListener('resize',()=>{if(hoverIdx!=null)pv.style.display='none';});
|
||||
|
||||
// ====================== 上传 ======================
|
||||
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;}}
|
||||
|
||||
// ====================== 导出 PDF(服务端生成 + 进度条)======================
|
||||
// ====================== PDF 导出(客户端 jsPDF)======================
|
||||
let exporting=false;
|
||||
const blobToDataURL=b=>new Promise((res,rej)=>{const r=new FileReader();r.onload=()=>res(r.result);r.onerror=()=>rej(r.error);r.readAsDataURL(b);});
|
||||
async function exportPdf(){
|
||||
if(exporting)return;
|
||||
const idxs=cur.frames.filter(keptOf).map(f=>f.idx);
|
||||
if(!idxs.length){alert('没有要导出的帧 —— 调低阈值或手动保留几帧再试');return;}
|
||||
if(idxs.length>1000 && !confirm(`要导出 ${idxs.length} 页,PDF 会比较大、耗时长,继续?`))return;
|
||||
const list=cur.frames.filter(keptOf);
|
||||
if(!list.length){alert('没有要导出的帧 —— 调低阈值或手动保留几帧再试');return;}
|
||||
exporting=true;
|
||||
const mask=$('#expmask'),bar=$('#ebar'),msg=$('#emsg');
|
||||
bar.style.width='0%';msg.textContent=`启动导出(${idxs.length} 帧)…`;mask.classList.add('on');
|
||||
const vid=curId;
|
||||
bar.style.width='0%';msg.textContent=`生成 PDF(${list.length} 帧)…`;mask.classList.add('on');
|
||||
try{
|
||||
await api(`/api/videos/${vid}/pdf`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({frames:idxs})});
|
||||
// 轮询进度
|
||||
await new Promise((resolve,reject)=>{
|
||||
const t=setInterval(async()=>{
|
||||
let j;try{j=await api(`/api/videos/${vid}/pdf/status`);}catch{return;}
|
||||
bar.style.width=Math.round((j.progress||0)*100)+'%';
|
||||
msg.textContent=j.message||j.status;
|
||||
if(j.status==='done'){clearInterval(t);resolve();}
|
||||
else if(j.status==='error'){clearInterval(t);reject(new Error(j.error||'导出出错'));}
|
||||
},400);
|
||||
});
|
||||
// 触发下载(GET + ?c=,<a download> 带不了头)
|
||||
const a=el('a');a.href=`/api/videos/${vid}/pdf/download?c=${encodeURIComponent(CLIENT)}`;a.download='';
|
||||
document.body.appendChild(a);a.click();a.remove();
|
||||
msg.textContent='完成,开始下载';
|
||||
await new Promise(r=>setTimeout(r,500));
|
||||
const { jsPDF }=window.jspdf;let doc=null;
|
||||
for(let i=0;i<list.length;i++){
|
||||
const f=list[i];
|
||||
const rec=await idbGet('frames',`${curId}|${f.idx}|preview`);
|
||||
if(!rec||!rec.blob)continue;
|
||||
const durl=await blobToDataURL(rec.blob); // base64,不解码像素
|
||||
const w=f.pw||1280,h=f.ph||720,o=w>=h?'l':'p';
|
||||
if(!doc)doc=new jsPDF({orientation:o,unit:'px',format:[w,h],compress:true});
|
||||
else doc.addPage([w,h],o);
|
||||
doc.addImage(durl,'JPEG',0,0,w,h);
|
||||
bar.style.width=Math.round((i+1)/list.length*100)+'%';msg.textContent=`生成 PDF ${i+1}/${list.length}`;
|
||||
await new Promise(r=>setTimeout(r)); // 让出事件循环,避免卡 UI
|
||||
}
|
||||
if(!doc)throw new Error('没有可用的帧');
|
||||
doc.save((cur.name||'slides').replace(/\.[^.]+$/,'')+'.pdf');
|
||||
msg.textContent='完成,开始下载';await new Promise(r=>setTimeout(r,400));
|
||||
}catch(e){alert(e.message||'导出失败');}
|
||||
finally{exporting=false;mask.classList.remove('on');}
|
||||
}
|
||||
|
||||
window.addEventListener('resize',()=>{if(hoverIdx!=null)pv.style.display='none';});
|
||||
|
||||
// ====================== 启动 ======================
|
||||
(async()=>{await loadVideos();render();})();
|
||||
(async()=>{
|
||||
try{await loadVideos();}catch(e){console.error(e);}
|
||||
render();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+398
File diff suppressed because one or more lines are too long
@@ -12,9 +12,6 @@ metadata:
|
||||
app: video2slides
|
||||
spec:
|
||||
replicas: 1
|
||||
# 用了 hostPath + 单副本,重建时先杀旧的再起新的,避免两个 pod 抢同一目录
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app: video2slides
|
||||
@@ -32,14 +29,6 @@ spec:
|
||||
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
|
||||
@@ -52,20 +41,14 @@ spec:
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 15
|
||||
# 纯静态文件服务,几乎不吃资源
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 128Mi
|
||||
cpu: 10m
|
||||
memory: 16Mi
|
||||
limits:
|
||||
# ffmpeg 抽帧 + image 解码比较吃 CPU/内存,给足
|
||||
cpu: "2"
|
||||
memory: 1Gi
|
||||
volumes:
|
||||
- name: data
|
||||
hostPath:
|
||||
# 上传的视频 + 抽出的帧落在节点本地盘,pod 重启不丢(单节点 k3s)
|
||||
path: /var/lib/cube/video2slides
|
||||
type: DirectoryOrCreate
|
||||
cpu: 200m
|
||||
memory: 64Mi
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
|
||||
@@ -1,639 +0,0 @@
|
||||
//! 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, BufWriter, 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(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PDF 导出:把选中的帧(彩色全图 JPEG)逐张以 DCTDecode 嵌进 PDF。
|
||||
// 关键:边读边写到磁盘文件,一次只在内存里拿一张 JPEG,避免大视频 OOM。
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub const EXPORT_FILE: &str = "export.pdf";
|
||||
|
||||
/// 只扫 JPEG 头拿到 (宽, 高, 分量数),不解码像素 —— 省内存。
|
||||
fn jpeg_info(b: &[u8]) -> Option<(u32, u32, u8)> {
|
||||
if b.len() < 4 || b[0] != 0xFF || b[1] != 0xD8 {
|
||||
return None;
|
||||
}
|
||||
let mut i = 2usize;
|
||||
while i + 4 <= b.len() {
|
||||
if b[i] != 0xFF {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let marker = b[i + 1];
|
||||
// 无长度的标记:填充 0xFF、RSTn(D0-D7)、SOI(D8)、EOI(D9)、TEM(01)
|
||||
if marker == 0xFF {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if marker == 0xD8 || marker == 0xD9 || marker == 0x01 || (0xD0..=0xD7).contains(&marker) {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if i + 4 > b.len() {
|
||||
break;
|
||||
}
|
||||
let len = ((b[i + 2] as usize) << 8) | b[i + 3] as usize;
|
||||
// SOF0..SOF15,排除 DHT(C4)/JPG(C8)/DAC(CC)
|
||||
let is_sof = matches!(marker,
|
||||
0xC0 | 0xC1 | 0xC2 | 0xC3 | 0xC5 | 0xC6 | 0xC7 | 0xC9 | 0xCA | 0xCB | 0xCD | 0xCE | 0xCF);
|
||||
if is_sof {
|
||||
let p = i + 4; // 段数据:precision(1) height(2) width(2) comps(1)
|
||||
if p + 6 > b.len() {
|
||||
return None;
|
||||
}
|
||||
let h = ((b[p + 1] as u32) << 8) | b[p + 2] as u32;
|
||||
let w = ((b[p + 3] as u32) << 8) | b[p + 4] as u32;
|
||||
let c = b[p + 5];
|
||||
return Some((w, h, c));
|
||||
}
|
||||
i += 2 + len;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn wb<W: Write>(w: &mut W, pos: &mut u64, bytes: &[u8]) -> std::io::Result<()> {
|
||||
w.write_all(bytes)?;
|
||||
*pos += bytes.len() as u64;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_pdf_to_file(
|
||||
root: &Path,
|
||||
client: &str,
|
||||
vid: &str,
|
||||
idxs: &[u32],
|
||||
job: &Mutex<Job>,
|
||||
) -> Result<(), String> {
|
||||
let frames_dir = video_dir(root, client, vid).join("frames");
|
||||
let n = idxs.len();
|
||||
if n == 0 {
|
||||
return Err("没有要导出的帧".into());
|
||||
}
|
||||
|
||||
let tmp = video_dir(root, client, vid).join("export.pdf.tmp");
|
||||
let f = std::fs::File::create(&tmp).map_err(|e| e.to_string())?;
|
||||
let mut w = BufWriter::new(f);
|
||||
let mut pos: u64 = 0;
|
||||
|
||||
let total_objs = 2 + n * 3; // catalog + pages + 每帧 3 个对象
|
||||
let mut offsets = vec![0u64; total_objs + 1]; // 1-based
|
||||
|
||||
let res = (|| -> std::io::Result<()> {
|
||||
wb(&mut w, &mut pos, b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n")?;
|
||||
|
||||
offsets[1] = pos;
|
||||
wb(&mut w, &mut pos, b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n")?;
|
||||
|
||||
offsets[2] = pos;
|
||||
let kids: String = (0..n).map(|k| format!("{} 0 R ", 3 + k * 3)).collect();
|
||||
wb(&mut w, &mut pos,
|
||||
format!("2 0 obj\n<< /Type /Pages /Kids [ {kids}] /Count {n} >>\nendobj\n").as_bytes())?;
|
||||
|
||||
for (k, &idx) in idxs.iter().enumerate() {
|
||||
let p = frames_dir.join(format!("{idx:06}.jpg"));
|
||||
let data = std::fs::read(&p)
|
||||
.map_err(|_| std::io::Error::other(format!("帧 {idx} 不存在")))?;
|
||||
let (iw, ih, comps) = jpeg_info(&data)
|
||||
.ok_or_else(|| std::io::Error::other(format!("帧 {idx} 不是有效 JPEG")))?;
|
||||
let cs = match comps {
|
||||
1 => "DeviceGray",
|
||||
4 => "DeviceCMYK",
|
||||
_ => "DeviceRGB",
|
||||
};
|
||||
let page_id = 3 + k * 3;
|
||||
let content_id = 4 + k * 3;
|
||||
let image_id = 5 + k * 3;
|
||||
|
||||
offsets[page_id] = pos;
|
||||
wb(&mut w, &mut pos, format!(
|
||||
"{page_id} 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {iw} {ih}] \
|
||||
/Resources << /XObject << /Im0 {image_id} 0 R >> >> /Contents {content_id} 0 R >>\nendobj\n"
|
||||
).as_bytes())?;
|
||||
|
||||
let content = format!("q\n{iw} 0 0 {ih} 0 0 cm\n/Im0 Do\nQ\n");
|
||||
offsets[content_id] = pos;
|
||||
wb(&mut w, &mut pos,
|
||||
format!("{content_id} 0 obj\n<< /Length {} >>\nstream\n", content.len()).as_bytes())?;
|
||||
wb(&mut w, &mut pos, content.as_bytes())?;
|
||||
wb(&mut w, &mut pos, b"endstream\nendobj\n")?;
|
||||
|
||||
offsets[image_id] = pos;
|
||||
wb(&mut w, &mut pos, format!(
|
||||
"{image_id} 0 obj\n<< /Type /XObject /Subtype /Image /Width {iw} /Height {ih} \
|
||||
/ColorSpace /{cs} /BitsPerComponent 8 /Filter /DCTDecode /Length {} >>\nstream\n",
|
||||
data.len()
|
||||
).as_bytes())?;
|
||||
wb(&mut w, &mut pos, &data)?;
|
||||
wb(&mut w, &mut pos, b"\nendstream\nendobj\n")?;
|
||||
// data 在这里 drop,内存峰值始终只有一张帧
|
||||
|
||||
upd(job, |j| {
|
||||
j.progress = (k + 1) as f64 / n as f64;
|
||||
j.frame_count = (k + 1) as u32;
|
||||
j.message = format!("写入 PDF {}/{}", k + 1, n);
|
||||
});
|
||||
}
|
||||
|
||||
// xref
|
||||
let xref_off = pos;
|
||||
wb(&mut w, &mut pos, format!("xref\n0 {}\n", total_objs + 1).as_bytes())?;
|
||||
wb(&mut w, &mut pos, b"0000000000 65535 f \n")?;
|
||||
for id in 1..=total_objs {
|
||||
wb(&mut w, &mut pos, format!("{:010} 00000 n \n", offsets[id]).as_bytes())?;
|
||||
}
|
||||
wb(&mut w, &mut pos, format!(
|
||||
"trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref_off}\n%%EOF\n",
|
||||
total_objs + 1
|
||||
).as_bytes())?;
|
||||
w.flush()?;
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
if let Err(e) = res {
|
||||
drop(w);
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(e.to_string());
|
||||
}
|
||||
drop(w);
|
||||
std::fs::rename(&tmp, video_dir(root, client, vid).join(EXPORT_FILE)).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 启动后台 PDF 导出任务。
|
||||
pub fn start_export(
|
||||
root: PathBuf,
|
||||
exports: JobMap,
|
||||
client: String,
|
||||
vid: String,
|
||||
idxs: Vec<u32>,
|
||||
) -> Result<(), String> {
|
||||
if idxs.is_empty() {
|
||||
return Err("没有要导出的帧".into());
|
||||
}
|
||||
if !video_dir(&root, &client, &vid).join("frames").is_dir() {
|
||||
return Err("帧不存在,请先分析".into());
|
||||
}
|
||||
// 删掉旧导出,避免下载到上一次的
|
||||
let _ = std::fs::remove_file(video_dir(&root, &client, &vid).join(EXPORT_FILE));
|
||||
|
||||
let job = Arc::new(Mutex::new(Job {
|
||||
status: "exporting".into(),
|
||||
message: "准备导出…".into(),
|
||||
..Default::default()
|
||||
}));
|
||||
if let Ok(mut map) = exports.lock() {
|
||||
map.insert(job_key(&client, &vid), job.clone());
|
||||
}
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let n = idxs.len();
|
||||
match build_pdf_to_file(&root, &client, &vid, &idxs, &job) {
|
||||
Ok(()) => upd(&job, |j| {
|
||||
j.status = "done".into();
|
||||
j.progress = 1.0;
|
||||
j.message = format!("完成,{n} 页");
|
||||
}),
|
||||
Err(e) => upd(&job, |j| {
|
||||
j.status = "error".into();
|
||||
j.error = e.clone();
|
||||
j.message = format!("导出出错: {e}");
|
||||
}),
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,417 +0,0 @@
|
||||
//! 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, Query, State};
|
||||
use axum::http::{header, HeaderMap, HeaderValue, 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, // 分析任务
|
||||
pub exports: core::JobMap, // PDF 导出任务
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(root: PathBuf) -> Self {
|
||||
AppState {
|
||||
root,
|
||||
jobs: Arc::new(Mutex::new(HashMap::new())),
|
||||
exports: 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"))
|
||||
}
|
||||
|
||||
/// `<img src>` 没法带自定义头,图片接口允许用 `?c=<clientId>` query 传 client,
|
||||
/// 兜底再看 header。
|
||||
#[derive(Deserialize)]
|
||||
pub struct ClientQuery {
|
||||
c: Option<String>,
|
||||
}
|
||||
|
||||
fn client_from(q: &ClientQuery, headers: &HeaderMap) -> Result<String, ApiErr> {
|
||||
if let Some(c) = q.c.as_ref().filter(|s| !s.is_empty()) {
|
||||
return Ok(c.clone());
|
||||
}
|
||||
client_id(headers)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 上传
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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)>,
|
||||
Query(q): Query<ClientQuery>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, ApiErr> {
|
||||
let client = client_from(&q, &headers)?;
|
||||
serve_img(&st, &client, &vid, "thumbs", idx).await
|
||||
}
|
||||
|
||||
pub async fn frame(
|
||||
State(st): State<AppState>,
|
||||
AxPath((vid, idx)): AxPath<(String, u32)>,
|
||||
Query(q): Query<ClientQuery>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, ApiErr> {
|
||||
let client = client_from(&q, &headers)?;
|
||||
serve_img(&st, &client, &vid, "frames", idx).await
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PDF 导出:启动后台任务 / 查进度 / 下载
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PdfReq {
|
||||
frames: Vec<u32>,
|
||||
}
|
||||
|
||||
pub async fn export_start(
|
||||
State(st): State<AppState>,
|
||||
AxPath(vid): AxPath<String>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<PdfReq>,
|
||||
) -> Result<Json<Value>, ApiErr> {
|
||||
let client = client_id(&headers)?;
|
||||
if req.frames.is_empty() {
|
||||
return Err(err(StatusCode::BAD_REQUEST, "没有要导出的帧"));
|
||||
}
|
||||
let count = req.frames.len();
|
||||
core::start_export(st.root.clone(), st.exports.clone(), client, vid, req.frames).map_err(|e| {
|
||||
if e.contains("不存在") {
|
||||
err(StatusCode::NOT_FOUND, e)
|
||||
} else {
|
||||
err(StatusCode::BAD_REQUEST, e)
|
||||
}
|
||||
})?;
|
||||
Ok(Json(json!({"ok": true, "count": count})))
|
||||
}
|
||||
|
||||
pub async fn export_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.exports, &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,
|
||||
})));
|
||||
}
|
||||
// 没有活动任务:若已有导出文件则视作完成
|
||||
if core::video_dir(&st.root, &client, &vid).join(core::EXPORT_FILE).exists() {
|
||||
return Ok(Json(json!({"status": "done", "progress": 1.0, "message": "已就绪"})));
|
||||
}
|
||||
Err(err(StatusCode::NOT_FOUND, "无导出任务"))
|
||||
}
|
||||
|
||||
/// 下载用 GET + `?c=`(用 <a download> 触发,带不了头)。
|
||||
fn ascii_fallback(s: &str) -> String {
|
||||
let f: String = s.chars().map(|c| if c.is_ascii_graphic() && c != '"' { c } else { '_' }).collect();
|
||||
if f.trim_matches('_').is_empty() {
|
||||
"slides.pdf".into()
|
||||
} else {
|
||||
f
|
||||
}
|
||||
}
|
||||
|
||||
fn pct_encode(s: &str) -> String {
|
||||
let mut o = String::new();
|
||||
for b in s.bytes() {
|
||||
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
|
||||
o.push(b as char);
|
||||
} else {
|
||||
o.push('%');
|
||||
o.push_str(&format!("{b:02X}"));
|
||||
}
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
pub async fn export_download(
|
||||
State(st): State<AppState>,
|
||||
AxPath(vid): AxPath<String>,
|
||||
Query(q): Query<ClientQuery>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, ApiErr> {
|
||||
let client = client_from(&q, &headers)?;
|
||||
let p = core::video_dir(&st.root, &client, &vid).join(core::EXPORT_FILE);
|
||||
// 流式回传,不把整份 PDF 读进内存(大视频导出可能上百 MB)
|
||||
let file = tokio::fs::File::open(&p)
|
||||
.await
|
||||
.map_err(|_| err(StatusCode::NOT_FOUND, "导出文件不存在,请先导出"))?;
|
||||
let len = file.metadata().await.map(|m| m.len()).ok();
|
||||
let body = axum::body::Body::from_stream(tokio_util::io::ReaderStream::new(file));
|
||||
|
||||
let base = core::read_meta(&st.root, &client, &vid)
|
||||
.map(|m| m.name)
|
||||
.map(|n| n.rsplit_once('.').map(|(a, _)| a.to_string()).unwrap_or(n))
|
||||
.unwrap_or_else(|| "slides".into());
|
||||
let fname = format!("{base}.pdf");
|
||||
let cd = format!(
|
||||
"attachment; filename=\"{}\"; filename*=UTF-8''{}",
|
||||
ascii_fallback(&fname),
|
||||
pct_encode(&fname)
|
||||
);
|
||||
let mut hm = HeaderMap::new();
|
||||
hm.insert(header::CONTENT_TYPE, HeaderValue::from_static("application/pdf"));
|
||||
if let Ok(v) = HeaderValue::from_str(&cd) {
|
||||
hm.insert(header::CONTENT_DISPOSITION, v);
|
||||
}
|
||||
if let Some(l) = len {
|
||||
hm.insert(header::CONTENT_LENGTH, HeaderValue::from(l));
|
||||
}
|
||||
Ok((hm, body).into_response())
|
||||
}
|
||||
@@ -1,46 +1,14 @@
|
||||
//! video2slides.famzheng.me — 长视频按固定间隔抽帧,逐帧差异比较,
|
||||
//! 拖动阈值挑出关键画面/幻灯片。
|
||||
//! 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;
|
||||
//! **纯客户端 app**:视频不上传,浏览器用 `<video>` + canvas 原生解码抽帧,
|
||||
//! 逐帧差异、缩略图、PDF 导出全在前端,结果存 IndexedDB(每浏览器各自持久化)。
|
||||
//!
|
||||
//! 所以这里没有任何业务后端 —— 这个进程只是个静态文件服务器,把前端发出去。
|
||||
//! `cube_core::base` 已经包含 healthz + 静态目录 + SPA fallback。
|
||||
|
||||
#[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))
|
||||
.route("/videos/:id/pdf", post(handlers::export_start))
|
||||
.route("/videos/:id/pdf/status", get(handlers::export_status))
|
||||
.route("/videos/:id/pdf/download", get(handlers::export_download))
|
||||
.layer(DefaultBodyLimit::disable()) // 视频上传可能很大,关掉默认 2MB 限制
|
||||
.with_state(state);
|
||||
|
||||
let app = cube_core::base(dist).nest("/api", api);
|
||||
cube_core::serve(app, 8080).await
|
||||
cube_core::serve(cube_core::base(dist), 8080).await
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user