Upload pocket_video_joiner_v1.6.html
Browse files- pocket_video_joiner_v1.6.html +153 -0
pocket_video_joiner_v1.6.html
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 Joiner</title>
|
| 7 |
+
<style>
|
| 8 |
+
: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>
|
| 9 |
+
</head>
|
| 10 |
+
<body>
|
| 11 |
+
<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>
|
| 12 |
+
<div class="wrap">
|
| 13 |
+
<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>
|
| 14 |
+
<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>
|
| 15 |
+
<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>
|
| 16 |
+
<video id="v" class="hiddenvid" playsinline preload="auto"></video><audio id="ga" preload="auto"></audio>
|
| 17 |
+
<script>
|
| 18 |
+
(()=>{
|
| 19 |
+
'use strict';
|
| 20 |
+
const $=id=>document.getElementById(id), v=$('v'), ga=$('ga'), c=$('c'), x=c.getContext('2d',{alpha:false});
|
| 21 |
+
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;
|
| 22 |
+
const wait=(el,ev)=>new Promise(r=>el.addEventListener(ev,r,{once:true}));
|
| 23 |
+
const clamp=(n,a,b)=>Math.min(b,Math.max(a,n));
|
| 24 |
+
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)}
|
| 25 |
+
function totalDuration(){return clips.reduce((a,b)=>a+b.d,0)}
|
| 26 |
+
function startOf(i){let t=0;for(let n=0;n<i;n++)t+=clips[n].d;return t}
|
| 27 |
+
function globalTime(){return startOf(cur)+(v.currentTime||0)}
|
| 28 |
+
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}}
|
| 29 |
+
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)}}
|
| 30 |
+
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'}
|
| 31 |
+
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)}}
|
| 32 |
+
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}
|
| 33 |
+
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)}
|
| 34 |
+
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)}
|
| 35 |
+
async function meta(video){if(video.readyState>=1)return;await Promise.race([wait(video,'loadedmetadata'),wait(video,'error'),new Promise(r=>setTimeout(r,8000))])}
|
| 36 |
+
async function data(video){if(video.readyState>=2)return;await Promise.race([wait(video,'loadeddata'),wait(video,'canplay'),new Promise(r=>setTimeout(r,3000))])}
|
| 37 |
+
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)}
|
| 38 |
+
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.'}
|
| 39 |
+
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,'"')}"></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()}
|
| 40 |
+
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()}
|
| 41 |
+
function updateMuteButton(){v.muted=playbackMuted;ga.muted=playbackMuted;$('muteBtn').textContent=playbackMuted?'🔇':'🔊'}
|
| 42 |
+
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()}
|
| 43 |
+
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.'}}
|
| 44 |
+
function pause(){playing=false;$('play').textContent='▶';v.pause();ga.pause();draw()}
|
| 45 |
+
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()}}
|
| 46 |
+
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)}
|
| 47 |
+
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''}
|
| 48 |
+
|
| 49 |
+
async function addAudioTracks(stream,mode){
|
| 50 |
+
if(mode==='none')return false;
|
| 51 |
+
const AC=window.AudioContext||window.webkitAudioContext;
|
| 52 |
+
if(!AC)return false;
|
| 53 |
+
if(!audioCtx){audioCtx=new AC();mixDest=audioCtx.createMediaStreamDestination()}
|
| 54 |
+
if(audioCtx.state==='suspended')await audioCtx.resume().catch(()=>{});
|
| 55 |
+
if(!clipSource)clipSource=audioCtx.createMediaElementSource(v);
|
| 56 |
+
if(!globalSource)globalSource=audioCtx.createMediaElementSource(ga);
|
| 57 |
+
try{clipSource.disconnect()}catch(e){}
|
| 58 |
+
try{globalSource.disconnect()}catch(e){}
|
| 59 |
+
if(mode==='both'||mode==='clip'){
|
| 60 |
+
clipSource.connect(mixDest);
|
| 61 |
+
clipSource.connect(audioCtx.destination);
|
| 62 |
+
}
|
| 63 |
+
if((mode==='both'||mode==='global')&&globalAudioUrl){
|
| 64 |
+
globalSource.connect(mixDest);
|
| 65 |
+
globalSource.connect(audioCtx.destination);
|
| 66 |
+
}
|
| 67 |
+
const tracks=mixDest.stream.getAudioTracks();
|
| 68 |
+
tracks.forEach(t=>stream.addTrack(t));
|
| 69 |
+
return tracks.length>0;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
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()}
|
| 73 |
+
|
| 74 |
+
async function exportVideo(){
|
| 75 |
+
if(!clips.length||exporting)return;
|
| 76 |
+
if(!c.captureStream||!window.MediaRecorder){$('status').textContent='Use Chrome or Edge for export.';return}
|
| 77 |
+
let mt=getMime();
|
| 78 |
+
if(!mt){$('status').textContent='No recorder support in this browser.';return}
|
| 79 |
+
const chosenMode=$('outAud').value;
|
| 80 |
+
const chosenFps=Math.max(1,Math.min(60,parseInt($('fps').value)||30));
|
| 81 |
+
async function recordOnce(mimeType,mode){
|
| 82 |
+
stopExp=false;pause();await load(0,0);draw();
|
| 83 |
+
$('export').disabled=true;$('stop').style.display='block';$('bar').style.display='block';$('bar').firstElementChild.style.width='0%';
|
| 84 |
+
$('status').textContent='Exporting... keep this tab visible.';
|
| 85 |
+
let stream=c.captureStream(0), videoTrack=stream.getVideoTracks()[0];
|
| 86 |
+
try{if(mode!=='none')addAudioTracks(stream,mode)}catch(e){mode='none'}
|
| 87 |
+
let rec,chunks=[];
|
| 88 |
+
try{rec=new MediaRecorder(stream,{mimeType:mimeType,videoBitsPerSecond:Math.max(1200000,Math.round(c.width*c.height*chosenFps*0.12))})}
|
| 89 |
+
catch(e){stream.getTracks().forEach(t=>t.stop());throw e}
|
| 90 |
+
rec.ondataavailable=e=>{if(e.data&&e.data.size)chunks.push(e.data)};
|
| 91 |
+
let stopped=new Promise(r=>rec.onstop=r);
|
| 92 |
+
try{rec.start(500)}catch(e){stream.getTracks().forEach(t=>t.stop());throw e}
|
| 93 |
+
exporting=true;playing=true;$('play').textContent='❚❚';
|
| 94 |
+
v.muted=(mode==='none');
|
| 95 |
+
ga.muted=(mode==='none');
|
| 96 |
+
if((mode==='both'||mode==='global')&&globalAudioUrl){ga.currentTime=0;await ga.play().catch(()=>{})}
|
| 97 |
+
await v.play().catch(()=>{});
|
| 98 |
+
let stoppedAlready=false;
|
| 99 |
+
function stopNow(){
|
| 100 |
+
if(stoppedAlready)return;stoppedAlready=true;
|
| 101 |
+
try{rec.requestData()}catch(e){}
|
| 102 |
+
try{rec.stop()}catch(e){}
|
| 103 |
+
}
|
| 104 |
+
let frameMs=Math.max(16,Math.round(1000/chosenFps));
|
| 105 |
+
let frameTimer=setInterval(()=>{
|
| 106 |
+
draw();
|
| 107 |
+
try{if(videoTrack&&videoTrack.requestFrame)videoTrack.requestFrame()}catch(e){}
|
| 108 |
+
let p=totalDuration()?globalTime()/totalDuration():0;
|
| 109 |
+
$('bar').firstElementChild.style.width=Math.min(100,p*100)+'%';
|
| 110 |
+
if(stopExp||p>=.999)stopNow();
|
| 111 |
+
},frameMs);
|
| 112 |
+
try{if(videoTrack&&videoTrack.requestFrame)videoTrack.requestFrame()}catch(e){}
|
| 113 |
+
await stopped;
|
| 114 |
+
clearInterval(frameTimer);
|
| 115 |
+
stream.getTracks().forEach(t=>{try{t.stop()}catch(e){}});
|
| 116 |
+
ga.pause();v.pause();
|
| 117 |
+
return {chunks,type:mimeType.split(';')[0],mode};
|
| 118 |
+
}
|
| 119 |
+
try{
|
| 120 |
+
exporting=true;
|
| 121 |
+
let result;
|
| 122 |
+
try{result=await recordOnce(mt,chosenMode)}
|
| 123 |
+
catch(e){
|
| 124 |
+
$('status').textContent='Audio export failed, retrying video-only WebM...';
|
| 125 |
+
result=await recordOnce('video/webm','none');
|
| 126 |
+
}
|
| 127 |
+
if(!result.chunks.length){
|
| 128 |
+
$('status').textContent='Empty export detected, retrying video-only WebM...';
|
| 129 |
+
result=await recordOnce('video/webm','none');
|
| 130 |
+
}
|
| 131 |
+
exporting=false;playing=false;$('export').disabled=false;$('stop').style.display='none';$('bar').style.display='none';$('play').textContent='▶';updateMuteButton();
|
| 132 |
+
if(!result.chunks.length){$('status').textContent='Export produced empty file. Try Scale 50% and Export audio: No audio.';return}
|
| 133 |
+
let ext=result.type.includes('mp4')?'mp4':'webm';
|
| 134 |
+
await saveBlob(new Blob(result.chunks,{type:result.type}),ext);
|
| 135 |
+
$('status').textContent='Export complete'+(result.mode==='none'?' (no audio)':'');
|
| 136 |
+
draw();
|
| 137 |
+
}catch(e){
|
| 138 |
+
exporting=false;playing=false;$('export').disabled=false;$('stop').style.display='none';$('bar').style.display='none';$('play').textContent='▶';updateMuteButton();
|
| 139 |
+
$('status').textContent='Export failed: '+(e&&e.message?e.message:'unknown error');
|
| 140 |
+
}
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
function isFileDrag(e){return e.dataTransfer&&Array.from(e.dataTransfer.types||[]).includes('Files')}
|
| 144 |
+
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)}
|
| 145 |
+
$('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=''};
|
| 146 |
+
['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);
|
| 147 |
+
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);
|
| 148 |
+
$('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});
|
| 149 |
+
updateMuteButton();render();draw();
|
| 150 |
+
})();
|
| 151 |
+
</script>
|
| 152 |
+
</body>
|
| 153 |
+
</html>
|