| <!doctype html> |
| <html lang="en"> |
| <head> |
| <meta charset="utf-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1"> |
| <title>Pocket Side By Side</title> |
| <style> |
| :root{ |
| --bg:#0e1116; --panel:#161b22; --panel2:#1c232d; --line:#2a323d; |
| --txt:#e6edf3; --dim:#8b98a5; --accent:#4cc2ff; --accent2:#2a9fd6; |
| --ok:#3fb950; --warn:#d29922; --danger:#f85149; --red:#7f1018; |
| --r:10px; |
| } |
| *{box-sizing:border-box} |
| html,body{margin:0;min-height:100%} |
| body{background:var(--bg);color:var(--txt);font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;-webkit-font-smoothing:antialiased} |
| .wrap{max-width:1180px;margin:0 auto;padding:20px 20px 60px} |
| header{display:flex;align-items:center;gap:12px;margin-bottom:18px} |
| header h1{font-size:17px;margin:0;font-weight:600;letter-spacing:.2px} |
| header .sub{color:var(--dim);font-size:12.5px} |
| header .verwrap{display:flex;flex-direction:column;align-items:center;gap:3px} |
| header .ver{color:var(--dim);font-size:12px;font-variant-numeric:tabular-nums;border:1px solid var(--line);border-radius:99px;padding:3px 10px;background:var(--panel)} |
| header .by{color:var(--dim);font-size:10px;letter-spacing:.3px;opacity:.8} |
| .spacer{flex:1} |
| button{font:inherit;color:var(--txt);background:var(--panel2);border:1px solid var(--line);padding:7px 13px;border-radius:8px;cursor:pointer;transition:.12s} |
| button:hover:not(:disabled){background:#242d39;border-color:#3a4553} |
| button:disabled{opacity:.45;cursor:not-allowed} |
| button.primary{background:var(--accent);border-color:var(--accent);color:#04121c;font-weight:600} |
| button.primary:hover:not(:disabled){background:#6bcfff} |
| button.ghost{background:transparent} |
| button.danger{color:#ffb4ae;border-color:#5a2b2b} |
| .grid{display:grid;grid-template-columns:1fr 330px;gap:18px;align-items:start} |
| @media(max-width:940px){.grid{grid-template-columns:1fr}} |
| .card{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);padding:14px} |
| .card h3,.panelbox h3{margin:0 0 12px;font-size:12px;text-transform:uppercase;letter-spacing:.7px;color:var(--dim);font-weight:600} |
| .panelbox{background:#11161d;border:1px solid var(--line);border-radius:10px;padding:10px 12px 12px;margin-bottom:14px} |
| .dropgrid{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:14px} |
| @media(max-width:700px){.dropgrid{grid-template-columns:1fr}} |
| .drop{border:2px dashed var(--line);border-radius:14px;padding:14px 18px;text-align:center;background:var(--panel);cursor:pointer;transition:.15s;min-height:142px;display:flex;align-items:center;justify-content:center;gap:14px} |
| .drop:hover,.drop.over{border-color:var(--accent);background:#12212b} |
| .drop .big{font-size:34px;line-height:1;opacity:.65} |
| .drop h2{margin:0 0 4px;font-size:16px;font-weight:600} |
| .drop p{margin:0;color:var(--dim);font-size:12.5px;word-break:break-word} |
| .dropText{text-align:left;min-width:0} |
| .thumb{width:128px;height:72px;border-radius:8px;border:1px solid var(--line);background:#07090c;object-fit:cover;display:none;flex:none} |
| .stage{background:#000;border-radius:var(--r);overflow:hidden;display:flex;align-items:center;justify-content:center;position:relative;border:1px solid var(--line);min-height:260px} |
| #previewCanvas{max-width:100%;max-height:62vh;width:auto;height:auto;display:block;background:#000} |
| .badge{position:absolute;top:10px;left:10px;z-index:3;background:rgba(0,0,0,.66);backdrop-filter:blur(4px);padding:4px 9px;border-radius:6px;font-size:11.5px;color:#cfe;letter-spacing:.3px} |
| .labelsUnder{margin-top:14px} |
| .labelgrid{display:grid;grid-template-columns:1fr 1fr;gap:10px} |
| @media(max-width:700px){.labelgrid{grid-template-columns:1fr}} |
| .transport{display:flex;align-items:center;gap:8px;margin-top:12px;flex-wrap:wrap} |
| .tc{font-variant-numeric:tabular-nums;font-size:13px;color:var(--dim)} |
| .tc b{color:var(--txt);font-weight:600} |
| .row{display:flex;align-items:center;gap:8px;margin-bottom:10px} |
| .row label{flex:0 0 100px;color:var(--dim);font-size:12.5px} |
| input[type=text],input[type=number],select{font:inherit;background:var(--panel2);color:var(--txt);border:1px solid var(--line);border-radius:7px;padding:6px 8px;width:100%;min-width:0} |
| input:focus,select:focus{outline:none;border-color:var(--accent)} |
| .seg{display:flex;gap:5px;flex-wrap:wrap;margin-bottom:10px} |
| .seg button{padding:5px 9px;font-size:12.5px;border-radius:7px} |
| .seg button.on{background:var(--accent);border-color:var(--accent);color:#04121c;font-weight:600} |
| .hint{color:var(--dim);font-size:11.5px;margin:-4px 0 12px} |
| .est{background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:9px 11px;font-size:12.5px;margin-bottom:12px} |
| .est div{display:flex;justify-content:space-between;margin:2px 0;gap:10px} |
| .est span{color:var(--dim)} |
| .est b{font-variant-numeric:tabular-nums;font-weight:600;text-align:right} |
| #exportBtn{width:100%;padding:11px} |
| #stopBtn{width:100%;padding:9px;margin-top:8px;display:none} |
| .bar{height:6px;background:var(--panel2);border-radius:99px;overflow:hidden;margin-top:10px;display:none} |
| .bar i{display:block;height:100%;width:0;background:var(--accent);transition:width .2s} |
| #status{font-size:12px;color:var(--dim);margin-top:8px;text-align:center;min-height:16px} |
| .note{font-size:11.5px;color:var(--warn);margin-top:8px;line-height:1.4} |
| .hiddenvid{position:fixed;left:-10000px;top:-10000px;width:2px;height:2px;opacity:.01;pointer-events:none} |
| </style> |
| </head> |
| <body> |
| <div class="wrap"> |
| <header> |
| <h1>Pocket Side By Side</h1> |
| <span class="sub">side-by-side or top/bottom</span> |
| <span class="spacer"></span> |
| <span class="verwrap"><span class="ver">v1.7</span><span class="by">By C_Nugget</span></span> |
| </header> |
|
|
| <div class="dropgrid"> |
| <div class="drop" id="drop1"> |
| <canvas class="thumb" id="thumb1"></canvas> |
| <div class="big" id="big1">▢</div> |
| <div class="dropText"><h2>Drop video 1 here</h2><p>or click to choose a file</p></div> |
| <input type="file" id="file1" accept="video/*" hidden> |
| </div> |
| <div class="drop" id="drop2"> |
| <canvas class="thumb" id="thumb2"></canvas> |
| <div class="big" id="big2">▢</div> |
| <div class="dropText"><h2>Drop video 2 here</h2><p>or click to choose a file</p></div> |
| <input type="file" id="file2" accept="video/*" hidden> |
| </div> |
| </div> |
|
|
| <div class="grid"> |
| <div> |
| <div class="stage" id="stage"> |
| <canvas id="previewCanvas"></canvas> |
| <div class="badge" id="badge">Load two videos to start</div> |
| </div> |
| <div class="panelbox labelsUnder" id="labelsPanel"> |
| <h3>Video Labels</h3> |
| <div class="labelgrid"> |
| <div class="row"><label>Video 1 text</label><input type="text" id="txt1" placeholder="Optional label for video 1"></div> |
| <div class="row"><label>Video 2 text</label><input type="text" id="txt2" placeholder="Optional label for video 2"></div> |
| </div> |
| <div class="row"><label>Text position</label><select id="labelPos"> |
| <option value="tl" selected>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> |
| <div class="hint">Labels are drawn onto the export as white text on a dark red background. Choose the placement above.</div> |
| </div> |
| <div class="transport"> |
| <button id="playBtn">▶</button> |
| <button id="prevF" class="ghost" title="Previous frame">↤</button> |
| <button id="nextF" class="ghost" title="Next frame">↦</button> |
| <button id="restartBtn" class="ghost">Restart</button> |
| <span class="tc"><b id="curTime">0:00.00</b> / <span id="totTime">0:00.00</span></span> |
| <input type="range" id="seek" min="0" max="1000" value="0" step="1" style="flex:1 1 260px;accent-color:var(--accent)"> |
| </div> |
| </div> |
|
|
| <div> |
| <div class="card"> |
| <div class="panelbox"> |
| <h3>Stitch Layout</h3> |
| <div class="seg" id="layoutSeg"><button data-layout="lr" class="on">Left / Right</button><button data-layout="tb">Top / Down</button></div> |
| <div class="hint">Left / Right places video 1 on the left. Top / Down places video 1 on top.</div> |
| <div class="row"><label>Audio source</label><select id="audioSrc"><option value="none">None</option><option value="v1">Video 1</option><option value="v2">Video 2</option><option value="both" selected>Both</option></select></div> |
| </div> |
| <div class="panelbox"> |
| <h3>Output</h3> |
| <div class="row"><label>Scale</label><select id="scale"><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></div> |
| <div class="row"><label>Frame rate</label><input type="number" id="fps" min="1" max="60" step="1" value="30"></div> |
| <div class="est"><div><span>Output</span><b id="outSize">—</b></div><div><span>Duration</span><b id="outDur">—</b></div><div><span>Format</span><b id="fmt">MP4 preferred</b></div></div> |
| <button id="exportBtn" class="primary">Export stitched video</button> |
| <button id="stopBtn" class="danger">Stop export</button> |
| <div class="bar" id="bar"><i id="barI"></i></div> |
| <div id="status"></div> |
| <div class="note">MP4 is tried first. If this browser cannot record MP4, the tool automatically falls back to WebM. Everything stays on this computer.</div> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| <video id="v1" class="hiddenvid" playsinline preload="auto"></video> |
| <video id="v2" class="hiddenvid" playsinline preload="auto"></video> |
| <script> |
| "use strict"; |
| (function(){ |
| const $=id=>document.getElementById(id), cvs=$("previewCanvas"), ctx=cvs.getContext("2d",{alpha:false}); |
| const v1=$("v1"), v2=$("v2"); |
| let url1=null,url2=null,fileName1="video1",fileName2="video2",ready1=false,ready2=false,playing=false,exporting=false,cancelExport=false,drawRaf=0,seekDragging=false; |
| const clamp=(v,a,b)=>Math.min(b,Math.max(a,v)); |
| function once(el,ev){return new Promise(r=>el.addEventListener(ev,r,{once:true}));} |
| function tc(t){if(!isFinite(t)||t<0)t=0;const h=Math.floor(t/3600),m=Math.floor((t-h*3600)/60),s=t-h*3600-m*60;return (h?h+":" : "")+(h?String(m).padStart(2,"0"):m)+":"+(s<10?"0":"")+s.toFixed(2);} |
| function safeName(s){return String(s||"stitched_video").replace(/[\\/:*?"<>|\x00-\x1f]+/g,"-").replace(/^\.+/,"").trim().slice(0,80)||"stitched_video";} |
| function status(s){$("status").textContent=s||"";} |
| function dur(){return ready1&&ready2?Math.min(v1.duration||0,v2.duration||0):0;} |
| function layout(){return document.querySelector("#layoutSeg button.on").dataset.layout;} |
| function scale(){return parseFloat($("scale").value)||1;} |
| function fps(){return clamp(Math.round(+$("fps").value||30),1,60);} |
| function dims(){ |
| const s=scale(); |
| const baseW = ready1 ? v1.videoWidth : (ready2 ? v2.videoWidth : 1280); |
| const baseH = ready1 ? v1.videoHeight : (ready2 ? v2.videoHeight : 720); |
| const v1w = ready1 ? v1.videoWidth : baseW, v1h = ready1 ? v1.videoHeight : baseH; |
| const v2w = ready2 ? v2.videoWidth : baseW, v2h = ready2 ? v2.videoHeight : baseH; |
| const aw1=Math.max(2,Math.round(v1w*s)), ah1=Math.max(2,Math.round(v1h*s)); |
| const aw2=Math.max(2,Math.round(v2w*s)), ah2=Math.max(2,Math.round(v2h*s)); |
| if(layout()==="lr"){ |
| const h=Math.max(ah1,ah2), w1=Math.round(aw1*h/ah1), w2=Math.round(aw2*h/ah2); |
| return {W:w1+w2,H:h,x1:0,y1:0,w1,h1:h,x2:w1,y2:0,w2,h2:h}; |
| } |
| const w=Math.max(aw1,aw2), h1=Math.round(ah1*w/aw1), h2=Math.round(ah2*w/aw2); |
| return {W:w,H:h1+h2,x1:0,y1:0,w1:w,h1,x2:0,y2:h1,w2:w,h2}; |
| } |
| function setCanvas(){const d=dims(); if(cvs.width!==d.W||cvs.height!==d.H){cvs.width=d.W;cvs.height=d.H;} $("outSize").textContent=ready1&&ready2?d.W+" × "+d.H:"—"; $("outDur").textContent=dur()?tc(dur()):"—"; $("totTime").textContent=dur()?tc(dur()):"0:00.00"; return d;} |
| function fit(video,x,y,w,h){ctx.fillStyle="#000";ctx.fillRect(x,y,w,h); if(!video.videoWidth||!video.videoHeight)return; const sc=Math.min(w/video.videoWidth,h/video.videoHeight), dw=video.videoWidth*sc, dh=video.videoHeight*sc; ctx.drawImage(video,x+(w-dw)/2,y+(h-dh)/2,dw,dh);} |
| function label(text,x,y,w,h){text=(text||"").trim(); if(!text)return; const font=Math.max(13,Math.round(cvs.height/42)), px=10, py=6, gap=10; ctx.font="600 "+font+"px -apple-system,BlinkMacSystemFont,Segoe UI,Arial,sans-serif"; let t=text; while(ctx.measureText(t).width>w-24&&t.length>3)t=t.slice(0,-2); if(t!==text)t=t.slice(0,-1)+"…"; const tw=ctx.measureText(t).width, bw=tw+px*2, bh=font+py*2; let bx=x+gap, by=y+gap; const pos=$("labelPos")?$("labelPos").value:"tl"; if(pos==="tr"){bx=x+w-bw-gap;by=y+gap;} else if(pos==="bl"){bx=x+gap;by=y+h-bh-gap;} else if(pos==="br"){bx=x+w-bw-gap;by=y+h-bh-gap;} else if(pos==="ct"){bx=x+(w-bw)/2;by=y+gap;} ctx.fillStyle="rgba(127,16,24,.94)"; ctx.fillRect(bx,by,bw,bh); ctx.fillStyle="#fff"; ctx.textBaseline="top"; ctx.fillText(t,bx+px,by+py);} |
| function draw(){const d=setCanvas(); ctx.fillStyle="#000"; ctx.fillRect(0,0,cvs.width,cvs.height); fit(v1,d.x1,d.y1,d.w1,d.h1); fit(v2,d.x2,d.y2,d.w2,d.h2); label($("txt1").value,d.x1,d.y1,d.w1,d.h1); label($("txt2").value,d.x2,d.y2,d.w2,d.h2); const t=Math.min(v1.currentTime||0,v2.currentTime||0); $("curTime").textContent=tc(t); if(!seekDragging && dur()) $("seek").value=String(Math.round(t/dur()*1000)); if(playing||exporting)drawRaf=requestAnimationFrame(draw);} |
| function badge(){if(ready1&&ready2)$("badge").textContent=(layout()==="lr"?"Left / Right":"Top / Down")+" · "+tc(dur())+" · "+fps()+" fps"; else $("badge").textContent="Load two videos to start"; setCanvas(); draw();} |
| async function waitReady(video){if(video.readyState>=2)return; await Promise.race([once(video,"loadeddata"),once(video,"canplay"),new Promise(r=>setTimeout(r,3000))]);} |
| async function seekTo(video,t){if(!isFinite(video.duration))return; if(Math.abs((video.currentTime||0)-t)<0.005)return; video.currentTime=clamp(t,0,Math.max(0,(video.duration||0)-0.001)); await Promise.race([once(video,"seeked"),new Promise(r=>setTimeout(r,800))]);} |
| async function makeThumb(video,n){ |
| await waitReady(video); await seekTo(video,0); |
| const th=$("thumb"+n), x=th.getContext("2d",{alpha:false}); |
| const maxW=128,maxH=72, ar=(video.videoWidth||16)/(video.videoHeight||9); |
| let cssW=maxW, cssH=Math.round(cssW/ar); |
| if(cssH>maxH){cssH=maxH;cssW=Math.round(cssH*ar);} |
| th.width=Math.max(2,Math.round(cssW*2)); th.height=Math.max(2,Math.round(cssH*2)); |
| th.style.width=cssW+"px"; th.style.height=cssH+"px"; |
| x.fillStyle="#000"; x.fillRect(0,0,th.width,th.height); |
| if(video.videoWidth){ |
| const sc=Math.min(th.width/video.videoWidth,th.height/video.videoHeight), dw=video.videoWidth*sc, dh=video.videoHeight*sc; |
| x.drawImage(video,(th.width-dw)/2,(th.height-dh)/2,dw,dh); |
| th.style.display="block"; $("big"+n).style.display="none"; |
| } |
| } |
| async function loadFile(file,n){ |
| const video=n===1?v1:v2; if(n===1&&url1)URL.revokeObjectURL(url1); if(n===2&&url2)URL.revokeObjectURL(url2); |
| const url=URL.createObjectURL(file); if(n===1){url1=url;fileName1=file.name.replace(/\.[^.]+$/,"")||"video1";ready1=false;} else {url2=url;fileName2=file.name.replace(/\.[^.]+$/,"")||"video2";ready2=false;} |
| video.pause(); video.src=url; video.muted=false; video.preload="auto"; video.load(); |
| const ok=await Promise.race([once(video,"loadedmetadata").then(()=>true),once(video,"error").then(()=>false),new Promise(r=>setTimeout(()=>r(false),10000))]); |
| if(!ok||!video.videoWidth){status("Could not read "+file.name+". Try an MP4, MOV, or WebM file."); return;} |
| if(n===1)ready1=true; else ready2=true; |
| const drop=$("drop"+n); drop.querySelector("h2").textContent="Video "+n+" loaded"; drop.querySelector("p").textContent=file.name; |
| await makeThumb(video,n); await seekTo(video,0); badge(); status(ready1&&ready2?"Ready to preview or export.":"Load the second video."); |
| } |
| function dropSetup(n){const d=$("drop"+n), f=$("file"+n); d.addEventListener("click",()=>f.click()); f.addEventListener("change",e=>{if(e.target.files[0])loadFile(e.target.files[0],n); e.target.value="";}); ["dragenter","dragover"].forEach(ev=>d.addEventListener(ev,e=>{e.preventDefault();d.classList.add("over")})); ["dragleave","drop"].forEach(ev=>d.addEventListener(ev,e=>{e.preventDefault();d.classList.remove("over")})); d.addEventListener("drop",e=>{const file=e.dataTransfer&&e.dataTransfer.files[0]; if(file)loadFile(file,n);});} |
| dropSetup(1); dropSetup(2); |
| document.querySelectorAll("#layoutSeg button").forEach(b=>b.addEventListener("click",()=>{document.querySelectorAll("#layoutSeg button").forEach(x=>x.classList.toggle("on",x===b)); badge();})); |
| ["scale","fps","txt1","txt2","labelPos"].forEach(id=>$(id).addEventListener("input",badge)); |
| function seekBoth(t){return Promise.all([seekTo(v1,t),seekTo(v2,t)]);} |
| async function play(){if(!ready1||!ready2||exporting)return; if(Math.min(v1.currentTime,v2.currentTime)>=dur()-0.05)await seekBoth(0); playing=true; $("playBtn").textContent="❚❚"; draw(); try{await Promise.all([v1.play(),v2.play()]);}catch(e){status("Playback could not start. Click the page and try again.");}} |
| function pause(){playing=false; $("playBtn").textContent="▶"; v1.pause(); v2.pause(); draw();} |
| $("playBtn").addEventListener("click",()=>playing?pause():play()); |
| $("restartBtn").addEventListener("click",async()=>{pause(); await seekBoth(0); draw();}); |
| $("prevF").addEventListener("click",async()=>{pause(); await seekBoth(Math.max(0,Math.min(v1.currentTime||0,v2.currentTime||0)-1/fps())); draw();}); |
| $("nextF").addEventListener("click",async()=>{pause(); await seekBoth(Math.min(dur(),Math.min(v1.currentTime||0,v2.currentTime||0)+1/fps())); draw();}); |
| $("seek").addEventListener("pointerdown",()=>{seekDragging=true;}); |
| $("seek").addEventListener("pointerup",async()=>{seekDragging=false; pause(); await seekBoth(dur()*($("seek").value/1000)); draw();}); |
| $("seek").addEventListener("input",async()=>{if(seekDragging && dur()){ $("curTime").textContent=tc(dur()*($("seek").value/1000)); }}); |
| function watch(){if(playing&&Math.min(v1.currentTime,v2.currentTime)>=dur()-0.03)pause(); requestAnimationFrame(watch);} watch(); |
| function pickMime(){const opts=["video/mp4;codecs=avc1.42E01E,mp4a.40.2","video/mp4;codecs=avc1.42E01E","video/mp4","video/webm;codecs=vp9,opus","video/webm;codecs=vp8,opus","video/webm"]; for(const m of opts)if(window.MediaRecorder&&MediaRecorder.isTypeSupported(m))return m; return "";} |
| async function choose(ext){const suggested=safeName(fileName1+"_"+fileName2+"_stitched")+"."+ext; if(window.showSaveFilePicker){try{const accept=ext==="mp4"?{"video/mp4":[".mp4"]}:{"video/webm":[".webm"]}; return await window.showSaveFilePicker({suggestedName:suggested,types:[{description:"Video file",accept}]});}catch(e){if(e&&e.name==="AbortError")return "cancelled";}} return null;} |
| function addAudio(out){const choice=$("audioSrc").value; if(choice==="none")return; function add(video){try{const s=video.captureStream?video.captureStream():(video.mozCaptureStream?video.mozCaptureStream():null); if(s)s.getAudioTracks().forEach(t=>out.addTrack(t));}catch(e){}} if(choice==="v1"||choice==="both")add(v1); if(choice==="v2"||choice==="both")add(v2);} |
| function browserDownload(blob,name){const a=document.createElement("a"); a.href=URL.createObjectURL(blob); a.download=name; a.style.display="none"; document.body.appendChild(a); a.click(); setTimeout(()=>{URL.revokeObjectURL(a.href);a.remove();},12000);} |
| $("stopBtn").addEventListener("click",()=>{cancelExport=true;status("Stopping export…");}); |
| $("exportBtn").addEventListener("click",exportVideo); |
| async function exportVideo(){ |
| if(exporting||!ready1||!ready2){status("Load both videos first."); return;} |
| if(!cvs.captureStream || !window.MediaRecorder){status("This browser cannot export video from canvas. Try Chrome or Edge."); return;} |
| if(!isFinite(dur()) || dur() <= 0){status("Could not read the video duration. Try converting the files to MP4 first."); return;} |
| let mime=pickMime(); if(!mime){status("This browser cannot record video. Try Chrome or Edge."); return;} |
| exporting=true; cancelExport=false; pause(); await seekBoth(0); setCanvas(); draw(); |
| $("exportBtn").disabled=true; $("stopBtn").style.display="block"; $("bar").style.display="block"; $("barI").style.width="0%"; status("Recording… keep this tab visible."); |
| const old={m1:v1.muted,m2:v2.muted,vol1:v1.volume,vol2:v2.volume}; |
| let useAudio=$("audioSrc").value!=="none"; |
| if(!useAudio){v1.muted=true;v2.muted=true;} |
| let playOk=false; |
| try{await Promise.all([v1.play(),v2.play()]); playOk=true;}catch(e){ |
| try{v1.muted=true;v2.muted=true; await Promise.all([v1.play(),v2.play()]); playOk=true; useAudio=false; status("Recording without audio because the browser blocked audio playback.");}catch(e2){playOk=false;} |
| } |
| if(!playOk){ |
| exporting=false; playing=false; $("exportBtn").disabled=false; $("stopBtn").style.display="none"; $("bar").style.display="none"; $("playBtn").textContent="▶"; |
| status("Export could not start. Click Play once, then Export again."); return; |
| } |
| const stream=cvs.captureStream(fps()); if(useAudio) addAudio(stream); |
| const chunks=[]; let rec; |
| function makeRecorder(type, withAudio){ |
| const opts={mimeType:type,videoBitsPerSecond:Math.max(1200000,Math.round(cvs.width*cvs.height*fps()*0.14))}; |
| if(withAudio) opts.audioBitsPerSecond=160000; |
| return new MediaRecorder(stream,opts); |
| } |
| try{rec=makeRecorder(mime,useAudio);}catch(e){ |
| if(useAudio){try{stream.getAudioTracks().forEach(t=>stream.removeTrack(t)); useAudio=false; rec=makeRecorder(mime,false);}catch(e2){rec=null;}} |
| if(!rec){try{mime="video/webm"; rec=makeRecorder(mime,false);}catch(e3){rec=null;}} |
| } |
| if(!rec){ |
| stream.getTracks().forEach(t=>{try{t.stop();}catch(_e){}}); v1.pause(); v2.pause(); v1.muted=old.m1; v2.muted=old.m2; v1.volume=old.vol1; v2.volume=old.vol2; |
| exporting=false; playing=false; $("exportBtn").disabled=false; $("stopBtn").style.display="none"; $("bar").style.display="none"; $("playBtn").textContent="▶"; |
| status("Recorder failed to start. Try a lower scale or use Chrome/Edge."); return; |
| } |
| rec.ondataavailable=e=>{if(e.data&&e.data.size)chunks.push(e.data);}; const stopped=new Promise(r=>rec.onstop=r); |
| try{rec.start(200);}catch(e){ |
| stream.getTracks().forEach(t=>{try{t.stop();}catch(_e){}}); v1.pause(); v2.pause(); v1.muted=old.m1; v2.muted=old.m2; v1.volume=old.vol1; v2.volume=old.vol2; |
| exporting=false; playing=false; $("exportBtn").disabled=false; $("stopBtn").style.display="none"; $("bar").style.display="none"; $("playBtn").textContent="▶"; |
| status("Recorder failed to start: "+e.message); return; |
| } |
| playing=true; draw(); |
| const end=dur(), start=performance.now(); |
| const timer=setInterval(()=>{const t=Math.min(v1.currentTime||0,v2.currentTime||0), p=end?clamp(t/end,0,1):0; $("barI").style.width=(p*100).toFixed(1)+"%"; if(cancelExport||t>=end-0.035||(performance.now()-start)>end*1000+10000){clearInterval(timer); v1.pause(); v2.pause(); playing=false; try{rec.stop();}catch(e){}}},80); |
| await stopped; stream.getTracks().forEach(t=>{try{t.stop();}catch(e){}}); v1.muted=old.m1; v2.muted=old.m2; v1.volume=old.vol1; v2.volume=old.vol2; |
| exporting=false; $("exportBtn").disabled=false; $("stopBtn").style.display="none"; $("bar").style.display="none"; $("playBtn").textContent="▶"; |
| if(cancelExport){status("Export stopped."); return;} |
| const type=mime.split(";")[0], ext=type.indexOf("mp4")>=0?"mp4":"webm", blob=new Blob(chunks,{type}); const name=safeName(fileName1+"_"+fileName2+"_stitched")+"."+ext; |
| if(!blob.size){status("Export produced an empty file. Try WebM fallback in Chrome or Edge."); return;} |
| let handle=null; try{handle=await choose(ext);}catch(e){handle=null;} |
| if(handle&&handle!=="cancelled"&&handle.createWritable){try{const w=await handle.createWritable(); await w.write(blob); await w.close(); status("Saved "+name+(useAudio?"":" (no audio)"));}catch(e){browserDownload(blob,name); status("Saved using browser download instead"+(useAudio?"":" (no audio)")+".");}} |
| else{browserDownload(blob,name); status("Saved "+name+(useAudio?"":" (no audio)"));} |
| await seekBoth(0); draw(); |
| } |
| badge(); |
| })(); |
| </script> |
| </body> |
| </html> |