Files
cube/apps/video2slides/frontend/index.html
T
Fam Zheng 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
video2slides: 新 app — 长视频抽帧 + 逐帧差异 + 拖阈值挑幻灯片
Rust + axum + cube-core,前端纯静态单页。ffmpeg 每 N 秒抽一帧(限宽
1280),image crate 逐帧灰度 MAE 算差异;前端拖阈值实时把相近帧变灰、
关键变化高亮,悬停出彩色大图,分页 + toolbar 常驻。按 X-Client-Id 分目录
隔离,存储走 hostPath。镜像非 scratch:debian-slim + ffmpeg + musl binary。
2026-06-14 21:29:47 +01:00

388 lines
19 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>video2slides — 长视频抽帧挑幻灯片</title>
<style>
:root{
--bg:#0f1115; --panel:#171a21; --panel2:#1f242e; --line:#2a303c;
--fg:#e8ebf0; --mut:#8b94a3; --accent:#f2b50c; --accent2:#3b82f6;
--keep:#f2b50c; --ok:#22c55e; --err:#ef4444;
}
*{box-sizing:border-box}
html,body{margin:0;height:100%}
body{background:var(--bg);color:var(--fg);font:14px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;display:flex;flex-direction:column;height:100vh;overflow:hidden}
header.appbar{display:flex;align-items:center;gap:12px;padding:10px 16px;background:var(--panel);border-bottom:1px solid var(--line);flex:0 0 auto;z-index:50}
header.appbar h1{font-size:15px;margin:0;font-weight:600;letter-spacing:.3px}
header.appbar .tag{color:var(--mut);font-size:12px}
header.appbar .spacer{flex:1}
.layout{display:flex;flex:1;min-height:0}
aside{width:240px;flex:0 0 240px;background:var(--panel);border-right:1px solid var(--line);display:flex;flex-direction:column;min-height:0}
aside .side-head{padding:10px 12px;border-bottom:1px solid var(--line);display:flex;gap:8px;align-items:center}
aside .vlist{overflow:auto;flex:1;padding:6px}
.vitem{padding:8px 10px;border-radius:8px;cursor:pointer;margin-bottom:4px;border:1px solid transparent}
.vitem:hover{background:var(--panel2)}
.vitem.active{background:var(--panel2);border-color:var(--accent)}
.vitem .vname{font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.vitem .vmeta{font-size:11px;color:var(--mut);display:flex;gap:6px;align-items:center;margin-top:2px}
.badge{font-size:10px;padding:1px 6px;border-radius:99px;background:#333}
.badge.done{background:rgba(34,197,94,.18);color:var(--ok)}
.badge.processing,.badge.extracting,.badge.queued{background:rgba(59,130,246,.18);color:var(--accent2)}
.badge.uploaded{background:rgba(139,148,163,.18);color:var(--mut)}
.badge.error{background:rgba(239,68,68,.18);color:var(--err)}
.vdel{margin-left:auto;color:var(--mut);cursor:pointer;font-size:14px;opacity:0;padding:0 4px}
.vitem:hover .vdel{opacity:1}
.vdel:hover{color:var(--err)}
main{flex:1;display:flex;flex-direction:column;min-width:0;min-height:0;position:relative}
/* sticky toolbar 常驻顶部 */
.toolbar{position:sticky;top:0;z-index:40;background:var(--panel2);border-bottom:1px solid var(--line);padding:10px 16px;display:flex;align-items:center;gap:18px;flex-wrap:wrap;box-shadow:0 2px 8px rgba(0,0,0,.3)}
.toolbar .grp{display:flex;align-items:center;gap:8px}
.toolbar label{color:var(--mut);font-size:12px;white-space:nowrap}
.toolbar input[type=range]{width:200px;accent-color:var(--accent)}
.thval{font-variant-numeric:tabular-nums;min-width:42px;text-align:right;color:var(--accent);font-weight:600}
.stat{font-size:12px;color:var(--mut)}
.stat b{color:var(--keep)}
.content{flex:1;overflow:auto;padding:16px;min-height:0}
/* 网格 */
.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 .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)}
/* 悬停大图预览 */
#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}
#preview img{display:block;max-width:560px;max-height:360px}
#preview .pcap{position:absolute;left:0;bottom:0;right:0;background:linear-gradient(transparent,rgba(0,0,0,.8));color:#fff;font-size:12px;padding:14px 10px 6px}
/* 状态屏 */
.pane{flex:1;display:flex;align-items:center;justify-content:center;padding:24px}
.pane .box{max-width:520px;width:100%;background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:28px}
.pane h2{margin:0 0 6px;font-size:18px}
.pane p{color:var(--mut);margin:0 0 18px}
.drop{border:2px dashed var(--line);border-radius:12px;padding:34px;text-align:center;color:var(--mut);cursor:pointer;transition:.15s}
.drop.over{border-color:var(--accent);background:rgba(242,181,12,.06);color:var(--fg)}
button{font:inherit;border:0;border-radius:8px;padding:8px 14px;background:var(--accent);color:#1a1500;font-weight:600;cursor:pointer}
button.ghost{background:var(--panel2);color:var(--fg);border:1px solid var(--line);font-weight:500}
button:disabled{opacity:.5;cursor:not-allowed}
button.sm{padding:5px 10px;font-size:12px}
input[type=number]{font:inherit;background:var(--bg);border:1px solid var(--line);color:var(--fg);border-radius:8px;padding:7px 10px;width:90px}
.progress{height:10px;background:var(--bg);border-radius:99px;overflow:hidden;border:1px solid var(--line)}
.progress > i{display:block;height:100%;background:linear-gradient(90deg,var(--accent2),var(--accent));width:0;transition:width .3s}
.pmsg{font-size:12px;color:var(--mut);margin-top:8px;text-align:center}
.row{display:flex;gap:10px;align-items:center}
.err{color:var(--err);font-size:12px}
.pager{display:flex;gap:6px;align-items:center}
.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}
</style>
</head>
<body>
<header class="appbar">
<h1>🎞️ video2slides</h1>
<span class="tag">长视频 → 抽帧 → 挑关键画面/幻灯片</span>
<span class="spacer"></span>
<button id="btnUpload" class="sm"> 上传视频</button>
<input type="file" id="fileInput" accept="video/*" hidden />
</header>
<div class="layout">
<aside>
<div class="side-head"><b style="font-size:13px">我的视频</b><span class="tag" style="margin-left:auto;font-size:11px" id="clientTag"></span></div>
<div class="vlist" id="vlist"></div>
</aside>
<main id="main"><!-- 动态 --></main>
</div>
<div id="preview"><img id="previewImg" alt="" /><div class="pcap" id="previewCap"></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;};
// ---- 每浏览器身份:localStorage ----
const CK='v2s_client';
let CLIENT=localStorage.getItem(CK);
if(!CLIENT){CLIENT=(crypto.randomUUID?crypto.randomUUID():'c'+Date.now()+Math.random().toString(16).slice(2)).replace(/-/g,'');localStorage.setItem(CK,CLIENT);}
$('#clientTag').textContent=CLIENT.slice(0,6);
const H={'X-Client-Id':CLIENT};
async function api(path,opts={}){
const r=await fetch(path,{...opts,headers:{...H,...(opts.headers||{})}});
if(!r.ok){let m='';try{m=(await r.json()).detail}catch{}throw new Error(m||r.status);}
const ct=r.headers.get('content-type')||'';
return ct.includes('json')?r.json():r;
}
function imgUrl(vid,kind,idx){return `/api/videos/${vid}/${kind}/${idx}`;}
const fmtDur=s=>{s=Math.round(s||0);const m=Math.floor(s/60),x=s%60;return `${m}:${String(x).padStart(2,'0')}`;};
const fmtSize=b=>b>1e9?(b/1e9).toFixed(1)+'G':b>1e6?(b/1e6).toFixed(1)+'M':(b/1e3|0)+'K';
const fmtT=s=>{s=Math.round(s);const h=Math.floor(s/3600),m=Math.floor(s%3600/60),x=s%60;const p=n=>String(n).padStart(2,'0');return h?`${h}:${p(m)}:${p(x)}`:`${m}:${p(x)}`;};
// ---- 全局状态 ----
let videos=[];
let cur=null; // 当前打开的 video 对象 (来自 frames 接口)
let curId=null;
let threshold=10, page=0, pageSize=48, onlyKept=false;
let pollTimer=null;
// ====================== 侧栏 ======================
async function loadVideos(){
videos=await api('/api/videos');
renderSidebar();
}
function renderSidebar(){
const box=$('#vlist');box.innerHTML='';
if(!videos.length){const e=el('div');e.style.cssText='color:var(--mut);font-size:12px;padding:14px;text-align:center';e.textContent='还没有视频,点右上角上传';box.appendChild(e);return;}
for(const v of videos){
const it=el('div','vitem'+(v.video_id===curId?' active':''));
const nm=el('div','vname');nm.textContent=v.name;nm.title=v.name;
const mt=el('div','vmeta');
const bd=el('span','badge '+v.status);bd.textContent=({done:'已分析',processing:'分析中',extracting:'抽帧中',queued:'排队',uploaded:'待分析',error:'出错'})[v.status]||v.status;
mt.appendChild(bd);
const info=el('span');info.textContent=fmtDur(v.duration)+(v.frame_count?` · ${v.frame_count}`:'');mt.appendChild(info);
const del=el('span','vdel');del.textContent='✕';del.title='删除';
del.onclick=async(ev)=>{ev.stopPropagation();if(!confirm('删除「'+v.name+'」?'))return;await api('/api/videos/'+v.video_id,{method:'DELETE'});if(curId===v.video_id){curId=null;cur=null;}await loadVideos();render();};
it.append(nm,mt,del);
it.onclick=()=>openVideo(v.video_id);
box.appendChild(it);
}
}
// ====================== 打开视频 / 路由渲染 ======================
async function openVideo(vid){
stopPoll();
curId=vid;page=0;
const v=videos.find(x=>x.video_id===vid);
renderSidebar();
if(v && (v.status==='processing'||v.status==='extracting'||v.status==='queued')){
cur=await api(`/api/videos/${vid}/frames`).catch(()=>null);
render();startPoll();return;
}
cur=await api(`/api/videos/${vid}/frames`);
render();
}
function render(){
const m=$('#main');m.innerHTML='';
if(!curId||!cur){m.appendChild(emptyPane());return;}
if(cur.status==='uploaded'||cur.status==='error') {m.appendChild(setupPane());return;}
if(cur.status!=='done'){m.appendChild(progressPane());return;}
// done
m.appendChild(buildToolbar());
const content=el('div','content');content.id='content';m.appendChild(content);
renderGrid();
}
// ---- 空状态 ----
function emptyPane(){
const p=el('div','pane'),b=el('div','box');
b.innerHTML='<h2>从演讲视频里挑幻灯片</h2><p>上传一个长视频,每隔 N 秒抽一帧,逐帧比差异,拖动阈值就能把重复画面变灰、留下关键变化。</p>';
const drop=el('div','drop');drop.innerHTML='把视频拖到这里,或 <b>点击选择</b>';
wireDrop(drop);b.appendChild(drop);p.appendChild(b);return p;
}
// ---- 上传中 / 分析参数设置 ----
function setupPane(){
const p=el('div','pane'),b=el('div','box');
const err=cur.status==='error';
b.innerHTML=`<h2>${err?'上次分析出错了':'设置分析参数'}</h2>
<p>${cur.name||''} · 时长 ${fmtDur(cur.duration)}</p>`;
const row=el('div','row');
row.innerHTML=`<label style="color:var(--mut)">每隔</label>`;
const inp=el('input');inp.type='number';inp.min='0.2';inp.max='600';inp.step='0.5';inp.value=cur.interval||8;
const unit=el('span');unit.textContent='秒抽一帧';unit.style.color='var(--mut)';
const go=el('button');go.textContent=err?'重新分析':'开始分析';
go.onclick=()=>startAnalyze(curId,parseFloat(inp.value));
row.append(inp,unit,go);b.appendChild(row);
const hint=el('p');hint.style.cssText='margin-top:14px;font-size:12px';hint.textContent='间隔越小帧越多、越慢但越细。演讲翻页慢,8 秒通常够。';
b.appendChild(hint);
p.appendChild(b);return p;
}
// ---- 分析进度 ----
function progressPane(){
const p=el('div','pane'),b=el('div','box');
b.innerHTML=`<h2>正在分析…</h2><p>${cur.name||''}</p>
<div class="progress"><i id="pbar"></i></div><div class="pmsg" id="pmsg">准备中…</div>`;
p.appendChild(b);return p;
}
// ====================== Toolbar + 网格 ======================
function keptOf(f){return f.diff===null||f.diff===undefined||f.diff>=threshold;}
function shown(){return onlyKept?cur.frames.filter(keptOf):cur.frames;}
function buildToolbar(){
const t=el('div','toolbar');
// 阈值
const g1=el('div','grp');
g1.innerHTML=`<label>差异阈值</label>`;
const sl=el('input');sl.type='range';sl.min='0';sl.max='100';sl.step='0.5';sl.value=threshold;
const val=el('span','thval');val.textContent=threshold;
sl.oninput=()=>{threshold=parseFloat(sl.value);val.textContent=threshold;updateStats();applyClasses();if(onlyKept){page=0;renderGrid();}};
g1.append(sl,val);
// 仅保留
const g2=el('label','switch');
const cb=el('input');cb.type='checkbox';cb.checked=onlyKept;
cb.onchange=()=>{onlyKept=cb.checked;page=0;renderGrid();};
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 re=el('button','ghost sm');re.textContent='重新分析';re.onclick=()=>{cur.status='uploaded';render();};
g4.appendChild(re);
// 分页
const g5=el('div','grp pager');g5.id='pager';
t.append(g1,g2,g3,g4,g5);
setTimeout(()=>{updateStats();renderPager();},0);
return t;
}
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}`;
}
function renderGrid(){
const c=$('#content');if(!c)return;c.innerHTML='';
const list=shown();
const pages=Math.max(1,Math.ceil(list.length/pageSize));
if(page>=pages)page=pages-1;
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 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);
bindHover(card,f);
grid.appendChild(card);
}
c.appendChild(grid);
if(!slice.length){const e=el('div');e.style.cssText='color:var(--mut);text-align:center;padding:40px';e.textContent='没有符合的帧';c.appendChild(e);}
renderPager();
}
function renderPager(){
const box=$('#pager');if(!box)return;box.innerHTML='';
const list=shown();const pages=Math.max(1,Math.ceil(list.length/pageSize));
if(pages<=1)return;
const mk=(label,p,cls='')=>{const b=el('span','pg '+cls);b.textContent=label;b.onclick=()=>{page=p;renderGrid();$('#content').scrollTop=0;};return b;};
box.appendChild(mk('',Math.max(0,page-1)));
const win=[];for(let i=0;i<pages;i++){if(i<2||i>=pages-2||Math.abs(i-page)<=1)win.push(i);}
let last=-1;
for(const i of win){if(i-last>1){const d=el('span');d.textContent='…';d.style.color='var(--mut)';box.appendChild(d);}box.appendChild(mk(i+1,i,i===page?'cur':''));last=i;}
box.appendChild(mk('',Math.min(pages-1,page+1)));
}
// 阈值变化时只切 class,不重建 DOM(性能)
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);
});
}
// ====================== 悬停大图 ======================
const pv=$('#preview'),pvImg=$('#previewImg'),pvCap=$('#previewCap');
let hoverIdx=null;
function bindHover(card,f){
card.onmouseenter=()=>{
hoverIdx=f.idx;
pvImg.onload=()=>{if(hoverIdx!==f.idx)return;positionPreview(card);pv.style.display='block';};
pvImg.src=imgUrl(curId,'frame',f.idx);
pvCap.textContent=`${fmtT(f.t)} · Δ${f.diff===null||f.diff===undefined?'起始帧':f.diff}`;
if(pvImg.complete&&pvImg.naturalWidth){positionPreview(card);pv.style.display='block';}
};
card.onmouseleave=()=>{hoverIdx=null;pv.style.display='none';};
}
function positionPreview(card){
const r=card.getBoundingClientRect();
const pw=pv.offsetWidth||560,ph=pv.offsetHeight||360;
let x=r.right+12;if(x+pw>innerWidth)x=r.left-pw-12;if(x<8)x=Math.max(8,(innerWidth-pw)/2);
let y=r.top+r.height/2-ph/2;y=Math.min(Math.max(8,y),innerHeight-ph-8);
pv.style.left=x+'px';pv.style.top=y+'px';
}
// ====================== 上传 ======================
function wireDrop(drop){
drop.onclick=()=>$('#fileInput').click();
drop.ondragover=e=>{e.preventDefault();drop.classList.add('over');};
drop.ondragleave=()=>drop.classList.remove('over');
drop.ondrop=e=>{e.preventDefault();drop.classList.remove('over');if(e.dataTransfer.files[0])upload(e.dataTransfer.files[0]);};
}
$('#btnUpload').onclick=()=>$('#fileInput').click();
$('#fileInput').onchange=e=>{if(e.target.files[0])upload(e.target.files[0]);e.target.value='';};
function upload(file){
const m=$('#main');m.innerHTML='';
const p=el('div','pane'),b=el('div','box');
b.innerHTML=`<h2>上传中…</h2><p>${file.name} · ${fmtSize(file.size)}</p><div class="progress"><i id="ubar"></i></div><div class="pmsg" id="umsg">0%</div>`;
p.appendChild(b);m.appendChild(p);curId=null;cur=null;renderSidebar();
const fd=new FormData();fd.append('file',file);
const xhr=new XMLHttpRequest();
xhr.open('POST','/api/videos');
xhr.setRequestHeader('X-Client-Id',CLIENT);
xhr.upload.onprogress=e=>{if(e.lengthComputable){const pc=Math.round(e.loaded/e.total*100);$('#ubar').style.width=pc+'%';$('#umsg').textContent=pc+'%'+(pc>=100?' · 服务器解析中…':'');}};
xhr.onload=async()=>{
if(xhr.status>=200&&xhr.status<300){
const v=JSON.parse(xhr.responseText);
await loadVideos();await openVideo(v.video_id);
}else{let d='上传失败';try{d=JSON.parse(xhr.responseText).detail}catch{}
b.innerHTML=`<h2>上传失败</h2><p class="err">${d}</p>`;
const again=el('button');again.textContent='重试';again.onclick=()=>$('#fileInput').click();b.appendChild(again);
}
};
xhr.onerror=()=>{b.innerHTML='<h2>上传失败</h2><p class="err">网络错误</p>';};
xhr.send(fd);
}
// ====================== 分析 + 轮询 ======================
async function startAnalyze(vid,interval){
if(!(interval>=0.2&&interval<=600)){alert('间隔需在 0.2~600 秒');return;}
await api(`/api/videos/${vid}/analyze`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({interval})});
cur.status='processing';cur.interval=interval;render();
await loadVideos();renderSidebar();
startPoll();
}
function startPoll(){
stopPoll();
pollTimer=setInterval(async()=>{
let j;try{j=await api(`/api/videos/${curId}/job`);}catch{return;}
const bar=$('#pbar'),msg=$('#pmsg');
if(bar)bar.style.width=Math.round((j.progress||0)*100)+'%';
if(msg)msg.textContent=j.message||j.status;
if(j.status==='done'){stopPoll();cur=await api(`/api/videos/${curId}/frames`);await loadVideos();render();}
else if(j.status==='error'){stopPoll();cur.status='error';await loadVideos();render();}
},600);
}
function stopPoll(){if(pollTimer){clearInterval(pollTimer);pollTimer=null;}}
window.addEventListener('resize',()=>{if(hoverIdx!=null)pv.style.display='none';});
// ====================== 启动 ======================
(async()=>{await loadVideos();render();})();
</script>
</body>
</html>