File size: 21,882 Bytes
d152cbc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Pocket Video Joiner</title>
<style>
:root{--bg:#0e1116;--p:#161b22;--p2:#1c232d;--l:#2a323d;--t:#e6edf3;--d:#8b98a5;--a:#4cc2ff;--warn:#d29922;--red:#7f1018}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--t);font:14px -apple-system,BlinkMacSystemFont,"Segoe UI",Arial,sans-serif}body.dragging .wrap{opacity:.35;filter:blur(1px)}.wrap{max-width:1180px;margin:auto;padding:20px;transition:.15s}header{display:flex;align-items:center;gap:12px;margin-bottom:16px}h1{font-size:17px;margin:0}.sub,.by,.hint,#status{color:var(--d);font-size:12px}.spacer{flex:1}.ver{border:1px solid var(--l);border-radius:99px;padding:3px 10px;background:var(--p);color:var(--d)}button{background:var(--p2);color:var(--t);border:1px solid var(--l);border-radius:8px;padding:7px 12px;cursor:pointer}button:hover{border-color:#465467}.primary{background:var(--a);border-color:var(--a);color:#04121c;font-weight:700}.danger{color:#ffb4ae}.icon{font-size:18px;width:40px;height:34px;display:inline-flex;align-items:center;justify-content:center}.grid{display:grid;grid-template-columns:1fr 330px;gap:18px}@media(max-width:900px){.grid{grid-template-columns:1fr}}#drop{border:2px dashed var(--l);border-radius:14px;padding:38px 20px;text-align:center;background:var(--p);margin-bottom:14px;cursor:pointer}#drop.over{border-color:var(--a);background:#12212b}#drop.hidden{display:none}.card,.box{background:var(--p);border:1px solid var(--l);border-radius:10px;padding:14px}.box{background:#11161d;margin-bottom:14px}h3{margin:0 0 12px;color:var(--d);letter-spacing:.7px;font-size:12px;text-transform:uppercase}.stage{background:#000;border:1px solid var(--l);border-radius:10px;min-height:300px;display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden}canvas{max-width:100%;max-height:62vh;background:#000}.badge{position:absolute;top:10px;left:10px;background:#000a;color:#cfe;border-radius:6px;padding:4px 9px;font-size:12px}.transport{display:flex;gap:8px;align-items:center;margin:12px 0;flex-wrap:wrap}.tc{font-variant-numeric:tabular-nums;color:var(--d)}input[type=range]{accent-color:var(--a);flex:1 1 240px}.row{display:flex;gap:8px;align-items:center;margin-bottom:10px}.row label{width:105px;color:var(--d);font-size:12px}input[type=text],input[type=number],select{width:100%;background:var(--p2);color:var(--t);border:1px solid var(--l);border-radius:7px;padding:7px}.addbar{display:none;align-items:center;justify-content:space-between;gap:8px;background:var(--p2);border:1px solid var(--l);border-radius:8px;padding:8px 10px;margin-bottom:10px;color:var(--d);font-size:12px}.addbar.show{display:flex}.clip{display:grid;grid-template-columns:30px 96px 1fr auto;gap:10px;align-items:center;border-top:1px solid var(--l);padding:8px;background:var(--p)}.clip:first-child{border-top:none}.clip.over{outline:2px solid var(--a);outline-offset:-2px}.thumb{width:96px;height:54px;object-fit:contain;background:#000;border:1px solid var(--l);border-radius:6px}.name{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.meta{min-width:0}.small{padding:4px 8px;font-size:12px}.bar{display:none;height:6px;background:var(--p2);border-radius:99px;overflow:hidden;margin-top:10px}.bar i{display:block;height:100%;width:0;background:var(--a)}.note{color:var(--warn);font-size:11.5px;line-height:1.4}.hiddenvid{position:fixed;left:-9999px;width:2px;height:2px;opacity:.01}.empty{padding:14px;text-align:center;color:var(--d)}#dragOverlay{display:none;position:fixed;inset:0;z-index:9999;align-items:center;justify-content:center;pointer-events:none}body.dragging #dragOverlay{display:flex}.dropCard{border:3px dashed var(--a);border-radius:18px;background:#0e1116e8;color:var(--t);padding:46px 70px;text-align:center;box-shadow:0 20px 60px #000a}.dropCard h2{margin:0 0 8px;font-size:24px}.audioName{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--d);font-size:12px;margin-top:6px}</style>
</head>
<body>
<div id="dragOverlay"><div class="dropCard"><h2>Drop videos here</h2><div class="hint">Release to add them to the join list</div></div></div>
<div class="wrap">
<header><h1>Pocket Video Joiner</h1><span class="sub">join clips in order</span><span class="spacer"></span><span class="ver">v1.6</span><span class="by">By C_Nugget</span></header>
<div id="drop"><h2>Drop videos here</h2><p class="hint">or click to choose multiple files</p><input id="file" type="file" accept="video/*" multiple hidden></div>
<div class="grid"><main><div class="stage"><canvas id="c"></canvas><div id="badge" class="badge">Add videos to start</div></div><div class="transport"><button id="play"></button><button id="restart">Restart</button><button id="muteBtn" class="icon" title="Mute/unmute playback">🔊</button><span class="tc"><b id="now">0:00.00</b> / <span id="total">0:00.00</span></span><input id="seek" type="range" min="0" max="1000" value="0"></div><div class="box"><h3>Clip Order</h3><div id="addbar" class="addbar"><span>Drop more videos anywhere on this page, or click Add videos.</span><button id="add" class="small">+ Add videos</button></div><div id="list"><div class="empty">No videos loaded yet.</div></div></div></main><aside><div class="card"><div class="box"><h3>Text Overlay</h3><div class="row"><label>Mode</label><select id="ovMode"><option value="none">None</option><option value="clip" selected>Use each clip's text</option><option value="global">Use global text</option><option value="name">Use file name</option></select></div><div class="row"><label>Global text</label><input id="ovGlobal" type="text" placeholder="Text for all clips"></div><div class="row"><label>Position</label><select id="ovPos"><option value="tl">Top left</option><option value="tr">Top right</option><option value="bl">Bottom left</option><option value="br">Bottom right</option><option value="ct">Center top</option></select></div><p class="hint">Choose None to disable overlays. In clip mode, blank clip text falls back to global text.</p></div><div class="box"><h3>Global Audio</h3><button id="audioBtn" class="small" style="width:100%">Choose global audio</button><input id="audioFile" type="file" accept="audio/*" hidden><div id="audioName" class="audioName">No global audio selected</div><p class="hint">Playback always uses clip audio plus global audio. The speaker button mutes/unmutes both. Global audio starts at the beginning and stops when the joined video ends.</p></div><div class="box"><h3>Output</h3><div class="row"><label>Scale</label><select id="scale"><option value="1" selected>100%</option><option value=".75">75%</option><option value=".5">50%</option></select></div><div class="row"><label>FPS</label><input id="fps" type="number" value="30" min="1" max="60"></div><div class="row"><label>Export audio</label><select id="outAud"><option value="both" selected>Clip + global</option><option value="clip">Clip only</option><option value="global">Global only</option><option value="none">No audio</option></select></div><button id="export" class="primary" style="width:100%">Export joined video</button><button id="stop" class="danger" style="display:none;width:100%;margin-top:8px">Stop export</button><div id="bar" class="bar"><i></i></div><div id="status"></div><p class="note">Reliable WebM export is preferred for joining. Audio is mixed through Web Audio for export.</p></div></div></aside></div></div>
<video id="v" class="hiddenvid" playsinline preload="auto"></video><audio id="ga" preload="auto"></audio>
<script>
(()=>{
'use strict';
const $=id=>document.getElementById(id), v=$('v'), ga=$('ga'), c=$('c'), x=c.getContext('2d',{alpha:false});
let clips=[], cur=0, playing=false, exporting=false, stopExp=false, draggingSeek=false, dragDepth=0, globalAudioUrl=null, playbackMuted=false, audioCtx=null, clipSource=null, globalSource=null, mixDest=null;
const wait=(el,ev)=>new Promise(r=>el.addEventListener(ev,r,{once:true}));
const clamp=(n,a,b)=>Math.min(b,Math.max(a,n));
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+':':'')+(h?String(m).padStart(2,'0'):m)+':'+(s<10?'0':'')+s.toFixed(2)}
function totalDuration(){return clips.reduce((a,b)=>a+b.d,0)}
function startOf(i){let t=0;for(let n=0;n<i;n++)t+=clips[n].d;return t}
function globalTime(){return startOf(cur)+(v.currentTime||0)}
function clipAt(t){let a=0;for(let i=0;i<clips.length;i++){if(t<a+clips[i].d||i===clips.length-1)return{i,t:clamp(t-a,0,clips[i].d)};a+=clips[i].d}return{i:0,t:0}}
function dims(){if(!clips.length)return{w:1280,h:720};let sc=parseFloat($('scale').value)||1,W=Math.max(...clips.map(q=>q.w)),H=Math.max(...clips.map(q=>q.h));return{w:Math.round(W*sc),h:Math.round(H*sc)}}
function setCanvas(){let d=dims();if(c.width!==d.w||c.height!==d.h){c.width=d.w;c.height=d.h}$('total').textContent=tc(totalDuration());$('badge').textContent=clips.length?clips.length+' clips · '+tc(totalDuration()):'Add videos to start'}
function fit(){setCanvas();x.fillStyle='#000';x.fillRect(0,0,c.width,c.height);if(v.videoWidth){let s=Math.min(c.width/v.videoWidth,c.height/v.videoHeight),w=v.videoWidth*s,h=v.videoHeight*s;x.drawImage(v,(c.width-w)/2,(c.height-h)/2,w,h)}}
function overlayText(){let mode=$('ovMode').value;if(mode==='none'||!clips[cur])return'';let global=($('ovGlobal').value||'').trim();if(mode==='global')return global;if(mode==='name')return clips[cur].name;return (clips[cur].text||'').trim()||global}
function drawText(t){if(!t)return;let f=Math.max(13,Math.round(c.height/42)),px=10,py=6,g=10;x.font='600 '+f+'px -apple-system,BlinkMacSystemFont,Segoe UI,Arial';while(x.measureText(t).width>c.width-24&&t.length>3)t=t.slice(0,-2);let w=x.measureText(t).width+px*2,h=f+py*2,bx=g,by=g,p=$('ovPos').value;if(p==='tr')bx=c.width-w-g;if(p==='bl')by=c.height-h-g;if(p==='br'){bx=c.width-w-g;by=c.height-h-g}if(p==='ct')bx=(c.width-w)/2;x.fillStyle='rgba(127,16,24,.94)';x.fillRect(bx,by,w,h);x.fillStyle='#fff';x.textBaseline='top';x.fillText(t,bx+px,by+py)}
function draw(){fit();drawText(overlayText());let gt=globalTime();$('now').textContent=tc(gt);if(!draggingSeek&&totalDuration())$('seek').value=Math.round(gt/totalDuration()*1000);if(playing||exporting)requestAnimationFrame(draw)}
async function meta(video){if(video.readyState>=1)return;await Promise.race([wait(video,'loadedmetadata'),wait(video,'error'),new Promise(r=>setTimeout(r,8000))])}
async function data(video){if(video.readyState>=2)return;await Promise.race([wait(video,'loadeddata'),wait(video,'canplay'),new Promise(r=>setTimeout(r,3000))])}
async function makeThumb(cl){let tv=document.createElement('video');tv.src=cl.url;tv.muted=true;tv.playsInline=true;await data(tv);let ca=document.createElement('canvas');ca.width=192;ca.height=108;let q=ca.getContext('2d');q.fillStyle='#000';q.fillRect(0,0,192,108);if(tv.videoWidth){let s=Math.min(192/tv.videoWidth,108/tv.videoHeight),w=tv.videoWidth*s,h=tv.videoHeight*s;q.drawImage(tv,(192-w)/2,(108-h)/2,w,h)}cl.thumb=ca.toDataURL('image/jpeg',.82)}
async function addFiles(files){let added=0;for(let f of files){if(!f.type.startsWith('video/'))continue;let u=URL.createObjectURL(f),tv=document.createElement('video');tv.src=u;tv.preload='metadata';tv.playsInline=true;await meta(tv);if(!tv.videoWidth||!isFinite(tv.duration)||tv.duration<=0){URL.revokeObjectURL(u);continue}let cl={id:Date.now()+Math.random(),url:u,name:f.name.replace(/\.[^.]+$/,''),d:tv.duration,w:tv.videoWidth,h:tv.videoHeight,text:'',thumb:''};await makeThumb(cl);clips.push(cl);added++}render();if(clips.length){await load(Math.max(0,clips.length-added),0);$('status').textContent=added?('Added '+added+' video'+(added===1?'':'s')+'.'):'No readable videos found.'}else $('status').textContent='No readable videos found.'}
function render(){let list=$('list');list.innerHTML='';$('drop').classList.toggle('hidden',clips.length>0);$('addbar').classList.toggle('show',clips.length>0);if(!clips.length){list.innerHTML='<div class="empty">No videos loaded yet.</div>';setCanvas();return}clips.forEach((cl,i)=>{let row=document.createElement('div');row.className='clip';row.draggable=true;row.innerHTML=`<div>${i+1}</div><img class="thumb" src="${cl.thumb}"><div class="meta"><div class="name">${cl.name}</div><div class="hint">${tc(cl.d)} · ${cl.w}×${cl.h}</div><input type="text" placeholder="Overlay text for this clip" value="${cl.text.replace(/"/g,'&quot;')}"></div><div><button class="small up">↑</button><button class="small dn">↓</button><button class="small pl">▶</button><button class="small danger del">✕</button></div>`;row.querySelector('input').oninput=e=>{cl.text=e.target.value;draw()};row.querySelector('.up').onclick=()=>move(i,i-1);row.querySelector('.dn').onclick=()=>move(i,i+1);row.querySelector('.pl').onclick=async()=>{pause();await load(i,0);play()};row.querySelector('.del').onclick=()=>{URL.revokeObjectURL(cl.url);clips.splice(i,1);cur=Math.min(cur,clips.length-1);render();clips.length?load(Math.max(cur,0),0):draw()};row.ondragstart=e=>e.dataTransfer.setData('text/plain',i);row.ondragover=e=>{e.preventDefault();row.classList.add('over')};row.ondragleave=()=>row.classList.remove('over');row.ondrop=e=>{e.preventDefault();row.classList.remove('over');move(+e.dataTransfer.getData('text/plain'),i)};list.appendChild(row)});setCanvas()}
function move(a,b){if(b<0||b>=clips.length)return;let [q]=clips.splice(a,1);clips.splice(b,0,q);if(cur===a)cur=b;render();draw()}
function updateMuteButton(){v.muted=playbackMuted;ga.muted=playbackMuted;$('muteBtn').textContent=playbackMuted?'🔇':'🔊'}
async function load(i,t){if(!clips[i])return;cur=i;v.src=clips[i].url;v.muted=playbackMuted;await data(v);try{v.currentTime=clamp(t,0,clips[i].d);await Promise.race([wait(v,'seeked'),new Promise(r=>setTimeout(r,500))])}catch(e){}draw()}
async function play(){if(!clips.length||exporting)return;updateMuteButton();playing=true;$('play').textContent='❚❚';draw();try{await v.play();if(globalAudioUrl){ga.currentTime=Math.min(globalTime(),ga.duration||globalTime());await ga.play().catch(()=>{})}}catch(e){$('status').textContent='Playback blocked. Click again.'}}
function pause(){playing=false;$('play').textContent='▶';v.pause();ga.pause();draw()}
v.onended=async()=>{if(cur<clips.length-1){await load(cur+1,0);if(playing||exporting){await v.play().catch(()=>{});if(globalAudioUrl)ga.play().catch(()=>{})}}else{stopExp=exporting;pause();ga.pause()}}
async function seekTo(t){let p=clipAt(t);pause();await load(p.i,p.t);if(globalAudioUrl)ga.currentTime=Math.min(t,ga.duration||t)}
function getMime(){for(let m of ['video/webm;codecs=vp9,opus','video/webm;codecs=vp8,opus','video/webm','video/mp4;codecs=avc1.42E01E,mp4a.40.2','video/mp4'])if(MediaRecorder.isTypeSupported(m))return m;return''}

async function addAudioTracks(stream,mode){
  if(mode==='none')return false;
  const AC=window.AudioContext||window.webkitAudioContext;
  if(!AC)return false;
  if(!audioCtx){audioCtx=new AC();mixDest=audioCtx.createMediaStreamDestination()}
  if(audioCtx.state==='suspended')await audioCtx.resume().catch(()=>{});
  if(!clipSource)clipSource=audioCtx.createMediaElementSource(v);
  if(!globalSource)globalSource=audioCtx.createMediaElementSource(ga);
  try{clipSource.disconnect()}catch(e){}
  try{globalSource.disconnect()}catch(e){}
  if(mode==='both'||mode==='clip'){
    clipSource.connect(mixDest);
    clipSource.connect(audioCtx.destination);
  }
  if((mode==='both'||mode==='global')&&globalAudioUrl){
    globalSource.connect(mixDest);
    globalSource.connect(audioCtx.destination);
  }
  const tracks=mixDest.stream.getAudioTracks();
  tracks.forEach(t=>stream.addTrack(t));
  return tracks.length>0;
}

async function saveBlob(blob,ext){let name='joined_video.'+ext;if(typeof window.showSaveFilePicker==="function"){try{let h=await showSaveFilePicker({suggestedName:name});let w=await h.createWritable();await w.write(blob);await w.close();return}catch(e){if(e.name==='AbortError')return}}let a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=name;a.click()}

async function exportVideo(){
  if(!clips.length||exporting)return;
  if(!c.captureStream||!window.MediaRecorder){$('status').textContent='Use Chrome or Edge for export.';return}
  let mt=getMime();
  if(!mt){$('status').textContent='No recorder support in this browser.';return}
  const chosenMode=$('outAud').value;
  const chosenFps=Math.max(1,Math.min(60,parseInt($('fps').value)||30));
  async function recordOnce(mimeType,mode){
    stopExp=false;pause();await load(0,0);draw();
    $('export').disabled=true;$('stop').style.display='block';$('bar').style.display='block';$('bar').firstElementChild.style.width='0%';
    $('status').textContent='Exporting... keep this tab visible.';
    let stream=c.captureStream(0), videoTrack=stream.getVideoTracks()[0];
    try{if(mode!=='none')addAudioTracks(stream,mode)}catch(e){mode='none'}
    let rec,chunks=[];
    try{rec=new MediaRecorder(stream,{mimeType:mimeType,videoBitsPerSecond:Math.max(1200000,Math.round(c.width*c.height*chosenFps*0.12))})}
    catch(e){stream.getTracks().forEach(t=>t.stop());throw e}
    rec.ondataavailable=e=>{if(e.data&&e.data.size)chunks.push(e.data)};
    let stopped=new Promise(r=>rec.onstop=r);
    try{rec.start(500)}catch(e){stream.getTracks().forEach(t=>t.stop());throw e}
    exporting=true;playing=true;$('play').textContent='❚❚';
    v.muted=(mode==='none');
    ga.muted=(mode==='none');
    if((mode==='both'||mode==='global')&&globalAudioUrl){ga.currentTime=0;await ga.play().catch(()=>{})}
    await v.play().catch(()=>{});
    let stoppedAlready=false;
    function stopNow(){
      if(stoppedAlready)return;stoppedAlready=true;
      try{rec.requestData()}catch(e){}
      try{rec.stop()}catch(e){}
    }
    let frameMs=Math.max(16,Math.round(1000/chosenFps));
    let frameTimer=setInterval(()=>{
      draw();
      try{if(videoTrack&&videoTrack.requestFrame)videoTrack.requestFrame()}catch(e){}
      let p=totalDuration()?globalTime()/totalDuration():0;
      $('bar').firstElementChild.style.width=Math.min(100,p*100)+'%';
      if(stopExp||p>=.999)stopNow();
    },frameMs);
    try{if(videoTrack&&videoTrack.requestFrame)videoTrack.requestFrame()}catch(e){}
    await stopped;
    clearInterval(frameTimer);
    stream.getTracks().forEach(t=>{try{t.stop()}catch(e){}});
    ga.pause();v.pause();
    return {chunks,type:mimeType.split(';')[0],mode};
  }
  try{
    exporting=true;
    let result;
    try{result=await recordOnce(mt,chosenMode)}
    catch(e){
      $('status').textContent='Audio export failed, retrying video-only WebM...';
      result=await recordOnce('video/webm','none');
    }
    if(!result.chunks.length){
      $('status').textContent='Empty export detected, retrying video-only WebM...';
      result=await recordOnce('video/webm','none');
    }
    exporting=false;playing=false;$('export').disabled=false;$('stop').style.display='none';$('bar').style.display='none';$('play').textContent='▶';updateMuteButton();
    if(!result.chunks.length){$('status').textContent='Export produced empty file. Try Scale 50% and Export audio: No audio.';return}
    let ext=result.type.includes('mp4')?'mp4':'webm';
    await saveBlob(new Blob(result.chunks,{type:result.type}),ext);
    $('status').textContent='Export complete'+(result.mode==='none'?' (no audio)':'');
    draw();
  }catch(e){
    exporting=false;playing=false;$('export').disabled=false;$('stop').style.display='none';$('bar').style.display='none';$('play').textContent='▶';updateMuteButton();
    $('status').textContent='Export failed: '+(e&&e.message?e.message:'unknown error');
  }
}

function isFileDrag(e){return e.dataTransfer&&Array.from(e.dataTransfer.types||[]).includes('Files')}
function dropped(e){e.preventDefault();dragDepth=0;document.body.classList.remove('dragging');$('drop').classList.remove('over');let fs=[...(e.dataTransfer?.files||[])];if(fs.length)addFiles(fs)}
$('drop').onclick=()=>$('file').click();$('add').onclick=()=>$('file').click();$('file').onchange=e=>{let fs=[...e.target.files];e.target.value='';if(fs.length)addFiles(fs)};$('audioBtn').onclick=()=>$('audioFile').click();$('audioFile').onchange=e=>{let f=e.target.files[0];if(!f)return;if(globalAudioUrl)URL.revokeObjectURL(globalAudioUrl);globalAudioUrl=URL.createObjectURL(f);ga.src=globalAudioUrl;ga.muted=playbackMuted;$('audioName').textContent=f.name;e.target.value=''};
['dragenter','dragover'].forEach(ev=>$('drop').addEventListener(ev,e=>{e.preventDefault();$('drop').classList.add('over')}));['dragleave','drop'].forEach(ev=>$('drop').addEventListener(ev,e=>{e.preventDefault();$('drop').classList.remove('over')}));$('drop').addEventListener('drop',dropped);
window.addEventListener('dragenter',e=>{if(isFileDrag(e)){dragDepth++;document.body.classList.add('dragging')}});window.addEventListener('dragover',e=>{if(isFileDrag(e)){e.preventDefault();document.body.classList.add('dragging')}});window.addEventListener('dragleave',e=>{dragDepth=Math.max(0,dragDepth-1);if(dragDepth===0)document.body.classList.remove('dragging')});window.addEventListener('drop',dropped);
$('play').onclick=()=>playing?pause():play();$('restart').onclick=()=>seekTo(0);$('muteBtn').onclick=()=>{playbackMuted=!playbackMuted;updateMuteButton()};$('seek').onpointerdown=()=>draggingSeek=true;$('seek').onpointerup=()=>{draggingSeek=false;seekTo(totalDuration()*$('seek').value/1000)};$('seek').oninput=()=>{if(draggingSeek)$('now').textContent=tc(totalDuration()*$('seek').value/1000)};$('export').onclick=exportVideo;$('stop').onclick=()=>stopExp=true;['scale','fps','ovMode','ovGlobal','ovPos'].forEach(id=>{$(id).oninput=draw;$(id).onchange=draw});
updateMuteButton();render();draw();
})();
</script>
</body>
</html>