Download Pocket_Video_Combiner_v1_0.html from PoopMan333/Video_Tools: direct link, hf CLI and curl.
- Browser
- Download file 69.6 kB
-
https://huggingface.co/PoopMan333/Video_Tools/resolve/main/Pocket_Video_Combiner_v1_0.html
- Command line
-
hf download hf://PoopMan333/Video_Tools/Pocket_Video_Combiner_v1_0.html
-
curl -L -o Pocket_Video_Combiner_v1_0.html https://huggingface.co/PoopMan333/Video_Tools/resolve/main/Pocket_Video_Combiner_v1_0.html
69.6 kB
| <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 · 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">×</button> | |
| <canvas class="thumb" id="thumb1"></canvas> | |
| <div class="big" id="big1">▢</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">⇆</button> | |
| <div class="drop" id="drop2"> | |
| <button class="clearx" id="clear2" title="Remove this file">×</button> | |
| <canvas class="thumb" id="thumb2"></canvas> | |
| <div class="big" id="big2">▢</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)">▶</button> | |
| <button id="sbsPrevF" class="icobtn ghost" title="Previous frame (Left arrow)">⇤</button> | |
| <button id="sbsNextF" class="icobtn ghost" title="Next frame (Right arrow)">⇥</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">▼</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">—</b></div> | |
| <div><span>Duration</span><b id="sbsOutDur">—</b></div> | |
| <div><span>Rough file size</span><b id="sbsOutBytes">—</b></div> | |
| <div><span>Format</span><b id="sbsFmt">—</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">▢</div> | |
| <h2>Drop videos here to add them to the sequence</h2> | |
| <p>or click to choose files · you can add several at once · 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)">▶</button> | |
| <button id="joinBack" class="icobtn ghost" title="Back 1 second (Left arrow)">⇤</button> | |
| <button id="joinFwd" class="icobtn ghost" title="Forward 1 second (Right arrow)">⇥</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 × 1080</option> | |
| <option value="1280x720">1280 × 720</option> | |
| <option value="1080x1920">1080 × 1920 (vertical)</option> | |
| <option value="1080x1080">1080 × 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">—</b></div> | |
| <div><span>Duration</span><b id="joinOutDur">—</b></div> | |
| <div><span>Rough file size</span><b id="joinOutBytes">—</b></div> | |
| <div><span>Format</span><b id="joinFmt">—</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 · | |
| <span class="kbd">←</span> <span class="kbd">→</span> step back or forward · | |
| <span class="kbd">Home</span> back to the start · | |
| 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> | |
| ; | |
| (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 = "❚❚"; | |
| cancelAnimationFrame(raf); draw(); | |
| try{ await Promise.all(videos().map(x => x.play())); } | |
| catch(e){ playing = false; $("sbsPlay").innerHTML = "▶"; 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 = "▶"; | |
| 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 = "▶"; | |
| 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 = "▶"; 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("▲", "Move up", () => move(i, -1), i === 0)); | |
| btns.appendChild(mk("▼", "Move down", () => move(i, 1), i === clips.length - 1)); | |
| const del = mk("×", "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 = "❚❚"; | |
| cancelAnimationFrame(raf); draw(); | |
| try{ await act().play(); } | |
| catch(e){ playing = false; $("joinPlay").innerHTML = "▶"; say($("joinStatus"), "The browser blocked playback. Click anywhere on the page, then press play again.", "err"); } | |
| } | |
| function pause(){ | |
| playing = false; $("joinPlay").innerHTML = "▶"; | |
| 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 = "▶"; | |
| 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> | |