Video_Tools / Pocket_Auto_Splitter_(beta)_v1.3.html
PoopMan333's picture
Rename Auto_Splitter_(beta)_v1.3.html to Pocket_Auto_Splitter_(beta)_v1.3.html
f0a46bd verified
Raw
History Blame
21 kB
<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Pocket Auto Splitter v1.3</title><style>
:root{--b:#0e1116;--p:#161b22;--p2:#1c232d;--l:#2a323d;--t:#e6edf3;--d:#8b98a5;--a:#4cc2ff;--g:#3fb950;--r:#f85149}*{box-sizing:border-box}body{margin:0;background:var(--b);color:var(--t);font:14px Segoe UI,Arial,sans-serif}.wrap{max-width:1160px;margin:auto;padding:20px}header{display:flex;align-items:center;gap:10px;margin-bottom:16px}h1{font-size:18px;margin:0}.muted{color:var(--d)}.sp{flex:1}.ver{border:1px solid var(--l);border-radius:20px;padding:4px 10px}.drop{padding:60px 20px;text-align:center;border:2px dashed var(--l);border-radius:14px;background:var(--p);cursor:pointer}.drop:hover,.over{border-color:var(--a)!important}.grid{display:grid;grid-template-columns:1fr 330px;gap:18px}@media(max-width:900px){.grid{grid-template-columns:1fr}}#app{display:none}.stage{background:#000;border:1px solid var(--l);border-radius:10px;display:flex;justify-content:center;min-height:300px;overflow:hidden;position:relative}.stage video{max-width:100%;max-height:56vh}.badge{position:absolute;left:10px;top:10px;background:#000b;padding:5px 9px;border-radius:6px}.transport,.marks,.actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:10px}button,input,select{font:inherit}button{background:var(--p2);color:var(--t);border:1px solid var(--l);border-radius:8px;padding:7px 12px;cursor:pointer}button:disabled{opacity:.4}.primary{background:var(--a);color:#04121c;font-weight:600}.danger{color:#ffb4ae}.timeline{position:relative;height:84px;margin-top:14px;background:var(--p2);border:1px solid var(--l);border-radius:8px;overflow:hidden;touch-action:none}.strip{position:absolute;inset:0;display:flex}.strip canvas{height:100%;flex:1;min-width:0}.mask{position:absolute;top:0;bottom:0;background:#05080cbb}.sel{position:absolute;top:0;bottom:0;border-left:2px solid var(--a);border-right:2px solid var(--a)}.handle{position:absolute;top:0;bottom:0;width:18px;margin-left:-9px;z-index:4;cursor:ew-resize}.handle:after{content:"";position:absolute;left:6px;top:27px;width:6px;height:30px;background:var(--a);border-radius:3px}.ph{position:absolute;top:0;bottom:0;width:2px;background:#fff;z-index:3}.cutmark{position:absolute;top:0;bottom:0;width:2px;background:var(--g);z-index:2}.pill,.box{background:var(--p);border:1px solid var(--l);border-radius:9px}.pill{padding:6px 10px}.box{padding:12px;margin-bottom:13px}.box h3{margin:0 0 11px;font-size:12px;text-transform:uppercase;color:var(--d);letter-spacing:.6px}.row{display:flex;align-items:center;gap:8px;margin-bottom:9px}.row label{flex:0 0 105px;color:var(--d)}.row input,.row select{width:100%;min-width:0;background:var(--p2);color:var(--t);border:1px solid var(--l);border-radius:7px;padding:6px}.wide{width:100%;padding:11px}.bar{height:6px;background:var(--p2);border-radius:5px;margin-top:10px;display:none;overflow:hidden}.bar i{display:block;height:100%;background:var(--a);width:0}.status{text-align:center;color:var(--d);font-size:12px;min-height:18px;margin-top:8px}.ok{color:#9ae6a8}.err{color:#ffb4ae}.list{border:1px solid var(--l);border-radius:8px;overflow:auto;max-height:300px}.item{display:flex;align-items:center;gap:8px;padding:7px;border-top:1px solid var(--l)}.item:first-child{border-top:0}.item span:nth-child(2){flex:1;font-variant-numeric:tabular-nums}.item button{padding:3px 8px}.empty{padding:14px;text-align:center;color:var(--d)}.note{font-size:11.5px;color:#d29922;margin-top:8px}.work{display:none}</style></head><body><div class="wrap"><header><h1>Pocket Auto Splitter</h1><span class="muted">find all scene changes frame by frame</span><span class="sp"></span><button id="new" style="display:none">Load another</button><span class="ver">v1.3 · C_Nugget</span></header>
<div id="drop" class="drop"><h2>Drop a video here</h2><p class="muted">or click to choose · processed locally</p><input id="file" type="file" accept="video/*" hidden></div><div id="app"><div class="grid"><main><div class="stage"><video id="v" playsinline></video><span id="badge" class="badge"></span></div><div class="transport"><button id="play"></button><button id="prev"></button><button id="next"></button><b id="cur">0:00.000</b><span class="muted">/</span><span id="tot">0:00.000</span><span class="sp"></span><button id="setA">Set start</button><button id="setB">Set end</button><label style="display:flex;align-items:center;gap:5px;color:var(--d);cursor:pointer"><input id="loop" type="checkbox">Loop</label></div><div id="tl" class="timeline"><div id="strip" class="strip"></div><div id="marks"></div><div id="ml" class="mask"></div><div id="mr" class="mask"></div><div id="sel" class="sel"></div><div id="ph" class="ph"></div><div id="ha" class="handle"></div><div id="hb" class="handle"></div></div><div class="marks"><span class="pill">Start <b id="at">0:00.000</b></span><span class="pill">End <b id="bt">0:00.000</b></span><span class="pill">Frames <b id="fc">0</b></span></div></main><aside><div class="box"><h3>Detect all cuts</h3><div class="row"><label>Source FPS</label><input id="fps" type="number" min="1" max="240" step=".001" value="30"></div><div class="row"><label>Sensitivity</label><select id="sens"><option value="low">Low · hard cuts</option><option value="normal" selected>Normal</option><option value="high">High · more cuts</option></select></div><div class="row"><label>Min. gap</label><input id="gap" type="number" min="0" max="30" step=".1" value=".35"></div><div class="row"><label>Analysis</label><select id="sz"><option value="128">Fast</option><option value="192" selected>Accurate</option><option value="256">Extra</option></select></div><button id="scan" class="primary wide">Find all scene changes</button><button id="stop" class="danger wide" style="display:none;margin-top:8px">Stop</button><div id="bar" class="bar"><i id="bi"></i></div><div id="status" class="status"></div><div class="note">Every detected boundary is added automatically. A boundary is the first frame of the new scene.</div></div><div class="box"><h3>Split points <span id="count"></span></h3><div id="list" class="list"><div class="empty">No scene changes scanned yet</div></div><div class="actions"><button id="csv">Export CSV</button><button id="json">Export JSON</button><button id="clear" class="danger">Clear</button></div><button id="zip" class="primary wide" style="margin-top:10px">Export split files as ZIP</button><button id="zipStop" class="danger wide" style="display:none;margin-top:8px">Stop export</button><div id="zbar" class="bar"><i id="zbi"></i></div><div id="zstatus" class="status"></div><div class="note">Each segment is re-encoded in real time (a 2-min clip takes ~2 min) then bundled into one ZIP. Keep this tab visible while it runs.</div></div></aside></div></div><canvas id="ca" class="work"></canvas><canvas id="cb" class="work"></canvas></div><script>
(()=>{'use strict';const $=x=>document.getElementById(x),V=$('v'),clamp=(x,a,b)=>Math.min(b,Math.max(a,x)),wait=m=>new Promise(r=>setTimeout(r,m)),once=(e,n)=>new Promise(r=>e.addEventListener(n,r,{once:true}));let url,name='video',dur=0,W=0,H=0,A=0,B=0,drag='',busy=false,zbusy=false,cancel=false,cuts=[];function fps(){return clamp(+$('fps').value||30,1,240)}function tc(t){t=Math.max(0,t||0);let h=Math.floor(t/3600),m=Math.floor(t%3600/60),s=t%60;return(h?h+':'+String(m).padStart(2,'0'):m+':')+s.toFixed(3).padStart(6,'0')}async function seek(t){t=clamp(t,0,Math.max(0,dur-.0001));if(Math.abs(V.currentTime-t)<.00005)return;V.currentTime=t;await Promise.race([once(V,'seeked'),wait(1800)])}function ui(){let a=A/dur*100||0,b=B/dur*100||100;$('ml').style.width=a+'%';$('mr').style.left=b+'%';$('mr').style.width=100-b+'%';$('sel').style.left=a+'%';$('sel').style.width=b-a+'%';$('ha').style.left=a+'%';$('hb').style.left=b+'%';$('at').textContent=tc(A);$('bt').textContent=tc(B);$('fc').textContent=Math.max(0,Math.floor(B*fps())-Math.ceil(A*fps())+1).toLocaleString();drawMarks()}function upd(){$('cur').textContent=tc(V.currentTime);$('ph').style.left=(V.currentTime/dur*100||0)+'%'}
async function load(f){url&&URL.revokeObjectURL(url);url=URL.createObjectURL(f);name=f.name.replace(/\.[^.]+$/,'');V.src=url;V.load();let ok=await Promise.race([once(V,'loadedmetadata').then(()=>1),once(V,'error').then(()=>0),wait(20000).then(()=>0)]);if(!ok)return alert('Could not open this video. Try MP4, MOV or WebM.');dur=V.duration;W=V.videoWidth;H=V.videoHeight;A=0;B=dur;cuts=[];$('drop').style.display='none';$('app').style.display='block';$('new').style.display='block';$('tot').textContent=tc(dur);$('badge').textContent=W+'×'+H+' · '+tc(dur);await detectFps();ui();render();strip();upd()}async function detectFps(){if(!('requestVideoFrameCallback'in V))return;let old=V.muted;V.muted=true;try{await seek(Math.min(.2,dur/4));let f=null,l=null,n=0,done=false,p=new Promise(res=>{let cb=(x,m)=>{if(done)return;if(f===null)f=m.mediaTime;else{l=m.mediaTime;n++}if(n>=18||(l!==null&&l-f>.7)){done=true;res()}else V.requestVideoFrameCallback(cb)};V.requestVideoFrameCallback(cb);setTimeout(()=>{done=true;res()},1600)});await V.play();await p;V.pause();let q=n/(l-f);if(q>5&&q<241)$('fps').value=(Math.round(q*1000)/1000)}catch(e){}V.pause();V.muted=old;await seek(0)}async function strip(){let d=$('strip');d.innerHTML='';let p=document.createElement('video');p.src=url;p.muted=true;await Promise.race([once(p,'loadeddata'),wait(5000)]);for(let i=0;i<12;i++){let c=document.createElement('canvas');c.width=100;c.height=84;d.appendChild(c);p.currentTime=Math.min(dur-.01,(i+.5)*dur/12);await Promise.race([once(p,'seeked'),wait(700)]);let x=c.getContext('2d'),q=Math.max(100/p.videoWidth,84/p.videoHeight);x.drawImage(p,(100-p.videoWidth*q)/2,(84-p.videoHeight*q)/2,p.videoWidth*q,p.videoHeight*q)}p.src=''}
function capture(c,m){c.width=m;c.height=Math.max(2,Math.round(m*H/W));let x=c.getContext('2d',{willReadFrequently:true});x.drawImage(V,0,0,c.width,c.height);return x.getImageData(0,0,c.width,c.height).data}function score(a,b){let n=a.length/4,p=0,ha=new Uint32Array(16),hb=new Uint32Array(16);for(let i=0;i<a.length;i+=4){let y=(a[i]*54+a[i+1]*183+a[i+2]*19)>>8,z=(b[i]*54+b[i+1]*183+b[i+2]*19)>>8;p+=Math.abs(y-z)/255;ha[a[i]>>4]++;hb[b[i]>>4]++}let h=0;for(let i=0;i<16;i++)h+=Math.abs(ha[i]-hb[i])/n;return .8*p/n+.2*h/2}async function scan(){if(busy)return;busy=true;cancel=false;cuts=[];render();$('scan').disabled=true;$('stop').style.display='block';$('bar').style.display='block';let F=fps(),first=Math.ceil(A*F),last=Math.min(Math.floor(B*F),Math.floor(dur*F)-1),ca=$('ca'),cb=$('cb'),prev,rec=[];try{await seek(first/F);prev=capture(ca,+$('sz').value);for(let f=first+1;f<=last;f++){if(cancel)break;await seek(f/F);let cur=capture(cb,+$('sz').value);rec.push({frame:f,time:f/F,score:score(prev,cur)});prev=cur;if((f-first)%3===0){let p=(f-first)/Math.max(1,last-first);$('bi').style.width=p*100+'%';$('status').textContent='Scanning frame '+f.toLocaleString()+' · '+Math.round(p*100)+'%';await wait(0)}}if(cancel){$('status').textContent='Stopped';return}let vals=rec.map(x=>x.score).sort((a,b)=>a-b),med=vals[Math.floor(vals.length/2)]||0,dev=rec.map(x=>Math.abs(x.score-med)).sort((a,b)=>a-b),mad=dev[Math.floor(dev.length/2)]||0,s=$('sens').value,k=s==='low'?10:s==='high'?4:6,ratio=s==='low'?5:s==='high'?2.1:3.1,floor=s==='low'?.055:s==='high'?.015:.028,th=Math.max(floor,med+k*mad,med*ratio),cand=[];for(let i=0;i<rec.length;i++){let x=rec[i],l=i?rec[i-1].score:-1,r=i+1<rec.length?rec[i+1].score:-1;if(x.score>=th&&x.score>=l&&x.score>r)cand.push(x)}cand.sort((a,b)=>b.score-a.score);let gap=Math.max(0,+$('gap').value||0),keep=[];for(let x of cand)if(!keep.some(y=>Math.abs(x.time-y.time)<gap))keep.push(x);cuts=keep.sort((a,b)=>a.time-b.time).map(x=>({...x,confidence:mad?(x.score-med)/mad:x.score/(med+.0001)}));render();$('status').textContent='Found and recorded '+cuts.length+' scene change'+(cuts.length===1?'':'s');$('status').className='status '+(cuts.length?'ok':'err')}catch(e){$('status').textContent='Scan failed: '+e.message;$('status').className='status err'}finally{busy=false;$('scan').disabled=false;$('stop').style.display='none';$('bar').style.display='none'}}
function render(){let d=$('list');d.innerHTML='';$('count').textContent='('+cuts.length+')';if(!cuts.length){d.innerHTML='<div class="empty">No detected scene changes</div>';drawMarks();return}cuts.forEach((c,i)=>{let r=document.createElement('div');r.className='item';r.innerHTML='<span>'+(i+1)+'.</span><span>'+tc(c.time)+' · frame '+c.frame+'</span>';let g=document.createElement('button');g.textContent='Go';g.onclick=()=>seek(c.time);let x=document.createElement('button');x.textContent='×';x.onclick=()=>{cuts.splice(i,1);render()};r.append(g,x);d.appendChild(r)});drawMarks()}function drawMarks(){let m=$('marks');m.innerHTML='';cuts.forEach(c=>{let x=document.createElement('i');x.className='cutmark';x.style.left=c.time/dur*100+'%';m.appendChild(x)})}function segs(){let b=[0,...cuts.map(x=>x.time),dur];return b.slice(0,-1).map((x,i)=>({index:i+1,start:x,end:b[i+1],duration:b[i+1]-x,start_frame:Math.round(x*fps()),end_frame:Math.round(b[i+1]*fps())}))}function save(t,n,y){let a=document.createElement('a');a.href=URL.createObjectURL(new Blob([t],{type:y}));a.download=n;a.click();setTimeout(()=>URL.revokeObjectURL(a.href),5000)}
$('drop').onclick=()=>$('file').click();$('file').onchange=e=>{e.target.files[0]&&load(e.target.files[0]);e.target.value=''};$('new').onclick=()=>$('file').click();$('play').onclick=()=>{if(V.paused){if(V.currentTime>=dur-.01)V.currentTime=0;V.play();}else V.pause();};V.onplay=()=>$('play').textContent='Ⅱ';V.onpause=()=>$('play').textContent='▶';V.onended=()=>{if($('loop').checked){V.currentTime=0;V.play();}else{$('play').textContent='▶';seek(0);}};V.ontimeupdate=upd;V.onseeked=upd;$('prev').onclick=()=>{V.pause();seek(V.currentTime-1/fps())};$('next').onclick=()=>{V.pause();seek(V.currentTime+1/fps())};$('setA').onclick=()=>{A=clamp(V.currentTime,0,B-1/fps());ui()};$('setB').onclick=()=>{B=clamp(V.currentTime,A+1/fps(),dur);ui()};$('fps').oninput=ui;$('scan').onclick=scan;$('stop').onclick=()=>cancel=true;$('clear').onclick=()=>{cuts=[];render()};$('csv').onclick=()=>save(['index,start,end,duration,start_frame,end_frame',...segs().map(x=>[x.index,x.start.toFixed(6),x.end.toFixed(6),x.duration.toFixed(6),x.start_frame,x.end_frame].join(','))].join('\n'),name+'_splits.csv','text/csv');$('json').onclick=()=>save(JSON.stringify({source:name,fps:fps(),duration:dur,cuts,segments:segs()},null,2),name+'_splits.json','application/json');let tl=$('tl');function pt(x){let r=tl.getBoundingClientRect();return clamp((x-r.left)/r.width,0,1)*dur}tl.onpointerdown=e=>{if(e.target===$('ha'))drag='a';else if(e.target===$('hb'))drag='b';else{drag='p';V.pause();seek(pt(e.clientX))}tl.setPointerCapture(e.pointerId)};tl.onpointermove=e=>{if(!drag)return;let t=pt(e.clientX);if(drag==='a'){A=clamp(t,0,B-1/fps());V.currentTime=A}else if(drag==='b'){B=clamp(t,A+1/fps(),dur);V.currentTime=B}else V.currentTime=t;ui()};tl.onpointerup=tl.onpointercancel=()=>drag='';window.ondragover=e=>e.preventDefault();window.ondrop=e=>{e.preventDefault();e.dataTransfer.files[0]&&load(e.dataTransfer.files[0])};
/* ---- store-only ZIP writer with CRC32 ---- */
const CRC=(()=>{let t=new Uint32Array(256);for(let n=0;n<256;n++){let c=n;for(let k=0;k<8;k++)c=c&1?0xEDB88320^(c>>>1):c>>>1;t[n]=c>>>0}return t})();
function crc32(u8){let c=0xFFFFFFFF;for(let i=0;i<u8.length;i++)c=CRC[(c^u8[i])&255]^(c>>>8);return(c^0xFFFFFFFF)>>>0}
function makeZip(files){/* files:[{name,data(Uint8Array)}] -> Blob */
let enc=new TextEncoder(),parts=[],central=[],offset=0;
const u16=v=>new Uint8Array([v&255,(v>>8)&255]);const u32=v=>new Uint8Array([v&255,(v>>8)&255,(v>>16)&255,(v>>>24)&255]);
const push=(a,arr)=>{for(let x of arr)a.push(x)};
for(let f of files){let name=enc.encode(f.name),crc=crc32(f.data),sz=f.data.length,lh=[];
push(lh,u32(0x04034b50));push(lh,u16(20));push(lh,u16(0));push(lh,u16(0));push(lh,u16(0));push(lh,u16(0));
push(lh,u32(crc));push(lh,u32(sz));push(lh,u32(sz));push(lh,u16(name.length));push(lh,u16(0));push(lh,name);
let lhu=new Uint8Array(lh);parts.push(lhu);parts.push(f.data);
let ch=[];push(ch,u32(0x02014b50));push(ch,u16(20));push(ch,u16(20));push(ch,u16(0));push(ch,u16(0));push(ch,u16(0));push(ch,u16(0));
push(ch,u32(crc));push(ch,u32(sz));push(ch,u32(sz));push(ch,u16(name.length));push(ch,u16(0));push(ch,u16(0));push(ch,u16(0));push(ch,u16(0));push(ch,u32(0));push(ch,u32(offset));push(ch,name);
central.push(new Uint8Array(ch));offset+=lhu.length+sz;}
let cstart=offset,cbytes=central.reduce((a,c)=>a+c.length,0),eo=[];
push(eo,u32(0x06054b50));push(eo,u16(0));push(eo,u16(0));push(eo,u16(files.length));push(eo,u16(files.length));push(eo,u32(cbytes));push(eo,u32(cstart));push(eo,u16(0));
return new Blob([...parts,...central,new Uint8Array(eo)],{type:'application/zip'});}
/* ---- pick a recordable mime ---- */
function pickMime(){let m=['video/mp4;codecs=avc1.42E01E,mp4a.40.2','video/mp4','video/webm;codecs=vp9,opus','video/webm;codecs=vp8,opus','video/webm'];for(let x of m)if(window.MediaRecorder&&MediaRecorder.isTypeSupported(x))return x;return''}
/* ---- record one [a,z] range off a canvas mirror of the video, with audio ---- */
let ZAC=null,ZNODE=null;
function zaudio(){if(ZAC)return ZAC;try{ZAC=new(window.AudioContext||window.webkitAudioContext)();let src=ZAC.createMediaElementSource(V),g=ZAC.createGain();g.gain.value=1;src.connect(g);g.connect(ZAC.destination);ZNODE={src,g};}catch(e){ZAC=null}return ZAC}
async function recordRange(a,z,mime,cv,ctx){
let stream=cv.captureStream(fps()),dest=null;
if(ZAC&&ZNODE){try{dest=ZAC.createMediaStreamDestination();ZNODE.g.connect(dest);dest.stream.getAudioTracks().forEach(t=>stream.addTrack(t));}catch(e){dest=null}}
let rec;try{rec=new MediaRecorder(stream,{mimeType:mime,videoBitsPerSecond:Math.round(clamp(W*H*fps()*0.14,1200000,45000000))});}catch(e){rec=new MediaRecorder(stream,{mimeType:'video/webm'});}
let chunks=[],stopped=new Promise(r=>rec.onstop=r);rec.ondataavailable=e=>{if(e.data&&e.data.size)chunks.push(e.data)};
await seek(a);ctx.drawImage(V,0,0,cv.width,cv.height);
let raf=0,done=false;const paint=()=>{ctx.drawImage(V,0,0,cv.width,cv.height);if(!done)raf=requestAnimationFrame(paint)};
rec.start(200);V.currentTime=a;await V.play().catch(()=>{});raf=requestAnimationFrame(paint);
await new Promise(res=>{let iv=setInterval(()=>{if(cancelZip||V.currentTime>=z-0.02||V.ended){clearInterval(iv);res()}},40)});
V.pause();done=true;cancelAnimationFrame(raf);await wait(150);
try{rec.stop()}catch(e){}await stopped;if(dest&&ZNODE){try{ZNODE.g.disconnect(dest)}catch(e){}}
stream.getTracks().forEach(t=>{try{t.stop()}catch(e){}});
let type=mime.split(';')[0],ext=type.indexOf('mp4')>=0?'mp4':'webm';
return{data:new Uint8Array(await new Blob(chunks,{type}).arrayBuffer()),ext};
}
let cancelZip=false;
async function exportZip(){
if(busy||zbusy)return;let sg=segs();if(!sg.length){$('zstatus').textContent='Nothing to export.';return}
let mime=pickMime();if(!mime){$('zstatus').textContent='This browser cannot record video. Try Chrome or Edge.';$('zstatus').className='status err';return}
zbusy=true;cancelZip=false;$('zip').disabled=true;$('scan').disabled=true;$('zipStop').style.display='block';$('zbar').style.display='block';$('zstatus').className='status';
let cv=document.createElement('canvas');cv.width=W;cv.height=H;let ctx=cv.getContext('2d',{alpha:false});
zaudio();if(ZAC&&ZAC.state==='suspended'){try{await ZAC.resume()}catch(e){}}
let files=[],total=sg.reduce((a,s)=>a+s.duration,0),done=0,ext='webm';
try{for(let i=0;i<sg.length;i++){if(cancelZip)break;let s=sg[i];
$('zstatus').textContent='Recording segment '+(i+1)+' of '+sg.length+'…';
let r=await recordRange(s.start,s.end,mime,cv,ctx);ext=r.ext;
files.push({name:name+'_seg'+String(i+1).padStart(2,'0')+'.'+r.ext,data:r.data});
done+=s.duration;$('zbi').style.width=(done/total*100).toFixed(1)+'%';}
if(cancelZip){$('zstatus').textContent='Export stopped. '+files.length+' segment(s) captured.';}
if(files.length){$('zstatus').textContent='Packaging '+files.length+' file(s) into ZIP…';let blob=makeZip(files);
let a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=name+'_segments.zip';a.click();setTimeout(()=>URL.revokeObjectURL(a.href),8000);
$('zstatus').textContent='Saved '+name+'_segments.zip ('+files.length+' segment'+(files.length===1?'':'s')+')';$('zstatus').className='status ok';}
}catch(e){$('zstatus').textContent='Export failed: '+e.message;$('zstatus').className='status err';}
finally{zbusy=false;$('zip').disabled=false;$('scan').disabled=false;$('zipStop').style.display='none';$('zbar').style.display='none';V.pause();await seek(0);}
}
$('zip').onclick=exportZip;$('zipStop').onclick=()=>cancelZip=true;
})();
</script></body></html>