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