Upload pocket_side_by_side_v1.7.html
Browse files- 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>
|