PoopMan333 commited on
Commit
cf03d2b
·
verified ·
1 Parent(s): 0133431

Upload pocket_side_by_side_v1.7.html

Browse files
Files changed (1) hide show
  1. pocket_side_by_side_v1.7.html +296 -0
pocket_side_by_side_v1.7.html ADDED
@@ -0,0 +1,296 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 Side By Side</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{background:var(--bg);color:var(--txt);font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;-webkit-font-smoothing:antialiased}
17
+ .wrap{max-width:1180px;margin:0 auto;padding:20px 20px 60px}
18
+ header{display:flex;align-items:center;gap:12px;margin-bottom:18px}
19
+ header h1{font-size:17px;margin:0;font-weight:600;letter-spacing:.2px}
20
+ header .sub{color:var(--dim);font-size:12.5px}
21
+ header .verwrap{display:flex;flex-direction:column;align-items:center;gap:3px}
22
+ 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)}
23
+ header .by{color:var(--dim);font-size:10px;letter-spacing:.3px;opacity:.8}
24
+ .spacer{flex:1}
25
+ button{font:inherit;color:var(--txt);background:var(--panel2);border:1px solid var(--line);padding:7px 13px;border-radius:8px;cursor:pointer;transition:.12s}
26
+ button:hover:not(:disabled){background:#242d39;border-color:#3a4553}
27
+ button:disabled{opacity:.45;cursor:not-allowed}
28
+ button.primary{background:var(--accent);border-color:var(--accent);color:#04121c;font-weight:600}
29
+ button.primary:hover:not(:disabled){background:#6bcfff}
30
+ button.ghost{background:transparent}
31
+ button.danger{color:#ffb4ae;border-color:#5a2b2b}
32
+ .grid{display:grid;grid-template-columns:1fr 330px;gap:18px;align-items:start}
33
+ @media(max-width:940px){.grid{grid-template-columns:1fr}}
34
+ .card{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);padding:14px}
35
+ .card h3,.panelbox h3{margin:0 0 12px;font-size:12px;text-transform:uppercase;letter-spacing:.7px;color:var(--dim);font-weight:600}
36
+ .panelbox{background:#11161d;border:1px solid var(--line);border-radius:10px;padding:10px 12px 12px;margin-bottom:14px}
37
+ .dropgrid{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:14px}
38
+ @media(max-width:700px){.dropgrid{grid-template-columns:1fr}}
39
+ .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}
40
+ .drop:hover,.drop.over{border-color:var(--accent);background:#12212b}
41
+ .drop .big{font-size:34px;line-height:1;opacity:.65}
42
+ .drop h2{margin:0 0 4px;font-size:16px;font-weight:600}
43
+ .drop p{margin:0;color:var(--dim);font-size:12.5px;word-break:break-word}
44
+ .dropText{text-align:left;min-width:0}
45
+ .thumb{width:128px;height:72px;border-radius:8px;border:1px solid var(--line);background:#07090c;object-fit:cover;display:none;flex:none}
46
+ .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}
47
+ #previewCanvas{max-width:100%;max-height:62vh;width:auto;height:auto;display:block;background:#000}
48
+ .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}
49
+ .labelsUnder{margin-top:14px}
50
+ .labelgrid{display:grid;grid-template-columns:1fr 1fr;gap:10px}
51
+ @media(max-width:700px){.labelgrid{grid-template-columns:1fr}}
52
+ .transport{display:flex;align-items:center;gap:8px;margin-top:12px;flex-wrap:wrap}
53
+ .tc{font-variant-numeric:tabular-nums;font-size:13px;color:var(--dim)}
54
+ .tc b{color:var(--txt);font-weight:600}
55
+ .row{display:flex;align-items:center;gap:8px;margin-bottom:10px}
56
+ .row label{flex:0 0 100px;color:var(--dim);font-size:12.5px}
57
+ 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}
58
+ input:focus,select:focus{outline:none;border-color:var(--accent)}
59
+ .seg{display:flex;gap:5px;flex-wrap:wrap;margin-bottom:10px}
60
+ .seg button{padding:5px 9px;font-size:12.5px;border-radius:7px}
61
+ .seg button.on{background:var(--accent);border-color:var(--accent);color:#04121c;font-weight:600}
62
+ .hint{color:var(--dim);font-size:11.5px;margin:-4px 0 12px}
63
+ .est{background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:9px 11px;font-size:12.5px;margin-bottom:12px}
64
+ .est div{display:flex;justify-content:space-between;margin:2px 0;gap:10px}
65
+ .est span{color:var(--dim)}
66
+ .est b{font-variant-numeric:tabular-nums;font-weight:600;text-align:right}
67
+ #exportBtn{width:100%;padding:11px}
68
+ #stopBtn{width:100%;padding:9px;margin-top:8px;display:none}
69
+ .bar{height:6px;background:var(--panel2);border-radius:99px;overflow:hidden;margin-top:10px;display:none}
70
+ .bar i{display:block;height:100%;width:0;background:var(--accent);transition:width .2s}
71
+ #status{font-size:12px;color:var(--dim);margin-top:8px;text-align:center;min-height:16px}
72
+ .note{font-size:11.5px;color:var(--warn);margin-top:8px;line-height:1.4}
73
+ .hiddenvid{position:fixed;left:-10000px;top:-10000px;width:2px;height:2px;opacity:.01;pointer-events:none}
74
+ </style>
75
+ </head>
76
+ <body>
77
+ <div class="wrap">
78
+ <header>
79
+ <h1>Pocket Side By Side</h1>
80
+ <span class="sub">side-by-side or top/bottom</span>
81
+ <span class="spacer"></span>
82
+ <span class="verwrap"><span class="ver">v1.7</span><span class="by">By C_Nugget</span></span>
83
+ </header>
84
+
85
+ <div class="dropgrid">
86
+ <div class="drop" id="drop1">
87
+ <canvas class="thumb" id="thumb1"></canvas>
88
+ <div class="big" id="big1">▢</div>
89
+ <div class="dropText"><h2>Drop video 1 here</h2><p>or click to choose a file</p></div>
90
+ <input type="file" id="file1" accept="video/*" hidden>
91
+ </div>
92
+ <div class="drop" id="drop2">
93
+ <canvas class="thumb" id="thumb2"></canvas>
94
+ <div class="big" id="big2">▢</div>
95
+ <div class="dropText"><h2>Drop video 2 here</h2><p>or click to choose a file</p></div>
96
+ <input type="file" id="file2" accept="video/*" hidden>
97
+ </div>
98
+ </div>
99
+
100
+ <div class="grid">
101
+ <div>
102
+ <div class="stage" id="stage">
103
+ <canvas id="previewCanvas"></canvas>
104
+ <div class="badge" id="badge">Load two videos to start</div>
105
+ </div>
106
+ <div class="panelbox labelsUnder" id="labelsPanel">
107
+ <h3>Video Labels</h3>
108
+ <div class="labelgrid">
109
+ <div class="row"><label>Video 1 text</label><input type="text" id="txt1" placeholder="Optional label for video 1"></div>
110
+ <div class="row"><label>Video 2 text</label><input type="text" id="txt2" placeholder="Optional label for video 2"></div>
111
+ </div>
112
+ <div class="row"><label>Text position</label><select id="labelPos">
113
+ <option value="tl" selected>Top left</option>
114
+ <option value="tr">Top right</option>
115
+ <option value="bl">Bottom left</option>
116
+ <option value="br">Bottom right</option>
117
+ <option value="ct">Center top</option>
118
+ </select></div>
119
+ <div class="hint">Labels are drawn onto the export as white text on a dark red background. Choose the placement above.</div>
120
+ </div>
121
+ <div class="transport">
122
+ <button id="playBtn">▶</button>
123
+ <button id="prevF" class="ghost" title="Previous frame">↤</button>
124
+ <button id="nextF" class="ghost" title="Next frame">↦</button>
125
+ <button id="restartBtn" class="ghost">Restart</button>
126
+ <span class="tc"><b id="curTime">0:00.00</b> / <span id="totTime">0:00.00</span></span>
127
+ <input type="range" id="seek" min="0" max="1000" value="0" step="1" style="flex:1 1 260px;accent-color:var(--accent)">
128
+ </div>
129
+ </div>
130
+
131
+ <div>
132
+ <div class="card">
133
+ <div class="panelbox">
134
+ <h3>Stitch Layout</h3>
135
+ <div class="seg" id="layoutSeg"><button data-layout="lr" class="on">Left / Right</button><button data-layout="tb">Top / Down</button></div>
136
+ <div class="hint">Left / Right places video 1 on the left. Top / Down places video 1 on top.</div>
137
+ <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>
138
+ </div>
139
+ <div class="panelbox">
140
+ <h3>Output</h3>
141
+ <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>
142
+ <div class="row"><label>Frame rate</label><input type="number" id="fps" min="1" max="60" step="1" value="30"></div>
143
+ <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>
144
+ <button id="exportBtn" class="primary">Export stitched video</button>
145
+ <button id="stopBtn" class="danger">Stop export</button>
146
+ <div class="bar" id="bar"><i id="barI"></i></div>
147
+ <div id="status"></div>
148
+ <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>
149
+ </div>
150
+ </div>
151
+ </div>
152
+ </div>
153
+ </div>
154
+ <video id="v1" class="hiddenvid" playsinline preload="auto"></video>
155
+ <video id="v2" class="hiddenvid" playsinline preload="auto"></video>
156
+ <script>
157
+ "use strict";
158
+ (function(){
159
+ const $=id=>document.getElementById(id), cvs=$("previewCanvas"), ctx=cvs.getContext("2d",{alpha:false});
160
+ const v1=$("v1"), v2=$("v2");
161
+ let url1=null,url2=null,fileName1="video1",fileName2="video2",ready1=false,ready2=false,playing=false,exporting=false,cancelExport=false,drawRaf=0,seekDragging=false;
162
+ const clamp=(v,a,b)=>Math.min(b,Math.max(a,v));
163
+ function once(el,ev){return new Promise(r=>el.addEventListener(ev,r,{once:true}));}
164
+ 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);}
165
+ function safeName(s){return String(s||"stitched_video").replace(/[\\/:*?"<>|\x00-\x1f]+/g,"-").replace(/^\.+/,"").trim().slice(0,80)||"stitched_video";}
166
+ function status(s){$("status").textContent=s||"";}
167
+ function dur(){return ready1&&ready2?Math.min(v1.duration||0,v2.duration||0):0;}
168
+ function layout(){return document.querySelector("#layoutSeg button.on").dataset.layout;}
169
+ function scale(){return parseFloat($("scale").value)||1;}
170
+ function fps(){return clamp(Math.round(+$("fps").value||30),1,60);}
171
+ function dims(){
172
+ const s=scale();
173
+ const baseW = ready1 ? v1.videoWidth : (ready2 ? v2.videoWidth : 1280);
174
+ const baseH = ready1 ? v1.videoHeight : (ready2 ? v2.videoHeight : 720);
175
+ const v1w = ready1 ? v1.videoWidth : baseW, v1h = ready1 ? v1.videoHeight : baseH;
176
+ const v2w = ready2 ? v2.videoWidth : baseW, v2h = ready2 ? v2.videoHeight : baseH;
177
+ const aw1=Math.max(2,Math.round(v1w*s)), ah1=Math.max(2,Math.round(v1h*s));
178
+ const aw2=Math.max(2,Math.round(v2w*s)), ah2=Math.max(2,Math.round(v2h*s));
179
+ if(layout()==="lr"){
180
+ const h=Math.max(ah1,ah2), w1=Math.round(aw1*h/ah1), w2=Math.round(aw2*h/ah2);
181
+ return {W:w1+w2,H:h,x1:0,y1:0,w1,h1:h,x2:w1,y2:0,w2,h2:h};
182
+ }
183
+ const w=Math.max(aw1,aw2), h1=Math.round(ah1*w/aw1), h2=Math.round(ah2*w/aw2);
184
+ return {W:w,H:h1+h2,x1:0,y1:0,w1:w,h1,x2:0,y2:h1,w2:w,h2};
185
+ }
186
+ 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;}
187
+ 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);}
188
+ 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);}
189
+ 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);}
190
+ 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();}
191
+ async function waitReady(video){if(video.readyState>=2)return; await Promise.race([once(video,"loadeddata"),once(video,"canplay"),new Promise(r=>setTimeout(r,3000))]);}
192
+ 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))]);}
193
+ async function makeThumb(video,n){
194
+ await waitReady(video); await seekTo(video,0);
195
+ const th=$("thumb"+n), x=th.getContext("2d",{alpha:false});
196
+ const maxW=128,maxH=72, ar=(video.videoWidth||16)/(video.videoHeight||9);
197
+ let cssW=maxW, cssH=Math.round(cssW/ar);
198
+ if(cssH>maxH){cssH=maxH;cssW=Math.round(cssH*ar);}
199
+ th.width=Math.max(2,Math.round(cssW*2)); th.height=Math.max(2,Math.round(cssH*2));
200
+ th.style.width=cssW+"px"; th.style.height=cssH+"px";
201
+ x.fillStyle="#000"; x.fillRect(0,0,th.width,th.height);
202
+ if(video.videoWidth){
203
+ const sc=Math.min(th.width/video.videoWidth,th.height/video.videoHeight), dw=video.videoWidth*sc, dh=video.videoHeight*sc;
204
+ x.drawImage(video,(th.width-dw)/2,(th.height-dh)/2,dw,dh);
205
+ th.style.display="block"; $("big"+n).style.display="none";
206
+ }
207
+ }
208
+ async function loadFile(file,n){
209
+ const video=n===1?v1:v2; if(n===1&&url1)URL.revokeObjectURL(url1); if(n===2&&url2)URL.revokeObjectURL(url2);
210
+ 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;}
211
+ video.pause(); video.src=url; video.muted=false; video.preload="auto"; video.load();
212
+ const ok=await Promise.race([once(video,"loadedmetadata").then(()=>true),once(video,"error").then(()=>false),new Promise(r=>setTimeout(()=>r(false),10000))]);
213
+ if(!ok||!video.videoWidth){status("Could not read "+file.name+". Try an MP4, MOV, or WebM file."); return;}
214
+ if(n===1)ready1=true; else ready2=true;
215
+ const drop=$("drop"+n); drop.querySelector("h2").textContent="Video "+n+" loaded"; drop.querySelector("p").textContent=file.name;
216
+ await makeThumb(video,n); await seekTo(video,0); badge(); status(ready1&&ready2?"Ready to preview or export.":"Load the second video.");
217
+ }
218
+ 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);});}
219
+ dropSetup(1); dropSetup(2);
220
+ document.querySelectorAll("#layoutSeg button").forEach(b=>b.addEventListener("click",()=>{document.querySelectorAll("#layoutSeg button").forEach(x=>x.classList.toggle("on",x===b)); badge();}));
221
+ ["scale","fps","txt1","txt2","labelPos"].forEach(id=>$(id).addEventListener("input",badge));
222
+ function seekBoth(t){return Promise.all([seekTo(v1,t),seekTo(v2,t)]);}
223
+ 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.");}}
224
+ function pause(){playing=false; $("playBtn").textContent="▶"; v1.pause(); v2.pause(); draw();}
225
+ $("playBtn").addEventListener("click",()=>playing?pause():play());
226
+ $("restartBtn").addEventListener("click",async()=>{pause(); await seekBoth(0); draw();});
227
+ $("prevF").addEventListener("click",async()=>{pause(); await seekBoth(Math.max(0,Math.min(v1.currentTime||0,v2.currentTime||0)-1/fps())); draw();});
228
+ $("nextF").addEventListener("click",async()=>{pause(); await seekBoth(Math.min(dur(),Math.min(v1.currentTime||0,v2.currentTime||0)+1/fps())); draw();});
229
+ $("seek").addEventListener("pointerdown",()=>{seekDragging=true;});
230
+ $("seek").addEventListener("pointerup",async()=>{seekDragging=false; pause(); await seekBoth(dur()*($("seek").value/1000)); draw();});
231
+ $("seek").addEventListener("input",async()=>{if(seekDragging && dur()){ $("curTime").textContent=tc(dur()*($("seek").value/1000)); }});
232
+ function watch(){if(playing&&Math.min(v1.currentTime,v2.currentTime)>=dur()-0.03)pause(); requestAnimationFrame(watch);} watch();
233
+ 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 "";}
234
+ 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;}
235
+ 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);}
236
+ 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);}
237
+ $("stopBtn").addEventListener("click",()=>{cancelExport=true;status("Stopping export…");});
238
+ $("exportBtn").addEventListener("click",exportVideo);
239
+ async function exportVideo(){
240
+ if(exporting||!ready1||!ready2){status("Load both videos first."); return;}
241
+ if(!cvs.captureStream || !window.MediaRecorder){status("This browser cannot export video from canvas. Try Chrome or Edge."); return;}
242
+ if(!isFinite(dur()) || dur() <= 0){status("Could not read the video duration. Try converting the files to MP4 first."); return;}
243
+ let mime=pickMime(); if(!mime){status("This browser cannot record video. Try Chrome or Edge."); return;}
244
+ exporting=true; cancelExport=false; pause(); await seekBoth(0); setCanvas(); draw();
245
+ $("exportBtn").disabled=true; $("stopBtn").style.display="block"; $("bar").style.display="block"; $("barI").style.width="0%"; status("Recording… keep this tab visible.");
246
+ const old={m1:v1.muted,m2:v2.muted,vol1:v1.volume,vol2:v2.volume};
247
+ let useAudio=$("audioSrc").value!=="none";
248
+ if(!useAudio){v1.muted=true;v2.muted=true;}
249
+ let playOk=false;
250
+ try{await Promise.all([v1.play(),v2.play()]); playOk=true;}catch(e){
251
+ 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;}
252
+ }
253
+ if(!playOk){
254
+ exporting=false; playing=false; $("exportBtn").disabled=false; $("stopBtn").style.display="none"; $("bar").style.display="none"; $("playBtn").textContent="▶";
255
+ status("Export could not start. Click Play once, then Export again."); return;
256
+ }
257
+ const stream=cvs.captureStream(fps()); if(useAudio) addAudio(stream);
258
+ const chunks=[]; let rec;
259
+ function makeRecorder(type, withAudio){
260
+ const opts={mimeType:type,videoBitsPerSecond:Math.max(1200000,Math.round(cvs.width*cvs.height*fps()*0.14))};
261
+ if(withAudio) opts.audioBitsPerSecond=160000;
262
+ return new MediaRecorder(stream,opts);
263
+ }
264
+ try{rec=makeRecorder(mime,useAudio);}catch(e){
265
+ if(useAudio){try{stream.getAudioTracks().forEach(t=>stream.removeTrack(t)); useAudio=false; rec=makeRecorder(mime,false);}catch(e2){rec=null;}}
266
+ if(!rec){try{mime="video/webm"; rec=makeRecorder(mime,false);}catch(e3){rec=null;}}
267
+ }
268
+ if(!rec){
269
+ 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;
270
+ exporting=false; playing=false; $("exportBtn").disabled=false; $("stopBtn").style.display="none"; $("bar").style.display="none"; $("playBtn").textContent="▶";
271
+ status("Recorder failed to start. Try a lower scale or use Chrome/Edge."); return;
272
+ }
273
+ rec.ondataavailable=e=>{if(e.data&&e.data.size)chunks.push(e.data);}; const stopped=new Promise(r=>rec.onstop=r);
274
+ try{rec.start(200);}catch(e){
275
+ 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;
276
+ exporting=false; playing=false; $("exportBtn").disabled=false; $("stopBtn").style.display="none"; $("bar").style.display="none"; $("playBtn").textContent="▶";
277
+ status("Recorder failed to start: "+e.message); return;
278
+ }
279
+ playing=true; draw();
280
+ const end=dur(), start=performance.now();
281
+ 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);
282
+ 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;
283
+ exporting=false; $("exportBtn").disabled=false; $("stopBtn").style.display="none"; $("bar").style.display="none"; $("playBtn").textContent="▶";
284
+ if(cancelExport){status("Export stopped."); return;}
285
+ const type=mime.split(";")[0], ext=type.indexOf("mp4")>=0?"mp4":"webm", blob=new Blob(chunks,{type}); const name=safeName(fileName1+"_"+fileName2+"_stitched")+"."+ext;
286
+ if(!blob.size){status("Export produced an empty file. Try WebM fallback in Chrome or Edge."); return;}
287
+ let handle=null; try{handle=await choose(ext);}catch(e){handle=null;}
288
+ 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)")+".");}}
289
+ else{browserDownload(blob,name); status("Saved "+name+(useAudio?"":" (no audio)"));}
290
+ await seekBoth(0); draw();
291
+ }
292
+ badge();
293
+ })();
294
+ </script>
295
+ </body>
296
+ </html>