video2slides: 新 app — 长视频抽帧 + 逐帧差异 + 拖阈值挑幻灯片
deploy articulate / build-and-deploy (push) Successful in 1m1s
deploy cube / build-and-deploy (push) Successful in 1m22s
deploy karaoke / build-and-deploy (push) Successful in 56s
deploy llm-proxy / build-and-deploy (push) Successful in 1m47s
deploy music / build-and-deploy (push) Successful in 2m14s
deploy notes / build-and-deploy (push) Successful in 1m41s
deploy simpleasm / build-and-deploy (push) Successful in 1m15s
deploy webgl / build-and-deploy (push) Successful in 1m5s
deploy video2slides / build-and-deploy (push) Successful in 2m3s
deploy werewolf / build-and-deploy (push) Successful in 1m8s
deploy write / build-and-deploy (push) Successful in 1m25s

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