video2slides: 加 PDF 导出(服务端流式)+ 点击切换帧 保留/弃用
deploy articulate / build-and-deploy (push) Successful in 58s
deploy cube / build-and-deploy (push) Successful in 1m22s
deploy karaoke / build-and-deploy (push) Successful in 59s
deploy llm-proxy / build-and-deploy (push) Successful in 1m49s
deploy music / build-and-deploy (push) Successful in 2m6s
deploy notes / build-and-deploy (push) Successful in 1m42s
deploy simpleasm / build-and-deploy (push) Successful in 1m18s
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 1m5s
deploy write / build-and-deploy (push) Successful in 1m24s
deploy articulate / build-and-deploy (push) Successful in 58s
deploy cube / build-and-deploy (push) Successful in 1m22s
deploy karaoke / build-and-deploy (push) Successful in 59s
deploy llm-proxy / build-and-deploy (push) Successful in 1m49s
deploy music / build-and-deploy (push) Successful in 2m6s
deploy notes / build-and-deploy (push) Successful in 1m42s
deploy simpleasm / build-and-deploy (push) Successful in 1m18s
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 1m5s
deploy write / build-and-deploy (push) Successful in 1m24s
- 点击卡片切换 use/discard,手动覆盖阈值判定;手动标记角标 + 重置按钮 - 导出 PDF:后台 job,逐张把帧 JPEG 以 DCTDecode 直接嵌入、边读边写到磁盘, 内存峰值只一张帧,防大视频 OOM;前端轮询进度条 - 下载走流式(ReaderStream),不把整份 PDF 读进内存;?c= query 触发下载 - 手撸极简 PDF writer(无新 PDF 依赖),只扫 JPEG 头取宽高分量数
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
//! meta.json { name, size, duration, interval, status, frames:[{idx,t,diff}] }
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::io::{BufRead, BufReader, BufWriter, Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::{Arc, Mutex};
|
||||
@@ -434,3 +434,206 @@ pub fn start_analysis(
|
||||
});
|
||||
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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use axum::extract::{Multipart, Path as AxPath, Query, State};
|
||||
use axum::http::{header, HeaderMap, StatusCode};
|
||||
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use serde::Deserialize;
|
||||
@@ -21,7 +21,8 @@ const UPLOAD_CHUNK_HINT: usize = 1 << 20;
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub root: PathBuf,
|
||||
pub jobs: core::JobMap,
|
||||
pub jobs: core::JobMap, // 分析任务
|
||||
pub exports: core::JobMap, // PDF 导出任务
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -29,6 +30,7 @@ impl AppState {
|
||||
AppState {
|
||||
root,
|
||||
jobs: Arc::new(Mutex::new(HashMap::new())),
|
||||
exports: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -302,3 +304,114 @@ pub async fn frame(
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -35,6 +35,9 @@ async fn main() -> std::io::Result<()> {
|
||||
.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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user