PoopMan333 commited on
Commit
9c1807f
·
verified ·
1 Parent(s): d152cbc

Upload 3 files

Browse files
Pocket_Auto_Splitter_v1.3.html ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!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>
2
+ :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>
3
+ <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>
4
+ (()=>{'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)+'%'}
5
+ 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=''}
6
+ 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'}}
7
+ 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)}
8
+ $('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])};
9
+ /* ---- store-only ZIP writer with CRC32 ---- */
10
+ 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})();
11
+ 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}
12
+ function makeZip(files){/* files:[{name,data(Uint8Array)}] -> Blob */
13
+ let enc=new TextEncoder(),parts=[],central=[],offset=0;
14
+ 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]);
15
+ const push=(a,arr)=>{for(let x of arr)a.push(x)};
16
+ for(let f of files){let name=enc.encode(f.name),crc=crc32(f.data),sz=f.data.length,lh=[];
17
+ push(lh,u32(0x04034b50));push(lh,u16(20));push(lh,u16(0));push(lh,u16(0));push(lh,u16(0));push(lh,u16(0));
18
+ push(lh,u32(crc));push(lh,u32(sz));push(lh,u32(sz));push(lh,u16(name.length));push(lh,u16(0));push(lh,name);
19
+ let lhu=new Uint8Array(lh);parts.push(lhu);parts.push(f.data);
20
+ 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));
21
+ 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);
22
+ central.push(new Uint8Array(ch));offset+=lhu.length+sz;}
23
+ let cstart=offset,cbytes=central.reduce((a,c)=>a+c.length,0),eo=[];
24
+ 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));
25
+ return new Blob([...parts,...central,new Uint8Array(eo)],{type:'application/zip'});}
26
+ /* ---- pick a recordable mime ---- */
27
+ 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''}
28
+ /* ---- record one [a,z] range off a canvas mirror of the video, with audio ---- */
29
+ let ZAC=null,ZNODE=null;
30
+ 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}
31
+ async function recordRange(a,z,mime,cv,ctx){
32
+ let stream=cv.captureStream(fps()),dest=null;
33
+ if(ZAC&&ZNODE){try{dest=ZAC.createMediaStreamDestination();ZNODE.g.connect(dest);dest.stream.getAudioTracks().forEach(t=>stream.addTrack(t));}catch(e){dest=null}}
34
+ 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'});}
35
+ let chunks=[],stopped=new Promise(r=>rec.onstop=r);rec.ondataavailable=e=>{if(e.data&&e.data.size)chunks.push(e.data)};
36
+ await seek(a);ctx.drawImage(V,0,0,cv.width,cv.height);
37
+ let raf=0,done=false;const paint=()=>{ctx.drawImage(V,0,0,cv.width,cv.height);if(!done)raf=requestAnimationFrame(paint)};
38
+ rec.start(200);V.currentTime=a;await V.play().catch(()=>{});raf=requestAnimationFrame(paint);
39
+ await new Promise(res=>{let iv=setInterval(()=>{if(cancelZip||V.currentTime>=z-0.02||V.ended){clearInterval(iv);res()}},40)});
40
+ V.pause();done=true;cancelAnimationFrame(raf);await wait(150);
41
+ try{rec.stop()}catch(e){}await stopped;if(dest&&ZNODE){try{ZNODE.g.disconnect(dest)}catch(e){}}
42
+ stream.getTracks().forEach(t=>{try{t.stop()}catch(e){}});
43
+ let type=mime.split(';')[0],ext=type.indexOf('mp4')>=0?'mp4':'webm';
44
+ return{data:new Uint8Array(await new Blob(chunks,{type}).arrayBuffer()),ext};
45
+ }
46
+ let cancelZip=false;
47
+ async function exportZip(){
48
+ if(busy||zbusy)return;let sg=segs();if(!sg.length){$('zstatus').textContent='Nothing to export.';return}
49
+ let mime=pickMime();if(!mime){$('zstatus').textContent='This browser cannot record video. Try Chrome or Edge.';$('zstatus').className='status err';return}
50
+ zbusy=true;cancelZip=false;$('zip').disabled=true;$('scan').disabled=true;$('zipStop').style.display='block';$('zbar').style.display='block';$('zstatus').className='status';
51
+ let cv=document.createElement('canvas');cv.width=W;cv.height=H;let ctx=cv.getContext('2d',{alpha:false});
52
+ zaudio();if(ZAC&&ZAC.state==='suspended'){try{await ZAC.resume()}catch(e){}}
53
+ let files=[],total=sg.reduce((a,s)=>a+s.duration,0),done=0,ext='webm';
54
+ try{for(let i=0;i<sg.length;i++){if(cancelZip)break;let s=sg[i];
55
+ $('zstatus').textContent='Recording segment '+(i+1)+' of '+sg.length+'…';
56
+ let r=await recordRange(s.start,s.end,mime,cv,ctx);ext=r.ext;
57
+ files.push({name:name+'_seg'+String(i+1).padStart(2,'0')+'.'+r.ext,data:r.data});
58
+ done+=s.duration;$('zbi').style.width=(done/total*100).toFixed(1)+'%';}
59
+ if(cancelZip){$('zstatus').textContent='Export stopped. '+files.length+' segment(s) captured.';}
60
+ if(files.length){$('zstatus').textContent='Packaging '+files.length+' file(s) into ZIP…';let blob=makeZip(files);
61
+ let a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=name+'_segments.zip';a.click();setTimeout(()=>URL.revokeObjectURL(a.href),8000);
62
+ $('zstatus').textContent='Saved '+name+'_segments.zip ('+files.length+' segment'+(files.length===1?'':'s')+')';$('zstatus').className='status ok';}
63
+ }catch(e){$('zstatus').textContent='Export failed: '+e.message;$('zstatus').className='status err';}
64
+ finally{zbusy=false;$('zip').disabled=false;$('scan').disabled=false;$('zipStop').style.display='none';$('zbar').style.display='none';V.pause();await seek(0);}
65
+ }
66
+ $('zip').onclick=exportZip;$('zipStop').onclick=()=>cancelZip=true;
67
+
68
+ })();
69
+ </script></body></html>
Pocket_Image_Converter_v1_4.html ADDED
@@ -0,0 +1,1120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Pocket Image Converter</title>
7
+ <style>
8
+ :root{
9
+ --bg:#0e1116; --panel:#161b22; --panel2:#1c232d; --line:#2a323d;
10
+ --txt:#e6edf3; --dim:#8b98a5; --accent:#4cc2ff; --accent2:#2a9fd6;
11
+ --ok:#3fb950; --warn:#d29922; --danger:#f85149; --red:#7f1018;
12
+ --r:10px;
13
+ }
14
+ *{box-sizing:border-box}
15
+ html,body{margin:0;min-height:100%}
16
+ body{
17
+ background:var(--bg); color:var(--txt);
18
+ font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
19
+ -webkit-font-smoothing:antialiased;
20
+ }
21
+ .wrap{max-width:1180px;margin:0 auto;padding:20px 20px 60px}
22
+ header{display:flex;align-items:center;gap:12px;margin-bottom:14px}
23
+ header h1{font-size:17px;margin:0;font-weight:600;letter-spacing:.2px}
24
+ header .sub{color:var(--dim);font-size:12.5px}
25
+ header .verwrap{display:flex;flex-direction:column;align-items:center;gap:3px}
26
+ header .ver{color:var(--dim);font-size:12px;font-variant-numeric:tabular-nums;
27
+ border:1px solid var(--line);border-radius:99px;padding:3px 10px;background:var(--panel)}
28
+ header .by{color:var(--dim);font-size:10px;letter-spacing:.3px;opacity:.8}
29
+ .spacer{flex:1}
30
+ button{
31
+ font:inherit;color:var(--txt);background:var(--panel2);border:1px solid var(--line);
32
+ padding:7px 13px;border-radius:8px;cursor:pointer;transition:.12s;
33
+ }
34
+ button:hover:not(:disabled){background:#242d39;border-color:#3a4553}
35
+ button:disabled{opacity:.4;cursor:not-allowed}
36
+ button:focus-visible,input:focus-visible,select:focus-visible,a:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
37
+ button.primary{background:var(--accent);border-color:var(--accent);color:#04121c;font-weight:600}
38
+ button.primary:hover:not(:disabled){background:#6bcfff}
39
+ button.ghost{background:transparent}
40
+ button.danger{color:#ffb4ae;border-color:#5a2b2b}
41
+ button.danger:hover:not(:disabled){background:#341c1c;border-color:#7a3a3a}
42
+ .small{padding:5px 10px;font-size:12.5px}
43
+ /* ---------- drop zone ---------- */
44
+ #drop{
45
+ border:2px dashed var(--line);border-radius:14px;padding:40px 24px;text-align:center;background:var(--panel);
46
+ cursor:pointer;transition:.15s;margin-bottom:16px;
47
+ }
48
+ #drop:hover,#drop.over{border-color:var(--accent);background:#12212b}
49
+ #drop .big{font-size:38px;line-height:1;margin-bottom:12px;opacity:.65}
50
+ #drop h2{margin:0 0 6px;font-size:17px;font-weight:600}
51
+ #drop p{margin:0;color:var(--dim);font-size:12.5px}
52
+ #drop .row2{display:flex;gap:8px;justify-content:center;margin-top:14px;flex-wrap:wrap}
53
+ /* ---------- layout ---------- */
54
+ .grid{display:grid;grid-template-columns:1fr 330px;gap:18px;align-items:start}
55
+ @media(max-width:940px){.grid{grid-template-columns:1fr}}
56
+ .card{background:#11161d;border:1px solid var(--line);border-radius:var(--r);padding:14px}
57
+ .card h3,.panelbox h3{margin:0 0 12px;font-size:12px;text-transform:uppercase;letter-spacing:.7px;color:var(--dim);font-weight:600}
58
+ .panelbox{background:#0d1219;border:1px solid var(--line);border-radius:10px;padding:10px 12px 12px;margin-bottom:14px}
59
+ .panelbox:last-child{margin-bottom:0}
60
+ h3.collap{display:flex;align-items:center;justify-content:space-between;cursor:pointer;user-select:none;
61
+ margin:-10px -12px 10px;padding:9px 12px;border-radius:9px 9px 0 0;background:#171d26;
62
+ border-bottom:1px solid var(--line);color:var(--txt)}
63
+ h3.collap:hover{background:#1c232d}
64
+ h3.collap.shut{margin-bottom:-12px;border-bottom:none;border-radius:9px}
65
+ h3.collap .chev{font-size:11px;color:var(--accent);transition:transform .15s}
66
+ h3.collap.shut .chev{transform:rotate(-90deg)}
67
+ .collapbody.shut{display:none}
68
+ /* ---------- view tabs ---------- */
69
+ .viewtabs{display:flex;gap:5px;margin-bottom:12px}
70
+ .viewtabs button{padding:6px 14px;font-size:12.5px;border-radius:8px}
71
+ .viewtabs button.on{background:var(--accent);border-color:var(--accent);color:#04121c;font-weight:600}
72
+ /* ---------- queue ---------- */
73
+ .q-head{display:flex;align-items:center;gap:8px;margin:0 0 8px;flex-wrap:wrap}
74
+ .q-head h3{margin:0;font-size:12px;text-transform:uppercase;letter-spacing:.7px;color:var(--dim);font-weight:600}
75
+ .qlist{border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--panel);max-height:420px;overflow-y:auto}
76
+ .qrow{display:flex;align-items:center;gap:10px;padding:7px 10px;border-top:1px solid var(--line);font-size:12.5px}
77
+ .qrow:first-child{border-top:none}
78
+ .qrow:hover{background:var(--panel2)}
79
+ .qrow.on{background:#12212b;box-shadow:inset 2px 0 0 var(--accent)}
80
+ .qrow input[type=checkbox]{width:16px;height:16px;flex:none;accent-color:var(--accent);cursor:pointer}
81
+ .qthumb{width:52px;height:40px;border-radius:4px;border:1px solid var(--line);background:#07090c;flex:none;object-fit:cover;display:block}
82
+ .qthumb.ph{display:flex;align-items:center;justify-content:center;color:var(--dim);font-size:16px}
83
+ .qmeta{flex:1 1 90px;min-width:60px;overflow:hidden}
84
+ .qname{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
85
+ .qsub{color:var(--dim);font-size:11px;font-variant-numeric:tabular-nums;margin-top:1px}
86
+ .qstate{font-size:11px;font-variant-numeric:tabular-nums;flex:none;width:74px;text-align:right}
87
+ .qstate.done{color:var(--ok)} .qstate.err{color:#ffb4ae} .qstate.work{color:var(--accent)}
88
+ .qx{padding:2px 8px;font-size:13px;border-radius:6px;flex:none}
89
+ .empty{color:var(--dim);font-size:12.5px;text-align:center;padding:24px 10px}
90
+ /* ---------- grid view ---------- */
91
+ .imgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(122px,1fr));gap:10px}
92
+ .gcard{position:relative;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--panel);cursor:pointer;transition:.12s}
93
+ .gcard:hover{border-color:#3a4553}
94
+ .gcard.on{box-shadow:0 0 0 2px var(--accent);border-color:var(--accent)}
95
+ .gcard img{width:100%;height:104px;object-fit:cover;display:block;background:#07090c}
96
+ .gcard .gph{width:100%;height:104px;display:flex;align-items:center;justify-content:center;color:var(--dim);font-size:20px;background:#07090c}
97
+ .gcard .gname{font-size:11px;padding:5px 7px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--txt)}
98
+ .gcard .gchk{position:absolute;top:6px;left:6px;width:18px;height:18px;accent-color:var(--accent);cursor:pointer;z-index:2}
99
+ .gcard .gx{position:absolute;top:4px;right:4px;padding:1px 7px;font-size:12px;line-height:1.5;border-radius:6px;
100
+ background:rgba(0,0,0,.55);color:var(--dim);z-index:2}
101
+ .gcard .gx:hover{color:#ffb4ae;border-color:#5a2b2b;background:#341c1c}
102
+ .gcard .gstate{position:absolute;bottom:24px;right:5px;font-size:10px;padding:1px 6px;border-radius:5px;background:rgba(0,0,0,.6);font-variant-numeric:tabular-nums}
103
+ .gcard .gstate.done{color:#9ae6a8} .gcard .gstate.err{color:#ffb4ae} .gcard .gstate.work{color:var(--accent)}
104
+ /* ---------- preview stage ---------- */
105
+ .stage{background:#000;border-radius:var(--r);overflow:hidden;display:flex;align-items:center;justify-content:center;
106
+ position:relative;border:1px solid var(--line);min-height:240px;margin-bottom:14px}
107
+ .stage canvas{max-width:100%;max-height:52vh;width:auto;height:auto;display:block;background:#000}
108
+ .badge{position:absolute;top:10px;left:10px;z-index:3;background:rgba(0,0,0,.66);backdrop-filter:blur(4px);
109
+ padding:4px 9px;border-radius:6px;font-size:11.5px;color:#cfe;letter-spacing:.3px}
110
+ .ba-right{left:auto;right:10px;color:#bfe9ff}
111
+ /* ---------- settings ---------- */
112
+ .row{display:flex;align-items:center;gap:8px;margin-bottom:10px}
113
+ .row label{flex:0 0 92px;color:var(--dim);font-size:12.5px}
114
+ .row:last-child{margin-bottom:0}
115
+ input[type=text],input[type=number],select{
116
+ font:inherit;background:var(--panel2);color:var(--txt);border:1px solid var(--line);
117
+ border-radius:7px;padding:6px 8px;width:100%;min-width:0;
118
+ }
119
+ input[type=color]{padding:2px;height:31px;width:44px;flex:none;background:var(--panel2);
120
+ border:1px solid var(--line);border-radius:7px;cursor:pointer}
121
+ input:focus,select:focus{outline:none;border-color:var(--accent)}
122
+ input[type=range]{width:100%;accent-color:var(--accent)}
123
+ .seg{display:flex;gap:5px;flex-wrap:wrap;margin-bottom:10px}
124
+ .seg button{padding:5px 9px;font-size:12.5px;border-radius:7px;flex:1 1 auto}
125
+ .seg button.on{background:var(--accent);border-color:var(--accent);color:#04121c;font-weight:600}
126
+ .hint{color:var(--dim);font-size:11.5px;margin:-2px 0 10px;line-height:1.45}
127
+ .chk{display:flex;align-items:center;gap:7px;color:var(--txt);font-size:12.5px;margin-bottom:10px;cursor:pointer}
128
+ .chk input{width:auto;flex:none;accent-color:var(--accent)}
129
+ .chk .sub{color:var(--dim);font-size:11px}
130
+ .est{background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:9px 11px;font-size:12.5px;margin-bottom:12px}
131
+ .est div{display:flex;justify-content:space-between;margin:2px 0;gap:10px}
132
+ .est span{color:var(--dim)}
133
+ .est b{font-variant-numeric:tabular-nums;font-weight:600;text-align:right}
134
+ .expBtn{width:100%;padding:11px}
135
+ .stopBtn{width:100%;padding:9px;margin-top:8px;display:none}
136
+ .bar{height:6px;background:var(--panel2);border-radius:99px;overflow:hidden;margin-top:10px;display:none}
137
+ .bar i{display:block;height:100%;width:0;background:var(--accent);transition:width .2s}
138
+ .status{font-size:12px;color:var(--dim);margin-top:8px;text-align:center;min-height:16px;line-height:1.4}
139
+ .status.err{color:#ffb4ae}
140
+ .status.ok{color:#9ae6a8}
141
+ .note{font-size:11.5px;color:var(--warn);margin-top:8px;line-height:1.4}
142
+ .toolbtns{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:6px}
143
+ .toolbtns button{flex:1 1 auto;padding:6px 8px;font-size:12.5px}
144
+ .keys{color:var(--dim);font-size:11.5px;margin-top:12px;line-height:1.7}
145
+ .kbd{display:inline-block;background:var(--panel2);border:1px solid var(--line);border-bottom-width:2px;
146
+ border-radius:5px;padding:0 5px;font-size:11px;font-family:ui-monospace,Menlo,Consolas,monospace}
147
+ code{background:var(--panel2);border:1px solid var(--line);border-radius:4px;padding:0 4px;font-size:11.5px}
148
+ @media(prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
149
+ </style>
150
+ </head>
151
+ <body>
152
+ <div class="wrap">
153
+ <header>
154
+ <h1>Pocket Image Converter</h1>
155
+ <span class="sub">HEIC &amp; more &middot; convert &middot; resize &middot; batch</span>
156
+ <span class="spacer"></span>
157
+ <span class="verwrap"><span class="ver">v1.4</span><span class="by">by C_Nugget</span></span>
158
+ </header>
159
+ <div id="drop">
160
+ <div class="big">&#9634;</div>
161
+ <h2>Drop images here to convert</h2>
162
+ <p>HEIC / HEIF &middot; JPEG / JFIF &middot; PNG &middot; WebP &middot; AVIF &middot; GIF &middot; BMP &middot; TIFF* &middot; ICO &nbsp;&middot;&nbsp; add as many as you like &nbsp;&middot;&nbsp; nothing is uploaded, it all stays on this computer</p>
163
+ <div class="row2">
164
+ <button id="pickFiles" class="small">Choose files</button>
165
+ <button id="pickFolder" class="small ghost">Choose a folder</button>
166
+ </div>
167
+ <input type="file" id="fileInput" accept="image/*,.heic,.heif,.heics,.jfif,.pjpeg,.pjp,.avif,.tif,.tiff,.bmp,.ico" multiple hidden>
168
+ <input type="file" id="folderInput" webkitdirectory directory multiple hidden>
169
+ </div>
170
+ <div class="grid">
171
+ <!-- LEFT: preview + queue -->
172
+ <div>
173
+ <div class="viewtabs" id="viewTabs">
174
+ <button data-v="preview" class="on">Preview</button>
175
+ <button data-v="grid">Grid</button>
176
+ </div>
177
+
178
+ <div id="previewView">
179
+ <div class="stage">
180
+ <canvas id="preview"></canvas>
181
+ <div class="badge" id="srcBadge">No image selected</div>
182
+ <div class="badge ba-right" id="outBadge"></div>
183
+ </div>
184
+ </div>
185
+
186
+ <div id="gridView" style="display:none">
187
+ <div class="imgrid" id="imgrid"></div>
188
+ </div>
189
+
190
+ <div class="q-head">
191
+ <h3>Queue</h3>
192
+ <span class="hint" id="qCount" style="margin:0">0 images</span>
193
+ <span class="spacer"></span>
194
+ <button class="small ghost" id="selAll">Select all</button>
195
+ <button class="small ghost" id="selNone">None</button>
196
+ <button class="small ghost" id="clearDone">Clear finished</button>
197
+ <button class="small ghost" id="clearAll">Remove all</button>
198
+ </div>
199
+ <div id="listView">
200
+ <div class="qlist" id="qList"><div class="empty">No images yet. Drop files above and they will appear here in order.</div></div>
201
+ </div>
202
+ <div class="keys">
203
+ <span class="kbd">&larr;</span> <span class="kbd">&rarr;</span> move between images &nbsp;&middot;&nbsp;
204
+ <span class="kbd">Space</span> toggle selected &nbsp;&middot;&nbsp;
205
+ <span class="kbd">R</span> rotate &nbsp;&middot;&nbsp; <span class="kbd">Del</span> remove &nbsp;&middot;&nbsp;
206
+ only <b style="color:var(--txt)">ticked</b> images are converted; edits apply to every image.
207
+ </div>
208
+ </div>
209
+ <!-- RIGHT: settings -->
210
+ <div class="card">
211
+ <!-- Output format -->
212
+ <div class="panelbox">
213
+ <h3>Output format</h3>
214
+ <div class="seg" id="fmtSeg">
215
+ <button data-fmt="image/png" class="on">PNG</button>
216
+ <button data-fmt="image/jpeg">JPEG</button>
217
+ <button data-fmt="image/webp">WebP</button>
218
+ <button data-fmt="image/avif">AVIF</button>
219
+ <button data-fmt="application/pdf">PDF</button>
220
+ </div>
221
+ <div class="row" id="qualityRow">
222
+ <label>Quality</label>
223
+ <input type="range" id="quality" min="1" max="100" value="82">
224
+ <b class="hint" id="qualityL" style="margin:0;flex:0 0 36px;text-align:right;color:var(--txt)">82</b>
225
+ </div>
226
+ <label class="chk" id="targetChk"><input type="checkbox" id="targetOn"> Target file size <span class="sub">(auto-tunes quality)</span></label>
227
+ <div class="row" id="targetRow" style="display:none">
228
+ <label>Max size</label>
229
+ <input type="number" id="targetSize" min="0.05" step="0.1" value="2" style="flex:1">
230
+ <select id="targetUnit" style="flex:0 0 78px"><option value="1048576" selected>MB</option><option value="1024">KB</option></select>
231
+ </div>
232
+ <!-- PNG size reduction -->
233
+ <div id="pngOpts" style="display:none">
234
+ <div class="row">
235
+ <label>PNG size</label>
236
+ <select id="pngColors">
237
+ <option value="0" selected>Full colour (lossless)</option>
238
+ <option value="256">Smaller &middot; 256 colours</option>
239
+ <option value="128">Smaller &middot; 128 colours</option>
240
+ <option value="64">Smallest &middot; 64 colours</option>
241
+ <option value="32">Tiny &middot; 32 colours</option>
242
+ </select>
243
+ </div>
244
+ <label class="chk" id="ditherChk" style="display:none"><input type="checkbox" id="pngDither" checked> Dither <span class="sub">(smoother gradients)</span></label>
245
+ </div>
246
+ <div class="hint" id="fmtHint">PNG is lossless — great for screenshots and graphics. The quality slider only affects JPEG, WebP and AVIF.</div>
247
+ </div>
248
+ <!-- Resize -->
249
+ <div class="panelbox">
250
+ <h3 class="collap shut" data-target="resizeBody">Resize <span class="chev">&#9660;</span></h3>
251
+ <div class="collapbody shut" id="resizeBody">
252
+ <div class="seg" id="resizeMode">
253
+ <button data-m="none" class="on">Original</button>
254
+ <button data-m="pct">Percent</button>
255
+ <button data-m="px">Pixels</button>
256
+ <button data-m="max">Max side</button>
257
+ </div>
258
+ <div class="row" id="pctRow" style="display:none"><label>Scale</label><input type="number" id="pct" min="1" max="400" step="1" value="100"><span class="hint" style="margin:0">%</span></div>
259
+ <div id="pxRows" style="display:none">
260
+ <div class="row"><label>Width</label><input type="number" id="pxW" min="1" step="1"></div>
261
+ <div class="row"><label>Height</label><input type="number" id="pxH" min="1" step="1"></div>
262
+ <label class="chk"><input type="checkbox" id="lockAr" checked> Lock aspect ratio</label>
263
+ </div>
264
+ <div class="row" id="maxRow" style="display:none">
265
+ <label>Longest side</label>
266
+ <select id="maxPreset">
267
+ <option value="640">640 px</option>
268
+ <option value="1024">1024 px</option>
269
+ <option value="1600" selected>1600 px</option>
270
+ <option value="1920">1920 px</option>
271
+ <option value="2560">2560 px</option>
272
+ <option value="4096">4096 px</option>
273
+ </select>
274
+ </div>
275
+ <div class="hint">Images are only ever scaled down to fit — never blown up past their real pixels unless you use Percent above 100.</div>
276
+ </div>
277
+ </div>
278
+ <!-- Adjust -->
279
+ <div class="panelbox">
280
+ <h3 class="collap shut" data-target="adjBody">Rotate &amp; flip <span class="chev">&#9660;</span></h3>
281
+ <div class="collapbody shut" id="adjBody">
282
+ <div class="toolbtns">
283
+ <button id="rotL" title="Rotate left">&#8630; Left</button>
284
+ <button id="rotR" title="Rotate right (R)">Right &#8631;</button>
285
+ </div>
286
+ <div class="toolbtns">
287
+ <button id="flipH">&#8646; Flip H</button>
288
+ <button id="flipV">&#8645; Flip V</button>
289
+ <button id="resetAdj" class="ghost">Reset</button>
290
+ </div>
291
+ <label class="chk"><input type="checkbox" id="autoExif" checked> Auto-rotate from EXIF <span class="sub">(fixes sideways iPhone photos)</span></label>
292
+ <div class="hint">Rotate / flip applies to every image in the queue.</div>
293
+ </div>
294
+ </div>
295
+ <!-- Watermark -->
296
+ <div class="panelbox">
297
+ <h3 class="collap shut" data-target="wmBody">Watermark <span class="chev">&#9660;</span></h3>
298
+ <div class="collapbody shut" id="wmBody">
299
+ <div class="row"><label>Text</label><input type="text" id="wmText" placeholder="Leave blank for none"></div>
300
+ <div class="row">
301
+ <label>Position</label>
302
+ <select id="wmPos">
303
+ <option value="br" selected>Bottom right</option>
304
+ <option value="bl">Bottom left</option>
305
+ <option value="tr">Top right</option>
306
+ <option value="tl">Top left</option>
307
+ <option value="c">Centre</option>
308
+ </select>
309
+ </div>
310
+ <div class="row">
311
+ <label>Size</label>
312
+ <select id="wmSize"><option value="0.03">Small</option><option value="0.05" selected>Medium</option><option value="0.08">Large</option></select>
313
+ <input type="color" id="wmColor" value="#ffffff" title="Text colour">
314
+ </div>
315
+ <div class="row"><label>Opacity</label><input type="range" id="wmOpacity" min="10" max="100" value="70"><b class="hint" id="wmOpacityL" style="margin:0;flex:0 0 36px;text-align:right;color:var(--txt)">70%</b></div>
316
+ </div>
317
+ </div>
318
+ <!-- Naming -->
319
+ <div class="panelbox">
320
+ <h3 class="collap shut" data-target="nameBody">File names <span class="chev">&#9660;</span></h3>
321
+ <div class="collapbody shut" id="nameBody">
322
+ <div class="seg" id="nameMode">
323
+ <button data-m="keep" class="on">Keep original</button>
324
+ <button data-m="tpl">Template</button>
325
+ </div>
326
+ <div id="tplRows" style="display:none">
327
+ <div class="row"><label>Prefix</label><input type="text" id="prefix" placeholder="e.g. web_"></div>
328
+ <div class="row"><label>Base</label><input type="text" id="tpl" value="IMG_{n}"></div>
329
+ <div class="row"><label>Suffix</label><input type="text" id="suffix" placeholder="e.g. _v2"></div>
330
+ <div class="hint">Tokens: <code>{n}</code> number &middot; <code>{name}</code> original name &middot; <code>{date}</code> today &middot; <code>{w}</code>/<code>{h}</code> output size.</div>
331
+ </div>
332
+ <div class="row"><label>Start #</label><input type="number" id="startN" min="0" step="1" value="1"></div>
333
+ </div>
334
+ </div>
335
+ <!-- Export -->
336
+ <div class="panelbox">
337
+ <h3>Export</h3>
338
+ <div class="est">
339
+ <div><span>Selected</span><b id="eCount">&mdash;</b></div>
340
+ <div><span>Output format</span><b id="eFmt">PNG</b></div>
341
+ <div><span>Selected output</span><b id="eDims">&mdash;</b></div>
342
+ <div><span>Est. size (selected)</span><b id="eBytes">&mdash;</b></div>
343
+ </div>
344
+ <button id="exportBtn" class="primary expBtn">Convert &amp; save</button>
345
+ <button id="stopBtn" class="danger stopBtn">Stop</button>
346
+ <div class="bar" id="bar"><i id="barI"></i></div>
347
+ <div class="status" id="status"></div>
348
+ <div class="note" id="note">Batches of 2+ save into a folder you pick (Chrome/Edge), otherwise they download one by one.</div>
349
+ </div>
350
+ </div>
351
+ </div>
352
+ <div class="panelbox" id="offlineNote" style="margin-top:14px;background:#11161d">
353
+ <h3 class="collap shut" data-target="offlineBody">Offline &amp; HEIC support <span class="chev">&#9660;</span></h3>
354
+ <div class="collapbody shut" id="offlineBody" style="color:var(--dim);font-size:12px;line-height:1.6">
355
+ <p style="margin:0 0 8px">This file is <b style="color:var(--txt)">fully self-contained</b> — no internet needed, nothing is uploaded. All the common formats (<b style="color:var(--txt)">JPEG / JFIF, PNG, WebP, AVIF, GIF, BMP</b>) are decoded and converted entirely by your browser.</p>
356
+ <p style="margin:0 0 8px"><b style="color:var(--txt)">HEIC</b> is the one format browsers can't decode on their own. This tool tries, in order &amp; all offline: your browser's native decoder &rarr; the OS codec via WebCodecs &rarr; a local <code>heic2any.min.js</code>. It works with no setup on:</p>
357
+ <ul style="margin:0 0 8px;padding-left:18px">
358
+ <li><b style="color:var(--txt)">Safari</b> (Mac/iPhone/iPad) — decodes HEIC natively, works instantly.</li>
359
+ <li><b style="color:var(--txt)">Chrome / Edge on Windows</b> — install the free <i>HEVC Video Extensions</i> from the Microsoft Store once, then HEIC decodes offline via the OS.</li>
360
+ <li><b style="color:var(--txt)">Chrome / Edge on macOS</b> — uses the system codec, usually works out of the box.</li>
361
+ </ul>
362
+ <p style="margin:0"><b style="color:var(--txt)">Guaranteed-offline HEIC on any browser:</b> download <code>heic2any.min.js</code> once (from <code>github.com/alexcorvi/heic2any</code>, the <code>/dist</code> folder) and save it <b style="color:var(--txt)">in the same folder as this HTML file</b>. The app auto-detects it and then decodes HEIC locally forever, with zero network.</p>
363
+ </div>
364
+ </div>
365
+ <div class="keys" style="margin-top:8px">
366
+ AVIF <i>output</i> depends on your browser (Chrome/Edge support it; Safari/Firefox may not). A few spec items (original file dates, Live Photo extraction, subfolder mirroring) need a desktop app.
367
+ </div>
368
+ </div>
369
+ <canvas id="work" style="display:none"></canvas>
370
+ <script>
371
+ "use strict";
372
+ (function(){
373
+ /* ======================= helpers ======================= */
374
+ const $ = id => document.getElementById(id);
375
+ const clamp = (v,a,b) => Math.min(b, Math.max(a, v));
376
+ function human(bytes){
377
+ if(!isFinite(bytes) || bytes <= 0) return "\u2014";
378
+ if(bytes < 1024) return bytes + " B";
379
+ if(bytes < 1024*1024) return Math.round(bytes/1024) + " KB";
380
+ if(bytes < 1024*1024*1024) return (bytes/1024/1024).toFixed(bytes < 10*1024*1024 ? 2 : 1) + " MB";
381
+ return (bytes/1024/1024/1024).toFixed(2) + " GB";
382
+ }
383
+ function baseName(n){ return String(n||"image").replace(/\.[^.]+$/, "") || "image"; }
384
+ function safeName(s, fallback){
385
+ return String(s || fallback).replace(/[\\/:*?"<>|\x00-\x1f]+/g,"-").replace(/^\.+/,"").trim().slice(0,90) || fallback;
386
+ }
387
+ function extFor(mime){
388
+ return ({"image/png":"png","image/jpeg":"jpg","image/webp":"webp","image/avif":"avif","application/pdf":"pdf"})[mime] || "img";
389
+ }
390
+ function isLossy(mime){ return mime==="image/jpeg" || mime==="image/webp" || mime==="image/avif"; }
391
+ function say(el, msg, kind){ el.textContent = msg || ""; el.className = "status" + (kind ? " " + kind : ""); }
392
+ function todayStr(){ const d = new Date(); const p = n => String(n).padStart(2,"0"); return d.getFullYear()+"-"+p(d.getMonth()+1)+"-"+p(d.getDate()); }
393
+ function wait(ms){ return new Promise(r => setTimeout(r, ms)); }
394
+ /* format support probe */
395
+ async function supportsMime(mime){
396
+ try{
397
+ const c = document.createElement("canvas"); c.width = c.height = 4;
398
+ const blob = await new Promise(r => c.toBlob(r, mime, 0.8));
399
+ return !!(blob && blob.type === mime);
400
+ }catch(e){ return false; }
401
+ }
402
+ /* ======================= state ======================= */
403
+ const items = []; // {id,file,name,selected,bitmap,w,h,status,outName,outSize,thumbUrl,err}
404
+ let sel = -1, uid = 0, exporting = false, cancel = false, view = "preview";
405
+ let outFmt = "image/png";
406
+ const support = {};
407
+ /* ======================= collapsibles ======================= */
408
+ document.querySelectorAll("h3.collap").forEach(h => {
409
+ h.addEventListener("click", () => {
410
+ h.classList.toggle("shut");
411
+ const b = $(h.dataset.target);
412
+ if(b) b.classList.toggle("shut");
413
+ });
414
+ });
415
+ /* ======================= loading files ======================= */
416
+ const drop = $("drop");
417
+ $("pickFiles").addEventListener("click", e => { e.stopPropagation(); $("fileInput").click(); });
418
+ $("pickFolder").addEventListener("click", e => { e.stopPropagation(); $("folderInput").click(); });
419
+ drop.addEventListener("click", () => $("fileInput").click());
420
+ $("fileInput").addEventListener("change", e => { addFiles(e.target.files); e.target.value = ""; });
421
+ $("folderInput").addEventListener("change", e => { addFiles(e.target.files); e.target.value = ""; });
422
+ ["dragenter","dragover"].forEach(ev => drop.addEventListener(ev, e => { e.preventDefault(); drop.classList.add("over"); }));
423
+ ["dragleave","drop"].forEach(ev => drop.addEventListener(ev, e => { e.preventDefault(); drop.classList.remove("over"); }));
424
+ window.addEventListener("dragover", e => e.preventDefault());
425
+ window.addEventListener("drop", e => { e.preventDefault(); drop.classList.remove("over"); if(e.dataTransfer && e.dataTransfer.files.length) addFiles(e.dataTransfer.files); });
426
+ function looksImage(f){
427
+ return (f.type && f.type.startsWith("image/")) ||
428
+ /\.(heic|heif|heics|jpe?g|jfif|pjpeg|pjp|png|webp|gif|bmp|tiff?|avif|ico)$/i.test(f.name);
429
+ }
430
+ async function addFiles(fileList){
431
+ const files = Array.from(fileList).filter(looksImage);
432
+ if(!files.length){ say($("status"), "Those files are not images. Add HEIC, JPEG, PNG, WebP, GIF or BMP.", "err"); return; }
433
+ say($("status"), "Reading " + files.length + (files.length === 1 ? " image\u2026" : " images\u2026"));
434
+ const firstNew = items.length;
435
+ for(const f of files){
436
+ items.push({ id: ++uid, file: f, name: f.name, selected: true, status: "queued",
437
+ bitmap: null, w: 0, h: 0, outName: "", outSize: 0, thumbUrl: "", err: "" });
438
+ }
439
+ render();
440
+ for(let i = firstNew; i < items.length; i++){
441
+ if(!items[i].bitmap && items[i].status !== "error") await decode(items[i]);
442
+ render();
443
+ }
444
+ if(sel < 0 && items.length){ sel = 0; drawPreview(); }
445
+ say($("status"), items.length + " image" + (items.length===1?"":"s") + " ready.", "ok");
446
+ updateEstimate();
447
+ }
448
+ const isHeic = f => /\.(heic|heif)$/i.test(f.name) || f.type === "image/heic" || f.type === "image/heif" || f.type === "image/heic-sequence";
449
+ /* ---- HEIC engine (optional, offline-first) ---- */
450
+ let heicPromise = null, heicSource = "";
451
+ function loadScript(src){
452
+ return new Promise((res, rej) => {
453
+ const s = document.createElement("script");
454
+ s.src = src; s.async = true;
455
+ s.onload = () => res(true);
456
+ s.onerror = () => rej(new Error("failed " + src));
457
+ document.head.appendChild(s);
458
+ });
459
+ }
460
+ function ensureHeic(){
461
+ if(typeof heic2any !== "undefined") return Promise.resolve(true);
462
+ if(heicPromise) return heicPromise;
463
+ const sources = [
464
+ { url: "heic2any.min.js", local: true },
465
+ { url: "libheif/heic2any.min.js", local: true },
466
+ { url: "https://cdn.jsdelivr.net/npm/heic2any@0.0.4/dist/heic2any.min.js", local: false },
467
+ { url: "https://unpkg.com/heic2any@0.0.4/dist/heic2any.min.js", local: false }
468
+ ];
469
+ heicPromise = (async () => {
470
+ for(const s of sources){
471
+ try{ await loadScript(s.url); if(typeof heic2any !== "undefined"){ heicSource = s.local ? "local file" : "CDN"; return true; } }
472
+ catch(e){ /* try next */ }
473
+ }
474
+ heicPromise = null;
475
+ throw new Error("no HEIC engine available offline");
476
+ })();
477
+ return heicPromise;
478
+ }
479
+ async function decodeViaWebCodecs(blob){
480
+ if(typeof ImageDecoder === "undefined") return null;
481
+ try{
482
+ const type = blob.type && blob.type !== "" ? blob.type : "image/heic";
483
+ if(ImageDecoder.isTypeSupported){
484
+ const ok = await ImageDecoder.isTypeSupported(type).catch(() => false);
485
+ if(!ok) return null;
486
+ }
487
+ const buf = await blob.arrayBuffer();
488
+ const dec = new ImageDecoder({ data: buf, type });
489
+ const { image } = await dec.decode();
490
+ const bmp = await createImageBitmap(image);
491
+ try{ image.close && image.close(); }catch(_){}
492
+ return bmp;
493
+ }catch(e){ return null; }
494
+ }
495
+ async function decodeNative(blob){
496
+ const useExif = $("autoExif").checked;
497
+ try{
498
+ return await createImageBitmap(blob, useExif ? { imageOrientation: "from-image" } : {});
499
+ }catch(e){
500
+ try{ return await loadViaImg(blob); }
501
+ catch(e2){ return null; }
502
+ }
503
+ }
504
+ async function decode(it){
505
+ it.status = "decoding"; render();
506
+ try{
507
+ let bmp = await decodeNative(it.file);
508
+ if(!bmp && isHeic(it.file)) bmp = await decodeViaWebCodecs(it.file);
509
+ if(!bmp && isHeic(it.file)){
510
+ try{
511
+ await ensureHeic();
512
+ let out;
513
+ try{ out = await heic2any({ blob: it.file, toType: "image/jpeg", quality: 0.92 }); }
514
+ catch(e){
515
+ if(/already browser readable/i.test(e && e.message || "")){ bmp = await decodeNative(it.file); }
516
+ else { out = await heic2any({ blob: it.file, toType: "image/png" }); }
517
+ }
518
+ if(!bmp && out){ bmp = await decodeNative(Array.isArray(out) ? out[0] : out); }
519
+ }catch(engineErr){}
520
+ }
521
+ if(!bmp) throw new Error(isHeic(it.file)
522
+ ? "HEIC needs a codec this browser lacks — see the offline HEIC note, or open in Safari"
523
+ : "this browser can't read that format");
524
+ it.bitmap = bmp; it.w = bmp.width; it.h = bmp.height; it.status = "ready";
525
+ it.thumbUrl = makeThumb(bmp);
526
+ }catch(e){
527
+ it.status = "error"; it.err = e.message || "could not decode";
528
+ }
529
+ }
530
+ function loadViaImg(blob){
531
+ return new Promise((res, rej) => {
532
+ const url = URL.createObjectURL(blob), img = new Image();
533
+ img.onload = () => { res(img); setTimeout(()=>URL.revokeObjectURL(url), 5000); };
534
+ img.onerror = () => { URL.revokeObjectURL(url); rej(new Error("decode failed")); };
535
+ img.src = url;
536
+ });
537
+ }
538
+ function makeThumb(bmp){
539
+ const c = document.createElement("canvas");
540
+ const w = 160, h = 120;
541
+ c.width = w; c.height = h;
542
+ const x = c.getContext("2d");
543
+ x.fillStyle = "#07090c"; x.fillRect(0,0,w,h);
544
+ const sc = Math.max(w/bmp.width, h/bmp.height);
545
+ const dw = bmp.width*sc, dh = bmp.height*sc;
546
+ x.drawImage(bmp, (w-dw)/2, (h-dh)/2, dw, dh);
547
+ return c.toDataURL("image/jpeg", 0.72);
548
+ }
549
+ /* ======================= transforms ======================= */
550
+ let rot = 0, flipH = false, flipV = false;
551
+ $("rotL").addEventListener("click", () => { rot = (rot + 270) % 360; drawPreview(); updateEstimate(); });
552
+ $("rotR").addEventListener("click", () => { rot = (rot + 90) % 360; drawPreview(); updateEstimate(); });
553
+ $("flipH").addEventListener("click", () => { flipH = !flipH; drawPreview(); });
554
+ $("flipV").addEventListener("click", () => { flipV = !flipV; drawPreview(); });
555
+ $("resetAdj").addEventListener("click", () => { rot = 0; flipH = flipV = false; drawPreview(); updateEstimate(); });
556
+ $("autoExif").addEventListener("change", async () => {
557
+ for(const it of items){ if(it.bitmap){ try{ it.bitmap.close && it.bitmap.close(); }catch(_){} it.bitmap = null; it.status = "queued"; } }
558
+ render();
559
+ for(const it of items) if(it.status !== "error") { await decode(it); render(); }
560
+ drawPreview();
561
+ });
562
+ function targetSize(sw, sh){
563
+ const mode = document.querySelector("#resizeMode button.on").dataset.m;
564
+ let w = sw, h = sh;
565
+ if(mode === "pct"){ const p = (+$("pct").value||100)/100; w = sw*p; h = sh*p; }
566
+ else if(mode === "px"){
567
+ const pw = +$("pxW").value||0, ph = +$("pxH").value||0;
568
+ if(pw && ph){ w = pw; h = ph; }
569
+ else if(pw){ w = pw; h = sh*pw/sw; }
570
+ else if(ph){ h = ph; w = sw*ph/sh; }
571
+ }
572
+ else if(mode === "max"){
573
+ const m = +$("maxPreset").value||1600;
574
+ const s = Math.min(1, m/Math.max(sw, sh));
575
+ w = sw*s; h = sh*s;
576
+ }
577
+ return { w: Math.max(1, Math.round(w)), h: Math.max(1, Math.round(h)) };
578
+ }
579
+ function renderToCanvas(bmp){
580
+ const swapped = (rot === 90 || rot === 270);
581
+ const bw = swapped ? bmp.height : bmp.width;
582
+ const bh = swapped ? bmp.width : bmp.height;
583
+ const t = targetSize(bw, bh);
584
+ const cv = $("work");
585
+ cv.width = t.w; cv.height = t.h;
586
+ const g = cv.getContext("2d");
587
+ g.imageSmoothingEnabled = true; g.imageSmoothingQuality = "high";
588
+ const reducedPng = (outFmt === "image/png" && (+$("pngColors").value||0) > 0);
589
+ if(outFmt === "image/jpeg" || outFmt === "application/pdf" || reducedPng){ g.fillStyle = "#fff"; g.fillRect(0,0,t.w,t.h); }
590
+ g.save();
591
+ g.translate(t.w/2, t.h/2);
592
+ g.rotate(rot * Math.PI/180);
593
+ g.scale(flipH ? -1 : 1, flipV ? -1 : 1);
594
+ const drawW = swapped ? t.h : t.w;
595
+ const drawH = swapped ? t.w : t.h;
596
+ g.drawImage(bmp, -drawW/2, -drawH/2, drawW, drawH);
597
+ g.restore();
598
+ drawWatermark(g, t.w, t.h);
599
+ return cv;
600
+ }
601
+ function drawWatermark(g, W, H){
602
+ const text = ($("wmText").value || "").trim();
603
+ if(!text) return;
604
+ const size = Math.max(11, Math.round(Math.min(W,H) * (parseFloat($("wmSize").value)||0.05)));
605
+ g.font = "600 " + size + "px -apple-system,BlinkMacSystemFont,Segoe UI,Arial,sans-serif";
606
+ g.textBaseline = "alphabetic";
607
+ const pad = Math.round(size*0.6);
608
+ const w = g.measureText(text).width;
609
+ const pos = $("wmPos").value;
610
+ let x = W - w - pad, y = H - pad;
611
+ if(pos === "bl"){ x = pad; }
612
+ else if(pos === "tr"){ y = size + pad; }
613
+ else if(pos === "tl"){ x = pad; y = size + pad; }
614
+ else if(pos === "c"){ x = (W - w)/2; y = (H + size)/2; }
615
+ g.globalAlpha = (+$("wmOpacity").value||70)/100;
616
+ g.fillStyle = "rgba(0,0,0,.45)";
617
+ g.fillText(text, x+Math.max(1,size*0.04), y+Math.max(1,size*0.04));
618
+ g.fillStyle = $("wmColor").value || "#ffffff";
619
+ g.fillText(text, x, y);
620
+ g.globalAlpha = 1;
621
+ }
622
+ /* ======================= preview ======================= */
623
+ function drawPreview(){
624
+ const cv = $("preview"), g = cv.getContext("2d");
625
+ const it = items[sel];
626
+ if(!it || !it.bitmap){
627
+ cv.width = 640; cv.height = 360;
628
+ g.fillStyle = "#000"; g.fillRect(0,0,cv.width,cv.height);
629
+ $("srcBadge").textContent = it ? (it.status === "error" ? "Could not decode this file" : "Decoding\u2026") : "No image selected";
630
+ $("outBadge").textContent = "";
631
+ return;
632
+ }
633
+ const out = renderToCanvas(it.bitmap);
634
+ cv.width = out.width; cv.height = out.height;
635
+ g.drawImage(out, 0, 0);
636
+ $("srcBadge").textContent = it.name + " \u00b7 " + it.w + "\u00d7" + it.h + (isHeic(it.file) ? " \u00b7 HEIC" : "");
637
+ $("outBadge").textContent = out.width + "\u00d7" + out.height + " \u00b7 " + fmtLabel(outFmt);
638
+ }
639
+ function fmtLabel(m){ return ({"image/png":"PNG","image/jpeg":"JPEG","image/webp":"WebP","image/avif":"AVIF","application/pdf":"PDF"})[m] || m; }
640
+ /* ======================= view toggle ======================= */
641
+ $("viewTabs").addEventListener("click", e => {
642
+ const b = e.target.closest("button"); if(!b) return;
643
+ view = b.dataset.v;
644
+ document.querySelectorAll("#viewTabs button").forEach(x => x.classList.toggle("on", x === b));
645
+ $("previewView").style.display = view === "preview" ? "block" : "none";
646
+ $("gridView").style.display = view === "grid" ? "block" : "none";
647
+ $("listView").style.display = view === "grid" ? "none" : "block";
648
+ if(view === "grid") renderGrid(); else drawPreview();
649
+ });
650
+ /* ======================= selection helpers ======================= */
651
+ function selectableCount(){ return items.filter(it => it.status !== "error").length; }
652
+ function selectedReady(){ return items.filter(it => it.selected && it.status !== "error" && it.bitmap); }
653
+ $("selAll").addEventListener("click", () => { items.forEach(it => { if(it.status !== "error") it.selected = true; }); render(); });
654
+ $("selNone").addEventListener("click", () => { items.forEach(it => it.selected = false); render(); });
655
+ /* ======================= queue rendering ======================= */
656
+ function render(){
657
+ $("qCount").textContent = items.length + (items.length===1?" image":" images") +
658
+ (items.length ? " \u00b7 " + selectedReady().length + " ticked" : "");
659
+ renderList();
660
+ if(view === "grid") renderGrid();
661
+ updateEstimate();
662
+ }
663
+ function renderList(){
664
+ const list = $("qList");
665
+ if(!items.length){ list.innerHTML = '<div class="empty">No images yet. Drop files above and they will appear here in order.</div>'; drawPreview(); return; }
666
+ list.textContent = "";
667
+ items.forEach((it, i) => {
668
+ const row = document.createElement("div");
669
+ row.className = "qrow" + (i === sel ? " on" : "");
670
+ row.addEventListener("click", () => { sel = i; drawPreview(); render(); });
671
+
672
+ const chk = document.createElement("input");
673
+ chk.type = "checkbox"; chk.checked = !!it.selected; chk.title = "Include in conversion";
674
+ chk.disabled = it.status === "error";
675
+ chk.addEventListener("click", ev => { ev.stopPropagation(); it.selected = chk.checked; render(); });
676
+ row.appendChild(chk);
677
+
678
+ if(it.thumbUrl){ const img = document.createElement("img"); img.className = "qthumb"; img.src = it.thumbUrl; row.appendChild(img); }
679
+ else { const ph = document.createElement("div"); ph.className = "qthumb ph"; ph.textContent = it.status === "error" ? "!" : "\u2026"; row.appendChild(ph); }
680
+
681
+ const meta = document.createElement("div"); meta.className = "qmeta";
682
+ const nm = document.createElement("div"); nm.className = "qname"; nm.textContent = it.name; nm.title = it.name;
683
+ const sub = document.createElement("div"); sub.className = "qsub";
684
+ sub.textContent = it.status === "error" ? ("error: " + it.err)
685
+ : it.w ? (it.w + "\u00d7" + it.h + " \u00b7 " + human(it.file.size)) : "\u2026";
686
+ meta.appendChild(nm); meta.appendChild(sub);
687
+ row.appendChild(meta);
688
+
689
+ const st = document.createElement("div");
690
+ st.className = "qstate " + (it.status === "done" ? "done" : it.status === "error" ? "err" : (it.status==="decoding"||it.status==="working") ? "work" : "");
691
+ st.textContent = it.status === "done" ? human(it.outSize)
692
+ : it.status === "error" ? "failed"
693
+ : it.status === "working" ? "\u2026"
694
+ : it.status === "decoding" ? "reading" : "ready";
695
+ row.appendChild(st);
696
+
697
+ const x = document.createElement("button");
698
+ x.className = "qx danger ghost"; x.innerHTML = "&times;"; x.title = "Remove";
699
+ x.addEventListener("click", ev => { ev.stopPropagation(); removeAt(i); });
700
+ row.appendChild(x);
701
+ list.appendChild(row);
702
+ });
703
+ }
704
+ function renderGrid(){
705
+ const g = $("imgrid");
706
+ if(!items.length){ g.innerHTML = '<div class="empty">No images yet.</div>'; return; }
707
+ g.textContent = "";
708
+ items.forEach((it, i) => {
709
+ const card = document.createElement("div");
710
+ card.className = "gcard" + (i === sel ? " on" : "");
711
+ card.addEventListener("click", () => { sel = i; if(view==="preview") drawPreview(); render(); });
712
+
713
+ const chk = document.createElement("input");
714
+ chk.type = "checkbox"; chk.className = "gchk"; chk.checked = !!it.selected;
715
+ chk.disabled = it.status === "error"; chk.title = "Include in conversion";
716
+ chk.addEventListener("click", ev => { ev.stopPropagation(); it.selected = chk.checked; render(); });
717
+ card.appendChild(chk);
718
+
719
+ const x = document.createElement("button");
720
+ x.className = "gx"; x.innerHTML = "&times;"; x.title = "Remove";
721
+ x.addEventListener("click", ev => { ev.stopPropagation(); removeAt(i); });
722
+ card.appendChild(x);
723
+
724
+ if(it.thumbUrl){ const img = document.createElement("img"); img.src = it.thumbUrl; card.appendChild(img); }
725
+ else { const ph = document.createElement("div"); ph.className = "gph"; ph.textContent = it.status === "error" ? "!" : "\u2026"; card.appendChild(ph); }
726
+
727
+ if(it.status !== "queued" && it.status !== "ready"){
728
+ const st = document.createElement("div");
729
+ st.className = "gstate " + (it.status === "done" ? "done" : it.status === "error" ? "err" : "work");
730
+ st.textContent = it.status === "done" ? human(it.outSize) : it.status === "error" ? "failed" : it.status === "decoding" ? "reading" : "\u2026";
731
+ card.appendChild(st);
732
+ }
733
+ const nm = document.createElement("div"); nm.className = "gname"; nm.textContent = it.name; nm.title = it.name;
734
+ card.appendChild(nm);
735
+ g.appendChild(card);
736
+ });
737
+ }
738
+ function removeAt(i){
739
+ if(exporting) return;
740
+ const it = items[i]; if(it && it.bitmap){ try{ it.bitmap.close && it.bitmap.close(); }catch(_){} }
741
+ items.splice(i, 1);
742
+ if(sel >= items.length) sel = items.length - 1;
743
+ render(); if(view==="preview") drawPreview();
744
+ }
745
+ $("clearAll").addEventListener("click", () => { if(exporting) return; items.forEach(it => it.bitmap && it.bitmap.close && it.bitmap.close()); items.length = 0; sel = -1; render(); drawPreview(); say($("status"),""); });
746
+ $("clearDone").addEventListener("click", () => {
747
+ if(exporting) return;
748
+ for(let i = items.length-1; i>=0; i--) if(items[i].status === "done") removeAt(i);
749
+ });
750
+ /* ======================= settings wiring ======================= */
751
+ function syncFmtUI(){
752
+ const lossy = isLossy(outFmt);
753
+ $("qualityRow").style.display = lossy ? "flex" : "none";
754
+ $("targetChk").style.display = lossy ? "flex" : "none";
755
+ $("targetRow").style.display = (lossy && $("targetOn").checked) ? "flex" : "none";
756
+ $("pngOpts").style.display = outFmt === "image/png" ? "block" : "none";
757
+ $("ditherChk").style.display = (outFmt === "image/png" && (+$("pngColors").value||0) > 0) ? "flex" : "none";
758
+ let hint = "";
759
+ if(outFmt === "image/png"){
760
+ hint = (+$("pngColors").value||0) > 0
761
+ ? "Reduced-colour PNG — far smaller files, still lossless within the palette. Best for graphics/screenshots; photos may posterise a little."
762
+ : "PNG is lossless — great for screenshots and graphics. For big photos, pick a colour count below to shrink, or use JPEG/WebP.";
763
+ }
764
+ else if(outFmt === "image/jpeg") hint = "JPEG is best for photos. No transparency — transparent areas become white.";
765
+ else if(outFmt === "image/webp") hint = "WebP gives smaller files than JPEG at similar quality, with transparency support.";
766
+ else if(outFmt === "image/avif") hint = support["image/avif"] === false ? "\u26a0 Your browser can\u2019t encode AVIF — try Chrome or Edge, or pick WebP." : "AVIF is the smallest for photos, but slower to encode. Chrome/Edge only.";
767
+ else if(outFmt === "application/pdf") hint = "Each ticked image becomes one PDF page (embedded as JPEG) in a single document.";
768
+ $("fmtHint").textContent = hint;
769
+ }
770
+ $("fmtSeg").addEventListener("click", async e => {
771
+ const b = e.target.closest("button"); if(!b || b.disabled) return;
772
+ document.querySelectorAll("#fmtSeg button").forEach(x => x.classList.toggle("on", x === b));
773
+ outFmt = b.dataset.fmt;
774
+ syncFmtUI();
775
+ drawPreview(); updateEstimate();
776
+ });
777
+ $("quality").addEventListener("input", () => { $("qualityL").textContent = $("quality").value; updateEstimate(); });
778
+ $("targetOn").addEventListener("change", () => { $("targetRow").style.display = ($("targetOn").checked && isLossy(outFmt)) ? "flex" : "none"; updateEstimate(); });
779
+ ["targetSize","targetUnit"].forEach(id => $(id).addEventListener("input", updateEstimate));
780
+ $("pngColors").addEventListener("change", () => { syncFmtUI(); updateEstimate(); });
781
+ $("pngDither").addEventListener("change", updateEstimate);
782
+ $("resizeMode").addEventListener("click", e => {
783
+ const b = e.target.closest("button"); if(!b) return;
784
+ document.querySelectorAll("#resizeMode button").forEach(x => x.classList.toggle("on", x === b));
785
+ const m = b.dataset.m;
786
+ $("pctRow").style.display = m === "pct" ? "flex" : "none";
787
+ $("pxRows").style.display = m === "px" ? "block" : "none";
788
+ $("maxRow").style.display = m === "max" ? "flex" : "none";
789
+ if(m === "px" && items[sel]){ const it = items[sel]; $("pxW").value = it.w; $("pxH").value = it.h; }
790
+ drawPreview(); updateEstimate();
791
+ });
792
+ ["pct","maxPreset"].forEach(id => $(id).addEventListener("input", () => { drawPreview(); updateEstimate(); }));
793
+ $("pxW").addEventListener("input", () => { if($("lockAr").checked && items[sel]){ const it = items[sel]; $("pxH").value = Math.round((+$("pxW").value||1) * it.h / it.w); } drawPreview(); updateEstimate(); });
794
+ $("pxH").addEventListener("input", () => { if($("lockAr").checked && items[sel]){ const it = items[sel]; $("pxW").value = Math.round((+$("pxH").value||1) * it.w / it.h); } drawPreview(); updateEstimate(); });
795
+ ["wmText","wmPos","wmSize","wmColor"].forEach(id => $(id).addEventListener("input", () => { drawPreview(); updateEstimate(); }));
796
+ $("wmOpacity").addEventListener("input", () => { $("wmOpacityL").textContent = $("wmOpacity").value + "%"; drawPreview(); });
797
+ $("nameMode").addEventListener("click", e => {
798
+ const b = e.target.closest("button"); if(!b) return;
799
+ document.querySelectorAll("#nameMode button").forEach(x => x.classList.toggle("on", x === b));
800
+ $("tplRows").style.display = b.dataset.m === "tpl" ? "block" : "none";
801
+ });
802
+ /* ======================= estimate ======================= */
803
+ let estTimer = 0;
804
+ function updateEstimate(){
805
+ const selN = selectedReady().length;
806
+ $("eCount").textContent = items.length ? (selN + " of " + items.length) : "\u2014";
807
+ $("eFmt").textContent = fmtLabel(outFmt) + (outFmt==="image/png" && (+$("pngColors").value||0)>0 ? " \u00b7 " + $("pngColors").value + "c" : "");
808
+ $("exportBtn").disabled = exporting || selN === 0;
809
+ const it = items[sel];
810
+ if(!it || !it.bitmap){ $("eDims").textContent = "\u2014"; $("eBytes").textContent = "\u2014"; return; }
811
+ const out = renderToCanvas(it.bitmap);
812
+ $("eDims").textContent = out.width + "\u00d7" + out.height;
813
+ clearTimeout(estTimer);
814
+ estTimer = setTimeout(async () => {
815
+ try{
816
+ const blob = await encodeCanvas(out, false);
817
+ $("eBytes").textContent = blob ? human(blob.size) : "\u2014";
818
+ }catch(e){ $("eBytes").textContent = "\u2014"; }
819
+ }, 250);
820
+ }
821
+ /* ======================= encoding ======================= */
822
+ function canvasToBlob(cv, mime, q){ return new Promise(res => cv.toBlob(res, mime, q)); }
823
+ async function encodeCanvas(cv, doTarget){
824
+ if(outFmt === "application/pdf") return makePdf([cv]);
825
+ if(outFmt === "image/png"){
826
+ const colors = +$("pngColors").value || 0;
827
+ if(colors > 0) return encodeReducedPNG(cv, colors, $("pngDither").checked);
828
+ return canvasToBlob(cv, "image/png");
829
+ }
830
+ const q = (+$("quality").value||82)/100;
831
+ if(doTarget && $("targetOn").checked){
832
+ const unit = +$("targetUnit").value, limit = (+$("targetSize").value||2) * unit;
833
+ return encodeToTarget(cv, limit);
834
+ }
835
+ return canvasToBlob(cv, outFmt, q);
836
+ }
837
+ async function encodeToTarget(cv, limit){
838
+ let lo = 0.05, hi = 0.98, best = null;
839
+ let blob = await canvasToBlob(cv, outFmt, hi);
840
+ if(blob && blob.size <= limit) return blob;
841
+ for(let i = 0; i < 8; i++){
842
+ const mid = (lo + hi) / 2;
843
+ blob = await canvasToBlob(cv, outFmt, mid);
844
+ if(!blob){ break; }
845
+ if(blob.size <= limit){ best = blob; lo = mid; } else { hi = mid; }
846
+ }
847
+ return best || await canvasToBlob(cv, outFmt, lo);
848
+ }
849
+ /* ---------- reduced-colour PNG encoder (median cut + zlib via CompressionStream) ---------- */
850
+ async function deflateZlib(u8){
851
+ if(typeof CompressionStream === "undefined") return null; // caller falls back to normal PNG
852
+ const cs = new CompressionStream("deflate"); // zlib wrapper (RFC1950) — what PNG needs
853
+ const w = cs.writable.getWriter(); w.write(u8); w.close();
854
+ const buf = await new Response(cs.readable).arrayBuffer();
855
+ return new Uint8Array(buf);
856
+ }
857
+ const _crcTable = (() => { const t = new Uint32Array(256);
858
+ 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; })();
859
+ function crc32(buf){ let c=0xFFFFFFFF; for(let i=0;i<buf.length;i++) c=_crcTable[(c^buf[i])&255]^(c>>>8); return (c^0xFFFFFFFF)>>>0; }
860
+ function u32be(n){ return new Uint8Array([(n>>>24)&255,(n>>>16)&255,(n>>>8)&255,n&255]); }
861
+ function pngChunk(type, data){
862
+ const t = new Uint8Array(4); for(let i=0;i<4;i++) t[i]=type.charCodeAt(i);
863
+ const body = new Uint8Array(t.length + data.length); body.set(t,0); body.set(data,t.length);
864
+ const out = new Uint8Array(4 + body.length + 4);
865
+ out.set(u32be(data.length),0); out.set(body,4); out.set(u32be(crc32(body)), 4+body.length);
866
+ return out;
867
+ }
868
+ /* median-cut palette from RGBA (5-bit histogram) */
869
+ const _hist = new Int32Array(32768), _rs = new Int32Array(32768), _gs = new Int32Array(32768), _bs = new Int32Array(32768);
870
+ const _binArr = new Int32Array(32768);
871
+ function buildPalette(data, maxColors){
872
+ _hist.fill(0); _rs.fill(0); _gs.fill(0); _bs.fill(0);
873
+ for(let i=0;i<data.length;i+=4){
874
+ const r=data[i],g=data[i+1],b=data[i+2];
875
+ const k=((r>>3)<<10)|((g>>3)<<5)|(b>>3);
876
+ _hist[k]++; _rs[k]+=r; _gs[k]+=g; _bs[k]+=b;
877
+ }
878
+ let m=0; for(let k=0;k<32768;k++) if(_hist[k]) _binArr[m++]=k;
879
+ const stat=(s,e)=>{
880
+ let rmin=31,rmax=0,gmin=31,gmax=0,bmin=31,bmax=0;
881
+ for(let i=s;i<e;i++){ const k=_binArr[i], r=(k>>10)&31,g=(k>>5)&31,b=k&31;
882
+ if(r<rmin)rmin=r; if(r>rmax)rmax=r; if(g<gmin)gmin=g; if(g>gmax)gmax=g; if(b<bmin)bmin=b; if(b>bmax)bmax=b; }
883
+ const dr=rmax-rmin,dg=gmax-gmin,db=bmax-bmin; let chan=0,ext=dr;
884
+ if(dg>ext){chan=1;ext=dg;} if(db>ext){chan=2;ext=db;}
885
+ return {s,e,chan,ext};
886
+ };
887
+ let boxes=[stat(0,m)];
888
+ while(boxes.length<maxColors){
889
+ let bi=-1,best=0;
890
+ for(let i=0;i<boxes.length;i++){ const B=boxes[i]; if(B.e-B.s<2) continue; if(B.ext>best){best=B.ext;bi=i;} }
891
+ if(bi<0) break;
892
+ const B=boxes[bi], shift=B.chan===0?10:B.chan===1?5:0;
893
+ const sub=Array.from(_binArr.subarray(B.s,B.e));
894
+ sub.sort((x,y)=>(((x>>shift)&31)-((y>>shift)&31)));
895
+ _binArr.set(sub,B.s);
896
+ let total=0; for(let i=B.s;i<B.e;i++) total+=_hist[_binArr[i]];
897
+ let acc=0,mid=B.s+1;
898
+ for(let i=B.s;i<B.e;i++){ acc+=_hist[_binArr[i]]; if(acc*2>=total){ mid=i+1; break; } }
899
+ if(mid<=B.s) mid=B.s+1; if(mid>=B.e) mid=B.e-1;
900
+ boxes.splice(bi,1,stat(B.s,mid),stat(mid,B.e));
901
+ }
902
+ const pal=new Uint8Array(768); let count=0;
903
+ for(let i=0;i<boxes.length;i++){
904
+ const B=boxes[i]; let c=0,R=0,G=0,Bl=0;
905
+ for(let x=B.s;x<B.e;x++){ const k=_binArr[x]; c+=_hist[k]; R+=_rs[k]; G+=_gs[k]; Bl+=_bs[k]; }
906
+ if(c){ pal[i*3]=Math.round(R/c); pal[i*3+1]=Math.round(G/c); pal[i*3+2]=Math.round(Bl/c); count++; }
907
+ }
908
+ return { pal, count: Math.max(1,count) };
909
+ }
910
+ const _near = new Int16Array(32768);
911
+ function mapToPalette(data, w, h, pal, palCount, dither){
912
+ const idx=new Uint8Array(w*h); _near.fill(-1);
913
+ const nearest=(r,g,b)=>{
914
+ const key=((r>>3)<<10)|((g>>3)<<5)|(b>>3); const hit=_near[key]; if(hit>=0) return hit;
915
+ let bd=Infinity,bi=0;
916
+ for(let i=0;i<palCount;i++){ const dr=r-pal[i*3],dg=g-pal[i*3+1],db=b-pal[i*3+2]; const d=dr*dr*3+dg*dg*6+db*db; if(d<bd){bd=d;bi=i;} }
917
+ _near[key]=bi; return bi;
918
+ };
919
+ if(!dither){ for(let p=0,q=0;q<idx.length;q++,p+=4) idx[q]=nearest(data[p],data[p+1],data[p+2]); return idx; }
920
+ const buf=new Float32Array(w*h*3);
921
+ for(let p=0,q=0;q<w*h;q++,p+=4){ buf[q*3]=data[p]; buf[q*3+1]=data[p+1]; buf[q*3+2]=data[p+2]; }
922
+ const cl=v=>v<0?0:(v>255?255:v);
923
+ for(let y=0;y<h;y++) for(let x=0;x<w;x++){
924
+ const q=y*w+x,o=q*3; const r=cl(buf[o]),g=cl(buf[o+1]),b=cl(buf[o+2]);
925
+ const i=nearest(r|0,g|0,b|0); idx[q]=i;
926
+ const er=(r-pal[i*3])*0.75,eg=(g-pal[i*3+1])*0.75,eb=(b-pal[i*3+2])*0.75;
927
+ if(x+1<w){ const oo=(q+1)*3; buf[oo]+=er*0.4375; buf[oo+1]+=eg*0.4375; buf[oo+2]+=eb*0.4375; }
928
+ if(y+1<h){
929
+ if(x>0){ const oo=(q+w-1)*3; buf[oo]+=er*0.1875; buf[oo+1]+=eg*0.1875; buf[oo+2]+=eb*0.1875; }
930
+ { const oo=(q+w)*3; buf[oo]+=er*0.3125; buf[oo+1]+=eg*0.3125; buf[oo+2]+=eb*0.3125; }
931
+ if(x+1<w){ const oo=(q+w+1)*3; buf[oo]+=er*0.0625; buf[oo+1]+=eg*0.0625; buf[oo+2]+=eb*0.0625; }
932
+ }
933
+ }
934
+ return idx;
935
+ }
936
+ async function encodeReducedPNG(cv, colors, dither){
937
+ const ctx = cv.getContext("2d");
938
+ const { data, width:w, height:h } = ctx.getImageData(0,0,cv.width,cv.height);
939
+ const { pal, count } = buildPalette(data, colors);
940
+ const idx = mapToPalette(data, w, h, pal, count, dither);
941
+ const raw = new Uint8Array(h*(w+1));
942
+ for(let y=0;y<h;y++){ raw[y*(w+1)] = 0; raw.set(idx.subarray(y*w, y*w+w), y*(w+1)+1); }
943
+ const idat = await deflateZlib(raw);
944
+ if(!idat) return canvasToBlob(cv, "image/png"); // no CompressionStream → normal PNG
945
+ const sig = new Uint8Array([137,80,78,71,13,10,26,10]);
946
+ const ihdr = new Uint8Array(13);
947
+ ihdr.set(u32be(w),0); ihdr.set(u32be(h),4); ihdr[8]=8; ihdr[9]=3; ihdr[10]=0; ihdr[11]=0; ihdr[12]=0;
948
+ const plte = pal.subarray(0, count*3);
949
+ const chunks = [ sig, pngChunk("IHDR",ihdr), pngChunk("PLTE",plte), pngChunk("IDAT",idat), pngChunk("IEND", new Uint8Array(0)) ];
950
+ let total=0; chunks.forEach(c=>total+=c.length);
951
+ const out=new Uint8Array(total); let off=0; chunks.forEach(c=>{ out.set(c,off); off+=c.length; });
952
+ return new Blob([out], { type:"image/png" });
953
+ }
954
+ /* ---------- PDF: one page per image, JPEG embedded (DCTDecode) ---------- */
955
+ async function makePdf(canvases){
956
+ const q = clamp((+$("quality").value||82)/100, 0.4, 0.95);
957
+ const bytes = [];
958
+ const push = s => { for(let i=0;i<s.length;i++) bytes.push(s.charCodeAt(i) & 255); };
959
+ const pushBytes = arr => { for(let i=0;i<arr.length;i++) bytes.push(arr[i]); };
960
+ const imgDatas = [];
961
+ for(const cv of canvases){ const b = await canvasToBlob(cv, "image/jpeg", q); imgDatas.push(new Uint8Array(await b.arrayBuffer())); }
962
+ const N = canvases.length;
963
+ const offsets = [];
964
+ const start = () => offsets.push(bytes.length);
965
+ push("%PDF-1.4\n%\xE2\xE3\xCF\xD3\n"); // header (this was the bug — never written before)
966
+ start(); push("1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
967
+ const kids = []; for(let i=0;i<N;i++) kids.push((3 + i*3) + " 0 R");
968
+ start(); push("2 0 obj\n<< /Type /Pages /Count " + N + " /Kids [ " + kids.join(" ") + " ] >>\nendobj\n");
969
+ for(let i=0;i<N;i++){
970
+ const cv = canvases[i], w = cv.width, h = cv.height;
971
+ const pageObj = 3 + i*3, contentObj = pageObj + 1, imgObj = pageObj + 2;
972
+ start();
973
+ push(pageObj + " 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 " + w + " " + h + "] " +
974
+ "/Resources << /XObject << /Im0 " + imgObj + " 0 R >> >> /Contents " + contentObj + " 0 R >>\nendobj\n");
975
+ const content = "q\n" + w + " 0 0 " + h + " 0 0 cm\n/Im0 Do\nQ\n";
976
+ start();
977
+ push(contentObj + " 0 obj\n<< /Length " + content.length + " >>\nstream\n" + content + "endstream\nendobj\n");
978
+ const data = imgDatas[i];
979
+ start();
980
+ push(imgObj + " 0 obj\n<< /Type /XObject /Subtype /Image /Width " + w + " /Height " + h +
981
+ " /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length " + data.length + " >>\nstream\n");
982
+ pushBytes(data);
983
+ push("\nendstream\nendobj\n");
984
+ }
985
+ const xrefPos = bytes.length, total = 2 + N*3;
986
+ push("xref\n0 " + (total+1) + "\n");
987
+ push("0000000000 65535 f \n");
988
+ for(const off of offsets){ push(String(off).padStart(10,"0") + " 00000 n \n"); }
989
+ push("trailer\n<< /Size " + (total+1) + " /Root 1 0 R >>\nstartxref\n" + xrefPos + "\n%%EOF");
990
+ return new Blob([new Uint8Array(bytes)], { type: "application/pdf" });
991
+ }
992
+ /* ======================= naming ======================= */
993
+ function outNameFor(it, i, w, h){
994
+ const mode = document.querySelector("#nameMode button.on").dataset.m;
995
+ const ext = extFor(outFmt);
996
+ const num = (+$("startN").value||1) + i;
997
+ if(mode === "keep") return safeName(baseName(it.name), "image") + "." + ext;
998
+ const tpl = ($("tpl").value || "IMG_{n}")
999
+ .replace(/\{n\}/g, String(num).padStart(3,"0"))
1000
+ .replace(/\{name\}/g, baseName(it.name))
1001
+ .replace(/\{date\}/g, todayStr())
1002
+ .replace(/\{w\}/g, w).replace(/\{h\}/g, h);
1003
+ return safeName(($("prefix").value||"") + tpl + ($("suffix").value||""), "image") + "." + ext;
1004
+ }
1005
+ /* ======================= save helpers ======================= */
1006
+ function download(blob, name){
1007
+ const a = document.createElement("a");
1008
+ a.href = URL.createObjectURL(blob); a.download = name; a.style.display = "none";
1009
+ document.body.appendChild(a); a.click();
1010
+ setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 12000);
1011
+ }
1012
+ /* ======================= export ======================= */
1013
+ $("stopBtn").addEventListener("click", () => { cancel = true; say($("status"), "Stopping\u2026"); });
1014
+ $("exportBtn").addEventListener("click", async () => {
1015
+ if(exporting) return;
1016
+ const ready = selectedReady();
1017
+ if(!ready.length){ say($("status"), "Tick at least one readable image first.", "err"); return; }
1018
+ if(outFmt === "image/avif" && support["image/avif"] === false){
1019
+ say($("status"), "This browser can\u2019t encode AVIF. Pick WebP or JPEG, or use Chrome/Edge.", "err"); return;
1020
+ }
1021
+ if(outFmt === "application/pdf") return exportPdf(ready);
1022
+
1023
+ exporting = true; cancel = false; lock(true);
1024
+ $("bar").style.display = "block"; $("barI").style.width = "0%";
1025
+ say($("status"), "Converting\u2026");
1026
+ let dirHandle = null;
1027
+ if(ready.length > 1 && window.showDirectoryPicker){
1028
+ try{ dirHandle = await window.showDirectoryPicker({ id:"pocket-img", mode:"readwrite", startIn:"pictures" }); }
1029
+ catch(e){ if(e && e.name === "AbortError"){ exporting=false; lock(false); $("bar").style.display="none"; say($("status"),"Export cancelled \u2014 no folder chosen."); return; } dirHandle = null; }
1030
+ }
1031
+ let done = 0, failed = 0, i = 0;
1032
+ for(const it of ready){
1033
+ if(cancel) break;
1034
+ it.status = "working"; render();
1035
+ try{
1036
+ const cv = renderToCanvas(it.bitmap);
1037
+ const blob = await encodeCanvas(cv, true);
1038
+ if(!blob || !blob.size) throw new Error("empty output");
1039
+ const name = outNameFor(it, i, cv.width, cv.height);
1040
+ it.outName = name; it.outSize = blob.size;
1041
+ if(dirHandle){
1042
+ const fh = await dirHandle.getFileHandle(name, { create: true });
1043
+ const ws = await fh.createWritable(); await ws.write(blob); await ws.close();
1044
+ }else{
1045
+ download(blob, name);
1046
+ if(ready.length > 1) await wait(300);
1047
+ }
1048
+ it.status = "done"; done++;
1049
+ }catch(e){ it.status = "error"; it.err = e.message || "convert failed"; failed++; }
1050
+ i++;
1051
+ $("barI").style.width = ((done+failed)/ready.length*100).toFixed(1) + "%";
1052
+ render();
1053
+ await wait(0);
1054
+ }
1055
+ exporting = false; lock(false);
1056
+ $("stopBtn").style.display = "none"; $("bar").style.display = "none";
1057
+ const where = dirHandle ? " to \u201c" + dirHandle.name + "\u201d" : (ready.length>1 ? " to your Downloads folder" : "");
1058
+ say($("status"), cancel ? ("Stopped \u2014 " + done + " saved.") :
1059
+ ("Done \u2014 " + done + " saved" + where + (failed ? " \u00b7 " + failed + " failed" : "") + "."), failed ? "err" : "ok");
1060
+ });
1061
+ async function exportPdf(ready){
1062
+ exporting = true; cancel = false; lock(true);
1063
+ $("bar").style.display = "block"; $("barI").style.width = "0%";
1064
+ say($("status"), "Building PDF\u2026");
1065
+ try{
1066
+ const canvases = [];
1067
+ for(let k=0;k<ready.length;k++){
1068
+ if(cancel) break;
1069
+ const cv = renderToCanvas(ready[k].bitmap);
1070
+ const c2 = document.createElement("canvas"); c2.width = cv.width; c2.height = cv.height;
1071
+ c2.getContext("2d").drawImage(cv, 0, 0);
1072
+ canvases.push(c2);
1073
+ $("barI").style.width = ((k+1)/ready.length*80).toFixed(1) + "%";
1074
+ say($("status"), "Rendering page " + (k+1) + " of " + ready.length + "\u2026");
1075
+ await wait(0);
1076
+ }
1077
+ if(cancel){ say($("status"), "Stopped."); }
1078
+ else{
1079
+ $("barI").style.width = "92%";
1080
+ const blob = await makePdf(canvases);
1081
+ const name = safeName(($("prefix").value||"") + "images_" + todayStr(), "images") + ".pdf";
1082
+ download(blob, name);
1083
+ ready.forEach(it => { it.status = "done"; it.outSize = Math.round(blob.size/ready.length); });
1084
+ say($("status"), "Saved " + name + " (" + human(blob.size) + ", " + ready.length + " page" + (ready.length===1?"":"s") + ").", "ok");
1085
+ }
1086
+ }catch(e){ say($("status"), e.message || "PDF failed.", "err"); }
1087
+ finally{ exporting = false; lock(false); $("stopBtn").style.display = "none"; $("bar").style.display = "none"; render(); }
1088
+ }
1089
+ function lock(on){
1090
+ $("exportBtn").disabled = on;
1091
+ $("stopBtn").style.display = on ? "block" : "none";
1092
+ ["clearAll","clearDone","pickFiles","pickFolder","selAll","selNone"].forEach(id => $(id).disabled = on);
1093
+ }
1094
+ /* ======================= keyboard ======================= */
1095
+ document.addEventListener("keydown", e => {
1096
+ const tag = (e.target.tagName||"").toLowerCase();
1097
+ if(tag === "input" || tag === "select" || tag === "textarea") return;
1098
+ if(e.metaKey || e.ctrlKey || e.altKey) return;
1099
+ if(exporting) return;
1100
+ if(e.key === "ArrowLeft"){ e.preventDefault(); if(sel > 0){ sel--; drawPreview(); render(); } }
1101
+ else if(e.key === "ArrowRight"){ e.preventDefault(); if(sel < items.length-1){ sel++; drawPreview(); render(); } }
1102
+ else if(e.key === " "){ e.preventDefault(); const it=items[sel]; if(it && it.status!=="error"){ it.selected=!it.selected; render(); } }
1103
+ else if(e.key === "r" || e.key === "R"){ e.preventDefault(); rot = (rot+90)%360; drawPreview(); updateEstimate(); }
1104
+ else if(e.key === "Delete" || e.key === "Backspace"){ if(sel>=0){ e.preventDefault(); removeAt(sel); } }
1105
+ });
1106
+ /* ======================= startup ======================= */
1107
+ (async function init(){
1108
+ support["image/webp"] = await supportsMime("image/webp");
1109
+ support["image/avif"] = await supportsMime("image/avif");
1110
+ if(!support["image/avif"]){
1111
+ const b = document.querySelector('#fmtSeg button[data-fmt="image/avif"]');
1112
+ if(b){ b.disabled = true; b.title = "Not supported by this browser"; b.style.opacity = ".4"; b.style.cursor = "not-allowed"; }
1113
+ }
1114
+ syncFmtUI();
1115
+ drawPreview(); render();
1116
+ })();
1117
+ })();
1118
+ </script>
1119
+ </body>
1120
+ </html>
Pocket_Video_Combiner_v1_0.html ADDED
@@ -0,0 +1,1413 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Pocket Video Combiner</title>
7
+ <style>
8
+ :root{
9
+ --bg:#0e1116; --panel:#161b22; --panel2:#1c232d; --line:#2a323d;
10
+ --txt:#e6edf3; --dim:#8b98a5; --accent:#4cc2ff; --accent2:#2a9fd6;
11
+ --ok:#3fb950; --warn:#d29922; --danger:#f85149; --red:#7f1018;
12
+ --r:10px;
13
+ }
14
+ *{box-sizing:border-box}
15
+ html,body{margin:0;min-height:100%}
16
+ body{
17
+ background:var(--bg); color:var(--txt);
18
+ font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
19
+ -webkit-font-smoothing:antialiased;
20
+ }
21
+ .wrap{max-width:1180px;margin:0 auto;padding:20px 20px 60px}
22
+ header{display:flex;align-items:center;gap:12px;margin-bottom:14px}
23
+ header h1{font-size:17px;margin:0;font-weight:600;letter-spacing:.2px}
24
+ header .sub{color:var(--dim);font-size:12.5px}
25
+ header .verwrap{display:flex;flex-direction:column;align-items:center;gap:3px}
26
+ header .ver{color:var(--dim);font-size:12px;font-variant-numeric:tabular-nums;
27
+ border:1px solid var(--line);border-radius:99px;padding:3px 10px;background:var(--panel)}
28
+ header .by{color:var(--dim);font-size:10px;letter-spacing:.3px;opacity:.8}
29
+ .spacer{flex:1}
30
+
31
+ button{
32
+ font:inherit;color:var(--txt);background:var(--panel2);border:1px solid var(--line);
33
+ padding:7px 13px;border-radius:8px;cursor:pointer;transition:.12s;
34
+ }
35
+ button:hover:not(:disabled){background:#242d39;border-color:#3a4553}
36
+ button:disabled{opacity:.4;cursor:not-allowed}
37
+ button:focus-visible,input:focus-visible,select:focus-visible,a:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
38
+ button.primary{background:var(--accent);border-color:var(--accent);color:#04121c;font-weight:600}
39
+ button.primary:hover:not(:disabled){background:#6bcfff}
40
+ button.ghost{background:transparent}
41
+ button.danger{color:#ffb4ae;border-color:#5a2b2b}
42
+ button.danger:hover:not(:disabled){background:#341c1c;border-color:#7a3a3a}
43
+ .icobtn{padding:7px 11px;font-variant-numeric:tabular-nums}
44
+ .small{padding:5px 10px;font-size:12.5px}
45
+
46
+ /* ---------- mode tabs (same browser-tab idiom as the trimmer) ---------- */
47
+ .modetabs{display:flex;gap:3px;margin:0 0 -1px;padding:0;border-bottom:1px solid var(--line);position:relative;z-index:1}
48
+ .modetabs button{flex:0 0 auto;min-width:180px;padding:9px 16px;font-size:13px;color:var(--dim);
49
+ background:transparent;border:1px solid transparent;border-bottom:none;
50
+ border-radius:9px 9px 0 0;margin-bottom:-1px;position:relative}
51
+ .modetabs button:hover:not(.on){color:var(--txt);background:rgba(255,255,255,.03)}
52
+ .modetabs button.on{background:var(--panel);border-color:var(--line);border-top:2px solid var(--accent);
53
+ color:#bfe9ff;font-weight:600;padding-top:8px}
54
+ .modetabs button.on::after{content:"";position:absolute;left:0;right:0;bottom:0;height:1px;background:var(--panel)}
55
+ .modepanel{border:1px solid var(--line);border-top:none;border-radius:0 0 var(--r) var(--r);
56
+ background:var(--panel);padding:16px;margin-bottom:18px}
57
+ @media(max-width:620px){
58
+ .modetabs button{flex:1 1 0;min-width:0;padding:9px 6px;font-size:12px}
59
+ .modepanel{padding:12px}
60
+ }
61
+
62
+ .grid{display:grid;grid-template-columns:1fr 330px;gap:18px;align-items:start}
63
+ @media(max-width:940px){.grid{grid-template-columns:1fr}}
64
+
65
+ .card{background:#11161d;border:1px solid var(--line);border-radius:var(--r);padding:14px}
66
+ .card h3,.panelbox h3{margin:0 0 12px;font-size:12px;text-transform:uppercase;letter-spacing:.7px;color:var(--dim);font-weight:600}
67
+ .panelbox{background:#11161d;border:1px solid var(--line);border-radius:10px;padding:10px 12px 12px;margin-bottom:14px}
68
+ .card .panelbox{background:#0d1219}
69
+ .panelbox:last-child{margin-bottom:0}
70
+
71
+ h3.collap{display:flex;align-items:center;justify-content:space-between;cursor:pointer;user-select:none;
72
+ margin:-10px -12px 10px;padding:9px 12px;border-radius:9px 9px 0 0;background:#171d26;
73
+ border-bottom:1px solid var(--line);color:var(--txt)}
74
+ h3.collap:hover{background:#1c232d}
75
+ h3.collap.shut{margin-bottom:-12px;border-bottom:none;border-radius:9px}
76
+ h3.collap .chev{font-size:11px;color:var(--accent);transition:transform .15s}
77
+ h3.collap.shut .chev{transform:rotate(-90deg)}
78
+ .collapbody.shut{display:none}
79
+
80
+ /* ---------- drop zones ---------- */
81
+ .dropgrid{display:grid;grid-template-columns:1fr auto 1fr;gap:12px;margin-bottom:14px;align-items:center}
82
+ @media(max-width:700px){.dropgrid{grid-template-columns:1fr}}
83
+ .drop{border:2px dashed var(--line);border-radius:14px;padding:14px 18px;text-align:center;background:var(--panel);
84
+ cursor:pointer;transition:.15s;min-height:142px;display:flex;align-items:center;justify-content:center;gap:14px;position:relative}
85
+ .drop:hover,.drop.over{border-color:var(--accent);background:#12212b}
86
+ .drop.filled{border-style:solid}
87
+ .drop .big{font-size:34px;line-height:1;opacity:.65}
88
+ .drop h2{margin:0 0 4px;font-size:15px;font-weight:600}
89
+ .drop p{margin:0;color:var(--dim);font-size:12.5px;word-break:break-word}
90
+ .dropText{text-align:left;min-width:0}
91
+ .clearx{position:absolute;top:6px;right:6px;padding:1px 8px;font-size:13px;line-height:1.5;border-radius:6px;
92
+ background:rgba(0,0,0,.45);color:var(--dim);display:none}
93
+ .clearx:hover{color:#ffb4ae;border-color:#5a2b2b;background:#341c1c}
94
+ .drop.filled .clearx{display:block}
95
+ .thumb{width:auto;height:auto;max-width:180px;max-height:110px;border-radius:8px;border:1px solid var(--line);
96
+ background:#07090c;display:none;flex:none}
97
+ #swapBtn{padding:9px 11px;font-size:15px;line-height:1}
98
+ @media(max-width:700px){#swapBtn{width:100%}}
99
+
100
+ #joinDrop{border:2px dashed var(--line);border-radius:14px;padding:26px 20px;text-align:center;background:var(--panel);
101
+ cursor:pointer;transition:.15s;margin-bottom:14px}
102
+ #joinDrop:hover,#joinDrop.over{border-color:var(--accent);background:#12212b}
103
+ #joinDrop .big{font-size:34px;line-height:1;opacity:.65;margin-bottom:8px}
104
+ #joinDrop h2{margin:0 0 5px;font-size:16px;font-weight:600}
105
+ #joinDrop p{margin:0;color:var(--dim);font-size:12.5px}
106
+
107
+ /* ---------- stage ---------- */
108
+ .stage{background:#000;border-radius:var(--r);overflow:hidden;display:flex;align-items:center;justify-content:center;
109
+ position:relative;border:1px solid var(--line);min-height:240px}
110
+ .stage canvas{max-width:100%;max-height:58vh;width:auto;height:auto;display:block;background:#000}
111
+ .badge{position:absolute;top:10px;left:10px;z-index:3;background:rgba(0,0,0,.66);backdrop-filter:blur(4px);
112
+ padding:4px 9px;border-radius:6px;font-size:11.5px;color:#cfe;letter-spacing:.3px}
113
+ .transport{display:flex;align-items:center;gap:8px;margin-top:12px;flex-wrap:wrap}
114
+ .tc{font-variant-numeric:tabular-nums;font-size:13px;color:var(--dim)}
115
+ .tc b{color:var(--txt);font-weight:600}
116
+ input[type=range]{width:100%;accent-color:var(--accent)}
117
+
118
+ /* ---------- clip list (join) ---------- */
119
+ .clips-head{display:flex;align-items:center;gap:10px;margin:0 0 8px}
120
+ .clips-head h3{margin:0;font-size:12px;text-transform:uppercase;letter-spacing:.7px;color:var(--dim);font-weight:600}
121
+ .cliplist{border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--panel)}
122
+ .crow{display:flex;align-items:center;gap:10px;padding:7px 10px;border-top:1px solid var(--line);font-size:12.5px}
123
+ .crow:first-child{border-top:none}
124
+ .crow:hover{background:var(--panel2)}
125
+ .crow.on{background:#12212b;box-shadow:inset 2px 0 0 var(--accent)}
126
+ .cnum{width:20px;color:var(--dim);font-variant-numeric:tabular-nums;flex:none;text-align:right}
127
+ .cthumb{border-radius:3px;border:1px solid var(--line);display:block;flex:none;background:#07090c}
128
+ .cname{flex:1 1 90px;min-width:60px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
129
+ .cmeta{font-variant-numeric:tabular-nums;color:var(--dim);flex:none}
130
+ .cdur{font-variant-numeric:tabular-nums;color:var(--accent);font-weight:600;width:64px;text-align:right;flex:none}
131
+ .cbtns{display:flex;gap:4px;flex:none}
132
+ .cbtns button{padding:3px 8px;font-size:11.5px;border-radius:6px}
133
+ @media(max-width:760px){.cmeta{display:none}}
134
+ .empty{color:var(--dim);font-size:12.5px;text-align:center;padding:18px 10px}
135
+
136
+ /* ---------- settings ---------- */
137
+ .row{display:flex;align-items:center;gap:8px;margin-bottom:10px}
138
+ .row label{flex:0 0 96px;color:var(--dim);font-size:12.5px}
139
+ .row:last-child{margin-bottom:0}
140
+ input[type=text],input[type=number],select{
141
+ font:inherit;background:var(--panel2);color:var(--txt);border:1px solid var(--line);
142
+ border-radius:7px;padding:6px 8px;width:100%;min-width:0;
143
+ }
144
+ input[type=color]{padding:2px;height:31px;width:44px;flex:none;background:var(--panel2);
145
+ border:1px solid var(--line);border-radius:7px;cursor:pointer}
146
+ input:focus,select:focus{outline:none;border-color:var(--accent)}
147
+ .seg{display:flex;gap:5px;flex-wrap:wrap;margin-bottom:10px}
148
+ .seg button{padding:5px 9px;font-size:12.5px;border-radius:7px;flex:1 1 auto}
149
+ .seg button.on{background:var(--accent);border-color:var(--accent);color:#04121c;font-weight:600}
150
+ .hint{color:var(--dim);font-size:11.5px;margin:-2px 0 10px;line-height:1.45}
151
+ .chk{display:flex;align-items:center;gap:7px;color:var(--dim);font-size:12.5px;margin-bottom:10px;cursor:pointer}
152
+ .chk input{width:auto;flex:none}
153
+ .est{background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:9px 11px;font-size:12.5px;margin-bottom:12px}
154
+ .est div{display:flex;justify-content:space-between;margin:2px 0;gap:10px}
155
+ .est span{color:var(--dim)}
156
+ .est b{font-variant-numeric:tabular-nums;font-weight:600;text-align:right}
157
+ .expBtn{width:100%;padding:11px}
158
+ .stopBtn{width:100%;padding:9px;margin-top:8px;display:none}
159
+ .bar{height:6px;background:var(--panel2);border-radius:99px;overflow:hidden;margin-top:10px;display:none}
160
+ .bar i{display:block;height:100%;width:0;background:var(--accent);transition:width .2s}
161
+ .status{font-size:12px;color:var(--dim);margin-top:8px;text-align:center;min-height:16px;line-height:1.4}
162
+ .status.err{color:#ffb4ae}
163
+ .status.ok{color:#9ae6a8}
164
+ .note{font-size:11.5px;color:var(--warn);margin-top:8px;line-height:1.4}
165
+ .keys{color:var(--dim);font-size:11.5px;margin-top:10px;line-height:1.9}
166
+ .kbd{display:inline-block;background:var(--panel2);border:1px solid var(--line);border-bottom-width:2px;
167
+ border-radius:5px;padding:0 5px;font-size:11px;font-family:ui-monospace,Menlo,Consolas,monospace}
168
+ .hiddenmedia{position:fixed;left:-10000px;top:-10000px;width:2px;height:2px;opacity:.01;pointer-events:none}
169
+ @media(prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
170
+ </style>
171
+ </head>
172
+ <body>
173
+ <div class="wrap">
174
+
175
+ <header>
176
+ <h1>Pocket Video Combiner</h1>
177
+ <span class="sub">side by side &middot; join end to end</span>
178
+ <span class="spacer"></span>
179
+ <span class="verwrap"><span class="ver">v1.0</span><span class="by">by C_Nugget</span></span>
180
+ </header>
181
+
182
+ <nav class="modetabs" id="modeTabs" role="tablist">
183
+ <button data-mode="sbs" class="on" role="tab" aria-selected="true">Side by side</button>
184
+ <button data-mode="join" role="tab" aria-selected="false">Join end to end</button>
185
+ </nav>
186
+
187
+ <!-- ============================ SIDE BY SIDE ============================ -->
188
+ <div class="modepanel" id="panelSbs" role="tabpanel">
189
+ <div class="dropgrid">
190
+ <div class="drop" id="drop1">
191
+ <button class="clearx" id="clear1" title="Remove this file">&times;</button>
192
+ <canvas class="thumb" id="thumb1"></canvas>
193
+ <div class="big" id="big1">&#9634;</div>
194
+ <div class="dropText"><h2>Video or image 1</h2><p>drop here, or click to choose</p></div>
195
+ <input type="file" id="file1" accept="video/*,image/*" hidden>
196
+ </div>
197
+ <button id="swapBtn" class="ghost" title="Swap the two files">&#8646;</button>
198
+ <div class="drop" id="drop2">
199
+ <button class="clearx" id="clear2" title="Remove this file">&times;</button>
200
+ <canvas class="thumb" id="thumb2"></canvas>
201
+ <div class="big" id="big2">&#9634;</div>
202
+ <div class="dropText"><h2>Video or image 2</h2><p>drop here, or click to choose</p></div>
203
+ <input type="file" id="file2" accept="video/*,image/*" hidden>
204
+ </div>
205
+ </div>
206
+
207
+ <div class="grid">
208
+ <div>
209
+ <div class="stage">
210
+ <canvas id="sbsCanvas"></canvas>
211
+ <div class="badge" id="sbsBadge">Load two files to start</div>
212
+ </div>
213
+ <div class="transport">
214
+ <button id="sbsPlay" class="icobtn" title="Play or pause (Space)">&#9654;</button>
215
+ <button id="sbsPrevF" class="icobtn ghost" title="Previous frame (Left arrow)">&#8676;</button>
216
+ <button id="sbsNextF" class="icobtn ghost" title="Next frame (Right arrow)">&#8677;</button>
217
+ <button id="sbsRestart" class="ghost small">Restart</button>
218
+ <span class="tc"><b id="sbsCur">0:00.00</b> / <span id="sbsTot">0:00.00</span></span>
219
+ <input type="range" id="sbsSeek" min="0" max="1000" value="0" step="1" style="flex:1 1 220px">
220
+ </div>
221
+
222
+ <div class="panelbox" style="margin-top:14px">
223
+ <h3 class="collap" data-target="labelsBody">Labels drawn onto the video <span class="chev">&#9660;</span></h3>
224
+ <div class="collapbody" id="labelsBody">
225
+ <div class="row"><label>Label 1</label><input type="text" id="txt1" placeholder="Optional, e.g. Before"></div>
226
+ <div class="row"><label>Label 2</label><input type="text" id="txt2" placeholder="Optional, e.g. After"></div>
227
+ <div class="row">
228
+ <label>Position</label>
229
+ <select id="labelPos">
230
+ <option value="tl" selected>Top left</option>
231
+ <option value="tr">Top right</option>
232
+ <option value="bl">Bottom left</option>
233
+ <option value="br">Bottom right</option>
234
+ <option value="ct">Top centre</option>
235
+ <option value="cb">Bottom centre</option>
236
+ </select>
237
+ </div>
238
+ <div class="row">
239
+ <label>Size</label>
240
+ <select id="labelSize"><option value="0.8">Small</option><option value="1" selected>Medium</option><option value="1.4">Large</option></select>
241
+ <input type="color" id="labelBg" value="#7f1018" title="Label background colour">
242
+ </div>
243
+ <div class="hint">Labels are burned into the export as white text on the colour above.</div>
244
+ </div>
245
+ </div>
246
+ </div>
247
+
248
+ <div class="card">
249
+ <div class="panelbox">
250
+ <h3>Layout</h3>
251
+ <div class="seg" id="layoutSeg">
252
+ <button data-layout="lr" class="on">Left / right</button>
253
+ <button data-layout="tb">Top / bottom</button>
254
+ </div>
255
+ <div class="row"><label>Framing</label>
256
+ <select id="sbsFit"><option value="fit" selected>Fit (show all, black bars)</option><option value="fill">Fill (crop to match)</option></select>
257
+ </div>
258
+ <div class="row"><label>Divider</label>
259
+ <select id="sbsGap"><option value="0" selected>None</option><option value="6">Thin</option><option value="16">Medium</option><option value="34">Thick</option></select>
260
+ <input type="color" id="sbsGapCol" value="#000000" title="Divider colour">
261
+ </div>
262
+ <div class="row"><label>Length</label>
263
+ <select id="sbsLen">
264
+ <option value="short" selected>Stop at shorter clip</option>
265
+ <option value="long">Run to longer clip</option>
266
+ </select>
267
+ </div>
268
+ <div class="hint" id="sbsLenHint">Both clips start together. The export ends when the shorter one ends.</div>
269
+ </div>
270
+
271
+ <div class="panelbox">
272
+ <h3>Audio</h3>
273
+ <div class="row"><label>Use</label>
274
+ <select id="sbsAudio"><option value="both" selected>Both clips</option><option value="v1">Clip 1 only</option><option value="v2">Clip 2 only</option><option value="none">No audio</option></select>
275
+ </div>
276
+ <div class="row"><label>Clip 1 vol</label><input type="range" id="vol1" min="0" max="100" value="70"><b class="tc" id="vol1L" style="flex:0 0 36px;text-align:right">70%</b></div>
277
+ <div class="row"><label>Clip 2 vol</label><input type="range" id="vol2" min="0" max="100" value="70"><b class="tc" id="vol2L" style="flex:0 0 36px;text-align:right">70%</b></div>
278
+ <div class="hint">Volumes apply to both the preview and the export.</div>
279
+ </div>
280
+
281
+ <div class="panelbox">
282
+ <h3>Export</h3>
283
+ <div class="row"><label>Scale</label>
284
+ <select id="sbsScale"><option value="1" selected>100%</option><option value="0.75">75%</option><option value="0.5">50%</option><option value="0.25">25%</option></select>
285
+ </div>
286
+ <div class="row"><label>Frame rate</label><input type="number" id="sbsFps" min="1" max="60" step="1" value="30"></div>
287
+ <div class="est">
288
+ <div><span>Output size</span><b id="sbsOutSize">&mdash;</b></div>
289
+ <div><span>Duration</span><b id="sbsOutDur">&mdash;</b></div>
290
+ <div><span>Rough file size</span><b id="sbsOutBytes">&mdash;</b></div>
291
+ <div><span>Format</span><b id="sbsFmt">&mdash;</b></div>
292
+ </div>
293
+ <button id="sbsExport" class="primary expBtn">Export side by side video</button>
294
+ <button id="sbsStop" class="danger stopBtn">Stop export</button>
295
+ <div class="bar" id="sbsBar"><i id="sbsBarI"></i></div>
296
+ <div class="status" id="sbsStatus"></div>
297
+ <div class="note" id="sbsNote">Export records in real time, so a 2 minute clip takes about 2 minutes. Keep this tab visible while it runs.</div>
298
+ </div>
299
+ </div>
300
+ </div>
301
+ </div>
302
+
303
+ <!-- ============================== JOIN ================================== -->
304
+ <div class="modepanel" id="panelJoin" role="tabpanel" hidden>
305
+ <div id="joinDrop">
306
+ <div class="big">&#9634;</div>
307
+ <h2>Drop videos here to add them to the sequence</h2>
308
+ <p>or click to choose files &nbsp;&middot;&nbsp; you can add several at once &nbsp;&middot;&nbsp; nothing is uploaded, it all stays on this computer</p>
309
+ <input type="file" id="joinFiles" accept="video/*" multiple hidden>
310
+ </div>
311
+
312
+ <div class="grid">
313
+ <div>
314
+ <div class="stage">
315
+ <canvas id="joinCanvas"></canvas>
316
+ <div class="badge" id="joinBadge">Add two or more videos to start</div>
317
+ </div>
318
+ <div class="transport">
319
+ <button id="joinPlay" class="icobtn" title="Play or pause (Space)">&#9654;</button>
320
+ <button id="joinBack" class="icobtn ghost" title="Back 1 second (Left arrow)">&#8676;</button>
321
+ <button id="joinFwd" class="icobtn ghost" title="Forward 1 second (Right arrow)">&#8677;</button>
322
+ <button id="joinRestart" class="ghost small">Restart</button>
323
+ <span class="tc"><b id="joinCur">0:00.00</b> / <span id="joinTot">0:00.00</span></span>
324
+ <input type="range" id="joinSeek" min="0" max="1000" value="0" step="1" style="flex:1 1 220px">
325
+ </div>
326
+
327
+ <div style="margin-top:16px">
328
+ <div class="clips-head">
329
+ <h3>Sequence</h3>
330
+ <span class="tc" id="joinCount">0 clips</span>
331
+ <span class="spacer"></span>
332
+ <button class="small ghost" id="joinClear">Remove all</button>
333
+ </div>
334
+ <div class="cliplist" id="clipList"><div class="empty">No clips yet. Add videos above and they will play in this order, top to bottom.</div></div>
335
+ </div>
336
+ </div>
337
+
338
+ <div class="card">
339
+ <div class="panelbox">
340
+ <h3>Output frame</h3>
341
+ <div class="row"><label>Size</label>
342
+ <select id="joinSize">
343
+ <option value="first" selected>Match the first clip</option>
344
+ <option value="largest">Largest clip</option>
345
+ <option value="1920x1080">1920 &times; 1080</option>
346
+ <option value="1280x720">1280 &times; 720</option>
347
+ <option value="1080x1920">1080 &times; 1920 (vertical)</option>
348
+ <option value="1080x1080">1080 &times; 1080 (square)</option>
349
+ </select>
350
+ </div>
351
+ <div class="row"><label>Framing</label>
352
+ <select id="joinFit"><option value="fit" selected>Fit (show all, black bars)</option><option value="fill">Fill (crop to frame)</option></select>
353
+ </div>
354
+ <div class="hint">Every clip is drawn into one fixed frame, so mixed sizes still join cleanly.</div>
355
+ </div>
356
+
357
+ <div class="panelbox">
358
+ <h3>Audio</h3>
359
+ <div class="row"><label>Sound</label>
360
+ <select id="joinAudio"><option value="on" selected>Keep clip audio</option><option value="none">No audio</option></select>
361
+ </div>
362
+ <div class="row"><label>Volume</label><input type="range" id="joinVol" min="0" max="100" value="100"><b class="tc" id="joinVolL" style="flex:0 0 36px;text-align:right">100%</b></div>
363
+ </div>
364
+
365
+ <div class="panelbox">
366
+ <h3>Export</h3>
367
+ <div class="row"><label>Scale</label>
368
+ <select id="joinScale"><option value="1" selected>100%</option><option value="0.75">75%</option><option value="0.5">50%</option><option value="0.25">25%</option></select>
369
+ </div>
370
+ <div class="row"><label>Frame rate</label><input type="number" id="joinFps" min="1" max="60" step="1" value="30"></div>
371
+ <div class="est">
372
+ <div><span>Output size</span><b id="joinOutSize">&mdash;</b></div>
373
+ <div><span>Duration</span><b id="joinOutDur">&mdash;</b></div>
374
+ <div><span>Rough file size</span><b id="joinOutBytes">&mdash;</b></div>
375
+ <div><span>Format</span><b id="joinFmt">&mdash;</b></div>
376
+ </div>
377
+ <button id="joinExport" class="primary expBtn">Export joined video</button>
378
+ <button id="joinStop" class="danger stopBtn">Stop export</button>
379
+ <div class="bar" id="joinBar"><i id="joinBarI"></i></div>
380
+ <div class="status" id="joinStatus"></div>
381
+ <div class="note">Export records in real time. Keep this tab visible while it runs.</div>
382
+ </div>
383
+ </div>
384
+ </div>
385
+ </div>
386
+
387
+ <div class="keys">
388
+ <span class="kbd">Space</span> play or pause &nbsp;&middot;&nbsp;
389
+ <span class="kbd">&larr;</span> <span class="kbd">&rarr;</span> step back or forward &nbsp;&middot;&nbsp;
390
+ <span class="kbd">Home</span> back to the start &nbsp;&middot;&nbsp;
391
+ MP4 is used when the browser supports it, otherwise WebM.
392
+ </div>
393
+ </div>
394
+
395
+ <video id="v1" class="hiddenmedia" playsinline preload="auto" crossorigin="anonymous"></video>
396
+ <video id="v2" class="hiddenmedia" playsinline preload="auto" crossorigin="anonymous"></video>
397
+ <img id="im1" class="hiddenmedia" alt="">
398
+ <img id="im2" class="hiddenmedia" alt="">
399
+ <video id="jA" class="hiddenmedia" playsinline preload="auto" crossorigin="anonymous"></video>
400
+ <video id="jB" class="hiddenmedia" playsinline preload="auto" crossorigin="anonymous"></video>
401
+
402
+ <script>
403
+ "use strict";
404
+ (function(){
405
+
406
+ /* ======================= shared helpers ======================= */
407
+ const $ = id => document.getElementById(id);
408
+ const clamp = (v,a,b) => Math.min(b, Math.max(a, v));
409
+ const even = n => { n = Math.round(n); return n % 2 ? n + 1 : n; }; // encoders dislike odd sizes
410
+ function once(el, ev){ return new Promise(r => el.addEventListener(ev, r, {once:true})); }
411
+ function wait(ms){ return new Promise(r => setTimeout(r, ms)); }
412
+
413
+ function tc(t){
414
+ if(!isFinite(t) || t < 0) t = 0;
415
+ const h = Math.floor(t/3600), m = Math.floor((t - h*3600)/60), s = t - h*3600 - m*60;
416
+ return (h ? h + ":" : "") + (h ? String(m).padStart(2,"0") : m) + ":" + (s < 10 ? "0" : "") + s.toFixed(2);
417
+ }
418
+ function safeName(s, fallback){
419
+ return String(s || fallback).replace(/[\\/:*?"<>|\x00-\x1f]+/g,"-").replace(/^\.+/,"").trim().slice(0,80) || fallback;
420
+ }
421
+ function baseName(n){ return n.replace(/\.[^.]+$/, "") || "video"; }
422
+ function human(bytes){
423
+ if(!isFinite(bytes) || bytes <= 0) return "\u2014";
424
+ if(bytes < 1024*1024) return Math.round(bytes/1024) + " KB";
425
+ if(bytes < 1024*1024*1024) return (bytes/1024/1024).toFixed(bytes < 10*1024*1024 ? 1 : 0) + " MB";
426
+ return (bytes/1024/1024/1024).toFixed(2) + " GB";
427
+ }
428
+ function say(el, msg, kind){
429
+ el.textContent = msg || "";
430
+ el.className = "status" + (kind ? " " + kind : "");
431
+ }
432
+ function bitrateFor(w, h, fps){
433
+ return clamp(Math.round(w * h * fps * 0.14), 1200000, 45000000);
434
+ }
435
+
436
+ const MIMES = [
437
+ "video/mp4;codecs=avc1.42E01E,mp4a.40.2",
438
+ "video/mp4;codecs=avc1.42E01E",
439
+ "video/mp4",
440
+ "video/webm;codecs=vp9,opus",
441
+ "video/webm;codecs=vp8,opus",
442
+ "video/webm"
443
+ ];
444
+ function pickMime(){
445
+ if(!window.MediaRecorder) return "";
446
+ for(const m of MIMES) if(MediaRecorder.isTypeSupported(m)) return m;
447
+ return "";
448
+ }
449
+ const CHOSEN_MIME = pickMime();
450
+ const FMT_LABEL = CHOSEN_MIME ? (CHOSEN_MIME.indexOf("mp4") >= 0 ? "MP4" : "WebM") : "not supported";
451
+ $("sbsFmt").textContent = FMT_LABEL;
452
+ $("joinFmt").textContent = FMT_LABEL;
453
+
454
+ async function seekTo(video, t){
455
+ if(!video || !isFinite(video.duration)) return;
456
+ const target = clamp(t, 0, Math.max(0, (video.duration || 0) - 0.001));
457
+ if(Math.abs((video.currentTime || 0) - target) < 0.005) return;
458
+ video.currentTime = target;
459
+ await Promise.race([once(video,"seeked"), wait(900)]);
460
+ }
461
+ async function waitReady(video){
462
+ if(video.readyState >= 2) return;
463
+ await Promise.race([once(video,"loadeddata"), once(video,"canplay"), wait(4000)]);
464
+ }
465
+
466
+ /* ---- drawing a source into a box, either letterboxed or cropped ---- */
467
+ function drawInto(ctx, media, x, y, w, h, mode, bg){
468
+ ctx.fillStyle = bg || "#000";
469
+ ctx.fillRect(x, y, w, h);
470
+ const mw = media.videoWidth || media.naturalWidth, mh = media.videoHeight || media.naturalHeight;
471
+ if(!mw || !mh) return;
472
+ const sc = mode === "fill" ? Math.max(w/mw, h/mh) : Math.min(w/mw, h/mh);
473
+ const dw = mw*sc, dh = mh*sc;
474
+ if(mode === "fill"){
475
+ ctx.save(); ctx.beginPath(); ctx.rect(x, y, w, h); ctx.clip();
476
+ ctx.drawImage(media, x + (w - dw)/2, y + (h - dh)/2, dw, dh);
477
+ ctx.restore();
478
+ }else{
479
+ ctx.drawImage(media, x + (w - dw)/2, y + (h - dh)/2, dw, dh);
480
+ }
481
+ }
482
+
483
+ /* ---- thumbnails ---- */
484
+ function paintThumb(canvasEl, media, maxW, maxH){
485
+ const mw = media.videoWidth || media.naturalWidth || 16;
486
+ const mh = media.videoHeight || media.naturalHeight || 9;
487
+ let cssW = maxW, cssH = Math.round(cssW * mh / mw);
488
+ if(cssH > maxH){ cssH = maxH; cssW = Math.round(cssH * mw / mh); }
489
+ canvasEl.width = Math.max(2, cssW * 2);
490
+ canvasEl.height = Math.max(2, cssH * 2);
491
+ canvasEl.style.width = cssW + "px";
492
+ canvasEl.style.height = cssH + "px";
493
+ const x = canvasEl.getContext("2d", {alpha:false});
494
+ x.fillStyle = "#000"; x.fillRect(0, 0, canvasEl.width, canvasEl.height);
495
+ const sc = Math.min(canvasEl.width/mw, canvasEl.height/mh);
496
+ x.drawImage(media, (canvasEl.width - mw*sc)/2, (canvasEl.height - mh*sc)/2, mw*sc, mh*sc);
497
+ canvasEl.style.display = "block";
498
+ }
499
+
500
+ function copyThumb(dst, src){
501
+ if(!src){ dst.style.display = "none"; return; }
502
+ dst.width = src.width; dst.height = src.height;
503
+ dst.style.width = src.style.width; dst.style.height = src.style.height;
504
+ dst.getContext("2d").drawImage(src, 0, 0);
505
+ dst.style.display = "block";
506
+ }
507
+
508
+ /* ---- Web Audio: one graph so several sources can be mixed and recorded ---- */
509
+ let AC = null, audioBroken = false;
510
+ const audioNodes = new WeakMap();
511
+ function ac(){
512
+ if(AC) return AC;
513
+ const C = window.AudioContext || window.webkitAudioContext;
514
+ if(!C){ audioBroken = true; return null; }
515
+ try{ AC = new C(); }catch(e){ audioBroken = true; return null; }
516
+ return AC;
517
+ }
518
+ function resumeAudio(){ const c = ac(); if(c && c.state === "suspended") c.resume().catch(()=>{}); }
519
+ function nodeFor(el){
520
+ if(audioBroken) return null;
521
+ if(audioNodes.has(el)) return audioNodes.get(el);
522
+ const c = ac();
523
+ if(!c) return null;
524
+ try{
525
+ const src = c.createMediaElementSource(el);
526
+ const gain = c.createGain();
527
+ src.connect(gain);
528
+ gain.connect(c.destination);
529
+ const o = {src, gain};
530
+ audioNodes.set(el, o);
531
+ return o;
532
+ }catch(e){ audioBroken = true; return null; }
533
+ }
534
+ function setGain(el, value){
535
+ const n = nodeFor(el);
536
+ if(n){ n.gain.gain.value = value; }
537
+ else { el.volume = clamp(value, 0, 1); } // fallback when Web Audio is unavailable
538
+ }
539
+
540
+ /* ---- recording ---- */
541
+ function startRecording(canvas, fps, audioEls){
542
+ if(!canvas.captureStream) throw new Error("This browser cannot record from a canvas. Try Chrome or Edge.");
543
+ if(!CHOSEN_MIME) throw new Error("This browser cannot record video. Try Chrome or Edge.");
544
+ const stream = canvas.captureStream(fps);
545
+ let dest = null, connected = [];
546
+ if(audioEls && audioEls.length && !audioBroken){
547
+ const c = ac();
548
+ if(c){
549
+ try{
550
+ if(c.state === "suspended") c.resume().catch(()=>{});
551
+ dest = c.createMediaStreamDestination();
552
+ audioEls.forEach(el => { const n = nodeFor(el); if(n){ n.gain.connect(dest); connected.push(n.gain); } });
553
+ if(connected.length) dest.stream.getAudioTracks().forEach(t => stream.addTrack(t));
554
+ else dest = null;
555
+ }catch(e){ dest = null; connected = []; }
556
+ }
557
+ }
558
+ const hasAudio = stream.getAudioTracks().length > 0;
559
+ let mime = CHOSEN_MIME, rec = null;
560
+ const opts = {mimeType: mime, videoBitsPerSecond: bitrateFor(canvas.width, canvas.height, fps)};
561
+ if(hasAudio) opts.audioBitsPerSecond = 192000;
562
+ try{ rec = new MediaRecorder(stream, opts); }
563
+ catch(e){
564
+ try{ mime = "video/webm"; rec = new MediaRecorder(stream, {mimeType: mime}); }
565
+ catch(e2){
566
+ stream.getTracks().forEach(t => { try{ t.stop(); }catch(_){} });
567
+ connected.forEach(g => { try{ g.disconnect(dest); }catch(_){} });
568
+ throw new Error("The recorder would not start. Try a lower scale, or use Chrome or Edge.");
569
+ }
570
+ }
571
+ const chunks = [];
572
+ rec.ondataavailable = e => { if(e.data && e.data.size) chunks.push(e.data); };
573
+ const stopped = new Promise(r => { rec.onstop = r; });
574
+ rec.start(200);
575
+ return {
576
+ rec, stream, chunks, mime, stopped, hasAudio,
577
+ finish(){
578
+ try{ if(rec.state !== "inactive") rec.stop(); }catch(_){}
579
+ return stopped.then(() => {
580
+ stream.getTracks().forEach(t => { try{ t.stop(); }catch(_){} });
581
+ connected.forEach(g => { try{ g.disconnect(dest); }catch(_){} });
582
+ const type = mime.split(";")[0];
583
+ return {blob: new Blob(chunks, {type}), ext: type.indexOf("mp4") >= 0 ? "mp4" : "webm"};
584
+ });
585
+ }
586
+ };
587
+ }
588
+ function browserDownload(blob, name){
589
+ const a = document.createElement("a");
590
+ a.href = URL.createObjectURL(blob); a.download = name; a.style.display = "none";
591
+ document.body.appendChild(a); a.click();
592
+ setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 15000);
593
+ }
594
+ async function saveBlob(blob, name, ext){
595
+ if(window.showSaveFilePicker){
596
+ try{
597
+ const accept = ext === "mp4" ? {"video/mp4":[".mp4"]} : {"video/webm":[".webm"]};
598
+ const handle = await window.showSaveFilePicker({suggestedName: name, types:[{description:"Video file", accept}]});
599
+ const w = await handle.createWritable();
600
+ await w.write(blob); await w.close();
601
+ return "saved";
602
+ }catch(e){
603
+ if(e && e.name === "AbortError") return "cancelled";
604
+ }
605
+ }
606
+ browserDownload(blob, name);
607
+ return "saved";
608
+ }
609
+
610
+ /* collapsible headings */
611
+ document.querySelectorAll("h3.collap").forEach(h => {
612
+ h.addEventListener("click", () => {
613
+ h.classList.toggle("shut");
614
+ const b = $(h.dataset.target);
615
+ if(b) b.classList.toggle("shut");
616
+ });
617
+ });
618
+
619
+ /* ======================= mode tabs ======================= */
620
+ let mode = "sbs";
621
+ document.querySelectorAll("#modeTabs button").forEach(b => {
622
+ b.addEventListener("click", () => {
623
+ if(SBS.exporting || JOIN.exporting) return;
624
+ SBS.pause(); JOIN.pause();
625
+ mode = b.dataset.mode;
626
+ document.querySelectorAll("#modeTabs button").forEach(x => {
627
+ const on = x === b;
628
+ x.classList.toggle("on", on);
629
+ x.setAttribute("aria-selected", on ? "true" : "false");
630
+ });
631
+ $("panelSbs").hidden = mode !== "sbs";
632
+ $("panelJoin").hidden = mode !== "join";
633
+ if(mode === "sbs") SBS.refresh(); else JOIN.refresh();
634
+ });
635
+ });
636
+
637
+ /* =========================================================================
638
+ MODE 1 — SIDE BY SIDE
639
+ ========================================================================= */
640
+ const SBS = (function(){
641
+ const cvs = $("sbsCanvas"), ctx = cvs.getContext("2d", {alpha:false});
642
+ const v = {1:$("v1"), 2:$("v2")}, im = {1:$("im1"), 2:$("im2")};
643
+ const slot = {
644
+ 1:{kind:null, ready:false, url:null, name:"clip1", file:"", thumb:null},
645
+ 2:{kind:null, ready:false, url:null, name:"clip2", file:"", thumb:null}
646
+ };
647
+ let playing = false, exporting = false, cancel = false, raf = 0, dragging = false;
648
+
649
+ const el = n => slot[n].kind === "image" ? im[n] : v[n];
650
+ const sw = n => { const e = el(n); return (e.videoWidth || e.naturalWidth) || 0; };
651
+ const sh = n => { const e = el(n); return (e.videoHeight || e.naturalHeight) || 0; };
652
+ const both = () => slot[1].ready && slot[2].ready;
653
+ const stillOnly = () => both() && slot[1].kind === "image" && slot[2].kind === "image";
654
+ const layout = () => document.querySelector("#layoutSeg button.on").dataset.layout;
655
+ const scale = () => parseFloat($("sbsScale").value) || 1;
656
+ const fps = () => clamp(Math.round(+$("sbsFps").value || 30), 1, 60);
657
+ const gap = () => parseInt($("sbsGap").value, 10) || 0;
658
+ const fitMode = () => $("sbsFit").value;
659
+ const longMode = () => $("sbsLen").value === "long";
660
+
661
+ function videos(){
662
+ const out = [];
663
+ if(slot[1].ready && slot[1].kind === "video") out.push(v[1]);
664
+ if(slot[2].ready && slot[2].kind === "video") out.push(v[2]);
665
+ return out;
666
+ }
667
+ function dur(){
668
+ const ds = videos().map(x => x.duration || 0).filter(d => isFinite(d) && d > 0);
669
+ if(!ds.length) return 0;
670
+ return longMode() ? Math.max.apply(null, ds) : Math.min.apply(null, ds);
671
+ }
672
+ function now(){
673
+ const ts = videos().map(x => x.currentTime || 0);
674
+ if(!ts.length) return 0;
675
+ return longMode() ? Math.max.apply(null, ts) : Math.min.apply(null, ts);
676
+ }
677
+
678
+ function dims(){
679
+ const s = scale(), g = gap();
680
+ if(!both()) return {W:1280, H:720, g:0, a:{x:0,y:0,w:640,h:360}, b:{x:640,y:0,w:640,h:360}};
681
+ const w1 = sw(1) || 1280, h1 = sh(1) || 720, w2 = sw(2) || 1280, h2 = sh(2) || 720;
682
+ if(fitMode() === "fill"){
683
+ // equal panes, sources cropped to fill them
684
+ const bw = even(Math.max(w1, w2) * s), bh = even(Math.max(h1, h2) * s);
685
+ if(layout() === "lr") return {W: even(bw*2 + g), H: bh, g, a:{x:0,y:0,w:bw,h:bh}, b:{x:bw+g,y:0,w:bw,h:bh}};
686
+ return {W: bw, H: even(bh*2 + g), g, a:{x:0,y:0,w:bw,h:bh}, b:{x:0,y:bh+g,w:bw,h:bh}};
687
+ }
688
+ const aw1 = Math.max(2, w1*s), ah1 = Math.max(2, h1*s), aw2 = Math.max(2, w2*s), ah2 = Math.max(2, h2*s);
689
+ if(layout() === "lr"){
690
+ const h = even(Math.max(ah1, ah2));
691
+ const pw1 = even(aw1 * h / ah1), pw2 = even(aw2 * h / ah2);
692
+ return {W: even(pw1 + g + pw2), H: h, g, a:{x:0,y:0,w:pw1,h}, b:{x:pw1+g,y:0,w:pw2,h}};
693
+ }
694
+ const w = even(Math.max(aw1, aw2));
695
+ const ph1 = even(ah1 * w / aw1), ph2 = even(ah2 * w / aw2);
696
+ return {W: w, H: even(ph1 + g + ph2), g, a:{x:0,y:0,w,h:ph1}, b:{x:0,y:ph1+g,w,h:ph2}};
697
+ }
698
+
699
+ function sizeCanvas(){
700
+ const d = dims();
701
+ if(cvs.width !== d.W || cvs.height !== d.H){ cvs.width = d.W; cvs.height = d.H; }
702
+ return d;
703
+ }
704
+
705
+ function label(text, x, y, w, h){
706
+ text = (text || "").trim();
707
+ if(!text) return;
708
+ const mult = parseFloat($("labelSize").value) || 1;
709
+ const font = Math.max(12, Math.round(cvs.height/42 * mult));
710
+ const px = Math.round(font*0.62), py = Math.round(font*0.38), pad = Math.round(font*0.7);
711
+ ctx.font = "600 " + font + "px -apple-system,BlinkMacSystemFont,Segoe UI,Arial,sans-serif";
712
+ let t = text;
713
+ while(ctx.measureText(t).width > w - pad*2 - px*2 && t.length > 2) t = t.slice(0, -2);
714
+ if(t !== text) t = t.slice(0, -1) + "\u2026";
715
+ const bw = ctx.measureText(t).width + px*2, bh = font + py*2;
716
+ const pos = $("labelPos").value;
717
+ let bx = x + pad, by = y + pad;
718
+ if(pos === "tr"){ bx = x + w - bw - pad; }
719
+ else if(pos === "bl"){ by = y + h - bh - pad; }
720
+ else if(pos === "br"){ bx = x + w - bw - pad; by = y + h - bh - pad; }
721
+ else if(pos === "ct"){ bx = x + (w - bw)/2; }
722
+ else if(pos === "cb"){ bx = x + (w - bw)/2; by = y + h - bh - pad; }
723
+ ctx.fillStyle = $("labelBg").value || "#7f1018";
724
+ ctx.globalAlpha = 0.94; ctx.fillRect(Math.round(bx), Math.round(by), Math.round(bw), Math.round(bh)); ctx.globalAlpha = 1;
725
+ ctx.fillStyle = "#fff"; ctx.textBaseline = "top";
726
+ ctx.fillText(t, Math.round(bx + px), Math.round(by + py));
727
+ }
728
+
729
+ function draw(){
730
+ const d = sizeCanvas();
731
+ ctx.fillStyle = $("sbsGapCol").value || "#000";
732
+ ctx.fillRect(0, 0, cvs.width, cvs.height);
733
+ if(both()){
734
+ drawInto(ctx, el(1), d.a.x, d.a.y, d.a.w, d.a.h, fitMode());
735
+ drawInto(ctx, el(2), d.b.x, d.b.y, d.b.w, d.b.h, fitMode());
736
+ label($("txt1").value, d.a.x, d.a.y, d.a.w, d.a.h);
737
+ label($("txt2").value, d.b.x, d.b.y, d.b.w, d.b.h);
738
+ }
739
+ const t = now(), total = dur();
740
+ $("sbsCur").textContent = tc(t);
741
+ if(!dragging && total) $("sbsSeek").value = String(Math.round(t/total*1000));
742
+ if(playing && !exporting && total && t >= total - 0.04) pause();
743
+ if(playing || exporting) raf = requestAnimationFrame(draw);
744
+ }
745
+ function redraw(){ if(!playing && !exporting){ cancelAnimationFrame(raf); draw(); } }
746
+
747
+ function readout(){
748
+ const d = dims(), total = dur();
749
+ $("sbsOutSize").textContent = both() ? d.W + " \u00d7 " + d.H : "\u2014";
750
+ $("sbsOutDur").textContent = stillOnly() ? "Still image" : (total ? tc(total) : "\u2014");
751
+ $("sbsTot").textContent = total ? tc(total) : "0:00.00";
752
+ if(both() && total){
753
+ const bytes = (bitrateFor(d.W, d.H, fps()) + ($("sbsAudio").value === "none" ? 0 : 192000)) * total / 8;
754
+ $("sbsOutBytes").textContent = "~" + human(bytes);
755
+ }else{
756
+ $("sbsOutBytes").textContent = stillOnly() ? "PNG image" : "\u2014";
757
+ }
758
+ $("sbsLenHint").textContent = longMode()
759
+ ? "The shorter clip freezes on its last frame until the longer one finishes."
760
+ : "Both clips start together. The export ends when the shorter one ends.";
761
+ let note = "Export records in real time, so a 2 minute clip takes about 2 minutes. Keep this tab visible while it runs.";
762
+ if(both() && (d.W > 4096 || d.H > 4096)) note = "That output is over 4096 px on one side, which many encoders refuse. Drop the scale to 50% or 75%.";
763
+ $("sbsNote").textContent = note;
764
+ $("sbsExport").textContent = stillOnly() ? "Export side by side image" : "Export side by side video";
765
+ $("sbsExport").disabled = !both() || exporting;
766
+ const noVid = !videos().length;
767
+ ["sbsPlay","sbsPrevF","sbsNextF","sbsRestart","sbsSeek"].forEach(id => { $(id).disabled = !both() || noVid; });
768
+ }
769
+ function badge(){
770
+ if(!both()){ $("sbsBadge").textContent = "Load two files to start"; return; }
771
+ const k = n => slot[n].kind === "image" ? "image" : "video";
772
+ $("sbsBadge").textContent = (layout() === "lr" ? "Left / right" : "Top / bottom")
773
+ + " \u00b7 " + k(1) + " + " + k(2) + (dur() ? " \u00b7 " + tc(dur()) : "");
774
+ }
775
+ function refresh(){ badge(); readout(); redraw(); }
776
+
777
+ /* ---- loading ---- */
778
+ function paintSlot(n){
779
+ const s = slot[n], d = $("drop" + n), th = $("thumb" + n);
780
+ d.classList.toggle("filled", !!s.ready);
781
+ if(s.ready){
782
+ d.querySelector("h2").textContent = (s.kind === "image" ? "Image " : "Video ") + n + " loaded";
783
+ d.querySelector("p").textContent = s.file;
784
+ copyThumb(th, s.thumb);
785
+ $("big" + n).style.display = s.thumb ? "none" : "";
786
+ }else{
787
+ d.querySelector("h2").textContent = "Video or image " + n;
788
+ d.querySelector("p").textContent = "drop here, or click to choose";
789
+ th.style.display = "none";
790
+ $("big" + n).style.display = "";
791
+ }
792
+ }
793
+ function reset(n){
794
+ const s = slot[n];
795
+ if(s.url) URL.revokeObjectURL(s.url);
796
+ s.url = null; s.ready = false; s.kind = null; s.name = "clip" + n; s.file = ""; s.thumb = null;
797
+ try{ v[n].pause(); }catch(_){}
798
+ v[n].removeAttribute("src"); v[n].load();
799
+ im[n].removeAttribute("src");
800
+ paintSlot(n);
801
+ refresh();
802
+ }
803
+
804
+ async function load(file, n){
805
+ if(exporting) return;
806
+ resumeAudio();
807
+ const s = slot[n];
808
+ if(s.url) URL.revokeObjectURL(s.url);
809
+ const url = URL.createObjectURL(file);
810
+ s.url = url; s.ready = false; s.name = baseName(file.name); s.file = file.name;
811
+ const isImg = file.type.startsWith("image/");
812
+
813
+ if(isImg){
814
+ const image = im[n];
815
+ image.src = url;
816
+ const ok = await Promise.race([once(image,"load").then(()=>true), once(image,"error").then(()=>false), wait(10000).then(()=>false)]);
817
+ if(!ok || !image.naturalWidth){ say($("sbsStatus"), "Could not read " + file.name + ". Try a JPG, PNG or WebP.", "err"); reset(n); return; }
818
+ s.kind = "image"; s.ready = true;
819
+ s.thumb = document.createElement("canvas"); paintThumb(s.thumb, image, 180, 110);
820
+ }else{
821
+ const video = v[n];
822
+ try{ video.pause(); }catch(_){}
823
+ video.src = url; video.muted = false; video.load();
824
+ const ok = await Promise.race([once(video,"loadedmetadata").then(()=>true), once(video,"error").then(()=>false), wait(15000).then(()=>false)]);
825
+ if(!ok || !video.videoWidth){ say($("sbsStatus"), "Could not read " + file.name + ". Try an MP4, MOV or WebM file.", "err"); reset(n); return; }
826
+ s.kind = "video"; s.ready = true;
827
+ setGain(video, 0); // silent until we know the audio settings
828
+ await waitReady(video); await seekTo(video, 0);
829
+ s.thumb = document.createElement("canvas"); paintThumb(s.thumb, video, 180, 110);
830
+ applyAudio();
831
+ }
832
+ paintSlot(n);
833
+ refresh();
834
+ say($("sbsStatus"), both() ? (stillOnly() ? "Ready to export a still image." : "Ready to preview or export.") : "Now load the second file.", both() ? "ok" : "");
835
+ }
836
+
837
+ function dropSetup(n){
838
+ const d = $("drop" + n), f = $("file" + n);
839
+ d.addEventListener("click", e => { if(e.target.classList.contains("clearx")) return; f.click(); });
840
+ f.addEventListener("change", e => { if(e.target.files[0]) load(e.target.files[0], n); e.target.value = ""; });
841
+ ["dragenter","dragover"].forEach(ev => d.addEventListener(ev, e => { e.preventDefault(); d.classList.add("over"); }));
842
+ ["dragleave","drop"].forEach(ev => d.addEventListener(ev, e => { e.preventDefault(); d.classList.remove("over"); }));
843
+ d.addEventListener("drop", e => { const file = e.dataTransfer && e.dataTransfer.files[0]; if(file) load(file, n); });
844
+ $("clear" + n).addEventListener("click", e => { e.stopPropagation(); if(!exporting) reset(n); });
845
+ }
846
+ dropSetup(1); dropSetup(2);
847
+
848
+ $("swapBtn").addEventListener("click", async () => {
849
+ if(exporting || !(slot[1].ready || slot[2].ready)) return;
850
+ pause();
851
+ const first = slot[1];
852
+ slot[1] = slot[2]; slot[2] = first;
853
+ const t = $("txt1").value; $("txt1").value = $("txt2").value; $("txt2").value = t;
854
+ [1, 2].forEach(paintSlot); // names and thumbnails move straight away
855
+ refresh();
856
+ for(const n of [1, 2]){
857
+ const s = slot[n];
858
+ try{ v[n].pause(); }catch(_){}
859
+ if(!s.ready){ v[n].removeAttribute("src"); v[n].load(); im[n].removeAttribute("src"); continue; }
860
+ if(s.kind === "image"){ im[n].src = s.url; v[n].removeAttribute("src"); v[n].load(); }
861
+ else { v[n].src = s.url; v[n].load(); }
862
+ }
863
+ await Promise.all([1, 2].map(async n => {
864
+ const s = slot[n];
865
+ if(!s.ready) return;
866
+ const e = el(n);
867
+ if(s.kind === "video"){
868
+ await Promise.race([once(e,"loadedmetadata"), wait(8000)]);
869
+ await waitReady(e); await seekTo(e, 0);
870
+ }else if(!e.complete){
871
+ await Promise.race([once(e,"load"), wait(8000)]);
872
+ }
873
+ }));
874
+ applyAudio(); refresh();
875
+ say($("sbsStatus"), "Swapped clip 1 and clip 2.");
876
+ });
877
+
878
+ /* ---- audio ---- */
879
+ function applyAudio(){
880
+ const choice = $("sbsAudio").value;
881
+ const g1 = choice === "none" || choice === "v2" ? 0 : (+$("vol1").value/100);
882
+ const g2 = choice === "none" || choice === "v1" ? 0 : (+$("vol2").value/100);
883
+ if(slot[1].kind === "video") setGain(v[1], g1);
884
+ if(slot[2].kind === "video") setGain(v[2], g2);
885
+ $("vol1L").textContent = $("vol1").value + "%";
886
+ $("vol2L").textContent = $("vol2").value + "%";
887
+ $("vol1").disabled = choice === "none" || choice === "v2";
888
+ $("vol2").disabled = choice === "none" || choice === "v1";
889
+ }
890
+
891
+ /* ---- transport ---- */
892
+ function seekAll(t){ return Promise.all(videos().map(x => seekTo(x, t))); }
893
+ async function play(){
894
+ if(!both() || exporting || !videos().length) return;
895
+ resumeAudio();
896
+ if(dur() && now() >= dur() - 0.05) await seekAll(0);
897
+ playing = true; $("sbsPlay").innerHTML = "&#10074;&#10074;";
898
+ cancelAnimationFrame(raf); draw();
899
+ try{ await Promise.all(videos().map(x => x.play())); }
900
+ catch(e){ playing = false; $("sbsPlay").innerHTML = "&#9654;"; say($("sbsStatus"), "The browser blocked playback. Click anywhere on the page, then press play again.", "err"); }
901
+ }
902
+ function pause(){
903
+ if(!playing) { videos().forEach(x => { try{ x.pause(); }catch(_){} }); return; }
904
+ playing = false; $("sbsPlay").innerHTML = "&#9654;";
905
+ videos().forEach(x => { try{ x.pause(); }catch(_){} });
906
+ cancelAnimationFrame(raf); draw();
907
+ }
908
+ $("sbsPlay").addEventListener("click", () => playing ? pause() : play());
909
+ $("sbsRestart").addEventListener("click", async () => { pause(); await seekAll(0); redraw(); });
910
+ $("sbsPrevF").addEventListener("click", async () => { pause(); await seekAll(Math.max(0, now() - 1/fps())); redraw(); });
911
+ $("sbsNextF").addEventListener("click", async () => { pause(); await seekAll(Math.min(dur(), now() + 1/fps())); redraw(); });
912
+ $("sbsSeek").addEventListener("pointerdown", () => { dragging = true; });
913
+ $("sbsSeek").addEventListener("input", () => { if(dur()) $("sbsCur").textContent = tc(dur() * $("sbsSeek").value/1000); });
914
+ $("sbsSeek").addEventListener("change", async () => {
915
+ const target = dur() * (+$("sbsSeek").value)/1000; // read before pausing, which repaints the slider
916
+ dragging = false;
917
+ if(!dur()) return;
918
+ pause(); await seekAll(target); redraw();
919
+ });
920
+
921
+ /* ---- settings wiring ---- */
922
+ document.querySelectorAll("#layoutSeg button").forEach(b => b.addEventListener("click", () => {
923
+ if(exporting) return;
924
+ document.querySelectorAll("#layoutSeg button").forEach(x => x.classList.toggle("on", x === b));
925
+ refresh();
926
+ }));
927
+ ["sbsScale","sbsFps","sbsFit","sbsGap","sbsGapCol","sbsLen","txt1","txt2","labelPos","labelSize","labelBg"]
928
+ .forEach(id => $(id).addEventListener("input", refresh));
929
+ ["sbsAudio","vol1","vol2"].forEach(id => $(id).addEventListener("input", () => { applyAudio(); readout(); }));
930
+
931
+ function lock(on){
932
+ ["sbsScale","sbsFps","sbsFit","sbsGap","sbsGapCol","sbsLen","sbsAudio","txt1","txt2","labelPos","labelSize","labelBg","swapBtn","clear1","clear2"]
933
+ .forEach(id => { $(id).disabled = on; });
934
+ document.querySelectorAll("#layoutSeg button").forEach(b => b.disabled = on);
935
+ document.querySelectorAll("#modeTabs button").forEach(b => b.disabled = on);
936
+ }
937
+
938
+ /* ---- export ---- */
939
+ $("sbsStop").addEventListener("click", () => { cancel = true; say($("sbsStatus"), "Stopping\u2026"); });
940
+ $("sbsExport").addEventListener("click", async () => {
941
+ if(exporting) return;
942
+ if(!both()){ say($("sbsStatus"), "Load a file into both slots first.", "err"); return; }
943
+
944
+ if(stillOnly()){
945
+ redraw();
946
+ const blob = await new Promise(r => cvs.toBlob(r, "image/png"));
947
+ if(!blob || !blob.size){ say($("sbsStatus"), "Could not build the image.", "err"); return; }
948
+ const name = safeName(slot[1].name + "_" + slot[2].name + "_sidebyside", "sidebyside") + ".png";
949
+ browserDownload(blob, name);
950
+ say($("sbsStatus"), "Saved " + name, "ok");
951
+ return;
952
+ }
953
+ const total = dur();
954
+ if(!isFinite(total) || total <= 0){ say($("sbsStatus"), "Could not read the clip length. Try converting the files to MP4 first.", "err"); return; }
955
+
956
+ exporting = true; cancel = false; lock(true);
957
+ pause(); await seekAll(0); sizeCanvas();
958
+ $("sbsExport").disabled = true; $("sbsStop").style.display = "block";
959
+ $("sbsBar").style.display = "block"; $("sbsBarI").style.width = "0%";
960
+ say($("sbsStatus"), "Recording\u2026 keep this tab visible.");
961
+
962
+ let session = null;
963
+ try{
964
+ const useAudio = $("sbsAudio").value !== "none";
965
+ session = startRecording(cvs, fps(), useAudio ? videos() : []);
966
+ cancelAnimationFrame(raf); draw();
967
+ await Promise.all(videos().map(x => x.play()));
968
+ playing = true;
969
+
970
+ const started = performance.now();
971
+ await new Promise(resolve => {
972
+ const timer = setInterval(() => {
973
+ const t = now();
974
+ $("sbsBarI").style.width = (clamp(t/total, 0, 1) * 100).toFixed(1) + "%";
975
+ const overrun = (performance.now() - started) > total*1000 + 15000;
976
+ if(cancel || t >= total - 0.035 || overrun){ clearInterval(timer); resolve(); }
977
+ }, 80);
978
+ });
979
+ videos().forEach(x => { try{ x.pause(); }catch(_){} });
980
+ playing = false;
981
+ await wait(220); // let the last frames land
982
+ const {blob, ext} = await session.finish();
983
+
984
+ if(cancel){ say($("sbsStatus"), "Export stopped. Nothing was saved."); }
985
+ else if(!blob.size){ say($("sbsStatus"), "The export came out empty. Try a lower scale, or use Chrome or Edge.", "err"); }
986
+ else{
987
+ const name = safeName(slot[1].name + "_" + slot[2].name + "_sidebyside", "sidebyside") + "." + ext;
988
+ const r = await saveBlob(blob, name, ext);
989
+ say($("sbsStatus"), r === "cancelled" ? "Save cancelled." : "Saved " + name + (session.hasAudio ? "" : " (no audio)"), r === "cancelled" ? "" : "ok");
990
+ }
991
+ }catch(e){
992
+ if(session){ try{ await session.finish(); }catch(_){} }
993
+ videos().forEach(x => { try{ x.pause(); }catch(_){} });
994
+ playing = false;
995
+ say($("sbsStatus"), e.message || "The export failed.", "err");
996
+ }finally{
997
+ exporting = false; lock(false);
998
+ $("sbsStop").style.display = "none"; $("sbsBar").style.display = "none";
999
+ $("sbsPlay").innerHTML = "&#9654;";
1000
+ applyAudio();
1001
+ await seekAll(0);
1002
+ refresh();
1003
+ }
1004
+ });
1005
+
1006
+ applyAudio(); refresh();
1007
+ return {
1008
+ refresh, pause, play,
1009
+ get exporting(){ return exporting; },
1010
+ get playing(){ return playing; },
1011
+ step(dir){ if(!both()) return; pause(); seekAll(clamp(now() + dir/fps(), 0, dur())).then(redraw); },
1012
+ home(){ if(!both()) return; pause(); seekAll(0).then(redraw); }
1013
+ };
1014
+ })();
1015
+
1016
+ /* =========================================================================
1017
+ MODE 2 — JOIN END TO END
1018
+ ========================================================================= */
1019
+ const JOIN = (function(){
1020
+ const cvs = $("joinCanvas"), ctx = cvs.getContext("2d", {alpha:false});
1021
+ const els = [$("jA"), $("jB")];
1022
+ const held = [-1, -1]; // which clip index each element currently holds
1023
+ let clips = [], activeEl = 0, curIdx = -1;
1024
+ let playing = false, exporting = false, cancel = false, raf = 0, dragging = false, advancing = false, seq = 0;
1025
+
1026
+ const act = () => els[activeEl];
1027
+ const idle = () => els[1 - activeEl];
1028
+ const fps = () => clamp(Math.round(+$("joinFps").value || 30), 1, 60);
1029
+ const scale = () => parseFloat($("joinScale").value) || 1;
1030
+ const fitMode = () => $("joinFit").value;
1031
+ const total = () => clips.reduce((a, c) => a + c.dur, 0);
1032
+ function startOf(i){ let a = 0; for(let k = 0; k < i; k++) a += clips[k].dur; return a; }
1033
+ function locate(t){
1034
+ let acc = 0;
1035
+ for(let i = 0; i < clips.length; i++){
1036
+ if(t < acc + clips[i].dur || i === clips.length - 1) return {i, local: clamp(t - acc, 0, clips[i].dur)};
1037
+ acc += clips[i].dur;
1038
+ }
1039
+ return {i:0, local:0};
1040
+ }
1041
+ function elapsed(){
1042
+ if(curIdx < 0) return 0;
1043
+ return startOf(curIdx) + (act().currentTime || 0);
1044
+ }
1045
+
1046
+ function frame(){
1047
+ if(!clips.length) return {W:1280, H:720};
1048
+ const s = scale(), pick = $("joinSize").value;
1049
+ let w, h;
1050
+ if(pick === "first"){ w = clips[0].w; h = clips[0].h; }
1051
+ else if(pick === "largest"){ w = Math.max.apply(null, clips.map(c => c.w)); h = Math.max.apply(null, clips.map(c => c.h)); }
1052
+ else { const p = pick.split("x"); w = +p[0]; h = +p[1]; }
1053
+ return {W: even(Math.max(2, w*s)), H: even(Math.max(2, h*s))};
1054
+ }
1055
+ function sizeCanvas(){
1056
+ const f = frame();
1057
+ if(cvs.width !== f.W || cvs.height !== f.H){ cvs.width = f.W; cvs.height = f.H; }
1058
+ return f;
1059
+ }
1060
+ function draw(){
1061
+ sizeCanvas();
1062
+ ctx.fillStyle = "#000"; ctx.fillRect(0, 0, cvs.width, cvs.height);
1063
+ const e = act();
1064
+ if(clips.length && e && e.videoWidth) drawInto(ctx, e, 0, 0, cvs.width, cvs.height, fitMode());
1065
+ const t = elapsed(), tot = total();
1066
+ $("joinCur").textContent = tc(t);
1067
+ if(!dragging && tot) $("joinSeek").value = String(Math.round(clamp(t/tot, 0, 1) * 1000));
1068
+ if(playing && !advancing && e && curIdx >= 0){
1069
+ const d = clips[curIdx].dur;
1070
+ if(e.ended || (e.currentTime || 0) >= d - 0.04) advance();
1071
+ }
1072
+ if(playing || exporting) raf = requestAnimationFrame(draw);
1073
+ }
1074
+ function redraw(){ if(!playing && !exporting){ cancelAnimationFrame(raf); draw(); } }
1075
+
1076
+ const loading = [null, null]; // {idx, p} so two callers never load the same thing twice
1077
+ function loadInto(elIdx, clipIdx){
1078
+ const e = els[elIdx], c = clips[clipIdx];
1079
+ if(!c) return Promise.resolve(false);
1080
+ if(held[elIdx] === clipIdx && e.readyState >= 2) return Promise.resolve(true);
1081
+ if(loading[elIdx] && loading[elIdx].idx === clipIdx) return loading[elIdx].p;
1082
+ const p = (async () => {
1083
+ try{ e.pause(); }catch(_){}
1084
+ e.src = c.url; e.muted = false; e.load();
1085
+ const ok = await Promise.race([once(e,"loadedmetadata").then(()=>true), once(e,"error").then(()=>false), wait(15000).then(()=>false)]);
1086
+ if(!ok || !e.videoWidth){ held[elIdx] = -1; return false; }
1087
+ setGain(e, gainValue());
1088
+ await waitReady(e);
1089
+ await seekTo(e, 0);
1090
+ held[elIdx] = clipIdx; // only commit once it is genuinely ready
1091
+ return true;
1092
+ })();
1093
+ loading[elIdx] = {idx: clipIdx, p};
1094
+ p.then(() => { if(loading[elIdx] && loading[elIdx].p === p) loading[elIdx] = null; },
1095
+ () => { if(loading[elIdx] && loading[elIdx].p === p) loading[elIdx] = null; });
1096
+ return p;
1097
+ }
1098
+ function gainValue(){ return $("joinAudio").value === "none" ? 0 : (+$("joinVol").value/100); }
1099
+ function applyAudio(){
1100
+ els.forEach(e => setGain(e, gainValue()));
1101
+ $("joinVolL").textContent = $("joinVol").value + "%";
1102
+ $("joinVol").disabled = $("joinAudio").value === "none";
1103
+ }
1104
+
1105
+ async function goto(t){
1106
+ if(!clips.length) return;
1107
+ const {i, local} = locate(clamp(t, 0, Math.max(0, total() - 0.001)));
1108
+ if(held[activeEl] !== i){
1109
+ if(held[1 - activeEl] === i) activeEl = 1 - activeEl;
1110
+ else await loadInto(activeEl, i);
1111
+ }
1112
+ curIdx = i;
1113
+ await seekTo(act(), local);
1114
+ if(i + 1 < clips.length) loadInto(1 - activeEl, i + 1);
1115
+ markRow();
1116
+ }
1117
+
1118
+ async function advance(){
1119
+ if(advancing) return;
1120
+ advancing = true;
1121
+ const mine = ++seq;
1122
+ try{
1123
+ const next = curIdx + 1;
1124
+ if(next >= clips.length){ playing = false; $("joinPlay").innerHTML = "&#9654;"; act().pause(); advancing = false; redraw(); return; }
1125
+ if(held[1 - activeEl] !== next){ await loadInto(1 - activeEl, next); }
1126
+ if(mine !== seq){ advancing = false; return; }
1127
+ try{ act().pause(); }catch(_){}
1128
+ activeEl = 1 - activeEl;
1129
+ curIdx = next;
1130
+ await seekTo(act(), 0);
1131
+ if(playing || exporting){ try{ await act().play(); }catch(_){} }
1132
+ markRow();
1133
+ if(next + 1 < clips.length) loadInto(1 - activeEl, next + 1);
1134
+ }finally{ advancing = false; }
1135
+ }
1136
+
1137
+ /* ---- clip list ---- */
1138
+ function readout(){
1139
+ const f = frame(), tot = total();
1140
+ $("joinOutSize").textContent = clips.length ? f.W + " \u00d7 " + f.H : "\u2014";
1141
+ $("joinOutDur").textContent = tot ? tc(tot) : "\u2014";
1142
+ $("joinOutBytes").textContent = clips.length && tot
1143
+ ? "~" + human((bitrateFor(f.W, f.H, fps()) + ($("joinAudio").value === "none" ? 0 : 192000)) * tot / 8)
1144
+ : "\u2014";
1145
+ $("joinTot").textContent = tot ? tc(tot) : "0:00.00";
1146
+ $("joinCount").textContent = clips.length + (clips.length === 1 ? " clip" : " clips");
1147
+ $("joinExport").disabled = clips.length < 1 || exporting;
1148
+ ["joinPlay","joinBack","joinFwd","joinRestart","joinSeek","joinClear"].forEach(id => { $(id).disabled = !clips.length || exporting; });
1149
+ $("joinBadge").textContent = clips.length
1150
+ ? clips.length + (clips.length === 1 ? " clip" : " clips") + " \u00b7 " + tc(tot) + " \u00b7 " + f.W + "\u00d7" + f.H
1151
+ : "Add two or more videos to start";
1152
+ }
1153
+ function markRow(){
1154
+ document.querySelectorAll("#clipList .crow").forEach((r, i) => r.classList.toggle("on", i === curIdx));
1155
+ }
1156
+ function renderList(){
1157
+ const list = $("clipList");
1158
+ list.textContent = "";
1159
+ if(!clips.length){
1160
+ const e = document.createElement("div");
1161
+ e.className = "empty";
1162
+ e.textContent = "No clips yet. Add videos above and they will play in this order, top to bottom.";
1163
+ list.appendChild(e);
1164
+ readout(); return;
1165
+ }
1166
+ clips.forEach((c, i) => {
1167
+ const row = document.createElement("div");
1168
+ row.className = "crow" + (i === curIdx ? " on" : "");
1169
+
1170
+ const num = document.createElement("span");
1171
+ num.className = "cnum"; num.textContent = (i + 1) + ".";
1172
+
1173
+ const th = document.createElement("canvas");
1174
+ th.className = "cthumb";
1175
+ if(c.thumb){ th.width = c.thumb.width; th.height = c.thumb.height; th.style.width = c.thumb.style.width; th.style.height = c.thumb.style.height;
1176
+ th.getContext("2d").drawImage(c.thumb, 0, 0); }
1177
+
1178
+ const name = document.createElement("span");
1179
+ name.className = "cname"; name.textContent = c.name; name.title = c.name;
1180
+
1181
+ const meta = document.createElement("span");
1182
+ meta.className = "cmeta"; meta.textContent = c.w + "\u00d7" + c.h;
1183
+
1184
+ const d = document.createElement("span");
1185
+ d.className = "cdur"; d.textContent = tc(c.dur);
1186
+
1187
+ const btns = document.createElement("span");
1188
+ btns.className = "cbtns";
1189
+ const mk = (txt, title, fn, dis) => { const b = document.createElement("button"); b.innerHTML = txt; b.title = title; b.disabled = !!dis; b.addEventListener("click", ev => { ev.stopPropagation(); fn(); }); return b; };
1190
+ btns.appendChild(mk("&#9650;", "Move up", () => move(i, -1), i === 0));
1191
+ btns.appendChild(mk("&#9660;", "Move down", () => move(i, 1), i === clips.length - 1));
1192
+ const del = mk("&times;", "Remove this clip", () => remove(i));
1193
+ del.className = "danger";
1194
+ btns.appendChild(del);
1195
+
1196
+ row.appendChild(num); row.appendChild(th); row.appendChild(name);
1197
+ row.appendChild(meta); row.appendChild(d); row.appendChild(btns);
1198
+ row.addEventListener("click", async () => { if(exporting) return; pause(); await goto(startOf(i) + 0.01); redraw(); });
1199
+ list.appendChild(row);
1200
+ });
1201
+ readout();
1202
+ }
1203
+ function move(i, dir){
1204
+ const j = i + dir;
1205
+ if(exporting || j < 0 || j >= clips.length) return;
1206
+ pause();
1207
+ const t = clips[i]; clips[i] = clips[j]; clips[j] = t;
1208
+ held[0] = held[1] = -1; curIdx = -1;
1209
+ renderList();
1210
+ goto(0).then(redraw);
1211
+ }
1212
+ function remove(i){
1213
+ if(exporting) return;
1214
+ pause();
1215
+ URL.revokeObjectURL(clips[i].url);
1216
+ clips.splice(i, 1);
1217
+ held[0] = held[1] = -1; curIdx = -1;
1218
+ renderList();
1219
+ if(clips.length) goto(0).then(redraw); else { sizeCanvas(); redraw(); }
1220
+ say($("joinStatus"), "");
1221
+ }
1222
+ $("joinClear").addEventListener("click", () => {
1223
+ if(exporting) return;
1224
+ pause();
1225
+ clips.forEach(c => URL.revokeObjectURL(c.url));
1226
+ clips = []; held[0] = held[1] = -1; curIdx = -1;
1227
+ els.forEach(e => { try{ e.pause(); }catch(_){} e.removeAttribute("src"); e.load(); });
1228
+ renderList(); redraw();
1229
+ say($("joinStatus"), "");
1230
+ });
1231
+
1232
+ /* ---- adding files ---- */
1233
+ async function addFiles(fileList){
1234
+ if(exporting) return;
1235
+ resumeAudio();
1236
+ const files = Array.from(fileList).filter(f => f.type.startsWith("video/") || /\.(mp4|mov|webm|mkv|m4v|avi)$/i.test(f.name));
1237
+ if(!files.length){ say($("joinStatus"), "Those files are not videos. Add MP4, MOV, WebM or MKV.", "err"); return; }
1238
+ say($("joinStatus"), "Reading " + files.length + (files.length === 1 ? " file\u2026" : " files\u2026"));
1239
+ const probe = document.createElement("video");
1240
+ probe.preload = "auto"; probe.muted = true; probe.playsInline = true;
1241
+ let added = 0, failed = [];
1242
+ for(const f of files){
1243
+ const url = URL.createObjectURL(f);
1244
+ probe.src = url; probe.load();
1245
+ const ok = await Promise.race([once(probe,"loadedmetadata").then(()=>true), once(probe,"error").then(()=>false), wait(15000).then(()=>false)]);
1246
+ if(!ok || !probe.videoWidth || !isFinite(probe.duration) || probe.duration <= 0){
1247
+ URL.revokeObjectURL(url); failed.push(f.name); continue;
1248
+ }
1249
+ await waitReady(probe); await seekTo(probe, Math.min(0.1, probe.duration/10));
1250
+ const thumb = document.createElement("canvas");
1251
+ paintThumb(thumb, probe, 64, 40);
1252
+ clips.push({url, name: f.name, w: probe.videoWidth, h: probe.videoHeight, dur: probe.duration, thumb});
1253
+ added++;
1254
+ }
1255
+ probe.removeAttribute("src"); probe.load();
1256
+ held[0] = held[1] = -1;
1257
+ renderList();
1258
+ if(clips.length && curIdx < 0){ await goto(0); }
1259
+ applyAudio(); redraw();
1260
+ let msg = added ? "Added " + added + (added === 1 ? " clip." : " clips.") : "";
1261
+ if(failed.length) msg += (msg ? " " : "") + "Could not read: " + failed.join(", ") + ".";
1262
+ say($("joinStatus"), msg, failed.length ? "err" : "ok");
1263
+ }
1264
+ const jd = $("joinDrop"), jf = $("joinFiles");
1265
+ jd.addEventListener("click", () => jf.click());
1266
+ jf.addEventListener("change", e => { if(e.target.files.length) addFiles(e.target.files); e.target.value = ""; });
1267
+ ["dragenter","dragover"].forEach(ev => jd.addEventListener(ev, e => { e.preventDefault(); jd.classList.add("over"); }));
1268
+ ["dragleave","drop"].forEach(ev => jd.addEventListener(ev, e => { e.preventDefault(); jd.classList.remove("over"); }));
1269
+ jd.addEventListener("drop", e => { if(e.dataTransfer && e.dataTransfer.files.length) addFiles(e.dataTransfer.files); });
1270
+
1271
+ /* ---- transport ---- */
1272
+ async function play(){
1273
+ if(!clips.length || exporting) return;
1274
+ resumeAudio();
1275
+ if(elapsed() >= total() - 0.05) await goto(0);
1276
+ if(curIdx < 0) await goto(0);
1277
+ playing = true; $("joinPlay").innerHTML = "&#10074;&#10074;";
1278
+ cancelAnimationFrame(raf); draw();
1279
+ try{ await act().play(); }
1280
+ catch(e){ playing = false; $("joinPlay").innerHTML = "&#9654;"; say($("joinStatus"), "The browser blocked playback. Click anywhere on the page, then press play again.", "err"); }
1281
+ }
1282
+ function pause(){
1283
+ playing = false; $("joinPlay").innerHTML = "&#9654;";
1284
+ els.forEach(e => { try{ e.pause(); }catch(_){} });
1285
+ cancelAnimationFrame(raf);
1286
+ if(clips.length) draw();
1287
+ }
1288
+ $("joinPlay").addEventListener("click", () => playing ? pause() : play());
1289
+ $("joinRestart").addEventListener("click", async () => { pause(); await goto(0); redraw(); });
1290
+ $("joinBack").addEventListener("click", async () => { pause(); await goto(Math.max(0, elapsed() - 1)); redraw(); });
1291
+ $("joinFwd").addEventListener("click", async () => { pause(); await goto(Math.min(total() - 0.05, elapsed() + 1)); redraw(); });
1292
+ $("joinSeek").addEventListener("pointerdown", () => { dragging = true; });
1293
+ $("joinSeek").addEventListener("input", () => { if(total()) $("joinCur").textContent = tc(total() * $("joinSeek").value/1000); });
1294
+ $("joinSeek").addEventListener("change", async () => {
1295
+ const target = total() * (+$("joinSeek").value)/1000; // read before pausing, which repaints the slider
1296
+ dragging = false;
1297
+ if(!clips.length) return;
1298
+ pause(); await goto(target); redraw();
1299
+ });
1300
+
1301
+ ["joinSize","joinFit","joinScale","joinFps"].forEach(id => $(id).addEventListener("input", () => { readout(); redraw(); }));
1302
+ ["joinAudio","joinVol"].forEach(id => $(id).addEventListener("input", () => { applyAudio(); readout(); }));
1303
+
1304
+ function lock(on){
1305
+ ["joinSize","joinFit","joinScale","joinFps","joinAudio","joinClear"].forEach(id => { $(id).disabled = on; });
1306
+ document.querySelectorAll("#clipList button").forEach(b => b.disabled = on);
1307
+ document.querySelectorAll("#modeTabs button").forEach(b => b.disabled = on);
1308
+ jd.style.pointerEvents = on ? "none" : "";
1309
+ jd.style.opacity = on ? ".5" : "";
1310
+ }
1311
+
1312
+ /* ---- export ---- */
1313
+ $("joinStop").addEventListener("click", () => { cancel = true; say($("joinStatus"), "Stopping\u2026"); });
1314
+ $("joinExport").addEventListener("click", async () => {
1315
+ if(exporting) return;
1316
+ if(!clips.length){ say($("joinStatus"), "Add at least one video first.", "err"); return; }
1317
+ const tot = total();
1318
+ if(!isFinite(tot) || tot <= 0){ say($("joinStatus"), "Could not read the clip lengths.", "err"); return; }
1319
+
1320
+ exporting = true; cancel = false; lock(true);
1321
+ pause();
1322
+ $("joinExport").disabled = true; $("joinStop").style.display = "block";
1323
+ $("joinBar").style.display = "block"; $("joinBarI").style.width = "0%";
1324
+ say($("joinStatus"), "Recording\u2026 keep this tab visible.");
1325
+
1326
+ let session = null;
1327
+ try{
1328
+ held[0] = held[1] = -1; curIdx = -1;
1329
+ await goto(0);
1330
+ sizeCanvas();
1331
+ if(clips.length > 1) await loadInto(1 - activeEl, 1);
1332
+ session = startRecording(cvs, fps(), $("joinAudio").value === "none" ? [] : els);
1333
+ cancelAnimationFrame(raf); draw();
1334
+ playing = true;
1335
+ await act().play();
1336
+
1337
+ const started = performance.now();
1338
+ await new Promise(resolve => {
1339
+ const timer = setInterval(() => {
1340
+ const t = elapsed();
1341
+ $("joinBarI").style.width = (clamp(t/tot, 0, 1) * 100).toFixed(1) + "%";
1342
+ const done = curIdx >= clips.length - 1 && (act().ended || (act().currentTime || 0) >= clips[clips.length - 1].dur - 0.04);
1343
+ const overrun = (performance.now() - started) > tot*1000 + 20000;
1344
+ if(cancel || done || overrun){ clearInterval(timer); resolve(); }
1345
+ }, 80);
1346
+ });
1347
+ playing = false;
1348
+ els.forEach(e => { try{ e.pause(); }catch(_){} });
1349
+ await wait(240);
1350
+ const {blob, ext} = await session.finish();
1351
+
1352
+ if(cancel){ say($("joinStatus"), "Export stopped. Nothing was saved."); }
1353
+ else if(!blob.size){ say($("joinStatus"), "The export came out empty. Try a lower scale, or use Chrome or Edge.", "err"); }
1354
+ else{
1355
+ const name = safeName(baseName(clips[0].name) + "_joined", "joined") + "." + ext;
1356
+ const r = await saveBlob(blob, name, ext);
1357
+ say($("joinStatus"), r === "cancelled" ? "Save cancelled." : "Saved " + name + (session.hasAudio ? "" : " (no audio)"), r === "cancelled" ? "" : "ok");
1358
+ }
1359
+ }catch(e){
1360
+ if(session){ try{ await session.finish(); }catch(_){} }
1361
+ playing = false;
1362
+ els.forEach(e => { try{ e.pause(); }catch(_){} });
1363
+ say($("joinStatus"), e.message || "The export failed.", "err");
1364
+ }finally{
1365
+ exporting = false; lock(false);
1366
+ $("joinStop").style.display = "none"; $("joinBar").style.display = "none";
1367
+ $("joinPlay").innerHTML = "&#9654;";
1368
+ applyAudio();
1369
+ await goto(0);
1370
+ renderList(); redraw();
1371
+ }
1372
+ });
1373
+
1374
+ applyAudio(); renderList(); redraw();
1375
+ return {
1376
+ refresh(){ readout(); redraw(); },
1377
+ pause, play,
1378
+ get exporting(){ return exporting; },
1379
+ get playing(){ return playing; },
1380
+ step(dir){ if(!clips.length) return; pause(); goto(clamp(elapsed() + dir, 0, Math.max(0, total() - 0.05))).then(redraw); },
1381
+ home(){ if(!clips.length) return; pause(); goto(0).then(redraw); }
1382
+ };
1383
+ })();
1384
+
1385
+ /* ======================= keyboard ======================= */
1386
+ document.addEventListener("keydown", e => {
1387
+ const tag = (e.target.tagName || "").toLowerCase();
1388
+ if(tag === "input" || tag === "select" || tag === "textarea" || e.target.isContentEditable) return;
1389
+ if(e.metaKey || e.ctrlKey || e.altKey) return;
1390
+ const M = mode === "sbs" ? SBS : JOIN;
1391
+ if(M.exporting) return;
1392
+ if(e.code === "Space"){ e.preventDefault(); M.playing ? M.pause() : M.play(); }
1393
+ else if(e.code === "ArrowLeft"){ e.preventDefault(); M.step(-1); }
1394
+ else if(e.code === "ArrowRight"){ e.preventDefault(); M.step(1); }
1395
+ else if(e.code === "Home"){ e.preventDefault(); M.home(); }
1396
+ });
1397
+
1398
+ /* first click anywhere unlocks audio */
1399
+ document.addEventListener("pointerdown", function unlock(){ resumeAudio(); document.removeEventListener("pointerdown", unlock); }, {once:true});
1400
+
1401
+ /* warn before leaving mid-export */
1402
+ window.addEventListener("beforeunload", e => {
1403
+ if(SBS.exporting || JOIN.exporting){ e.preventDefault(); e.returnValue = ""; }
1404
+ });
1405
+
1406
+ if(!CHOSEN_MIME){
1407
+ say($("sbsStatus"), "This browser cannot record video. Chrome or Edge will work.", "err");
1408
+ say($("joinStatus"), "This browser cannot record video. Chrome or Edge will work.", "err");
1409
+ }
1410
+ })();
1411
+ </script>
1412
+ </body>
1413
+ </html>