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

- 点击卡片切换 use/discard,手动覆盖阈值判定;手动标记角标 + 重置按钮
- 导出 PDF:后台 job,逐张把帧 JPEG 以 DCTDecode 直接嵌入、边读边写到磁盘,
  内存峰值只一张帧,防大视频 OOM;前端轮询进度条
- 下载走流式(ReaderStream),不把整份 PDF 读进内存;?c= query 触发下载
- 手撸极简 PDF writer(无新 PDF 依赖),只扫 JPEG 头取宽高分量数
This commit is contained in:
Fam Zheng
2026-06-14 21:58:27 +01:00
parent 25a28ea092
commit b36e30ecbf
6 changed files with 415 additions and 16 deletions
+1
View File
@@ -15,3 +15,4 @@ serde_json = { workspace = true }
tracing = { workspace = true }
image = { workspace = true }
uuid = { workspace = true }
tokio-util = { version = "0.7", features = ["io"] }
+91 -13
View File
@@ -52,13 +52,21 @@
/* 网格 */
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px}
.card{background:var(--panel);border:2px solid transparent;border-radius:10px;overflow:hidden;transition:opacity .15s,border-color .15s,filter .15s;cursor:pointer}
.card img{width:100%;aspect-ratio:16/9;object-fit:cover;display:block;background:#000}
.card{position:relative;background:var(--panel);border:2px solid transparent;border-radius:10px;overflow:hidden;transition:opacity .15s,border-color .15s;cursor:pointer}
.card img{width:100%;aspect-ratio:16/9;object-fit:cover;display:block;background:#000;transition:filter .15s}
.card .cap{display:flex;justify-content:space-between;padding:5px 8px;font-size:11px;color:var(--mut)}
.card .cap .d{font-variant-numeric:tabular-nums}
.card.keep{border-color:var(--keep)}
.card.dim{opacity:.4;filter:grayscale(1)}
.card.dim:hover{opacity:.85;filter:grayscale(0)}
.card.dim{opacity:.45}
.card.dim img{filter:grayscale(1)}
.card.dim:hover{opacity:.92}
.card.dim:hover img{filter:grayscale(0)}
/* 手动 use/discard 标记 —— 放图片上层、保持彩色(不被 dim 灰化),不挡点击 */
.card .pin{position:absolute;top:6px;left:6px;font-size:10px;line-height:1;padding:3px 6px;border-radius:6px;font-weight:700;display:none;z-index:2;pointer-events:none;box-shadow:0 1px 4px rgba(0,0,0,.4)}
.card.pinned .pin{display:block}
.card .pin.use{background:var(--keep);color:#1a1500}
.card .pin.discard{background:var(--err);color:#fff}
.card.pinned{box-shadow:0 0 0 2px rgba(255,255,255,.12) inset}
/* 悬停大图预览 */
#preview{position:fixed;z-index:100;pointer-events:none;display:none;border:2px solid var(--accent);border-radius:10px;overflow:hidden;box-shadow:0 12px 40px rgba(0,0,0,.6);background:#000}
@@ -86,6 +94,10 @@
.row{display:flex;gap:10px;align-items:center}
.err{color:var(--err);font-size:12px}
.pager{display:flex;gap:6px;align-items:center}
#expmask{position:fixed;inset:0;z-index:200;background:rgba(0,0,0,.65);display:none;align-items:center;justify-content:center}
#expmask.on{display:flex}
#expmask .box2{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:24px 30px;text-align:center}
#ebar{display:block;height:100%;background:linear-gradient(90deg,var(--accent2),var(--accent));width:0;transition:width .15s}
.pager .pg{padding:4px 9px;background:var(--panel);border:1px solid var(--line);border-radius:6px;cursor:pointer;font-size:12px}
.pager .pg.cur{background:var(--accent);color:#1a1500;border-color:var(--accent)}
.switch{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--mut);cursor:pointer;user-select:none}
@@ -111,6 +123,11 @@
<div id="preview"><img id="previewImg" alt="" /><div class="pcap" id="previewCap"></div></div>
<div id="expmask"><div class="box2">
<div class="progress" style="width:280px"><i id="ebar"></i></div>
<div class="pmsg" id="emsg">导出中…</div>
</div></div>
<script>
const $ = (s,r=document)=>r.querySelector(s);
const el = (t,c)=>{const e=document.createElement(t);if(c)e.className=c;return e;};
@@ -138,6 +155,7 @@ let videos=[];
let cur=null; // 当前打开的 video 对象 (来自 frames 接口)
let curId=null;
let threshold=10, page=0, pageSize=48, onlyKept=false;
let overrides={}; // idx -> true(手动保留) / false(手动弃用),覆盖阈值判定
let pollTimer=null;
// ====================== 侧栏 ======================
@@ -166,7 +184,7 @@ function renderSidebar(){
// ====================== 打开视频 / 路由渲染 ======================
async function openVideo(vid){
stopPoll();
curId=vid;page=0;
curId=vid;page=0;overrides={};
const v=videos.find(x=>x.video_id===vid);
renderSidebar();
if(v && (v.status==='processing'||v.status==='extracting'||v.status==='queued')){
@@ -223,9 +241,30 @@ function progressPane(){
}
// ====================== Toolbar + 网格 ======================
function keptOf(f){return f.diff===null||f.diff===undefined||f.diff>=threshold;}
// 阈值给的是默认判定;overrides 是手动例外(点击切换),手动优先。
function autoKeep(f){return f.diff===null||f.diff===undefined||f.diff>=threshold;}
function keptOf(f){return (f.idx in overrides)?overrides[f.idx]:autoKeep(f);}
function shown(){return onlyKept?cur.frames.filter(keptOf):cur.frames;}
// 点击卡片:切换 保留/弃用。若切回与阈值默认一致,则清掉该例外(回归自动)。
function toggleFrame(f){
const next=!keptOf(f);
if(next===autoKeep(f)) delete overrides[f.idx]; else overrides[f.idx]=next;
updateStats();
if(onlyKept){renderGrid();} // 弃用后该帧应从「仅保留」视图消失
else { const card=document.querySelector(`.card[data-idx="${f.idx}"]`); if(card)paintCard(card,f); }
}
// 统一刷新一张卡片的 保留/弃用/手动标记 外观
function paintCard(card,f){
const k=keptOf(f), pinned=(f.idx in overrides);
card.classList.toggle('keep',k);
card.classList.toggle('dim',!k);
card.classList.toggle('pinned',pinned);
let pin=card.querySelector('.pin');
if(pin){pin.className='pin '+(k?'use':'discard');pin.textContent=k?'✓ 手动保留':'✕ 手动弃用';}
}
function buildToolbar(){
const t=el('div','toolbar');
// 阈值
@@ -242,9 +281,12 @@ function buildToolbar(){
g2.append(cb,document.createTextNode('仅显示保留帧'));
// 统计
const g3=el('div','grp');g3.innerHTML=`<span class="stat" id="stat"></span>`;
// 间隔 + 重分析
// 导出 / 重置手动 / 间隔 / 重分析
const g4=el('div','grp');g4.style.marginLeft='auto';
g4.innerHTML=`<span class="stat">间隔 ${cur.interval}s</span>`;
const exp=el('button','sm');exp.textContent='⬇ 导出 PDF';exp.onclick=exportPdf;
const rs=el('button','ghost sm');rs.textContent='重置手动';rs.title='清除所有手动 保留/弃用,回到纯阈值判定';
rs.onclick=()=>{overrides={};updateStats();onlyKept?renderGrid():applyClasses();};
g4.append(exp,rs,el('span','stat'));g4.lastChild.textContent=`间隔 ${cur.interval}s`;
const re=el('button','ghost sm');re.textContent='重新分析';re.onclick=()=>{cur.status='uploaded';render();};
g4.appendChild(re);
// 分页
@@ -257,7 +299,8 @@ function buildToolbar(){
function updateStats(){
const total=cur.frames.length;
const kept=cur.frames.filter(keptOf).length;
const s=$('#stat');if(s)s.innerHTML=`保留 <b>${kept}</b> / 共 ${total}`;
const man=Object.keys(overrides).length;
const s=$('#stat');if(s)s.innerHTML=`保留 <b>${kept}</b> / 共 ${total}`+(man?` <span style="opacity:.7">· 手动 ${man}</span>`:'');
}
function renderGrid(){
@@ -268,12 +311,15 @@ function renderGrid(){
const slice=list.slice(page*pageSize,(page+1)*pageSize);
const grid=el('div','grid');
for(const f of slice){
const card=el('div','card'+(keptOf(f)?' keep':' dim'));
card.dataset.idx=f.idx;
const card=el('div','card');
card.dataset.idx=f.idx;card.title='点击切换 保留/弃用';
const pin=el('span','pin');
const img=el('img');img.loading='lazy';img.src=imgUrl(curId,'thumb',f.idx);img.alt='';
const cap=el('div','cap');
cap.innerHTML=`<span>${fmtT(f.t)}</span><span class="d">${f.diff===null||f.diff===undefined?'—':'Δ'+f.diff}</span>`;
card.append(img,cap);
card.append(pin,img,cap);
paintCard(card,f);
card.onclick=()=>toggleFrame(f);
bindHover(card,f);
grid.appendChild(card);
}
@@ -298,7 +344,7 @@ function renderPager(){
function applyClasses(){
document.querySelectorAll('.card').forEach(card=>{
const idx=+card.dataset.idx;const f=cur.frames.find(x=>x.idx===idx);if(!f)return;
const k=keptOf(f);card.classList.toggle('keep',k);card.classList.toggle('dim',!k);
paintCard(card,f);
});
}
@@ -378,6 +424,38 @@ function startPoll(){
}
function stopPoll(){if(pollTimer){clearInterval(pollTimer);pollTimer=null;}}
// ====================== 导出 PDF(服务端生成 + 进度条)======================
let exporting=false;
async function exportPdf(){
if(exporting)return;
const idxs=cur.frames.filter(keptOf).map(f=>f.idx);
if(!idxs.length){alert('没有要导出的帧 —— 调低阈值或手动保留几帧再试');return;}
if(idxs.length>1000 && !confirm(`要导出 ${idxs.length} 页,PDF 会比较大、耗时长,继续?`))return;
exporting=true;
const mask=$('#expmask'),bar=$('#ebar'),msg=$('#emsg');
bar.style.width='0%';msg.textContent=`启动导出(${idxs.length} 帧)…`;mask.classList.add('on');
const vid=curId;
try{
await api(`/api/videos/${vid}/pdf`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({frames:idxs})});
// 轮询进度
await new Promise((resolve,reject)=>{
const t=setInterval(async()=>{
let j;try{j=await api(`/api/videos/${vid}/pdf/status`);}catch{return;}
bar.style.width=Math.round((j.progress||0)*100)+'%';
msg.textContent=j.message||j.status;
if(j.status==='done'){clearInterval(t);resolve();}
else if(j.status==='error'){clearInterval(t);reject(new Error(j.error||'导出出错'));}
},400);
});
// 触发下载(GET + ?c=<a download> 带不了头)
const a=el('a');a.href=`/api/videos/${vid}/pdf/download?c=${encodeURIComponent(CLIENT)}`;a.download='';
document.body.appendChild(a);a.click();a.remove();
msg.textContent='完成,开始下载';
await new Promise(r=>setTimeout(r,500));
}catch(e){alert(e.message||'导出失败');}
finally{exporting=false;mask.classList.remove('on');}
}
window.addEventListener('resize',()=>{if(hoverIdx!=null)pv.style.display='none';});
// ====================== 启动 ======================
+204 -1
View File
@@ -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(())
}
+115 -2
View File
@@ -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())
}
+3
View File
@@ -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);