4ee9b6ce78
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。
437 lines
14 KiB
Rust
437 lines
14 KiB
Rust
//! 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(())
|
|
}
|