Video_Tools / Pocket_Video_Combiner_v1_0.html
PoopMan333's picture
Rename Video_Combiner_v1_0.html to Pocket_Video_Combiner_v1_0.html
c4e1a99 verified
Raw History Blame Contribute Delete
69.6 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pocket Video Combiner</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:14px}
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:.4;cursor:not-allowed}
button:focus-visible,input:focus-visible,select:focus-visible,a:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
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}
button.danger:hover:not(:disabled){background:#341c1c;border-color:#7a3a3a}
.icobtn{padding:7px 11px;font-variant-numeric:tabular-nums}
.small{padding:5px 10px;font-size:12.5px}
/* ---------- mode tabs (same browser-tab idiom as the trimmer) ---------- */
.modetabs{display:flex;gap:3px;margin:0 0 -1px;padding:0;border-bottom:1px solid var(--line);position:relative;z-index:1}
.modetabs button{flex:0 0 auto;min-width:180px;padding:9px 16px;font-size:13px;color:var(--dim);
background:transparent;border:1px solid transparent;border-bottom:none;
border-radius:9px 9px 0 0;margin-bottom:-1px;position:relative}
.modetabs button:hover:not(.on){color:var(--txt);background:rgba(255,255,255,.03)}
.modetabs button.on{background:var(--panel);border-color:var(--line);border-top:2px solid var(--accent);
color:#bfe9ff;font-weight:600;padding-top:8px}
.modetabs button.on::after{content:"";position:absolute;left:0;right:0;bottom:0;height:1px;background:var(--panel)}
.modepanel{border:1px solid var(--line);border-top:none;border-radius:0 0 var(--r) var(--r);
background:var(--panel);padding:16px;margin-bottom:18px}
@media(max-width:620px){
.modetabs button{flex:1 1 0;min-width:0;padding:9px 6px;font-size:12px}
.modepanel{padding:12px}
}
.grid{display:grid;grid-template-columns:1fr 330px;gap:18px;align-items:start}
@media(max-width:940px){.grid{grid-template-columns:1fr}}
.card{background:#11161d;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}
.card .panelbox{background:#0d1219}
.panelbox:last-child{margin-bottom:0}
h3.collap{display:flex;align-items:center;justify-content:space-between;cursor:pointer;user-select:none;
margin:-10px -12px 10px;padding:9px 12px;border-radius:9px 9px 0 0;background:#171d26;
border-bottom:1px solid var(--line);color:var(--txt)}
h3.collap:hover{background:#1c232d}
h3.collap.shut{margin-bottom:-12px;border-bottom:none;border-radius:9px}
h3.collap .chev{font-size:11px;color:var(--accent);transition:transform .15s}
h3.collap.shut .chev{transform:rotate(-90deg)}
.collapbody.shut{display:none}
/* ---------- drop zones ---------- */
.dropgrid{display:grid;grid-template-columns:1fr auto 1fr;gap:12px;margin-bottom:14px;align-items:center}
@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;position:relative}
.drop:hover,.drop.over{border-color:var(--accent);background:#12212b}
.drop.filled{border-style:solid}
.drop .big{font-size:34px;line-height:1;opacity:.65}
.drop h2{margin:0 0 4px;font-size:15px;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}
.clearx{position:absolute;top:6px;right:6px;padding:1px 8px;font-size:13px;line-height:1.5;border-radius:6px;
background:rgba(0,0,0,.45);color:var(--dim);display:none}
.clearx:hover{color:#ffb4ae;border-color:#5a2b2b;background:#341c1c}
.drop.filled .clearx{display:block}
.thumb{width:auto;height:auto;max-width:180px;max-height:110px;border-radius:8px;border:1px solid var(--line);
background:#07090c;display:none;flex:none}
#swapBtn{padding:9px 11px;font-size:15px;line-height:1}
@media(max-width:700px){#swapBtn{width:100%}}
#joinDrop{border:2px dashed var(--line);border-radius:14px;padding:26px 20px;text-align:center;background:var(--panel);
cursor:pointer;transition:.15s;margin-bottom:14px}
#joinDrop:hover,#joinDrop.over{border-color:var(--accent);background:#12212b}
#joinDrop .big{font-size:34px;line-height:1;opacity:.65;margin-bottom:8px}
#joinDrop h2{margin:0 0 5px;font-size:16px;font-weight:600}
#joinDrop p{margin:0;color:var(--dim);font-size:12.5px}
/* ---------- stage ---------- */
.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:240px}
.stage canvas{max-width:100%;max-height:58vh;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}
.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}
input[type=range]{width:100%;accent-color:var(--accent)}
/* ---------- clip list (join) ---------- */
.clips-head{display:flex;align-items:center;gap:10px;margin:0 0 8px}
.clips-head h3{margin:0;font-size:12px;text-transform:uppercase;letter-spacing:.7px;color:var(--dim);font-weight:600}
.cliplist{border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--panel)}
.crow{display:flex;align-items:center;gap:10px;padding:7px 10px;border-top:1px solid var(--line);font-size:12.5px}
.crow:first-child{border-top:none}
.crow:hover{background:var(--panel2)}
.crow.on{background:#12212b;box-shadow:inset 2px 0 0 var(--accent)}
.cnum{width:20px;color:var(--dim);font-variant-numeric:tabular-nums;flex:none;text-align:right}
.cthumb{border-radius:3px;border:1px solid var(--line);display:block;flex:none;background:#07090c}
.cname{flex:1 1 90px;min-width:60px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.cmeta{font-variant-numeric:tabular-nums;color:var(--dim);flex:none}
.cdur{font-variant-numeric:tabular-nums;color:var(--accent);font-weight:600;width:64px;text-align:right;flex:none}
.cbtns{display:flex;gap:4px;flex:none}
.cbtns button{padding:3px 8px;font-size:11.5px;border-radius:6px}
@media(max-width:760px){.cmeta{display:none}}
.empty{color:var(--dim);font-size:12.5px;text-align:center;padding:18px 10px}
/* ---------- settings ---------- */
.row{display:flex;align-items:center;gap:8px;margin-bottom:10px}
.row label{flex:0 0 96px;color:var(--dim);font-size:12.5px}
.row:last-child{margin-bottom:0}
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[type=color]{padding:2px;height:31px;width:44px;flex:none;background:var(--panel2);
border:1px solid var(--line);border-radius:7px;cursor:pointer}
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;flex:1 1 auto}
.seg button.on{background:var(--accent);border-color:var(--accent);color:#04121c;font-weight:600}
.hint{color:var(--dim);font-size:11.5px;margin:-2px 0 10px;line-height:1.45}
.chk{display:flex;align-items:center;gap:7px;color:var(--dim);font-size:12.5px;margin-bottom:10px;cursor:pointer}
.chk input{width:auto;flex:none}
.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}
.expBtn{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;line-height:1.4}
.status.err{color:#ffb4ae}
.status.ok{color:#9ae6a8}
.note{font-size:11.5px;color:var(--warn);margin-top:8px;line-height:1.4}
.keys{color:var(--dim);font-size:11.5px;margin-top:10px;line-height:1.9}
.kbd{display:inline-block;background:var(--panel2);border:1px solid var(--line);border-bottom-width:2px;
border-radius:5px;padding:0 5px;font-size:11px;font-family:ui-monospace,Menlo,Consolas,monospace}
.hiddenmedia{position:fixed;left:-10000px;top:-10000px;width:2px;height:2px;opacity:.01;pointer-events:none}
@media(prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>Pocket Video Combiner</h1>
<span class="sub">side by side &middot; join end to end</span>
<span class="spacer"></span>
<span class="verwrap"><span class="ver">v1.0</span><span class="by">by C_Nugget</span></span>
</header>
<nav class="modetabs" id="modeTabs" role="tablist">
<button data-mode="sbs" class="on" role="tab" aria-selected="true">Side by side</button>
<button data-mode="join" role="tab" aria-selected="false">Join end to end</button>
</nav>
<!-- ============================ SIDE BY SIDE ============================ -->
<div class="modepanel" id="panelSbs" role="tabpanel">
<div class="dropgrid">
<div class="drop" id="drop1">
<button class="clearx" id="clear1" title="Remove this file">&times;</button>
<canvas class="thumb" id="thumb1"></canvas>
<div class="big" id="big1">&#9634;</div>
<div class="dropText"><h2>Video or image 1</h2><p>drop here, or click to choose</p></div>
<input type="file" id="file1" accept="video/*,image/*" hidden>
</div>
<button id="swapBtn" class="ghost" title="Swap the two files">&#8646;</button>
<div class="drop" id="drop2">
<button class="clearx" id="clear2" title="Remove this file">&times;</button>
<canvas class="thumb" id="thumb2"></canvas>
<div class="big" id="big2">&#9634;</div>
<div class="dropText"><h2>Video or image 2</h2><p>drop here, or click to choose</p></div>
<input type="file" id="file2" accept="video/*,image/*" hidden>
</div>
</div>
<div class="grid">
<div>
<div class="stage">
<canvas id="sbsCanvas"></canvas>
<div class="badge" id="sbsBadge">Load two files to start</div>
</div>
<div class="transport">
<button id="sbsPlay" class="icobtn" title="Play or pause (Space)">&#9654;</button>
<button id="sbsPrevF" class="icobtn ghost" title="Previous frame (Left arrow)">&#8676;</button>
<button id="sbsNextF" class="icobtn ghost" title="Next frame (Right arrow)">&#8677;</button>
<button id="sbsRestart" class="ghost small">Restart</button>
<span class="tc"><b id="sbsCur">0:00.00</b> / <span id="sbsTot">0:00.00</span></span>
<input type="range" id="sbsSeek" min="0" max="1000" value="0" step="1" style="flex:1 1 220px">
</div>
<div class="panelbox" style="margin-top:14px">
<h3 class="collap" data-target="labelsBody">Labels drawn onto the video <span class="chev">&#9660;</span></h3>
<div class="collapbody" id="labelsBody">
<div class="row"><label>Label 1</label><input type="text" id="txt1" placeholder="Optional, e.g. Before"></div>
<div class="row"><label>Label 2</label><input type="text" id="txt2" placeholder="Optional, e.g. After"></div>
<div class="row">
<label>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">Top centre</option>
<option value="cb">Bottom centre</option>
</select>
</div>
<div class="row">
<label>Size</label>
<select id="labelSize"><option value="0.8">Small</option><option value="1" selected>Medium</option><option value="1.4">Large</option></select>
<input type="color" id="labelBg" value="#7f1018" title="Label background colour">
</div>
<div class="hint">Labels are burned into the export as white text on the colour above.</div>
</div>
</div>
</div>
<div class="card">
<div class="panelbox">
<h3>Layout</h3>
<div class="seg" id="layoutSeg">
<button data-layout="lr" class="on">Left / right</button>
<button data-layout="tb">Top / bottom</button>
</div>
<div class="row"><label>Framing</label>
<select id="sbsFit"><option value="fit" selected>Fit (show all, black bars)</option><option value="fill">Fill (crop to match)</option></select>
</div>
<div class="row"><label>Divider</label>
<select id="sbsGap"><option value="0" selected>None</option><option value="6">Thin</option><option value="16">Medium</option><option value="34">Thick</option></select>
<input type="color" id="sbsGapCol" value="#000000" title="Divider colour">
</div>
<div class="row"><label>Length</label>
<select id="sbsLen">
<option value="short" selected>Stop at shorter clip</option>
<option value="long">Run to longer clip</option>
</select>
</div>
<div class="hint" id="sbsLenHint">Both clips start together. The export ends when the shorter one ends.</div>
</div>
<div class="panelbox">
<h3>Audio</h3>
<div class="row"><label>Use</label>
<select id="sbsAudio"><option value="both" selected>Both clips</option><option value="v1">Clip 1 only</option><option value="v2">Clip 2 only</option><option value="none">No audio</option></select>
</div>
<div class="row"><label>Clip 1 vol</label><input type="range" id="vol1" min="0" max="100" value="70"><b class="tc" id="vol1L" style="flex:0 0 36px;text-align:right">70%</b></div>
<div class="row"><label>Clip 2 vol</label><input type="range" id="vol2" min="0" max="100" value="70"><b class="tc" id="vol2L" style="flex:0 0 36px;text-align:right">70%</b></div>
<div class="hint">Volumes apply to both the preview and the export.</div>
</div>
<div class="panelbox">
<h3>Export</h3>
<div class="row"><label>Scale</label>
<select id="sbsScale"><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="sbsFps" min="1" max="60" step="1" value="30"></div>
<div class="est">
<div><span>Output size</span><b id="sbsOutSize">&mdash;</b></div>
<div><span>Duration</span><b id="sbsOutDur">&mdash;</b></div>
<div><span>Rough file size</span><b id="sbsOutBytes">&mdash;</b></div>
<div><span>Format</span><b id="sbsFmt">&mdash;</b></div>
</div>
<button id="sbsExport" class="primary expBtn">Export side by side video</button>
<button id="sbsStop" class="danger stopBtn">Stop export</button>
<div class="bar" id="sbsBar"><i id="sbsBarI"></i></div>
<div class="status" id="sbsStatus"></div>
<div class="note" id="sbsNote">Export records in real time, so a 2 minute clip takes about 2 minutes. Keep this tab visible while it runs.</div>
</div>
</div>
</div>
</div>
<!-- ============================== JOIN ================================== -->
<div class="modepanel" id="panelJoin" role="tabpanel" hidden>
<div id="joinDrop">
<div class="big">&#9634;</div>
<h2>Drop videos here to add them to the sequence</h2>
<p>or click to choose files &nbsp;&middot;&nbsp; you can add several at once &nbsp;&middot;&nbsp; nothing is uploaded, it all stays on this computer</p>
<input type="file" id="joinFiles" accept="video/*" multiple hidden>
</div>
<div class="grid">
<div>
<div class="stage">
<canvas id="joinCanvas"></canvas>
<div class="badge" id="joinBadge">Add two or more videos to start</div>
</div>
<div class="transport">
<button id="joinPlay" class="icobtn" title="Play or pause (Space)">&#9654;</button>
<button id="joinBack" class="icobtn ghost" title="Back 1 second (Left arrow)">&#8676;</button>
<button id="joinFwd" class="icobtn ghost" title="Forward 1 second (Right arrow)">&#8677;</button>
<button id="joinRestart" class="ghost small">Restart</button>
<span class="tc"><b id="joinCur">0:00.00</b> / <span id="joinTot">0:00.00</span></span>
<input type="range" id="joinSeek" min="0" max="1000" value="0" step="1" style="flex:1 1 220px">
</div>
<div style="margin-top:16px">
<div class="clips-head">
<h3>Sequence</h3>
<span class="tc" id="joinCount">0 clips</span>
<span class="spacer"></span>
<button class="small ghost" id="joinClear">Remove all</button>
</div>
<div class="cliplist" id="clipList"><div class="empty">No clips yet. Add videos above and they will play in this order, top to bottom.</div></div>
</div>
</div>
<div class="card">
<div class="panelbox">
<h3>Output frame</h3>
<div class="row"><label>Size</label>
<select id="joinSize">
<option value="first" selected>Match the first clip</option>
<option value="largest">Largest clip</option>
<option value="1920x1080">1920 &times; 1080</option>
<option value="1280x720">1280 &times; 720</option>
<option value="1080x1920">1080 &times; 1920 (vertical)</option>
<option value="1080x1080">1080 &times; 1080 (square)</option>
</select>
</div>
<div class="row"><label>Framing</label>
<select id="joinFit"><option value="fit" selected>Fit (show all, black bars)</option><option value="fill">Fill (crop to frame)</option></select>
</div>
<div class="hint">Every clip is drawn into one fixed frame, so mixed sizes still join cleanly.</div>
</div>
<div class="panelbox">
<h3>Audio</h3>
<div class="row"><label>Sound</label>
<select id="joinAudio"><option value="on" selected>Keep clip audio</option><option value="none">No audio</option></select>
</div>
<div class="row"><label>Volume</label><input type="range" id="joinVol" min="0" max="100" value="100"><b class="tc" id="joinVolL" style="flex:0 0 36px;text-align:right">100%</b></div>
</div>
<div class="panelbox">
<h3>Export</h3>
<div class="row"><label>Scale</label>
<select id="joinScale"><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="joinFps" min="1" max="60" step="1" value="30"></div>
<div class="est">
<div><span>Output size</span><b id="joinOutSize">&mdash;</b></div>
<div><span>Duration</span><b id="joinOutDur">&mdash;</b></div>
<div><span>Rough file size</span><b id="joinOutBytes">&mdash;</b></div>
<div><span>Format</span><b id="joinFmt">&mdash;</b></div>
</div>
<button id="joinExport" class="primary expBtn">Export joined video</button>
<button id="joinStop" class="danger stopBtn">Stop export</button>
<div class="bar" id="joinBar"><i id="joinBarI"></i></div>
<div class="status" id="joinStatus"></div>
<div class="note">Export records in real time. Keep this tab visible while it runs.</div>
</div>
</div>
</div>
</div>
<div class="keys">
<span class="kbd">Space</span> play or pause &nbsp;&middot;&nbsp;
<span class="kbd">&larr;</span> <span class="kbd">&rarr;</span> step back or forward &nbsp;&middot;&nbsp;
<span class="kbd">Home</span> back to the start &nbsp;&middot;&nbsp;
MP4 is used when the browser supports it, otherwise WebM.
</div>
</div>
<video id="v1" class="hiddenmedia" playsinline preload="auto" crossorigin="anonymous"></video>
<video id="v2" class="hiddenmedia" playsinline preload="auto" crossorigin="anonymous"></video>
<img id="im1" class="hiddenmedia" alt="">
<img id="im2" class="hiddenmedia" alt="">
<video id="jA" class="hiddenmedia" playsinline preload="auto" crossorigin="anonymous"></video>
<video id="jB" class="hiddenmedia" playsinline preload="auto" crossorigin="anonymous"></video>
<script>
"use strict";
(function(){
/* ======================= shared helpers ======================= */
const $ = id => document.getElementById(id);
const clamp = (v,a,b) => Math.min(b, Math.max(a, v));
const even = n => { n = Math.round(n); return n % 2 ? n + 1 : n; }; // encoders dislike odd sizes
function once(el, ev){ return new Promise(r => el.addEventListener(ev, r, {once:true})); }
function wait(ms){ return new Promise(r => setTimeout(r, ms)); }
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, fallback){
return String(s || fallback).replace(/[\\/:*?"<>|\x00-\x1f]+/g,"-").replace(/^\.+/,"").trim().slice(0,80) || fallback;
}
function baseName(n){ return n.replace(/\.[^.]+$/, "") || "video"; }
function human(bytes){
if(!isFinite(bytes) || bytes <= 0) return "\u2014";
if(bytes < 1024*1024) return Math.round(bytes/1024) + " KB";
if(bytes < 1024*1024*1024) return (bytes/1024/1024).toFixed(bytes < 10*1024*1024 ? 1 : 0) + " MB";
return (bytes/1024/1024/1024).toFixed(2) + " GB";
}
function say(el, msg, kind){
el.textContent = msg || "";
el.className = "status" + (kind ? " " + kind : "");
}
function bitrateFor(w, h, fps){
return clamp(Math.round(w * h * fps * 0.14), 1200000, 45000000);
}
const MIMES = [
"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"
];
function pickMime(){
if(!window.MediaRecorder) return "";
for(const m of MIMES) if(MediaRecorder.isTypeSupported(m)) return m;
return "";
}
const CHOSEN_MIME = pickMime();
const FMT_LABEL = CHOSEN_MIME ? (CHOSEN_MIME.indexOf("mp4") >= 0 ? "MP4" : "WebM") : "not supported";
$("sbsFmt").textContent = FMT_LABEL;
$("joinFmt").textContent = FMT_LABEL;
async function seekTo(video, t){
if(!video || !isFinite(video.duration)) return;
const target = clamp(t, 0, Math.max(0, (video.duration || 0) - 0.001));
if(Math.abs((video.currentTime || 0) - target) < 0.005) return;
video.currentTime = target;
await Promise.race([once(video,"seeked"), wait(900)]);
}
async function waitReady(video){
if(video.readyState >= 2) return;
await Promise.race([once(video,"loadeddata"), once(video,"canplay"), wait(4000)]);
}
/* ---- drawing a source into a box, either letterboxed or cropped ---- */
function drawInto(ctx, media, x, y, w, h, mode, bg){
ctx.fillStyle = bg || "#000";
ctx.fillRect(x, y, w, h);
const mw = media.videoWidth || media.naturalWidth, mh = media.videoHeight || media.naturalHeight;
if(!mw || !mh) return;
const sc = mode === "fill" ? Math.max(w/mw, h/mh) : Math.min(w/mw, h/mh);
const dw = mw*sc, dh = mh*sc;
if(mode === "fill"){
ctx.save(); ctx.beginPath(); ctx.rect(x, y, w, h); ctx.clip();
ctx.drawImage(media, x + (w - dw)/2, y + (h - dh)/2, dw, dh);
ctx.restore();
}else{
ctx.drawImage(media, x + (w - dw)/2, y + (h - dh)/2, dw, dh);
}
}
/* ---- thumbnails ---- */
function paintThumb(canvasEl, media, maxW, maxH){
const mw = media.videoWidth || media.naturalWidth || 16;
const mh = media.videoHeight || media.naturalHeight || 9;
let cssW = maxW, cssH = Math.round(cssW * mh / mw);
if(cssH > maxH){ cssH = maxH; cssW = Math.round(cssH * mw / mh); }
canvasEl.width = Math.max(2, cssW * 2);
canvasEl.height = Math.max(2, cssH * 2);
canvasEl.style.width = cssW + "px";
canvasEl.style.height = cssH + "px";
const x = canvasEl.getContext("2d", {alpha:false});
x.fillStyle = "#000"; x.fillRect(0, 0, canvasEl.width, canvasEl.height);
const sc = Math.min(canvasEl.width/mw, canvasEl.height/mh);
x.drawImage(media, (canvasEl.width - mw*sc)/2, (canvasEl.height - mh*sc)/2, mw*sc, mh*sc);
canvasEl.style.display = "block";
}
function copyThumb(dst, src){
if(!src){ dst.style.display = "none"; return; }
dst.width = src.width; dst.height = src.height;
dst.style.width = src.style.width; dst.style.height = src.style.height;
dst.getContext("2d").drawImage(src, 0, 0);
dst.style.display = "block";
}
/* ---- Web Audio: one graph so several sources can be mixed and recorded ---- */
let AC = null, audioBroken = false;
const audioNodes = new WeakMap();
function ac(){
if(AC) return AC;
const C = window.AudioContext || window.webkitAudioContext;
if(!C){ audioBroken = true; return null; }
try{ AC = new C(); }catch(e){ audioBroken = true; return null; }
return AC;
}
function resumeAudio(){ const c = ac(); if(c && c.state === "suspended") c.resume().catch(()=>{}); }
function nodeFor(el){
if(audioBroken) return null;
if(audioNodes.has(el)) return audioNodes.get(el);
const c = ac();
if(!c) return null;
try{
const src = c.createMediaElementSource(el);
const gain = c.createGain();
src.connect(gain);
gain.connect(c.destination);
const o = {src, gain};
audioNodes.set(el, o);
return o;
}catch(e){ audioBroken = true; return null; }
}
function setGain(el, value){
const n = nodeFor(el);
if(n){ n.gain.gain.value = value; }
else { el.volume = clamp(value, 0, 1); } // fallback when Web Audio is unavailable
}
/* ---- recording ---- */
function startRecording(canvas, fps, audioEls){
if(!canvas.captureStream) throw new Error("This browser cannot record from a canvas. Try Chrome or Edge.");
if(!CHOSEN_MIME) throw new Error("This browser cannot record video. Try Chrome or Edge.");
const stream = canvas.captureStream(fps);
let dest = null, connected = [];
if(audioEls && audioEls.length && !audioBroken){
const c = ac();
if(c){
try{
if(c.state === "suspended") c.resume().catch(()=>{});
dest = c.createMediaStreamDestination();
audioEls.forEach(el => { const n = nodeFor(el); if(n){ n.gain.connect(dest); connected.push(n.gain); } });
if(connected.length) dest.stream.getAudioTracks().forEach(t => stream.addTrack(t));
else dest = null;
}catch(e){ dest = null; connected = []; }
}
}
const hasAudio = stream.getAudioTracks().length > 0;
let mime = CHOSEN_MIME, rec = null;
const opts = {mimeType: mime, videoBitsPerSecond: bitrateFor(canvas.width, canvas.height, fps)};
if(hasAudio) opts.audioBitsPerSecond = 192000;
try{ rec = new MediaRecorder(stream, opts); }
catch(e){
try{ mime = "video/webm"; rec = new MediaRecorder(stream, {mimeType: mime}); }
catch(e2){
stream.getTracks().forEach(t => { try{ t.stop(); }catch(_){} });
connected.forEach(g => { try{ g.disconnect(dest); }catch(_){} });
throw new Error("The recorder would not start. Try a lower scale, or use Chrome or Edge.");
}
}
const chunks = [];
rec.ondataavailable = e => { if(e.data && e.data.size) chunks.push(e.data); };
const stopped = new Promise(r => { rec.onstop = r; });
rec.start(200);
return {
rec, stream, chunks, mime, stopped, hasAudio,
finish(){
try{ if(rec.state !== "inactive") rec.stop(); }catch(_){}
return stopped.then(() => {
stream.getTracks().forEach(t => { try{ t.stop(); }catch(_){} });
connected.forEach(g => { try{ g.disconnect(dest); }catch(_){} });
const type = mime.split(";")[0];
return {blob: new Blob(chunks, {type}), ext: type.indexOf("mp4") >= 0 ? "mp4" : "webm"};
});
}
};
}
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(); }, 15000);
}
async function saveBlob(blob, name, ext){
if(window.showSaveFilePicker){
try{
const accept = ext === "mp4" ? {"video/mp4":[".mp4"]} : {"video/webm":[".webm"]};
const handle = await window.showSaveFilePicker({suggestedName: name, types:[{description:"Video file", accept}]});
const w = await handle.createWritable();
await w.write(blob); await w.close();
return "saved";
}catch(e){
if(e && e.name === "AbortError") return "cancelled";
}
}
browserDownload(blob, name);
return "saved";
}
/* collapsible headings */
document.querySelectorAll("h3.collap").forEach(h => {
h.addEventListener("click", () => {
h.classList.toggle("shut");
const b = $(h.dataset.target);
if(b) b.classList.toggle("shut");
});
});
/* ======================= mode tabs ======================= */
let mode = "sbs";
document.querySelectorAll("#modeTabs button").forEach(b => {
b.addEventListener("click", () => {
if(SBS.exporting || JOIN.exporting) return;
SBS.pause(); JOIN.pause();
mode = b.dataset.mode;
document.querySelectorAll("#modeTabs button").forEach(x => {
const on = x === b;
x.classList.toggle("on", on);
x.setAttribute("aria-selected", on ? "true" : "false");
});
$("panelSbs").hidden = mode !== "sbs";
$("panelJoin").hidden = mode !== "join";
if(mode === "sbs") SBS.refresh(); else JOIN.refresh();
});
});
/* =========================================================================
MODE 1 — SIDE BY SIDE
========================================================================= */
const SBS = (function(){
const cvs = $("sbsCanvas"), ctx = cvs.getContext("2d", {alpha:false});
const v = {1:$("v1"), 2:$("v2")}, im = {1:$("im1"), 2:$("im2")};
const slot = {
1:{kind:null, ready:false, url:null, name:"clip1", file:"", thumb:null},
2:{kind:null, ready:false, url:null, name:"clip2", file:"", thumb:null}
};
let playing = false, exporting = false, cancel = false, raf = 0, dragging = false;
const el = n => slot[n].kind === "image" ? im[n] : v[n];
const sw = n => { const e = el(n); return (e.videoWidth || e.naturalWidth) || 0; };
const sh = n => { const e = el(n); return (e.videoHeight || e.naturalHeight) || 0; };
const both = () => slot[1].ready && slot[2].ready;
const stillOnly = () => both() && slot[1].kind === "image" && slot[2].kind === "image";
const layout = () => document.querySelector("#layoutSeg button.on").dataset.layout;
const scale = () => parseFloat($("sbsScale").value) || 1;
const fps = () => clamp(Math.round(+$("sbsFps").value || 30), 1, 60);
const gap = () => parseInt($("sbsGap").value, 10) || 0;
const fitMode = () => $("sbsFit").value;
const longMode = () => $("sbsLen").value === "long";
function videos(){
const out = [];
if(slot[1].ready && slot[1].kind === "video") out.push(v[1]);
if(slot[2].ready && slot[2].kind === "video") out.push(v[2]);
return out;
}
function dur(){
const ds = videos().map(x => x.duration || 0).filter(d => isFinite(d) && d > 0);
if(!ds.length) return 0;
return longMode() ? Math.max.apply(null, ds) : Math.min.apply(null, ds);
}
function now(){
const ts = videos().map(x => x.currentTime || 0);
if(!ts.length) return 0;
return longMode() ? Math.max.apply(null, ts) : Math.min.apply(null, ts);
}
function dims(){
const s = scale(), g = gap();
if(!both()) return {W:1280, H:720, g:0, a:{x:0,y:0,w:640,h:360}, b:{x:640,y:0,w:640,h:360}};
const w1 = sw(1) || 1280, h1 = sh(1) || 720, w2 = sw(2) || 1280, h2 = sh(2) || 720;
if(fitMode() === "fill"){
// equal panes, sources cropped to fill them
const bw = even(Math.max(w1, w2) * s), bh = even(Math.max(h1, h2) * s);
if(layout() === "lr") return {W: even(bw*2 + g), H: bh, g, a:{x:0,y:0,w:bw,h:bh}, b:{x:bw+g,y:0,w:bw,h:bh}};
return {W: bw, H: even(bh*2 + g), g, a:{x:0,y:0,w:bw,h:bh}, b:{x:0,y:bh+g,w:bw,h:bh}};
}
const aw1 = Math.max(2, w1*s), ah1 = Math.max(2, h1*s), aw2 = Math.max(2, w2*s), ah2 = Math.max(2, h2*s);
if(layout() === "lr"){
const h = even(Math.max(ah1, ah2));
const pw1 = even(aw1 * h / ah1), pw2 = even(aw2 * h / ah2);
return {W: even(pw1 + g + pw2), H: h, g, a:{x:0,y:0,w:pw1,h}, b:{x:pw1+g,y:0,w:pw2,h}};
}
const w = even(Math.max(aw1, aw2));
const ph1 = even(ah1 * w / aw1), ph2 = even(ah2 * w / aw2);
return {W: w, H: even(ph1 + g + ph2), g, a:{x:0,y:0,w,h:ph1}, b:{x:0,y:ph1+g,w,h:ph2}};
}
function sizeCanvas(){
const d = dims();
if(cvs.width !== d.W || cvs.height !== d.H){ cvs.width = d.W; cvs.height = d.H; }
return d;
}
function label(text, x, y, w, h){
text = (text || "").trim();
if(!text) return;
const mult = parseFloat($("labelSize").value) || 1;
const font = Math.max(12, Math.round(cvs.height/42 * mult));
const px = Math.round(font*0.62), py = Math.round(font*0.38), pad = Math.round(font*0.7);
ctx.font = "600 " + font + "px -apple-system,BlinkMacSystemFont,Segoe UI,Arial,sans-serif";
let t = text;
while(ctx.measureText(t).width > w - pad*2 - px*2 && t.length > 2) t = t.slice(0, -2);
if(t !== text) t = t.slice(0, -1) + "\u2026";
const bw = ctx.measureText(t).width + px*2, bh = font + py*2;
const pos = $("labelPos").value;
let bx = x + pad, by = y + pad;
if(pos === "tr"){ bx = x + w - bw - pad; }
else if(pos === "bl"){ by = y + h - bh - pad; }
else if(pos === "br"){ bx = x + w - bw - pad; by = y + h - bh - pad; }
else if(pos === "ct"){ bx = x + (w - bw)/2; }
else if(pos === "cb"){ bx = x + (w - bw)/2; by = y + h - bh - pad; }
ctx.fillStyle = $("labelBg").value || "#7f1018";
ctx.globalAlpha = 0.94; ctx.fillRect(Math.round(bx), Math.round(by), Math.round(bw), Math.round(bh)); ctx.globalAlpha = 1;
ctx.fillStyle = "#fff"; ctx.textBaseline = "top";
ctx.fillText(t, Math.round(bx + px), Math.round(by + py));
}
function draw(){
const d = sizeCanvas();
ctx.fillStyle = $("sbsGapCol").value || "#000";
ctx.fillRect(0, 0, cvs.width, cvs.height);
if(both()){
drawInto(ctx, el(1), d.a.x, d.a.y, d.a.w, d.a.h, fitMode());
drawInto(ctx, el(2), d.b.x, d.b.y, d.b.w, d.b.h, fitMode());
label($("txt1").value, d.a.x, d.a.y, d.a.w, d.a.h);
label($("txt2").value, d.b.x, d.b.y, d.b.w, d.b.h);
}
const t = now(), total = dur();
$("sbsCur").textContent = tc(t);
if(!dragging && total) $("sbsSeek").value = String(Math.round(t/total*1000));
if(playing && !exporting && total && t >= total - 0.04) pause();
if(playing || exporting) raf = requestAnimationFrame(draw);
}
function redraw(){ if(!playing && !exporting){ cancelAnimationFrame(raf); draw(); } }
function readout(){
const d = dims(), total = dur();
$("sbsOutSize").textContent = both() ? d.W + " \u00d7 " + d.H : "\u2014";
$("sbsOutDur").textContent = stillOnly() ? "Still image" : (total ? tc(total) : "\u2014");
$("sbsTot").textContent = total ? tc(total) : "0:00.00";
if(both() && total){
const bytes = (bitrateFor(d.W, d.H, fps()) + ($("sbsAudio").value === "none" ? 0 : 192000)) * total / 8;
$("sbsOutBytes").textContent = "~" + human(bytes);
}else{
$("sbsOutBytes").textContent = stillOnly() ? "PNG image" : "\u2014";
}
$("sbsLenHint").textContent = longMode()
? "The shorter clip freezes on its last frame until the longer one finishes."
: "Both clips start together. The export ends when the shorter one ends.";
let note = "Export records in real time, so a 2 minute clip takes about 2 minutes. Keep this tab visible while it runs.";
if(both() && (d.W > 4096 || d.H > 4096)) note = "That output is over 4096 px on one side, which many encoders refuse. Drop the scale to 50% or 75%.";
$("sbsNote").textContent = note;
$("sbsExport").textContent = stillOnly() ? "Export side by side image" : "Export side by side video";
$("sbsExport").disabled = !both() || exporting;
const noVid = !videos().length;
["sbsPlay","sbsPrevF","sbsNextF","sbsRestart","sbsSeek"].forEach(id => { $(id).disabled = !both() || noVid; });
}
function badge(){
if(!both()){ $("sbsBadge").textContent = "Load two files to start"; return; }
const k = n => slot[n].kind === "image" ? "image" : "video";
$("sbsBadge").textContent = (layout() === "lr" ? "Left / right" : "Top / bottom")
+ " \u00b7 " + k(1) + " + " + k(2) + (dur() ? " \u00b7 " + tc(dur()) : "");
}
function refresh(){ badge(); readout(); redraw(); }
/* ---- loading ---- */
function paintSlot(n){
const s = slot[n], d = $("drop" + n), th = $("thumb" + n);
d.classList.toggle("filled", !!s.ready);
if(s.ready){
d.querySelector("h2").textContent = (s.kind === "image" ? "Image " : "Video ") + n + " loaded";
d.querySelector("p").textContent = s.file;
copyThumb(th, s.thumb);
$("big" + n).style.display = s.thumb ? "none" : "";
}else{
d.querySelector("h2").textContent = "Video or image " + n;
d.querySelector("p").textContent = "drop here, or click to choose";
th.style.display = "none";
$("big" + n).style.display = "";
}
}
function reset(n){
const s = slot[n];
if(s.url) URL.revokeObjectURL(s.url);
s.url = null; s.ready = false; s.kind = null; s.name = "clip" + n; s.file = ""; s.thumb = null;
try{ v[n].pause(); }catch(_){}
v[n].removeAttribute("src"); v[n].load();
im[n].removeAttribute("src");
paintSlot(n);
refresh();
}
async function load(file, n){
if(exporting) return;
resumeAudio();
const s = slot[n];
if(s.url) URL.revokeObjectURL(s.url);
const url = URL.createObjectURL(file);
s.url = url; s.ready = false; s.name = baseName(file.name); s.file = file.name;
const isImg = file.type.startsWith("image/");
if(isImg){
const image = im[n];
image.src = url;
const ok = await Promise.race([once(image,"load").then(()=>true), once(image,"error").then(()=>false), wait(10000).then(()=>false)]);
if(!ok || !image.naturalWidth){ say($("sbsStatus"), "Could not read " + file.name + ". Try a JPG, PNG or WebP.", "err"); reset(n); return; }
s.kind = "image"; s.ready = true;
s.thumb = document.createElement("canvas"); paintThumb(s.thumb, image, 180, 110);
}else{
const video = v[n];
try{ video.pause(); }catch(_){}
video.src = url; video.muted = false; video.load();
const ok = await Promise.race([once(video,"loadedmetadata").then(()=>true), once(video,"error").then(()=>false), wait(15000).then(()=>false)]);
if(!ok || !video.videoWidth){ say($("sbsStatus"), "Could not read " + file.name + ". Try an MP4, MOV or WebM file.", "err"); reset(n); return; }
s.kind = "video"; s.ready = true;
setGain(video, 0); // silent until we know the audio settings
await waitReady(video); await seekTo(video, 0);
s.thumb = document.createElement("canvas"); paintThumb(s.thumb, video, 180, 110);
applyAudio();
}
paintSlot(n);
refresh();
say($("sbsStatus"), both() ? (stillOnly() ? "Ready to export a still image." : "Ready to preview or export.") : "Now load the second file.", both() ? "ok" : "");
}
function dropSetup(n){
const d = $("drop" + n), f = $("file" + n);
d.addEventListener("click", e => { if(e.target.classList.contains("clearx")) return; f.click(); });
f.addEventListener("change", e => { if(e.target.files[0]) load(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) load(file, n); });
$("clear" + n).addEventListener("click", e => { e.stopPropagation(); if(!exporting) reset(n); });
}
dropSetup(1); dropSetup(2);
$("swapBtn").addEventListener("click", async () => {
if(exporting || !(slot[1].ready || slot[2].ready)) return;
pause();
const first = slot[1];
slot[1] = slot[2]; slot[2] = first;
const t = $("txt1").value; $("txt1").value = $("txt2").value; $("txt2").value = t;
[1, 2].forEach(paintSlot); // names and thumbnails move straight away
refresh();
for(const n of [1, 2]){
const s = slot[n];
try{ v[n].pause(); }catch(_){}
if(!s.ready){ v[n].removeAttribute("src"); v[n].load(); im[n].removeAttribute("src"); continue; }
if(s.kind === "image"){ im[n].src = s.url; v[n].removeAttribute("src"); v[n].load(); }
else { v[n].src = s.url; v[n].load(); }
}
await Promise.all([1, 2].map(async n => {
const s = slot[n];
if(!s.ready) return;
const e = el(n);
if(s.kind === "video"){
await Promise.race([once(e,"loadedmetadata"), wait(8000)]);
await waitReady(e); await seekTo(e, 0);
}else if(!e.complete){
await Promise.race([once(e,"load"), wait(8000)]);
}
}));
applyAudio(); refresh();
say($("sbsStatus"), "Swapped clip 1 and clip 2.");
});
/* ---- audio ---- */
function applyAudio(){
const choice = $("sbsAudio").value;
const g1 = choice === "none" || choice === "v2" ? 0 : (+$("vol1").value/100);
const g2 = choice === "none" || choice === "v1" ? 0 : (+$("vol2").value/100);
if(slot[1].kind === "video") setGain(v[1], g1);
if(slot[2].kind === "video") setGain(v[2], g2);
$("vol1L").textContent = $("vol1").value + "%";
$("vol2L").textContent = $("vol2").value + "%";
$("vol1").disabled = choice === "none" || choice === "v2";
$("vol2").disabled = choice === "none" || choice === "v1";
}
/* ---- transport ---- */
function seekAll(t){ return Promise.all(videos().map(x => seekTo(x, t))); }
async function play(){
if(!both() || exporting || !videos().length) return;
resumeAudio();
if(dur() && now() >= dur() - 0.05) await seekAll(0);
playing = true; $("sbsPlay").innerHTML = "&#10074;&#10074;";
cancelAnimationFrame(raf); draw();
try{ await Promise.all(videos().map(x => x.play())); }
catch(e){ playing = false; $("sbsPlay").innerHTML = "&#9654;"; say($("sbsStatus"), "The browser blocked playback. Click anywhere on the page, then press play again.", "err"); }
}
function pause(){
if(!playing) { videos().forEach(x => { try{ x.pause(); }catch(_){} }); return; }
playing = false; $("sbsPlay").innerHTML = "&#9654;";
videos().forEach(x => { try{ x.pause(); }catch(_){} });
cancelAnimationFrame(raf); draw();
}
$("sbsPlay").addEventListener("click", () => playing ? pause() : play());
$("sbsRestart").addEventListener("click", async () => { pause(); await seekAll(0); redraw(); });
$("sbsPrevF").addEventListener("click", async () => { pause(); await seekAll(Math.max(0, now() - 1/fps())); redraw(); });
$("sbsNextF").addEventListener("click", async () => { pause(); await seekAll(Math.min(dur(), now() + 1/fps())); redraw(); });
$("sbsSeek").addEventListener("pointerdown", () => { dragging = true; });
$("sbsSeek").addEventListener("input", () => { if(dur()) $("sbsCur").textContent = tc(dur() * $("sbsSeek").value/1000); });
$("sbsSeek").addEventListener("change", async () => {
const target = dur() * (+$("sbsSeek").value)/1000; // read before pausing, which repaints the slider
dragging = false;
if(!dur()) return;
pause(); await seekAll(target); redraw();
});
/* ---- settings wiring ---- */
document.querySelectorAll("#layoutSeg button").forEach(b => b.addEventListener("click", () => {
if(exporting) return;
document.querySelectorAll("#layoutSeg button").forEach(x => x.classList.toggle("on", x === b));
refresh();
}));
["sbsScale","sbsFps","sbsFit","sbsGap","sbsGapCol","sbsLen","txt1","txt2","labelPos","labelSize","labelBg"]
.forEach(id => $(id).addEventListener("input", refresh));
["sbsAudio","vol1","vol2"].forEach(id => $(id).addEventListener("input", () => { applyAudio(); readout(); }));
function lock(on){
["sbsScale","sbsFps","sbsFit","sbsGap","sbsGapCol","sbsLen","sbsAudio","txt1","txt2","labelPos","labelSize","labelBg","swapBtn","clear1","clear2"]
.forEach(id => { $(id).disabled = on; });
document.querySelectorAll("#layoutSeg button").forEach(b => b.disabled = on);
document.querySelectorAll("#modeTabs button").forEach(b => b.disabled = on);
}
/* ---- export ---- */
$("sbsStop").addEventListener("click", () => { cancel = true; say($("sbsStatus"), "Stopping\u2026"); });
$("sbsExport").addEventListener("click", async () => {
if(exporting) return;
if(!both()){ say($("sbsStatus"), "Load a file into both slots first.", "err"); return; }
if(stillOnly()){
redraw();
const blob = await new Promise(r => cvs.toBlob(r, "image/png"));
if(!blob || !blob.size){ say($("sbsStatus"), "Could not build the image.", "err"); return; }
const name = safeName(slot[1].name + "_" + slot[2].name + "_sidebyside", "sidebyside") + ".png";
browserDownload(blob, name);
say($("sbsStatus"), "Saved " + name, "ok");
return;
}
const total = dur();
if(!isFinite(total) || total <= 0){ say($("sbsStatus"), "Could not read the clip length. Try converting the files to MP4 first.", "err"); return; }
exporting = true; cancel = false; lock(true);
pause(); await seekAll(0); sizeCanvas();
$("sbsExport").disabled = true; $("sbsStop").style.display = "block";
$("sbsBar").style.display = "block"; $("sbsBarI").style.width = "0%";
say($("sbsStatus"), "Recording\u2026 keep this tab visible.");
let session = null;
try{
const useAudio = $("sbsAudio").value !== "none";
session = startRecording(cvs, fps(), useAudio ? videos() : []);
cancelAnimationFrame(raf); draw();
await Promise.all(videos().map(x => x.play()));
playing = true;
const started = performance.now();
await new Promise(resolve => {
const timer = setInterval(() => {
const t = now();
$("sbsBarI").style.width = (clamp(t/total, 0, 1) * 100).toFixed(1) + "%";
const overrun = (performance.now() - started) > total*1000 + 15000;
if(cancel || t >= total - 0.035 || overrun){ clearInterval(timer); resolve(); }
}, 80);
});
videos().forEach(x => { try{ x.pause(); }catch(_){} });
playing = false;
await wait(220); // let the last frames land
const {blob, ext} = await session.finish();
if(cancel){ say($("sbsStatus"), "Export stopped. Nothing was saved."); }
else if(!blob.size){ say($("sbsStatus"), "The export came out empty. Try a lower scale, or use Chrome or Edge.", "err"); }
else{
const name = safeName(slot[1].name + "_" + slot[2].name + "_sidebyside", "sidebyside") + "." + ext;
const r = await saveBlob(blob, name, ext);
say($("sbsStatus"), r === "cancelled" ? "Save cancelled." : "Saved " + name + (session.hasAudio ? "" : " (no audio)"), r === "cancelled" ? "" : "ok");
}
}catch(e){
if(session){ try{ await session.finish(); }catch(_){} }
videos().forEach(x => { try{ x.pause(); }catch(_){} });
playing = false;
say($("sbsStatus"), e.message || "The export failed.", "err");
}finally{
exporting = false; lock(false);
$("sbsStop").style.display = "none"; $("sbsBar").style.display = "none";
$("sbsPlay").innerHTML = "&#9654;";
applyAudio();
await seekAll(0);
refresh();
}
});
applyAudio(); refresh();
return {
refresh, pause, play,
get exporting(){ return exporting; },
get playing(){ return playing; },
step(dir){ if(!both()) return; pause(); seekAll(clamp(now() + dir/fps(), 0, dur())).then(redraw); },
home(){ if(!both()) return; pause(); seekAll(0).then(redraw); }
};
})();
/* =========================================================================
MODE 2 — JOIN END TO END
========================================================================= */
const JOIN = (function(){
const cvs = $("joinCanvas"), ctx = cvs.getContext("2d", {alpha:false});
const els = [$("jA"), $("jB")];
const held = [-1, -1]; // which clip index each element currently holds
let clips = [], activeEl = 0, curIdx = -1;
let playing = false, exporting = false, cancel = false, raf = 0, dragging = false, advancing = false, seq = 0;
const act = () => els[activeEl];
const idle = () => els[1 - activeEl];
const fps = () => clamp(Math.round(+$("joinFps").value || 30), 1, 60);
const scale = () => parseFloat($("joinScale").value) || 1;
const fitMode = () => $("joinFit").value;
const total = () => clips.reduce((a, c) => a + c.dur, 0);
function startOf(i){ let a = 0; for(let k = 0; k < i; k++) a += clips[k].dur; return a; }
function locate(t){
let acc = 0;
for(let i = 0; i < clips.length; i++){
if(t < acc + clips[i].dur || i === clips.length - 1) return {i, local: clamp(t - acc, 0, clips[i].dur)};
acc += clips[i].dur;
}
return {i:0, local:0};
}
function elapsed(){
if(curIdx < 0) return 0;
return startOf(curIdx) + (act().currentTime || 0);
}
function frame(){
if(!clips.length) return {W:1280, H:720};
const s = scale(), pick = $("joinSize").value;
let w, h;
if(pick === "first"){ w = clips[0].w; h = clips[0].h; }
else if(pick === "largest"){ w = Math.max.apply(null, clips.map(c => c.w)); h = Math.max.apply(null, clips.map(c => c.h)); }
else { const p = pick.split("x"); w = +p[0]; h = +p[1]; }
return {W: even(Math.max(2, w*s)), H: even(Math.max(2, h*s))};
}
function sizeCanvas(){
const f = frame();
if(cvs.width !== f.W || cvs.height !== f.H){ cvs.width = f.W; cvs.height = f.H; }
return f;
}
function draw(){
sizeCanvas();
ctx.fillStyle = "#000"; ctx.fillRect(0, 0, cvs.width, cvs.height);
const e = act();
if(clips.length && e && e.videoWidth) drawInto(ctx, e, 0, 0, cvs.width, cvs.height, fitMode());
const t = elapsed(), tot = total();
$("joinCur").textContent = tc(t);
if(!dragging && tot) $("joinSeek").value = String(Math.round(clamp(t/tot, 0, 1) * 1000));
if(playing && !advancing && e && curIdx >= 0){
const d = clips[curIdx].dur;
if(e.ended || (e.currentTime || 0) >= d - 0.04) advance();
}
if(playing || exporting) raf = requestAnimationFrame(draw);
}
function redraw(){ if(!playing && !exporting){ cancelAnimationFrame(raf); draw(); } }
const loading = [null, null]; // {idx, p} so two callers never load the same thing twice
function loadInto(elIdx, clipIdx){
const e = els[elIdx], c = clips[clipIdx];
if(!c) return Promise.resolve(false);
if(held[elIdx] === clipIdx && e.readyState >= 2) return Promise.resolve(true);
if(loading[elIdx] && loading[elIdx].idx === clipIdx) return loading[elIdx].p;
const p = (async () => {
try{ e.pause(); }catch(_){}
e.src = c.url; e.muted = false; e.load();
const ok = await Promise.race([once(e,"loadedmetadata").then(()=>true), once(e,"error").then(()=>false), wait(15000).then(()=>false)]);
if(!ok || !e.videoWidth){ held[elIdx] = -1; return false; }
setGain(e, gainValue());
await waitReady(e);
await seekTo(e, 0);
held[elIdx] = clipIdx; // only commit once it is genuinely ready
return true;
})();
loading[elIdx] = {idx: clipIdx, p};
p.then(() => { if(loading[elIdx] && loading[elIdx].p === p) loading[elIdx] = null; },
() => { if(loading[elIdx] && loading[elIdx].p === p) loading[elIdx] = null; });
return p;
}
function gainValue(){ return $("joinAudio").value === "none" ? 0 : (+$("joinVol").value/100); }
function applyAudio(){
els.forEach(e => setGain(e, gainValue()));
$("joinVolL").textContent = $("joinVol").value + "%";
$("joinVol").disabled = $("joinAudio").value === "none";
}
async function goto(t){
if(!clips.length) return;
const {i, local} = locate(clamp(t, 0, Math.max(0, total() - 0.001)));
if(held[activeEl] !== i){
if(held[1 - activeEl] === i) activeEl = 1 - activeEl;
else await loadInto(activeEl, i);
}
curIdx = i;
await seekTo(act(), local);
if(i + 1 < clips.length) loadInto(1 - activeEl, i + 1);
markRow();
}
async function advance(){
if(advancing) return;
advancing = true;
const mine = ++seq;
try{
const next = curIdx + 1;
if(next >= clips.length){ playing = false; $("joinPlay").innerHTML = "&#9654;"; act().pause(); advancing = false; redraw(); return; }
if(held[1 - activeEl] !== next){ await loadInto(1 - activeEl, next); }
if(mine !== seq){ advancing = false; return; }
try{ act().pause(); }catch(_){}
activeEl = 1 - activeEl;
curIdx = next;
await seekTo(act(), 0);
if(playing || exporting){ try{ await act().play(); }catch(_){} }
markRow();
if(next + 1 < clips.length) loadInto(1 - activeEl, next + 1);
}finally{ advancing = false; }
}
/* ---- clip list ---- */
function readout(){
const f = frame(), tot = total();
$("joinOutSize").textContent = clips.length ? f.W + " \u00d7 " + f.H : "\u2014";
$("joinOutDur").textContent = tot ? tc(tot) : "\u2014";
$("joinOutBytes").textContent = clips.length && tot
? "~" + human((bitrateFor(f.W, f.H, fps()) + ($("joinAudio").value === "none" ? 0 : 192000)) * tot / 8)
: "\u2014";
$("joinTot").textContent = tot ? tc(tot) : "0:00.00";
$("joinCount").textContent = clips.length + (clips.length === 1 ? " clip" : " clips");
$("joinExport").disabled = clips.length < 1 || exporting;
["joinPlay","joinBack","joinFwd","joinRestart","joinSeek","joinClear"].forEach(id => { $(id).disabled = !clips.length || exporting; });
$("joinBadge").textContent = clips.length
? clips.length + (clips.length === 1 ? " clip" : " clips") + " \u00b7 " + tc(tot) + " \u00b7 " + f.W + "\u00d7" + f.H
: "Add two or more videos to start";
}
function markRow(){
document.querySelectorAll("#clipList .crow").forEach((r, i) => r.classList.toggle("on", i === curIdx));
}
function renderList(){
const list = $("clipList");
list.textContent = "";
if(!clips.length){
const e = document.createElement("div");
e.className = "empty";
e.textContent = "No clips yet. Add videos above and they will play in this order, top to bottom.";
list.appendChild(e);
readout(); return;
}
clips.forEach((c, i) => {
const row = document.createElement("div");
row.className = "crow" + (i === curIdx ? " on" : "");
const num = document.createElement("span");
num.className = "cnum"; num.textContent = (i + 1) + ".";
const th = document.createElement("canvas");
th.className = "cthumb";
if(c.thumb){ th.width = c.thumb.width; th.height = c.thumb.height; th.style.width = c.thumb.style.width; th.style.height = c.thumb.style.height;
th.getContext("2d").drawImage(c.thumb, 0, 0); }
const name = document.createElement("span");
name.className = "cname"; name.textContent = c.name; name.title = c.name;
const meta = document.createElement("span");
meta.className = "cmeta"; meta.textContent = c.w + "\u00d7" + c.h;
const d = document.createElement("span");
d.className = "cdur"; d.textContent = tc(c.dur);
const btns = document.createElement("span");
btns.className = "cbtns";
const mk = (txt, title, fn, dis) => { const b = document.createElement("button"); b.innerHTML = txt; b.title = title; b.disabled = !!dis; b.addEventListener("click", ev => { ev.stopPropagation(); fn(); }); return b; };
btns.appendChild(mk("&#9650;", "Move up", () => move(i, -1), i === 0));
btns.appendChild(mk("&#9660;", "Move down", () => move(i, 1), i === clips.length - 1));
const del = mk("&times;", "Remove this clip", () => remove(i));
del.className = "danger";
btns.appendChild(del);
row.appendChild(num); row.appendChild(th); row.appendChild(name);
row.appendChild(meta); row.appendChild(d); row.appendChild(btns);
row.addEventListener("click", async () => { if(exporting) return; pause(); await goto(startOf(i) + 0.01); redraw(); });
list.appendChild(row);
});
readout();
}
function move(i, dir){
const j = i + dir;
if(exporting || j < 0 || j >= clips.length) return;
pause();
const t = clips[i]; clips[i] = clips[j]; clips[j] = t;
held[0] = held[1] = -1; curIdx = -1;
renderList();
goto(0).then(redraw);
}
function remove(i){
if(exporting) return;
pause();
URL.revokeObjectURL(clips[i].url);
clips.splice(i, 1);
held[0] = held[1] = -1; curIdx = -1;
renderList();
if(clips.length) goto(0).then(redraw); else { sizeCanvas(); redraw(); }
say($("joinStatus"), "");
}
$("joinClear").addEventListener("click", () => {
if(exporting) return;
pause();
clips.forEach(c => URL.revokeObjectURL(c.url));
clips = []; held[0] = held[1] = -1; curIdx = -1;
els.forEach(e => { try{ e.pause(); }catch(_){} e.removeAttribute("src"); e.load(); });
renderList(); redraw();
say($("joinStatus"), "");
});
/* ---- adding files ---- */
async function addFiles(fileList){
if(exporting) return;
resumeAudio();
const files = Array.from(fileList).filter(f => f.type.startsWith("video/") || /\.(mp4|mov|webm|mkv|m4v|avi)$/i.test(f.name));
if(!files.length){ say($("joinStatus"), "Those files are not videos. Add MP4, MOV, WebM or MKV.", "err"); return; }
say($("joinStatus"), "Reading " + files.length + (files.length === 1 ? " file\u2026" : " files\u2026"));
const probe = document.createElement("video");
probe.preload = "auto"; probe.muted = true; probe.playsInline = true;
let added = 0, failed = [];
for(const f of files){
const url = URL.createObjectURL(f);
probe.src = url; probe.load();
const ok = await Promise.race([once(probe,"loadedmetadata").then(()=>true), once(probe,"error").then(()=>false), wait(15000).then(()=>false)]);
if(!ok || !probe.videoWidth || !isFinite(probe.duration) || probe.duration <= 0){
URL.revokeObjectURL(url); failed.push(f.name); continue;
}
await waitReady(probe); await seekTo(probe, Math.min(0.1, probe.duration/10));
const thumb = document.createElement("canvas");
paintThumb(thumb, probe, 64, 40);
clips.push({url, name: f.name, w: probe.videoWidth, h: probe.videoHeight, dur: probe.duration, thumb});
added++;
}
probe.removeAttribute("src"); probe.load();
held[0] = held[1] = -1;
renderList();
if(clips.length && curIdx < 0){ await goto(0); }
applyAudio(); redraw();
let msg = added ? "Added " + added + (added === 1 ? " clip." : " clips.") : "";
if(failed.length) msg += (msg ? " " : "") + "Could not read: " + failed.join(", ") + ".";
say($("joinStatus"), msg, failed.length ? "err" : "ok");
}
const jd = $("joinDrop"), jf = $("joinFiles");
jd.addEventListener("click", () => jf.click());
jf.addEventListener("change", e => { if(e.target.files.length) addFiles(e.target.files); e.target.value = ""; });
["dragenter","dragover"].forEach(ev => jd.addEventListener(ev, e => { e.preventDefault(); jd.classList.add("over"); }));
["dragleave","drop"].forEach(ev => jd.addEventListener(ev, e => { e.preventDefault(); jd.classList.remove("over"); }));
jd.addEventListener("drop", e => { if(e.dataTransfer && e.dataTransfer.files.length) addFiles(e.dataTransfer.files); });
/* ---- transport ---- */
async function play(){
if(!clips.length || exporting) return;
resumeAudio();
if(elapsed() >= total() - 0.05) await goto(0);
if(curIdx < 0) await goto(0);
playing = true; $("joinPlay").innerHTML = "&#10074;&#10074;";
cancelAnimationFrame(raf); draw();
try{ await act().play(); }
catch(e){ playing = false; $("joinPlay").innerHTML = "&#9654;"; say($("joinStatus"), "The browser blocked playback. Click anywhere on the page, then press play again.", "err"); }
}
function pause(){
playing = false; $("joinPlay").innerHTML = "&#9654;";
els.forEach(e => { try{ e.pause(); }catch(_){} });
cancelAnimationFrame(raf);
if(clips.length) draw();
}
$("joinPlay").addEventListener("click", () => playing ? pause() : play());
$("joinRestart").addEventListener("click", async () => { pause(); await goto(0); redraw(); });
$("joinBack").addEventListener("click", async () => { pause(); await goto(Math.max(0, elapsed() - 1)); redraw(); });
$("joinFwd").addEventListener("click", async () => { pause(); await goto(Math.min(total() - 0.05, elapsed() + 1)); redraw(); });
$("joinSeek").addEventListener("pointerdown", () => { dragging = true; });
$("joinSeek").addEventListener("input", () => { if(total()) $("joinCur").textContent = tc(total() * $("joinSeek").value/1000); });
$("joinSeek").addEventListener("change", async () => {
const target = total() * (+$("joinSeek").value)/1000; // read before pausing, which repaints the slider
dragging = false;
if(!clips.length) return;
pause(); await goto(target); redraw();
});
["joinSize","joinFit","joinScale","joinFps"].forEach(id => $(id).addEventListener("input", () => { readout(); redraw(); }));
["joinAudio","joinVol"].forEach(id => $(id).addEventListener("input", () => { applyAudio(); readout(); }));
function lock(on){
["joinSize","joinFit","joinScale","joinFps","joinAudio","joinClear"].forEach(id => { $(id).disabled = on; });
document.querySelectorAll("#clipList button").forEach(b => b.disabled = on);
document.querySelectorAll("#modeTabs button").forEach(b => b.disabled = on);
jd.style.pointerEvents = on ? "none" : "";
jd.style.opacity = on ? ".5" : "";
}
/* ---- export ---- */
$("joinStop").addEventListener("click", () => { cancel = true; say($("joinStatus"), "Stopping\u2026"); });
$("joinExport").addEventListener("click", async () => {
if(exporting) return;
if(!clips.length){ say($("joinStatus"), "Add at least one video first.", "err"); return; }
const tot = total();
if(!isFinite(tot) || tot <= 0){ say($("joinStatus"), "Could not read the clip lengths.", "err"); return; }
exporting = true; cancel = false; lock(true);
pause();
$("joinExport").disabled = true; $("joinStop").style.display = "block";
$("joinBar").style.display = "block"; $("joinBarI").style.width = "0%";
say($("joinStatus"), "Recording\u2026 keep this tab visible.");
let session = null;
try{
held[0] = held[1] = -1; curIdx = -1;
await goto(0);
sizeCanvas();
if(clips.length > 1) await loadInto(1 - activeEl, 1);
session = startRecording(cvs, fps(), $("joinAudio").value === "none" ? [] : els);
cancelAnimationFrame(raf); draw();
playing = true;
await act().play();
const started = performance.now();
await new Promise(resolve => {
const timer = setInterval(() => {
const t = elapsed();
$("joinBarI").style.width = (clamp(t/tot, 0, 1) * 100).toFixed(1) + "%";
const done = curIdx >= clips.length - 1 && (act().ended || (act().currentTime || 0) >= clips[clips.length - 1].dur - 0.04);
const overrun = (performance.now() - started) > tot*1000 + 20000;
if(cancel || done || overrun){ clearInterval(timer); resolve(); }
}, 80);
});
playing = false;
els.forEach(e => { try{ e.pause(); }catch(_){} });
await wait(240);
const {blob, ext} = await session.finish();
if(cancel){ say($("joinStatus"), "Export stopped. Nothing was saved."); }
else if(!blob.size){ say($("joinStatus"), "The export came out empty. Try a lower scale, or use Chrome or Edge.", "err"); }
else{
const name = safeName(baseName(clips[0].name) + "_joined", "joined") + "." + ext;
const r = await saveBlob(blob, name, ext);
say($("joinStatus"), r === "cancelled" ? "Save cancelled." : "Saved " + name + (session.hasAudio ? "" : " (no audio)"), r === "cancelled" ? "" : "ok");
}
}catch(e){
if(session){ try{ await session.finish(); }catch(_){} }
playing = false;
els.forEach(e => { try{ e.pause(); }catch(_){} });
say($("joinStatus"), e.message || "The export failed.", "err");
}finally{
exporting = false; lock(false);
$("joinStop").style.display = "none"; $("joinBar").style.display = "none";
$("joinPlay").innerHTML = "&#9654;";
applyAudio();
await goto(0);
renderList(); redraw();
}
});
applyAudio(); renderList(); redraw();
return {
refresh(){ readout(); redraw(); },
pause, play,
get exporting(){ return exporting; },
get playing(){ return playing; },
step(dir){ if(!clips.length) return; pause(); goto(clamp(elapsed() + dir, 0, Math.max(0, total() - 0.05))).then(redraw); },
home(){ if(!clips.length) return; pause(); goto(0).then(redraw); }
};
})();
/* ======================= keyboard ======================= */
document.addEventListener("keydown", e => {
const tag = (e.target.tagName || "").toLowerCase();
if(tag === "input" || tag === "select" || tag === "textarea" || e.target.isContentEditable) return;
if(e.metaKey || e.ctrlKey || e.altKey) return;
const M = mode === "sbs" ? SBS : JOIN;
if(M.exporting) return;
if(e.code === "Space"){ e.preventDefault(); M.playing ? M.pause() : M.play(); }
else if(e.code === "ArrowLeft"){ e.preventDefault(); M.step(-1); }
else if(e.code === "ArrowRight"){ e.preventDefault(); M.step(1); }
else if(e.code === "Home"){ e.preventDefault(); M.home(); }
});
/* first click anywhere unlocks audio */
document.addEventListener("pointerdown", function unlock(){ resumeAudio(); document.removeEventListener("pointerdown", unlock); }, {once:true});
/* warn before leaving mid-export */
window.addEventListener("beforeunload", e => {
if(SBS.exporting || JOIN.exporting){ e.preventDefault(); e.returnValue = ""; }
});
if(!CHOSEN_MIME){
say($("sbsStatus"), "This browser cannot record video. Chrome or Edge will work.", "err");
say($("joinStatus"), "This browser cannot record video. Chrome or Edge will work.", "err");
}
})();
</script>
</body>
</html>