Upload 3 files
Browse files- Pocket_Auto_Splitter_v1.3.html +69 -0
- Pocket_Image_Converter_v1_4.html +1120 -0
- Pocket_Video_Combiner_v1_0.html +1413 -0
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 & more · convert · resize · 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">▢</div>
|
| 161 |
+
<h2>Drop images here to convert</h2>
|
| 162 |
+
<p>HEIC / HEIF · JPEG / JFIF · PNG · WebP · AVIF · GIF · BMP · TIFF* · ICO · add as many as you like · 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">←</span> <span class="kbd">→</span> move between images ·
|
| 204 |
+
<span class="kbd">Space</span> toggle selected ·
|
| 205 |
+
<span class="kbd">R</span> rotate · <span class="kbd">Del</span> remove ·
|
| 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 · 256 colours</option>
|
| 239 |
+
<option value="128">Smaller · 128 colours</option>
|
| 240 |
+
<option value="64">Smallest · 64 colours</option>
|
| 241 |
+
<option value="32">Tiny · 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">▼</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 & flip <span class="chev">▼</span></h3>
|
| 281 |
+
<div class="collapbody shut" id="adjBody">
|
| 282 |
+
<div class="toolbtns">
|
| 283 |
+
<button id="rotL" title="Rotate left">↶ Left</button>
|
| 284 |
+
<button id="rotR" title="Rotate right (R)">Right ↷</button>
|
| 285 |
+
</div>
|
| 286 |
+
<div class="toolbtns">
|
| 287 |
+
<button id="flipH">⇆ Flip H</button>
|
| 288 |
+
<button id="flipV">⇅ 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">▼</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">▼</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 · <code>{name}</code> original name · <code>{date}</code> today · <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">—</b></div>
|
| 340 |
+
<div><span>Output format</span><b id="eFmt">PNG</b></div>
|
| 341 |
+
<div><span>Selected output</span><b id="eDims">—</b></div>
|
| 342 |
+
<div><span>Est. size (selected)</span><b id="eBytes">—</b></div>
|
| 343 |
+
</div>
|
| 344 |
+
<button id="exportBtn" class="primary expBtn">Convert & 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 & HEIC support <span class="chev">▼</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 & all offline: your browser's native decoder → the OS codec via WebCodecs → 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 = "×"; 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 = "×"; 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 · 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">×</button>
|
| 192 |
+
<canvas class="thumb" id="thumb1"></canvas>
|
| 193 |
+
<div class="big" id="big1">▢</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">⇆</button>
|
| 198 |
+
<div class="drop" id="drop2">
|
| 199 |
+
<button class="clearx" id="clear2" title="Remove this file">×</button>
|
| 200 |
+
<canvas class="thumb" id="thumb2"></canvas>
|
| 201 |
+
<div class="big" id="big2">▢</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)">▶</button>
|
| 215 |
+
<button id="sbsPrevF" class="icobtn ghost" title="Previous frame (Left arrow)">⇤</button>
|
| 216 |
+
<button id="sbsNextF" class="icobtn ghost" title="Next frame (Right arrow)">⇥</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">▼</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">—</b></div>
|
| 289 |
+
<div><span>Duration</span><b id="sbsOutDur">—</b></div>
|
| 290 |
+
<div><span>Rough file size</span><b id="sbsOutBytes">—</b></div>
|
| 291 |
+
<div><span>Format</span><b id="sbsFmt">—</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">▢</div>
|
| 307 |
+
<h2>Drop videos here to add them to the sequence</h2>
|
| 308 |
+
<p>or click to choose files · you can add several at once · 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)">▶</button>
|
| 320 |
+
<button id="joinBack" class="icobtn ghost" title="Back 1 second (Left arrow)">⇤</button>
|
| 321 |
+
<button id="joinFwd" class="icobtn ghost" title="Forward 1 second (Right arrow)">⇥</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 × 1080</option>
|
| 346 |
+
<option value="1280x720">1280 × 720</option>
|
| 347 |
+
<option value="1080x1920">1080 × 1920 (vertical)</option>
|
| 348 |
+
<option value="1080x1080">1080 × 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">—</b></div>
|
| 373 |
+
<div><span>Duration</span><b id="joinOutDur">—</b></div>
|
| 374 |
+
<div><span>Rough file size</span><b id="joinOutBytes">—</b></div>
|
| 375 |
+
<div><span>Format</span><b id="joinFmt">—</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 ·
|
| 389 |
+
<span class="kbd">←</span> <span class="kbd">→</span> step back or forward ·
|
| 390 |
+
<span class="kbd">Home</span> back to the start ·
|
| 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 = "❚❚";
|
| 898 |
+
cancelAnimationFrame(raf); draw();
|
| 899 |
+
try{ await Promise.all(videos().map(x => x.play())); }
|
| 900 |
+
catch(e){ playing = false; $("sbsPlay").innerHTML = "▶"; 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 = "▶";
|
| 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 = "▶";
|
| 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 = "▶"; 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("▲", "Move up", () => move(i, -1), i === 0));
|
| 1191 |
+
btns.appendChild(mk("▼", "Move down", () => move(i, 1), i === clips.length - 1));
|
| 1192 |
+
const del = mk("×", "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 = "❚❚";
|
| 1278 |
+
cancelAnimationFrame(raf); draw();
|
| 1279 |
+
try{ await act().play(); }
|
| 1280 |
+
catch(e){ playing = false; $("joinPlay").innerHTML = "▶"; 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 = "▶";
|
| 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 = "▶";
|
| 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>
|