//! video2slides 核心:存储布局、后台任务进度、ffmpeg 抽帧、逐帧灰度差异。 //! //! 存储布局(data 根下):: //! //! /// //! source. 原始上传 //! frames/000001.jpg 抽出的帧(彩色,限宽,用作悬停大图预览) //! thumbs/000001.jpg 网格缩略图(小、压得狠) //! meta.json { name, size, duration, interval, status, frames:[{idx,t,diff}] } use std::collections::HashMap; use std::io::{BufRead, BufReader, Read, Write}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::sync::{Arc, Mutex}; use image::imageops::FilterType; use serde::{Deserialize, Serialize}; pub const PREVIEW_MAX_W: u32 = 1280; // 抽帧时直接限宽,悬停大图用这个 pub const THUMB_W: u32 = 320; // 网格缩略图宽度 pub const DIFF_W: u32 = 256; // 算差异时降采样宽度(灰度) pub const JPEG_Q_FRAME: &str = "4"; // ffmpeg -q:v(越小越清晰) pub const JPEG_Q_THUMB: u8 = 70; /// clientId / videoId 只允许字母数字和 `_` `-`,挡路径穿越。 pub fn safe_id(s: &str) -> String { let cleaned: String = s .chars() .filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-') .take(64) .collect(); if cleaned.is_empty() { "anon".into() } else { cleaned } } pub fn client_dir(root: &Path, client: &str) -> PathBuf { root.join(safe_id(client)) } pub fn video_dir(root: &Path, client: &str, vid: &str) -> PathBuf { client_dir(root, client).join(safe_id(vid)) } // --------------------------------------------------------------------------- // meta.json // --------------------------------------------------------------------------- #[derive(Serialize, Deserialize, Clone)] pub struct FrameMeta { pub idx: u32, pub t: f64, pub diff: Option, } #[derive(Serialize, Deserialize, Clone)] pub struct Meta { pub name: String, pub size: u64, pub duration: f64, pub interval: Option, pub status: String, // uploaded | processing | done | error #[serde(default)] pub frames: Vec, } pub fn read_meta(root: &Path, client: &str, vid: &str) -> Option { let p = video_dir(root, client, vid).join("meta.json"); let data = std::fs::read(p).ok()?; serde_json::from_slice(&data).ok() } pub fn write_meta(root: &Path, client: &str, vid: &str, meta: &Meta) -> std::io::Result<()> { let d = video_dir(root, client, vid); std::fs::create_dir_all(&d)?; let data = serde_json::to_vec(meta).expect("serialize meta"); std::fs::write(d.join("meta.json"), data) } #[derive(Serialize)] pub struct VideoSummary { pub video_id: String, pub name: String, pub size: u64, pub duration: f64, pub interval: Option, pub status: String, pub frame_count: usize, } pub fn list_videos(root: &Path, client: &str) -> Vec { let base = client_dir(root, client); let Ok(entries) = std::fs::read_dir(&base) else { return vec![]; }; let mut dirs: Vec = entries .flatten() .filter(|e| e.path().is_dir()) .map(|e| e.file_name().to_string_lossy().into_owned()) .collect(); dirs.sort(); dirs.reverse(); // 新的在前(video_id 用 uuid,无时间序,但保证稳定顺序) dirs.into_iter() .filter_map(|vid| { let m = read_meta(root, client, &vid)?; Some(VideoSummary { video_id: vid, name: m.name, size: m.size, duration: m.duration, interval: m.interval, status: m.status, frame_count: m.frames.len(), }) }) .collect() } pub fn delete_video(root: &Path, client: &str, vid: &str) -> bool { let d = video_dir(root, client, vid); if d.is_dir() { std::fs::remove_dir_all(&d).is_ok() } else { false } } // --------------------------------------------------------------------------- // ffprobe 时长 // --------------------------------------------------------------------------- pub fn probe_duration(path: &Path) -> f64 { let out = Command::new("ffprobe") .args([ "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", ]) .arg(path) .output(); match out { Ok(o) => String::from_utf8_lossy(&o.stdout).trim().parse().unwrap_or(0.0), Err(_) => 0.0, } } // --------------------------------------------------------------------------- // 任务进度(内存态,进程级) // --------------------------------------------------------------------------- #[derive(Serialize, Clone)] pub struct Job { pub status: String, // queued | extracting | processing | done | error pub progress: f64, // 0..1 pub message: String, pub frame_count: u32, pub error: String, } impl Default for Job { fn default() -> Self { Job { status: "queued".into(), progress: 0.0, message: "排队中…".into(), frame_count: 0, error: String::new(), } } } pub type JobMap = Arc>>>>; fn job_key(client: &str, vid: &str) -> String { format!("{}/{}", safe_id(client), safe_id(vid)) } pub fn get_job(jobs: &JobMap, client: &str, vid: &str) -> Option { let map = jobs.lock().ok()?; let j = map.get(&job_key(client, vid))?; j.lock().ok().map(|g| g.clone()) } fn upd(job: &Mutex, f: impl FnOnce(&mut Job)) { if let Ok(mut g) = job.lock() { f(&mut g); } } // --------------------------------------------------------------------------- // 差异计算 // --------------------------------------------------------------------------- fn round2(x: f64) -> f64 { (x * 100.0).round() / 100.0 } fn mae_0_100(a: &image::GrayImage, b: &image::GrayImage) -> f64 { let (pa, pb) = (a.as_raw(), b.as_raw()); let n = pa.len().min(pb.len()); if n == 0 { return 0.0; } let mut sum: u64 = 0; for i in 0..n { sum += (pa[i] as i32 - pb[i] as i32).unsigned_abs() as u64; } round2(sum as f64 / n as f64 / 255.0 * 100.0) } // --------------------------------------------------------------------------- // 抽帧 + 处理(在 spawn_blocking 里跑) // --------------------------------------------------------------------------- fn run_extract( src: &Path, frames_dir: &Path, interval: f64, duration: f64, job: &Mutex, ) -> Result<(), String> { std::fs::create_dir_all(frames_dir).map_err(|e| e.to_string())?; let vf = format!("fps=1/{interval},scale='min({PREVIEW_MAX_W},iw)':-2"); let mut child = Command::new("ffmpeg") .args(["-nostdin", "-y", "-i"]) .arg(src) .args(["-vf", &vf, "-q:v", JPEG_Q_FRAME, "-progress", "pipe:1", "-loglevel", "error"]) .arg(frames_dir.join("%06d.jpg")) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .map_err(|e| format!("启动 ffmpeg 失败: {e}"))?; let dur_us = (duration.max(0.001)) * 1_000_000.0; if let Some(stdout) = child.stdout.take() { for line in BufReader::new(stdout).lines().map_while(Result::ok) { if let Some(rest) = line.strip_prefix("out_time_us=") { if let Ok(us) = rest.trim().parse::() { let p = (us / dur_us).min(1.0); upd(job, |j| j.progress = p * 0.5); } } } } let status = child.wait().map_err(|e| e.to_string())?; if !status.success() { let mut err = String::new(); if let Some(mut s) = child.stderr.take() { let _ = s.read_to_string(&mut err); } return Err(format!("ffmpeg 抽帧失败: {}", err.trim().chars().take(500).collect::())); } upd(job, |j| j.progress = 0.5); Ok(()) } fn frame_index(p: &Path) -> Option { p.file_stem()?.to_str()?.parse().ok() } fn save_thumb(img: &image::DynamicImage, dst: &Path) -> Result<(), String> { let (w, h) = (img.width().max(1), img.height().max(1)); let nh = ((THUMB_W as f64) * h as f64 / w as f64).round().max(1.0) as u32; let small = img.resize_exact(THUMB_W, nh, FilterType::Triangle).to_rgb8(); let mut f = std::fs::File::create(dst).map_err(|e| e.to_string())?; let mut buf = Vec::new(); let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, JPEG_Q_THUMB); enc.encode_image(&small).map_err(|e| e.to_string())?; f.write_all(&buf).map_err(|e| e.to_string()) } fn gray_small(img: &image::DynamicImage) -> image::GrayImage { let (w, h) = (img.width().max(1), img.height().max(1)); let nh = ((DIFF_W as f64) * h as f64 / w as f64).round().max(1.0) as u32; img.resize_exact(DIFF_W, nh, FilterType::Triangle).to_luma8() } #[allow(clippy::too_many_arguments)] fn process( root: PathBuf, client: String, vid: String, src: PathBuf, interval: f64, duration: f64, name: String, size: u64, job: Arc>, ) { let vdir = video_dir(&root, &client, &vid); let frames_dir = vdir.join("frames"); let thumbs_dir = vdir.join("thumbs"); let result = (|| -> Result { upd(&job, |j| { j.status = "extracting".into(); j.message = "抽帧中…".into(); }); std::fs::create_dir_all(&thumbs_dir).map_err(|e| e.to_string())?; run_extract(&src, &frames_dir, interval, duration, &job)?; let mut frame_files: Vec = std::fs::read_dir(&frames_dir) .map_err(|e| e.to_string())? .flatten() .map(|e| e.path()) .filter(|p| p.extension().map(|x| x == "jpg").unwrap_or(false)) .collect(); frame_files.sort(); let total = frame_files.len(); if total == 0 { return Err("没抽到任何帧,检查视频是否有效。".into()); } upd(&job, |j| { j.status = "processing".into(); j.message = "比对差异中…".into(); }); let mut frames_meta: Vec = Vec::with_capacity(total); let mut prev_gray: Option = None; for (i, fp) in frame_files.iter().enumerate() { let idx = frame_index(fp).unwrap_or((i + 1) as u32); let img = image::open(fp).map_err(|e| format!("解码 {fp:?} 失败: {e}"))?; save_thumb(&img, &thumbs_dir.join(format!("{idx:06}.jpg")))?; let gray = gray_small(&img); let diff = prev_gray.as_ref().map(|p| mae_0_100(p, &gray)); prev_gray = Some(gray); frames_meta.push(FrameMeta { idx, t: round2((idx.saturating_sub(1)) as f64 * interval), diff, }); let p = 0.5 + 0.5 * (i + 1) as f64 / total as f64; upd(&job, |j| { j.progress = p; j.frame_count = (i + 1) as u32; }); } let meta = Meta { name: name.clone(), size, duration, interval: Some(interval), status: "done".into(), frames: frames_meta, }; write_meta(&root, &client, &vid, &meta).map_err(|e| e.to_string())?; Ok(total) })(); match result { Ok(total) => upd(&job, |j| { j.status = "done".into(); j.progress = 1.0; j.message = format!("完成,共 {total} 帧"); }), Err(e) => { upd(&job, |j| { j.status = "error".into(); j.error = e.clone(); j.message = format!("出错: {e}"); }); // 把 meta 标成 error,供无任务时回退 let mut meta = read_meta(&root, &client, &vid).unwrap_or(Meta { name, size, duration, interval: Some(interval), status: "error".into(), frames: vec![], }); meta.status = "error".into(); let _ = write_meta(&root, &client, &vid, &meta); } } } /// 启动后台分析。清掉旧 frames/thumbs(保留 source),允许换间隔重跑。 pub fn start_analysis( root: PathBuf, jobs: JobMap, client: String, vid: String, interval: f64, ) -> Result<(), String> { let vdir = video_dir(&root, &client, &vid); let src = std::fs::read_dir(&vdir) .map_err(|_| "源视频不存在".to_string())? .flatten() .map(|e| e.path()) .find(|p| p.file_stem().map(|s| s == "source").unwrap_or(false)) .ok_or_else(|| "源视频不存在".to_string())?; let duration = probe_duration(&src); let prev = read_meta(&root, &client, &vid); let name = prev .as_ref() .map(|m| m.name.clone()) .unwrap_or_else(|| src.file_name().unwrap_or_default().to_string_lossy().into_owned()); let size = std::fs::metadata(&src).map(|m| m.len()).unwrap_or(0); let _ = std::fs::remove_dir_all(vdir.join("frames")); let _ = std::fs::remove_dir_all(vdir.join("thumbs")); write_meta( &root, &client, &vid, &Meta { name: name.clone(), size, duration, interval: Some(interval), status: "processing".into(), frames: vec![], }, ) .map_err(|e| e.to_string())?; let job = Arc::new(Mutex::new(Job::default())); if let Ok(mut map) = jobs.lock() { map.insert(job_key(&client, &vid), job.clone()); } tokio::task::spawn_blocking(move || { process(root, client, vid, src, interval, duration, name, size, job); }); Ok(()) }