Download Pocket_Video_Trimmer_v3_6.html from PoopMan333/Video_Tools: direct link, hf CLI and curl.
- Browser
- Download file 132 kB
-
https://huggingface.co/PoopMan333/Video_Tools/resolve/main/Pocket_Video_Trimmer_v3_6.html
- Command line
-
hf download hf://PoopMan333/Video_Tools/Pocket_Video_Trimmer_v3_6.html
-
curl -L -o Pocket_Video_Trimmer_v3_6.html https://huggingface.co/PoopMan333/Video_Tools/resolve/main/Pocket_Video_Trimmer_v3_6.html
132 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>Pocket Video Trimmer</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; | |
| --r:10px; | |
| } | |
| *{box-sizing:border-box} | |
| html,body{margin:0;height:100%} | |
| body{ | |
| background:var(--bg); color:var(--txt); | |
| font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; | |
| -webkit-font-smoothing:antialiased; | |
| } | |
| .wrap{max-width:1180px;margin:0 auto;padding:20px 20px 60px} | |
| header{display:flex;align-items:center;gap:12px;margin-bottom:18px} | |
| header h1{font-size:17px;margin:0;font-weight:600;letter-spacing:.2px} | |
| header .sub{color:var(--dim);font-size:12.5px} | |
| header .verwrap{display:flex;flex-direction:column;align-items:center;gap:3px; | |
| text-decoration:none;cursor:pointer} | |
| header .verwrap:hover .ver{border-color:var(--accent);color:var(--txt)} | |
| header .verwrap:hover .by{opacity:1;text-decoration:underline} | |
| 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.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} | |
| button.link{ | |
| background:none;border:0;padding:4px 0;margin:6px auto 0;display:block; | |
| color:var(--dim);font-size:12px;text-decoration:underline;text-underline-offset:2px; | |
| } | |
| button.link:hover:not(:disabled){background:none;color:var(--accent)} | |
| .icobtn{padding:7px 11px;font-variant-numeric:tabular-nums} | |
| .small{padding:5px 10px;font-size:12.5px} | |
| /* ---------- drop zone ---------- */ | |
| #drop{ | |
| border:2px dashed var(--line);border-radius:14px;padding:64px 24px;text-align:center; | |
| background:var(--panel);cursor:pointer;transition:.15s; | |
| } | |
| #drop:hover,#drop.over{border-color:var(--accent);background:#12212b} | |
| #drop h2{margin:0 0 6px;font-size:18px;font-weight:600} | |
| #drop p{margin:0;color:var(--dim);font-size:13px} | |
| #drop .big{font-size:38px;line-height:1;margin-bottom:14px;opacity:.65} | |
| /* ---------- layout ---------- */ | |
| #app{display:none} | |
| .grid{display:grid;grid-template-columns:1fr 316px;gap:18px;align-items:start} | |
| @media(max-width:900px){.grid{grid-template-columns:1fr}} | |
| .card{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);padding:14px} | |
| .card 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 4px;margin-bottom:14px} | |
| 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:-4px;border-bottom:none;border-radius:9px} | |
| h3.collap .chev{font-size:11px;color:var(--accent);transition:transform .15s;transform:rotate(0deg)} | |
| h3.collap.shut .chev{transform:rotate(-90deg)} | |
| .stage{background:#000;border-radius:var(--r);overflow:hidden;display:flex;align-items:center;justify-content:center;position:relative;border:1px solid var(--line)} | |
| .stage video,.stage canvas{max-width:100%;max-height:56vh;display:block} | |
| #exportCanvas{display:none} | |
| .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} | |
| /* ---------- crop overlay ---------- */ | |
| #cropLayer{position:absolute;display:none;touch-action:none;z-index:2} | |
| #cropBox{position:absolute;box-shadow:0 0 0 9999px rgba(4,7,11,.62); | |
| border:1px solid rgba(255,255,255,.92);cursor:move;box-sizing:border-box} | |
| #cropBox::after{content:"";position:absolute;inset:0;pointer-events:none; | |
| background: | |
| linear-gradient(to right,transparent calc(33.33% - 0.5px),rgba(255,255,255,.3) calc(33.33% - 0.5px),rgba(255,255,255,.3) calc(33.33% + 0.5px),transparent calc(33.33% + 0.5px),transparent calc(66.66% - 0.5px),rgba(255,255,255,.3) calc(66.66% - 0.5px),rgba(255,255,255,.3) calc(66.66% + 0.5px),transparent calc(66.66% + 0.5px)), | |
| linear-gradient(to bottom,transparent calc(33.33% - 0.5px),rgba(255,255,255,.3) calc(33.33% - 0.5px),rgba(255,255,255,.3) calc(33.33% + 0.5px),transparent calc(33.33% + 0.5px),transparent calc(66.66% - 0.5px),rgba(255,255,255,.3) calc(66.66% - 0.5px),rgba(255,255,255,.3) calc(66.66% + 0.5px),transparent calc(66.66% + 0.5px));} | |
| .ch{position:absolute;width:16px;height:16px;background:#fff;border:1px solid rgba(0,0,0,.65);border-radius:3px;z-index:2} | |
| .ch:hover{background:var(--accent)} | |
| .ch[data-h=nw]{left:-8px;top:-8px;cursor:nwse-resize} | |
| .ch[data-h=ne]{right:-8px;top:-8px;cursor:nesw-resize} | |
| .ch[data-h=sw]{left:-8px;bottom:-8px;cursor:nesw-resize} | |
| .ch[data-h=se]{right:-8px;bottom:-8px;cursor:nwse-resize} | |
| .ch[data-h=n]{left:calc(50% - 8px);top:-8px;cursor:ns-resize} | |
| .ch[data-h=s]{left:calc(50% - 8px);bottom:-8px;cursor:ns-resize} | |
| .ch[data-h=w]{left:-8px;top:calc(50% - 8px);cursor:ew-resize} | |
| .ch[data-h=e]{right:-8px;top:calc(50% - 8px);cursor:ew-resize} | |
| .cropdims{position:absolute;left:50%;transform:translateX(-50%);bottom:5px; | |
| background:rgba(0,0,0,.75);color:#dff;padding:2px 8px;border-radius:5px; | |
| font-size:11px;font-variant-numeric:tabular-nums;white-space:nowrap;pointer-events:none} | |
| #cropBtn.on{background:var(--accent);border-color:var(--accent);color:#04121c;font-weight:600} | |
| #cropBtn.on .kbd{background:rgba(0,0,0,.15);border-color:rgba(0,0,0,.25);color:#04121c} | |
| .arlab{color:var(--dim);font-size:11px;text-transform:uppercase;letter-spacing:.6px;margin:2px 0 5px} | |
| .arRow{margin-bottom:8px} | |
| .arcustom{display:flex;align-items:center;gap:6px;margin-bottom:4px} | |
| .arcustom input{width:100%;min-width:0;text-align:center} | |
| .arcustom span{color:var(--dim)} | |
| .arcustom.on input{border-color:var(--accent)} | |
| /* ---------- transport ---------- */ | |
| .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} | |
| /* ---------- timeline ---------- */ | |
| .tl-wrap{margin-top:14px;user-select:none} | |
| .tl{position:relative;height:70px;background:var(--panel2);border:1px solid var(--line);border-radius:8px;overflow:hidden;touch-action:none;cursor:pointer} | |
| #strip{position:absolute;inset:0;display:flex;opacity:.9} | |
| #strip canvas{height:100%;flex:1 1 0;min-width:0;display:block} | |
| .mask{position:absolute;top:0;bottom:0;background:rgba(6,9,13,.72);pointer-events:none} | |
| .sel{position:absolute;top:0;bottom:0;border-left:2px solid var(--accent);border-right:2px solid var(--accent); | |
| box-shadow:inset 0 0 0 1px rgba(76,194,255,.25);pointer-events:none} | |
| .handle{position:absolute;top:0;bottom:0;width:16px;margin-left:-8px;cursor:ew-resize;touch-action:none;z-index:5} | |
| .handle::after{content:"";position:absolute;left:5px;top:50%;transform:translateY(-50%); | |
| width:6px;height:26px;border-radius:3px;background:var(--accent);box-shadow:0 0 0 1px rgba(0,0,0,.4)} | |
| .handle:hover::after{background:#8bd8ff} | |
| .playhead{position:absolute;top:0;bottom:0;width:2px;margin-left:-1px;background:#fff;pointer-events:none;z-index:6;box-shadow:0 0 6px rgba(255,255,255,.7)} | |
| .ruler{display:flex;justify-content:space-between;margin-top:4px;color:var(--dim);font-size:11px;font-variant-numeric:tabular-nums} | |
| /* saved clips drawn along the bottom of the timeline */ | |
| #segBand{position:absolute;left:0;right:0;bottom:0;height:16px;pointer-events:none;z-index:4} | |
| .segbar{position:absolute;bottom:0;height:16px;min-width:3px;background:rgba(63,185,80,.42); | |
| border:1px solid rgba(63,185,80,.95);border-radius:3px;overflow:hidden; | |
| font-size:9.5px;line-height:14px;text-align:center;color:#dfffe6;font-weight:600} | |
| .segbar.on{background:rgba(76,194,255,.5);border-color:#8bd8ff;color:#eaf7ff} | |
| /* ---------- shift bar ---------- */ | |
| .shiftrow{display:flex;align-items:center;gap:8px;margin-top:7px} | |
| .shift-lbl{color:var(--dim);font-size:11px;font-variant-numeric:tabular-nums;letter-spacing:.2px} | |
| .shift-lbl b{color:var(--accent);font-weight:600} | |
| .shiftbtns{display:flex;gap:2px} | |
| .shiftbtns button{min-width:26px;padding:0 6px;font-size:14px;line-height:20px;border-radius:5px; | |
| background:transparent;border-color:transparent;color:var(--dim);text-align:center} | |
| .shiftbtns button:hover:not(:disabled){background:var(--panel2);border-color:var(--line);color:var(--txt)} | |
| /* 4px of visible track sitting 2px under the timeline; the grab area extends | |
| below it so a thin line is still easy to catch */ | |
| .shiftbar{position:relative;height:12px;margin-top:2px;touch-action:none;cursor:pointer} | |
| .shiftbar::before{content:"";position:absolute;left:0;right:0;top:0;height:4px; | |
| background:#252d38;border-radius:99px} | |
| .shiftbar:hover .shiftblk{background:#6bcfff} | |
| .shiftblk{position:absolute;top:0;height:4px;min-width:12px; | |
| background:var(--accent);border-radius:99px;cursor:grab;transition:background .12s} | |
| .shiftblk:active{cursor:grabbing} | |
| .marks{display:flex;gap:8px;margin-top:12px;flex-wrap:wrap;align-items:center} | |
| .pill{background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:6px 10px;font-size:12.5px;font-variant-numeric:tabular-nums} | |
| .pill span{color:var(--dim)} | |
| .pill b{font-weight:600} | |
| .pill.dur b{color:var(--accent)} | |
| .edit{cursor:text;border-bottom:1px dotted #4a5663;padding-bottom:1px} | |
| .edit:hover{border-bottom-color:var(--accent);color:var(--accent)} | |
| .edit input{width:88px;padding:0 3px;border:1px solid var(--accent);border-radius:4px; | |
| background:#0b0f14;color:var(--txt);font:inherit;font-weight:600;text-align:center} | |
| /* ---------- clip list ---------- */ | |
| #clipsWrap{display:none;margin-top:18px} | |
| .clips-head{display:flex;align-items:center;gap:10px;margin-bottom: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)} | |
| .cthumb{border-radius:3px;border:1px solid var(--line);display:block;flex:none} | |
| .crow.over{opacity:.45} | |
| .crow.over .cthumb{filter:grayscale(1)} | |
| .crow.over .cdur{color:var(--warn)} | |
| .cnum{width:20px;color:var(--dim);font-variant-numeric:tabular-nums;flex:none} | |
| .cname{flex:1 1 90px;min-width:60px;max-width:230px;display:flex;align-items:center} | |
| .cname input{width:100%;background:transparent;border:1px solid transparent;border-radius:6px; | |
| padding:2px 6px;color:var(--txt);font:inherit} | |
| .cname input:hover{border-color:var(--line)} | |
| .cname input:focus{outline:none;border-color:var(--accent);background:#0b0f14} | |
| .cname input::placeholder{color:#5a6572} | |
| .ctimes{font-variant-numeric:tabular-nums;color:var(--dim);flex:none} | |
| .ctimes b{color:var(--txt);font-weight:600} | |
| .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){ .ctimes{display:none} } | |
| /* ---------- settings ---------- */ | |
| .row{display:flex;align-items:center;gap:8px;margin-bottom:10px} | |
| .row label{flex:0 0 78px;color:var(--dim);font-size:12.5px} | |
| input[type=text],input[type=number],select{ | |
| font:inherit;background:var(--panel2);color:var(--txt);border:1px solid var(--line); | |
| border-radius:7px;padding:6px 8px;width:100%;min-width:0; | |
| } | |
| input:focus,select:focus{outline:none;border-color:var(--accent)} | |
| input[type=range]{width:100%;accent-color:var(--accent)} | |
| .seg{display:flex;gap:5px;flex-wrap:wrap;margin-bottom:10px} | |
| .seg button{padding:5px 9px;font-size:12.5px;border-radius:7px} | |
| .seg button.on{background:var(--accent);border-color:var(--accent);color:#04121c;font-weight:600} | |
| .hint{color:var(--dim);font-size:11.5px;margin:-4px 0 12px} | |
| .chk{display:flex;align-items:center;gap:7px;color:var(--dim);font-size:12.5px;margin-bottom:10px;cursor:pointer} | |
| hr{border:none;border-top:1px solid var(--line);margin:14px 0} | |
| .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} | |
| .est span{color:var(--dim)} | |
| .est b{font-variant-numeric:tabular-nums;font-weight:600} | |
| #exportBtn{width:100%;padding:11px} | |
| #snapBtn,#sbBtn,#gifBtn,#msBtn{width:100%;padding:9px;margin-top:2px} | |
| /* ---------- tabs ---------- */ | |
| /* browser-style tabs: the active one joins onto the panel below it */ | |
| /* no left padding, and the strip's underline matches the panel border, so the | |
| active tab's edges line up exactly with the box beneath it */ | |
| .tabs{display:flex;gap:3px;margin:0;padding:0;border-bottom:1px solid #2f5f79;position:relative;z-index:1} | |
| .tabs button{flex:1;padding:8px 10px;font-size:12.5px;color:var(--dim); | |
| background:transparent;border:1px solid transparent;border-bottom:none; | |
| border-radius:9px 9px 0 0;margin-bottom:-1px;position:relative} | |
| .tabs button:hover:not(.on){color:var(--txt);background:rgba(255,255,255,.03);border-color:transparent} | |
| .tabs button.on{background:#16303f;border-color:#2f5f79;border-top:2px solid var(--accent); | |
| color:#bfe9ff;font-weight:600;padding-top:7px} | |
| .tabs button.on::after{content:"";position:absolute;left:0;right:0;bottom:0;height:1px;background:#16303f} | |
| .tabpanel{background:#16303f;border:1px solid #2f5f79;border-top:none; | |
| border-radius:0 0 10px 10px;padding:13px 12px 12px;margin-bottom:12px} | |
| .tabpanel hr{border-top-color:#2f5f79} | |
| .tabpanel h3{color:#8fc9e6} | |
| .ends{display:flex;justify-content:space-between;color:var(--dim);font-size:11px;margin:3px 0 8px} | |
| .grid2{display:flex;align-items:center;gap:6px;margin-bottom:8px} | |
| .grid2 input{width:100%;min-width:0;text-align:center} | |
| .grid2 span{color:var(--dim);font-size:12.5px} | |
| .grid2 label{flex:0 0 44px;color:var(--dim);font-size:12.5px} | |
| #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} | |
| #result{display:none;margin-top:14px} | |
| #result video{width:100%;border-radius:8px;background:#000;border:1px solid var(--line)} | |
| #saved{display:none;margin-top:14px;border:1px solid var(--line);border-radius:8px;background:var(--panel2);padding:10px 12px} | |
| #saved h4{margin:0 0 6px;font-size:12px;text-transform:uppercase;letter-spacing:.7px;color:var(--dim)} | |
| #savedList{margin:0;padding-left:16px;font-size:12px;color:var(--txt);line-height:1.7;max-height:190px;overflow:auto} | |
| .note{font-size:11.5px;color:var(--warn);margin-top:8px;line-height:1.4} | |
| .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} | |
| .keys{color:var(--dim);font-size:11.5px;margin-top:12px;line-height:1.9} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="wrap"> | |
| <header> | |
| <h1>Pocket Video Trimmer</h1> | |
| <span class="sub">trim · resize · fps · storyboard</span> | |
| <span class="spacer"></span> | |
| <button id="newBtn" class="ghost" style="display:none">Load another video</button> | |
| <a class="verwrap" href="https://huggingface.co/PoopMan333" target="_blank" rel="noopener noreferrer" | |
| title="C_Nugget on Hugging Face"><span class="ver">v3.6</span><span class="by">by C_Nugget</span></a> | |
| </header> | |
| <div id="drop"> | |
| <div class="big">▢</div> | |
| <h2>Drop a video here</h2> | |
| <p>or click to choose a file · MP4, MOV, WebM, MKV · nothing is uploaded, it all stays on this computer</p> | |
| <input type="file" id="file" accept="video/*" hidden> | |
| </div> | |
| <div id="app"> | |
| <div class="grid"> | |
| <!-- LEFT --> | |
| <div> | |
| <div class="stage" id="stage"> | |
| <video id="video" playsinline></video> | |
| <canvas id="exportCanvas"></canvas> | |
| <div id="cropLayer"> | |
| <div id="cropBox" data-h="move"> | |
| <span class="ch" data-h="nw"></span><span class="ch" data-h="n"></span><span class="ch" data-h="ne"></span> | |
| <span class="ch" data-h="w"></span><span class="ch" data-h="e"></span> | |
| <span class="ch" data-h="sw"></span><span class="ch" data-h="s"></span><span class="ch" data-h="se"></span> | |
| <span class="cropdims" id="cropDims"></span> | |
| </div> | |
| </div> | |
| <div class="badge" id="srcBadge"></div> | |
| </div> | |
| <div class="transport"> | |
| <button id="playBtn" class="icobtn" title="Play / pause (Space)">▶</button> | |
| <button id="prevF" class="icobtn" title="Previous frame (←)">⇤</button> | |
| <button id="nextF" class="icobtn" title="Next frame (→)">⇥</button> | |
| <span class="tc"><b id="curTime">0:00.00</b> / <span id="totTime">0:00.00</span></span> | |
| <span class="spacer"></span> | |
| <button id="cropBtn" title="Show or hide the crop box (C)">Crop <span class="kbd">C</span></button> | |
| <button id="setIn">Set start <span class="kbd">I</span></button> | |
| <button id="setOut">Set end <span class="kbd">O</span></button> | |
| <button id="loopBtn" class="ghost" title="Loop the selection">Loop: off</button> | |
| </div> | |
| <div class="tl-wrap"> | |
| <div class="tl" id="tl"> | |
| <div id="strip"></div> | |
| <div class="mask" id="maskL"></div> | |
| <div class="mask" id="maskR"></div> | |
| <div class="sel" id="sel"></div> | |
| <div id="segBand"></div> | |
| <div class="playhead" id="ph"></div> | |
| <div class="handle" id="hIn"></div> | |
| <div class="handle" id="hOut"></div> | |
| </div> | |
| <div class="shiftbar" id="shiftBar" title="Drag to move the selection without changing its length"> | |
| <div class="shiftblk" id="shiftBlk"></div> | |
| </div> | |
| <div class="ruler"><span>0:00</span><span id="rulMid"></span><span id="rulEnd"></span></div> | |
| <div class="shiftrow"> | |
| <span class="shift-lbl" id="shiftLbl"></span> | |
| <span class="spacer"></span> | |
| <span class="shiftbtns" id="shiftBtns"> | |
| <button data-d="-1" title="Back one second (Shift + [)">«</button> | |
| <button data-d="-f" title="Back one frame ([)">‹</button> | |
| <button data-d="f" title="Forward one frame (])">›</button> | |
| <button data-d="1" title="Forward one second (Shift + ])">»</button> | |
| </span> | |
| </div> | |
| <div class="marks"> | |
| <span class="pill"><span>Start</span> <b id="pIn" class="edit" title="Click to type an exact time">0:00.00</b></span> | |
| <span class="pill"><span>End</span> <b id="pOut" class="edit" title="Click to type an exact time">0:00.00</b></span> | |
| <span class="pill dur"><span>Duration</span> <b id="pDur" class="edit" title="Click to type an exact length, e.g. 30 or 1:30">0:00.00</b></span> | |
| <button id="addClip" class="primary small" title="Save this selection to the clip list (A)">+ Add clip <span class="kbd" style="background:rgba(0,0,0,.15);border-color:rgba(0,0,0,.25)">A</span></button> | |
| <button id="resetSel" class="ghost small">Reset to full clip</button> | |
| </div> | |
| <div class="keys"> | |
| <span class="kbd">Space</span> play/pause <span class="kbd">←</span><span class="kbd">→</span> step 1 frame | |
| <span class="kbd">Shift</span>+<span class="kbd">←</span><span class="kbd">→</span> step 1 sec | |
| <span class="kbd">I</span> set start <span class="kbd">O</span> set end <span class="kbd">A</span> <span id="keyA">add clip</span> <span class="kbd">C</span> crop <span class="kbd">S</span> snapshot <span class="kbd">Home</span>/<span class="kbd">End</span> jump | |
| <br>Click <b style="color:var(--txt)">Start</b>, <b style="color:var(--txt)">End</b> or <b style="color:var(--txt)">Duration</b> above to type an exact value — seconds (<code>90</code>) or minutes:seconds (<code>1:30</code>). | |
| </div> | |
| <!-- manual storyboard frames: only in picture mode --> | |
| <div id="snapsWrap" style="display:none"> | |
| <div class="clips-head"> | |
| <h3 id="snapsTitle">Snapshots</h3> | |
| <span class="spacer"></span> | |
| <button id="clearSnaps" class="ghost small">Clear all</button> | |
| </div> | |
| <div id="snapList"></div> | |
| <div class="hint" style="margin-top:8px"> | |
| The exact frames your manual storyboard will use, in this order. Park the playhead and press | |
| <b style="color:var(--txt)">+ Add snapshot</b> (or <span class="kbd">A</span>). Set the grid over in | |
| <b style="color:var(--txt)">Manual Storyboard</b>, then export. | |
| </div> | |
| </div> | |
| <!-- clip list: only appears once you add something --> | |
| <div id="clipsWrap"> | |
| <div class="clips-head"> | |
| <h3 id="clipsTitle">Clip list</h3> | |
| <span class="spacer"></span> | |
| <button id="clearClips" class="ghost small">Clear all</button> | |
| </div> | |
| <div id="clipList"></div> | |
| <div class="hint" style="margin-top:8px"> | |
| Export uses this list — the timeline selection is only used for adding new clips. | |
| Use ▶ to preview a clip, Set to overwrite it with the current timeline selection, ✕ to remove it. Click its times to type exact values. | |
| Names are optional and become the file name. | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- RIGHT --> | |
| <div> | |
| <div class="card"> | |
| <div class="panelbox"> | |
| <h3 class="collap open" id="sizeHead">Size & crop<span class="chev">▾</span></h3> | |
| <div id="sizeBody"> | |
| <div id="cropSection" style="display:none"> | |
| <h3>Crop & aspect ratio</h3> | |
| <div class="seg arRow"> | |
| <button data-ar="0" data-lbl="free">Free</button> | |
| <button data-ar="src" data-lbl="original">Original</button> | |
| <button data-ar="1" data-lbl="1:1">1:1 square</button> | |
| </div> | |
| <div class="arlab">Landscape</div> | |
| <div class="seg arRow"> | |
| <button data-ar="2.33333" data-lbl="21:9">21:9</button> | |
| <button data-ar="1.77778" data-lbl="16:9">16:9</button> | |
| <button data-ar="1.5" data-lbl="3:2">3:2</button> | |
| <button data-ar="1.33333" data-lbl="4:3">4:3</button> | |
| </div> | |
| <div class="arlab">Portrait</div> | |
| <div class="seg arRow"> | |
| <button data-ar="0.5625" data-lbl="9:16">9:16</button> | |
| <button data-ar="0.5" data-lbl="1:2">1:2</button> | |
| <button data-ar="0.66667" data-lbl="2:3">2:3</button> | |
| <button data-ar="0.75" data-lbl="3:4">3:4</button> | |
| <button data-ar="0.8" data-lbl="4:5">4:5</button> | |
| </div> | |
| <div class="arlab">Custom</div> | |
| <div class="arcustom" id="arCustom"> | |
| <input type="number" id="arW" min="1" max="9999" step="1" placeholder="9"> | |
| <span>:</span> | |
| <input type="number" id="arH" min="1" max="9999" step="1" placeholder="16"> | |
| <button id="arApply" class="small">Use</button> | |
| </div> | |
| <div class="hint" id="cropInfo" style="margin:8px 0"></div> | |
| <button id="cropMax" class="small" style="width:100%">Biggest box that fits</button> | |
| <hr> | |
| </div> | |
| <div class="seg" id="scaleSeg"> | |
| <button data-s="1">100%</button> | |
| <button data-s="0.75">75%</button> | |
| <button data-s="0.5">50%</button> | |
| <button data-s="0.25">25%</button> | |
| </div> | |
| <div class="row"> | |
| <label>Width</label><input type="number" id="w" min="16" step="2"> | |
| </div> | |
| <div class="row"> | |
| <label>Height</label><input type="number" id="h" min="16" step="2"> | |
| </div> | |
| <label class="chk"><input type="checkbox" id="lockAr" checked> Lock aspect ratio</label> | |
| <div class="hint" id="sizeHint"></div> | |
| </div> | |
| </div> | |
| <div class="tabs" id="tabs"> | |
| <button data-tab="video" class="on">Export video</button> | |
| <button data-tab="pic">Export picture</button> | |
| </div> | |
| <div id="tabVideo" class="tabpanel"> | |
| <h3>Frame rate</h3> | |
| <div class="seg" id="fpsSeg"></div> | |
| <div class="row"> | |
| <label>Custom</label><input type="number" id="fps" min="1" max="120" step="1"> | |
| </div> | |
| <div class="hint">Higher than the source frame rate won't add detail.</div> | |
| <label class="chk" style="margin-top:10px"><input type="checkbox" id="cfr"> Fixed frame rate</label> | |
| <label class="chk"><input type="checkbox" id="h3"> H3-compliant frame count (17n+5)</label> | |
| <div class="hint" id="cfrHint"></div> | |
| <hr> | |
| <h3>Compression</h3> | |
| <input type="range" id="q" min="0" max="100" value="78"> | |
| <div class="ends"><span>Smaller file</span><span>Bigger file</span></div> | |
| <div class="hint" id="qHint"></div> | |
| <label class="chk"><input type="checkbox" id="keepAudio" checked> Include audio</label> | |
| <div class="est"> | |
| <div><span>Output</span><b id="eSize">—</b></div> | |
| <div><span>Bitrate</span><b id="eRate">—</b></div> | |
| <div><span>Encoder</span><b id="eCodec">—</b></div> | |
| <div><span id="eBytesLbl">Est. file size</span><b id="eBytes">—</b></div> | |
| <div><span>Export takes about</span><b id="eTime">—</b></div> | |
| </div> | |
| <button id="exportBtn" class="primary">Export clip</button> | |
| <button id="audioBtn" class="link" title="Save the selection's audio on its own, as a WAV">Extract audio only</button> | |
| </div> | |
| <div id="tabPic" class="tabpanel" style="display:none"> | |
| <h3>Largest side</h3> | |
| <div class="row"> | |
| <label>Max px</label><input type="number" id="imgMax" min="64" max="8000" step="10" value="1920"> | |
| </div> | |
| <div class="hint" id="imgMaxHint">Pictures are scaled down to fit inside this. Applies to snapshots, storyboards and GIFs.</div> | |
| <label class="chk" style="margin-top:10px"><input type="checkbox" id="bgWork" checked> Keep working in the background</label> | |
| <div class="hint">Storyboards and GIFs carry on while you're in another tab. Video exports still have to pause — browsers stop decoding video for a hidden tab.</div> | |
| <hr> | |
| <h3>Single frame</h3> | |
| <div class="hint">Saves the frame you are parked on right now, as a PNG.</div> | |
| <button id="snapBtn" class="primary" title="Save the current frame as an image (S)">Take snapshot</button> | |
| <div id="autoSbSec"> | |
| <hr> | |
| <h3>Auto Storyboard <span style="color:var(--warn);font-size:10px">BETA</span></h3> | |
| <div class="grid2"> | |
| <label>Grid</label> | |
| <input type="number" id="sbCols" min="1" max="10" step="1" value="3"> | |
| <span>×</span> | |
| <input type="number" id="sbRows" min="1" max="10" step="1" value="3"> | |
| </div> | |
| <div class="hint" id="sbHint"></div> | |
| <label class="chk"><input type="checkbox" id="sbNum"> Number the frames (1, 2, 3…)</label> | |
| <button id="sbBtn" class="primary" title="A sheet of frames spread evenly across the selection">Export storyboard</button> | |
| </div> | |
| <div id="manSbSec" style="display:none"> | |
| <hr> | |
| <h3>Manual Storyboard <span style="color:var(--warn);font-size:10px">BETA</span></h3> | |
| <div class="grid2"> | |
| <label>Grid</label> | |
| <input type="number" id="msCols" min="1" max="10" step="1" value="3"> | |
| <span>×</span> | |
| <input type="number" id="msRows" min="1" max="10" step="1" value="3"> | |
| </div> | |
| <div class="hint" id="msHint"></div> | |
| <label class="chk"><input type="checkbox" id="msNum"> Number the frames (1, 2, 3…)</label> | |
| <button id="msBtn" class="primary">Export manual storyboard</button> | |
| </div> | |
| <hr> | |
| <h3>Animated GIF</h3> | |
| <div class="row"> | |
| <label>Frames/s</label><input type="number" id="gfps" min="1" max="20" step="1" value="10"> | |
| </div> | |
| <div class="hint" id="gifHint">256 colours and no sound, and files grow fast. A smaller width helps.</div> | |
| <button id="gifBtn" class="primary">Export as GIF</button> | |
| </div> | |
| <button id="stopBtn" class="danger">Stop</button> | |
| <div class="bar" id="bar"><i id="barI"></i></div> | |
| <div id="status"></div> | |
| <div class="hint" id="expNote" style="display:none;margin:8px 0 0;text-align:center"> | |
| Leave this tab on screen — if you switch away, the export pauses and picks up when you come back. | |
| </div> | |
| <div id="failBox" style="display:none;margin:10px 0 0;padding:11px 12px;border:1px solid #5a2b2b; | |
| border-radius:9px;background:#241a1a"> | |
| <div class="note" id="failNote" style="margin:0"></div> | |
| <button id="logBtn" class="small" style="margin-top:9px" | |
| title="Copy a technical log of the last export">Copy export log</button> | |
| </div> | |
| <div id="result"> | |
| <video id="outVid" controls></video> | |
| <img id="outImg" alt="" style="display:none;width:100%;border-radius:8px;background:#000;border:1px solid var(--line)"> | |
| <div style="display:flex;gap:8px;margin-top:10px"> | |
| <button id="dlBtn" class="primary" style="flex:1">Download again</button> | |
| <button id="clearRes" class="ghost">Clear</button> | |
| </div> | |
| <div class="note" id="outNote"></div> | |
| </div> | |
| <div id="saved"> | |
| <h4 id="savedWhere">Saved</h4> | |
| <ol id="savedList"></ol> | |
| <div class="note" id="savedNote"></div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| ; | |
| (function(){ | |
| /* ---------------- helpers ---------------- */ | |
| const $ = id => document.getElementById(id); | |
| const clamp = (v,a,b) => Math.min(b, Math.max(a, v)); | |
| function tc(t, frac){ // 1:23.45 / 1:05:03.20 | |
| if(!isFinite(t)||t<0) t=0; | |
| const h = Math.floor(t/3600); | |
| const m = Math.floor((t-h*3600)/60), s = t - h*3600 - m*60; | |
| const mm = h ? String(m).padStart(2,"0") : String(m); | |
| const hh = h ? h+":" : ""; | |
| return frac===false ? hh+mm+":"+String(Math.floor(s)).padStart(2,"0") | |
| : hh+mm+":"+(s<10?"0":"")+s.toFixed(2); | |
| } | |
| function stamp(t){ t=Math.max(0,t||0); return Math.floor(t/60)+"m"+String(Math.floor(t%60)).padStart(2,"0")+"s"; } | |
| /* Decimal, not binary. Bit rates are decimal (1 Mbps = 1,000,000 bits) so a | |
| 1024-based readout made every estimate read about 5% low against the number | |
| it was calculated from, and 7% low once it reached GB. macOS and most Linux | |
| file managers count the same way; Windows Explorer is the odd one out and | |
| will show these files as slightly smaller. */ | |
| function bytes(n){ | |
| if(!isFinite(n)) return "—"; | |
| if(n < 1e6) return (n/1e3).toFixed(0)+" KB"; | |
| if(n < 1e9) return (n/1e6).toFixed(1)+" MB"; | |
| return (n/1e9).toFixed(2)+" GB"; | |
| } | |
| function even(n){ n = Math.round(n); return n%2 ? n+1 : n; } | |
| function safeName(s, n){ return String(s||"").replace(/[\\/:*?"<>|\x00-\x1f]+/g,"-").replace(/^\.+/,"").trim().slice(0,n||60); } | |
| function once(el,ev){ return new Promise(r=>el.addEventListener(ev,r,{once:true})); } | |
| /* ---------------- state ---------------- */ | |
| const V = $("video"); | |
| let srcUrl=null, srcName="clip", srcFile=null, dur=0, natW=0, natH=0, srcFps=30; | |
| let inT=0, outT=0, looping=false, dragging=null, exporting=false, cancelExport=false; | |
| let audioCtx=null, mediaSrc=null, monitorGain=null, streamDest=null; | |
| let lastBlob=null, lastExt="webm", lastMeta=null, sheetKind="auto"; | |
| let segments=[], activeSeg=-1, segId=0; | |
| /* ---------------- file loading ---------------- */ | |
| const drop=$("drop"), fileInput=$("file"); | |
| fileInput.addEventListener("click", e=>e.stopPropagation()); | |
| drop.addEventListener("click", ()=>fileInput.click()); | |
| fileInput.addEventListener("change", e=>{ if(e.target.files[0]) load(e.target.files[0]); e.target.value=""; }); | |
| ["dragenter","dragover"].forEach(ev=>drop.addEventListener(ev,e=>{e.preventDefault();drop.classList.add("over")})); | |
| ["dragleave","drop"].forEach(ev=>drop.addEventListener(ev,e=>{e.preventDefault();drop.classList.remove("over")})); | |
| // ONE drop handler only, on the window — two handlers meant a drop started the load twice | |
| window.addEventListener("dragover", e=>e.preventDefault()); | |
| window.addEventListener("drop", e=>{ | |
| e.preventDefault(); drop.classList.remove("over"); | |
| const f = e.dataTransfer && e.dataTransfer.files[0]; | |
| if(f) load(f); | |
| }); | |
| $("newBtn").addEventListener("click", ()=>fileInput.click()); | |
| const DROP_HTML = drop.innerHTML; | |
| let loadSeq = 0; // only the newest load() is allowed to touch the UI | |
| let loadedOnce = false; | |
| async function load(file){ | |
| if(exporting){ alert("Hang on until the export finishes, then load the next video."); return; } | |
| if(segments.length && loadedOnce && | |
| !confirm("Loading a new video clears the clip list ("+segments.length+" clips). Continue?")) return; | |
| const myId = ++loadSeq; | |
| const newUrl = URL.createObjectURL(file); | |
| const prevUrl = srcUrl; | |
| const bail = (msg)=>{ | |
| URL.revokeObjectURL(newUrl); | |
| if(myId !== loadSeq) return; // a newer load took over — stay quiet | |
| srcUrl = prevUrl; | |
| if(loadedOnce){ // don't destroy a session that's already open | |
| if(V.src !== prevUrl && prevUrl){ V.src = prevUrl; } | |
| alert(msg+"\n\nYour current video is still open."); | |
| }else{ | |
| drop.innerHTML = DROP_HTML; drop.style.display="block"; $("app").style.display="none"; | |
| alert(msg); | |
| } | |
| }; | |
| srcName = (file.name||"clip").replace(/\.[^.]+$/,"") || "clip"; | |
| srcFile = file; // kept so the audio can be pulled out later | |
| srcUrl = newUrl; | |
| clearResult(); clearSaved(); | |
| V.src = newUrl; | |
| V.muted = false; | |
| const how = await new Promise(res=>{ | |
| let settled=false, timer=0; | |
| const done = v => { if(settled) return; settled=true; clearTimeout(timer); | |
| V.onloadedmetadata=null; V.onerror=null; res(v); }; | |
| V.onloadedmetadata = ()=>done("ok"); | |
| V.onerror = ()=>done("err"); | |
| timer = setTimeout(()=>done("slow"), 120000); | |
| }); | |
| if(myId !== loadSeq){ URL.revokeObjectURL(newUrl); return; } | |
| if(how==="err") return bail("This browser can't open “"+file.name+"”.\n\nTry an MP4, MOV or WebM file — Chrome can't read every format (AVI and some MKVs won't work)."); | |
| if(how==="slow") return bail("“"+file.name+"” is taking too long to open. If it's on a network or cloud drive, copy it to this computer first."); | |
| dur = V.duration; | |
| if(!isFinite(dur) || dur<=0){ // some WebM/MKV files report no duration until you seek past the end | |
| try{ | |
| V.currentTime = 1e7; | |
| await Promise.race([once(V,"seeked"), new Promise(r=>setTimeout(r,2500))]); | |
| dur = isFinite(V.duration) ? V.duration : V.currentTime; | |
| V.currentTime = 0; | |
| await Promise.race([once(V,"seeked"), new Promise(r=>setTimeout(r,1500))]); | |
| }catch(e){} | |
| } | |
| if(myId !== loadSeq){ URL.revokeObjectURL(newUrl); return; } | |
| if(!isFinite(dur) || dur<=0) return bail("Couldn't work out how long that video is, so it can't be trimmed. Try converting it to MP4 first."); | |
| if(prevUrl) URL.revokeObjectURL(prevUrl); // safe now: the new file is definitely in | |
| natW = V.videoWidth || 640; natH = V.videoHeight || 360; | |
| inT = 0; outT = dur; | |
| segments = []; activeSeg = -1; | |
| removeCrop(); | |
| if(!loadedOnce) drop.innerHTML = '<div class="big">▢</div><h2>Reading video…</h2><p>one moment</p>'; | |
| srcFps = 30; | |
| await detectFps(); | |
| if(myId !== loadSeq) return; | |
| loadedOnce = true; | |
| drop.style.display="none"; $("app").style.display="block"; $("newBtn").style.display="inline-block"; | |
| $("w").value = natW; $("h").value = natH; | |
| $("totTime").textContent = tc(dur); | |
| $("rulEnd").textContent = tc(dur,false); | |
| $("rulMid").textContent = tc(dur/2,false); | |
| buildFpsSeg(); | |
| $("fps").value = Math.round(srcFps); | |
| $("srcBadge").textContent = natW+"×"+natH+" · "+Math.round(srcFps)+" fps · "+tc(dur,false); | |
| document.title = srcName+" — Pocket Video Trimmer"; | |
| markScale(1); | |
| refresh(); update(); | |
| buildStrip(newUrl, myId); | |
| } | |
| /* estimate source fps by counting presented frames over a short muted sample */ | |
| async function detectFps(){ | |
| if(!("requestVideoFrameCallback" in HTMLVideoElement.prototype)) return; | |
| const prevMuted = V.muted; | |
| V.muted = true; | |
| let stop = false; | |
| try{ | |
| V.currentTime = Math.min(0.2, dur/4); | |
| await Promise.race([once(V,"seeked"), new Promise(r=>setTimeout(r,1500))]); | |
| let first=null, last=null, n=0; | |
| const done = new Promise(res=>{ | |
| const finish = ()=>{ stop=true; res(); }; | |
| const cb=(now,md)=>{ | |
| if(stop) return; | |
| if(first===null) first=md.mediaTime; else { last=md.mediaTime; n++; } | |
| if(n>=12 || (last!==null && last-first>0.5)) return finish(); | |
| V.requestVideoFrameCallback(cb); | |
| }; | |
| V.requestVideoFrameCallback(cb); | |
| setTimeout(finish, 1400); // rVFC never fired (or is throttled) — give up | |
| }); | |
| await V.play().catch(()=>{}); | |
| await done; | |
| if(n>2 && last>first){ | |
| const f = n/(last-first); | |
| if(f>4 && f<125) srcFps = Math.round(f*100)/100; | |
| } | |
| }catch(e){} | |
| stop = true; | |
| try{ V.pause(); }catch(e){} | |
| V.muted = prevMuted; | |
| V.currentTime = 0; | |
| await Promise.race([once(V,"seeked"), new Promise(r=>setTimeout(r,800))]); | |
| } | |
| /* ---------------- filmstrip ---------------- */ | |
| async function buildStrip(url, id){ | |
| const strip=$("strip"); strip.innerHTML=""; | |
| const total=dur; | |
| const v=document.createElement("video"); | |
| v.src=url; v.muted=true; v.playsInline=true; | |
| try{ | |
| await Promise.race([once(v,"loadeddata"), once(v,"error"), new Promise(r=>setTimeout(r,15000))]); | |
| }catch(e){ return; } | |
| const vw=v.videoWidth||16, vh=v.videoHeight||9; | |
| const stripW=Math.max(240, strip.clientWidth || $("tl").clientWidth || 900); | |
| const stripH=Math.max(28, strip.clientHeight || $("tl").clientHeight || 64); | |
| // Pick the number of frames from the video's own shape rather than a fixed 12. | |
| // A portrait clip gets lots of narrow thumbnails instead of squashed wide ones. | |
| // Keep the count modest: every thumbnail is a full decode+seek of the source, | |
| // and on tall phone footage that used to mean 30-40 of them. 1x pixels is | |
| // plenty at this size. | |
| const ideal = Math.max(16, Math.round(stripH*vw/vh)); | |
| const N = clamp(Math.round(stripW/ideal), 6, 16); | |
| const cw = Math.max(8, Math.round(stripW/N)), ch = Math.max(8, Math.round(stripH)); | |
| const sc = Math.max(cw/vw, ch/vh); // fill the cell without stretching | |
| const dw = vw*sc, dh = vh*sc, ox=(cw-dw)/2, oy=(ch-dh)/2; | |
| for(let i=0;i<N;i++){ | |
| if(id !== loadSeq){ v.removeAttribute("src"); return; } | |
| // Stand aside while an export is running. Decoding thumbnails from a second | |
| // copy of the video used to fight the export for the same decoder, which | |
| // dropped frames and made the output stutter. | |
| while(exporting){ | |
| await new Promise(r=>setTimeout(r,300)); | |
| if(id !== loadSeq){ v.removeAttribute("src"); return; } | |
| } | |
| const c=document.createElement("canvas"); c.width=cw; c.height=ch; | |
| strip.appendChild(c); | |
| try{ | |
| v.currentTime = Math.min(total-0.03, (i+0.5)*total/N); | |
| await Promise.race([once(v,"seeked"), new Promise(r=>setTimeout(r,1500))]); | |
| const cx=c.getContext("2d",{alpha:false}); | |
| cx.imageSmoothingEnabled=true; cx.imageSmoothingQuality="high"; | |
| cx.drawImage(v, ox, oy, dw, dh); | |
| }catch(e){} | |
| } | |
| v.removeAttribute("src"); | |
| } | |
| /* ---------------- transport ---------------- */ | |
| $("playBtn").addEventListener("click", togglePlay); | |
| function togglePlay(){ | |
| if(exporting) return; | |
| if(V.paused){ if(V.currentTime<inT-0.01||V.currentTime>=outT-0.01) V.currentTime=inT; V.play(); } | |
| else V.pause(); | |
| } | |
| V.addEventListener("play", ()=>$("playBtn").innerHTML="❙❙"); | |
| V.addEventListener("pause",()=>$("playBtn").innerHTML="▶"); | |
| V.addEventListener("timeupdate", update); | |
| V.addEventListener("seeked", update); | |
| function rafLoop(){ | |
| if(!V.paused && !exporting){ | |
| if(V.currentTime>=outT-0.02){ if(looping){ V.currentTime=inT; } else { V.pause(); V.currentTime=outT; } } | |
| update(); | |
| } | |
| requestAnimationFrame(rafLoop); | |
| } | |
| requestAnimationFrame(rafLoop); | |
| $("prevF").addEventListener("click", ()=>step(-1)); | |
| $("nextF").addEventListener("click", ()=>step(1)); | |
| function step(dir, big){ | |
| V.pause(); | |
| const d = big ? 1 : 1/(srcFps||30); | |
| V.currentTime = clamp(V.currentTime + dir*d, 0, dur); | |
| } | |
| /* Setting a start later than the current end used to clamp the start back onto | |
| the end. Marking a new clip after the previous one is the normal way to work, | |
| so the other mark gets out of the way instead. */ | |
| function setIn(t){ | |
| inT = clamp(t, 0, Math.max(0, dur-0.02)); | |
| if(outT <= inT + 0.02) outT = dur; // start moved past the end — open the end up | |
| refresh(); | |
| } | |
| function setOut(t){ | |
| outT = clamp(t, 0.02, dur); | |
| if(inT >= outT - 0.02) inT = 0; // end moved before the start — open the start up | |
| refresh(); | |
| } | |
| $("setIn").addEventListener("click", ()=>setIn(V.currentTime)); | |
| $("setOut").addEventListener("click", ()=>setOut(V.currentTime)); | |
| $("resetSel").addEventListener("click", ()=>{ if(picMode){ clearSnaps(); return; } inT=0; outT=dur; refresh(); }); | |
| $("loopBtn").addEventListener("click", e=>{ looping=!looping; e.target.textContent="Loop: "+(looping?"on":"off"); e.target.classList.toggle("ghost",!looping); }); | |
| /* ---------------- click-to-type time fields ---------------- */ | |
| function parseTime(s){ // "1:23.4", "0:05", "83.4", "1:02:03" | |
| s = String(s).trim(); if(!s) return null; | |
| const parts = s.split(":").map(x=>parseFloat(x)); | |
| if(parts.some(x=>isNaN(x))) return null; | |
| return parts.reduce((a,x)=>a*60+x, 0); | |
| } | |
| function makeTimeEditor(el, get, set){ | |
| el.addEventListener("click", function(e){ | |
| e.stopPropagation(); | |
| if(exporting || el.querySelector("input")) return; | |
| const inp = document.createElement("input"); | |
| inp.value = tc(get()); el.textContent=""; el.appendChild(inp); | |
| inp.focus(); inp.select(); | |
| let done=false; | |
| const commit = ok => { | |
| if(done) return; done=true; | |
| const v = ok ? parseTime(inp.value) : null; | |
| inp.remove(); | |
| if(v !== null && isFinite(v)) set(v); | |
| refresh(); | |
| }; | |
| inp.addEventListener("blur", ()=>commit(true)); | |
| inp.addEventListener("click", ev=>ev.stopPropagation()); | |
| inp.addEventListener("keydown", ev=>{ | |
| ev.stopPropagation(); | |
| if(ev.key==="Enter"){ ev.preventDefault(); commit(true); } | |
| if(ev.key==="Escape"){ ev.preventDefault(); commit(false); } | |
| }); | |
| }); | |
| } | |
| makeTimeEditor($("pIn"), ()=>inT, v=>{ setIn(v); V.currentTime=inT; }); | |
| makeTimeEditor($("pOut"), ()=>outT, v=>{ setOut(v); V.currentTime=outT; }); | |
| makeTimeEditor($("pDur"), ()=>outT-inT, v=>{ | |
| const len = clamp(v, 0.02, dur); | |
| if(inT + len <= dur){ outT = inT + len; } | |
| else { outT = dur; inT = Math.max(0, dur - len); } | |
| V.currentTime = inT; | |
| }); | |
| document.addEventListener("keydown", e=>{ | |
| if(exporting) return; | |
| const t=e.target.tagName; | |
| if(t==="INPUT"||t==="SELECT"||t==="TEXTAREA") return; | |
| const k=e.key; | |
| if(k===" "){ e.preventDefault(); togglePlay(); } | |
| else if(k==="ArrowLeft"){ e.preventDefault(); step(-1, e.shiftKey); } | |
| else if(k==="ArrowRight"){ e.preventDefault(); step(1, e.shiftKey); } | |
| else if(k==="i"||k==="I"){ setIn(V.currentTime); } | |
| else if(k==="o"||k==="O"){ setOut(V.currentTime); } | |
| else if(k==="a"||k==="A"){ e.preventDefault(); picMode ? addSnap() : addClip(); } | |
| else if(k==="c"||k==="C"){ e.preventDefault(); toggleCrop(); } | |
| // holding Shift turns "[" into "{" on most layouts, so accept both | |
| else if(k==="["||k==="{"){ e.preventDefault(); nudge(e.shiftKey ? -1 : -1/(srcFps||30)); } | |
| else if(k==="]"||k==="}"){ e.preventDefault(); nudge(e.shiftKey ? 1 : 1/(srcFps||30)); } | |
| else if(k==="s"||k==="S"){ e.preventDefault(); takeSnapshot(); } | |
| else if(k==="Home"){ V.currentTime=inT; } | |
| else if(k==="End"){ V.currentTime=outT; } | |
| }); | |
| /* ---------------- timeline drag ---------------- */ | |
| const tl=$("tl"); | |
| function posToTime(clientX){ | |
| const r=tl.getBoundingClientRect(); | |
| return clamp((clientX-r.left)/r.width,0,1)*dur; | |
| } | |
| tl.addEventListener("pointerdown", e=>{ | |
| if(exporting||!dur) return; | |
| if(e.target===$("hIn")) dragging="in"; | |
| else if(e.target===$("hOut")) dragging="out"; | |
| else { dragging="seek"; V.pause(); V.currentTime=posToTime(e.clientX); } | |
| tl.setPointerCapture(e.pointerId); | |
| }); | |
| tl.addEventListener("pointermove", e=>{ | |
| if(!dragging) return; | |
| const t=posToTime(e.clientX); | |
| if(dragging==="in"){ inT=clamp(t,0,outT-0.02); V.currentTime=inT; } | |
| else if(dragging==="out"){ outT=clamp(t,inT+0.02,dur); V.currentTime=outT; } | |
| else V.currentTime=t; | |
| refresh(); | |
| }); | |
| ["pointerup","pointercancel"].forEach(ev=>tl.addEventListener(ev,()=>dragging=null)); | |
| function refresh(){ | |
| const a = dur? inT/dur*100 : 0, b = dur? outT/dur*100 : 100; | |
| $("maskL").style.left="0"; $("maskL").style.width=a+"%"; | |
| $("maskR").style.left=b+"%"; $("maskR").style.width=(100-b)+"%"; | |
| $("sel").style.left=a+"%"; $("sel").style.width=(b-a)+"%"; | |
| $("hIn").style.left=a+"%"; $("hOut").style.left=b+"%"; | |
| if(activeSeg>=0){ // highlight only while the selection still matches | |
| const sg=segments[activeSeg]; | |
| if(!sg || Math.abs(sg.start-inT)>0.001 || Math.abs(sg.end-outT)>0.001) activeSeg=-1; | |
| } | |
| if(!$("pIn").querySelector("input")) $("pIn").textContent=tc(inT); | |
| if(!$("pOut").querySelector("input")) $("pOut").textContent=tc(outT); | |
| if(!$("pDur").querySelector("input")) $("pDur").textContent=tc(outT-inT); | |
| renderClips(); renderSnaps(); renderBand(); renderSnapBand(); renderShift(); estimate(); | |
| } | |
| function update(){ | |
| $("curTime").textContent=tc(V.currentTime); | |
| $("ph").style.left=(dur? V.currentTime/dur*100 : 0)+"%"; | |
| } | |
| /* ---------------- shift the whole selection ---------------- | |
| Moves the start and end together so the length stays put. If the selection | |
| currently matches a saved clip, that clip moves with it. */ | |
| const shiftBar=$("shiftBar"), shiftBlk=$("shiftBlk"); | |
| let shiftDrag=null; | |
| function renderShift(){ | |
| if(!dur) return; | |
| shiftBlk.style.left=(inT/dur*100)+"%"; | |
| shiftBlk.style.width=(Math.max(0.3,(outT-inT)/dur*100))+"%"; | |
| $("shiftLbl").innerHTML = (activeSeg>=0 && segments[activeSeg]) | |
| ? "Shift <b>clip "+(activeSeg+1)+"</b> · "+tc(outT-inT) | |
| : "Shift selection · "+tc(outT-inT); | |
| } | |
| function shiftTo(newStart){ | |
| const len = outT-inT; | |
| const a = clamp(newStart, 0, Math.max(0, dur-len)); | |
| inT = a; outT = a+len; | |
| if(activeSeg>=0 && segments[activeSeg]){ // carry the saved clip along | |
| segments[activeSeg].start = inT; | |
| segments[activeSeg].end = outT; | |
| } | |
| V.currentTime = inT; | |
| refresh(); | |
| } | |
| function nudge(sec){ if(!exporting && dur) shiftTo(inT+sec); } | |
| shiftBar.addEventListener("pointerdown", e=>{ | |
| if(exporting || !dur) return; | |
| e.preventDefault(); | |
| const r=shiftBar.getBoundingClientRect(); | |
| if(e.target !== shiftBlk){ // clicked the track — centre the block there | |
| shiftTo(clamp((e.clientX-r.left)/r.width,0,1)*dur - (outT-inT)/2); | |
| } | |
| shiftDrag={px:e.clientX, start:inT, w:r.width}; | |
| shiftBar.setPointerCapture(e.pointerId); | |
| }); | |
| shiftBar.addEventListener("pointermove", e=>{ | |
| if(!shiftDrag) return; | |
| shiftTo(shiftDrag.start + (e.clientX-shiftDrag.px)/shiftDrag.w*dur); | |
| }); | |
| ["pointerup","pointercancel"].forEach(ev=>shiftBar.addEventListener(ev, ()=>shiftDrag=null)); | |
| $("shiftBtns").addEventListener("click", e=>{ | |
| const b=e.target.closest("button"); if(!b) return; | |
| const d=b.dataset.d, f=1/(srcFps||30); | |
| nudge(d==="f" ? f : d==="-f" ? -f : parseFloat(d)); | |
| }); | |
| /* ---------------- manual storyboard frames ---------------- */ | |
| let snaps=[], snapId=0; | |
| function msGrid(){ | |
| return {c: clamp(Math.round(+$("msCols").value||3),1,10), | |
| r: clamp(Math.round(+$("msRows").value||3),1,10)}; | |
| } | |
| const SNAP_MAX = 25; | |
| function makeThumb(){ | |
| const t = targets(); | |
| const w = 56, h = Math.max(12, Math.round(w * t.h / t.w)); | |
| const c = document.createElement("canvas"); | |
| c.width = w*2; c.height = h*2; // 2x so it stays sharp | |
| c.className = "cthumb"; c.style.width = w+"px"; c.style.height = h+"px"; | |
| const x = c.getContext("2d", {alpha:false}); | |
| x.imageSmoothingEnabled = true; x.imageSmoothingQuality = "high"; | |
| try{ drawSource(x, c.width, c.height); }catch(e){} | |
| return c; | |
| } | |
| function addSnap(){ | |
| if(exporting || !dur) return; | |
| if(snaps.length >= SNAP_MAX){ | |
| setStatus("That's the limit — "+SNAP_MAX+" snapshots in a manual storyboard."); | |
| return; | |
| } | |
| // pressing A twice without moving would just repeat the same frame | |
| const last = snaps[snaps.length-1]; | |
| if(last && Math.abs(last.t - V.currentTime) < 0.001){ | |
| setStatus("That's the same frame as snapshot "+snaps.length+" — move the playhead first."); | |
| return; | |
| } | |
| snaps.push({id:++snapId, t:V.currentTime, thumb:makeThumb()}); | |
| // grow the grid so every frame you take has somewhere to go | |
| let {c,r} = msGrid(); | |
| while(c*r < snaps.length && r < 10) r++; | |
| while(c*r < snaps.length && c < 10) c++; | |
| $("msCols").value=c; $("msRows").value=r; | |
| setStatus(""); | |
| refresh(); | |
| } | |
| function clearSnaps(){ | |
| if(exporting || !snaps.length) return; | |
| if(snaps.length>1 && !confirm("Remove all "+snaps.length+" snapshots?")) return; | |
| snaps=[]; refresh(); | |
| } | |
| $("clearSnaps").addEventListener("click", clearSnaps); | |
| function renderSnaps(){ | |
| const wrap=$("snapsWrap"), list=$("snapList"); | |
| wrap.style.display = (picMode && snaps.length) ? "block" : "none"; | |
| // taking snapshots swaps the auto storyboard out for the manual one | |
| $("autoSbSec").style.display = snaps.length ? "none" : "block"; | |
| $("manSbSec").style.display = snaps.length ? "block" : "none"; | |
| $("snapsTitle").textContent = "Snapshots ("+snaps.length+" of "+SNAP_MAX+")"; | |
| if(!picMode || !snaps.length){ list.innerHTML=""; return; } | |
| if(list.querySelector("input:focus")) return; | |
| const {c,r} = msGrid(), cells = c*r; | |
| list.innerHTML=""; | |
| snaps.forEach((sn,i)=>{ | |
| const row=document.createElement("div"); | |
| row.className="crow"+(i>=cells?" over":""); | |
| const num=document.createElement("span"); num.className="cnum"; num.textContent=(i+1); | |
| const spacer=document.createElement("span"); spacer.className="cname"; | |
| if(sn.thumb) spacer.appendChild(sn.thumb); | |
| const times=document.createElement("span"); times.className="ctimes"; | |
| const bT=document.createElement("b"); bT.className="edit"; bT.textContent=tc(sn.t); | |
| times.appendChild(bT); | |
| makeTimeEditor(bT, ()=>sn.t, v=>{ sn.t = clamp(v, 0, dur); }); | |
| const note=document.createElement("span"); note.className="cdur"; | |
| note.textContent = i>=cells ? "no cell" : ""; | |
| const btns=document.createElement("span"); btns.className="cbtns"; | |
| const go=document.createElement("button"); | |
| go.textContent="▶"; go.title="Jump to this frame"; | |
| go.addEventListener("click", ev=>{ ev.stopPropagation(); if(!exporting) V.currentTime=sn.t; }); | |
| const use=document.createElement("button"); | |
| use.textContent="Set"; use.title="Move this snapshot to where the playhead is now"; | |
| use.addEventListener("click", ev=>{ ev.stopPropagation(); if(exporting) return; sn.t=V.currentTime; refresh(); }); | |
| const del=document.createElement("button"); | |
| del.textContent="✕"; del.title="Remove"; del.className="danger"; | |
| del.addEventListener("click", ev=>{ ev.stopPropagation(); if(exporting) return; | |
| snaps.splice(i,1); refresh(); }); | |
| btns.appendChild(go); btns.appendChild(use); btns.appendChild(del); | |
| row.appendChild(num); row.appendChild(spacer); row.appendChild(times); | |
| row.appendChild(note); row.appendChild(btns); | |
| row.addEventListener("click", ()=>{ if(!exporting) V.currentTime=sn.t; }); | |
| list.appendChild(row); | |
| }); | |
| } | |
| function renderSnapBand(){ | |
| if(!picMode || !dur) return; | |
| const band=$("segBand"); | |
| snaps.forEach((sn,i)=>{ | |
| const el=document.createElement("div"); | |
| el.className="segbar"; el.style.left=(sn.t/dur*100)+"%"; | |
| el.style.width="0"; el.style.minWidth="3px"; el.textContent=""; | |
| band.appendChild(el); | |
| }); | |
| } | |
| /* ---------------- clip list ---------------- */ | |
| $("addClip").addEventListener("click", ()=> picMode ? addSnap() : addClip()); | |
| function addClip(){ | |
| if(exporting || !dur) return; | |
| if(outT - inT < 0.05){ setStatus("Selection is too short to add."); return; } | |
| segments.push({id:++segId, start:inT, end:outT, name:""}); | |
| // Leave the selection unlinked so the shift bar positions the NEXT clip | |
| // rather than dragging the one you just saved. Click a row to link it. | |
| activeSeg = -1; | |
| setStatus(""); | |
| refresh(); | |
| } | |
| $("clearClips").addEventListener("click", ()=>{ | |
| if(exporting || !segments.length) return; | |
| if(segments.length>1 && !confirm("Remove all "+segments.length+" clips from the list?")) return; | |
| segments=[]; activeSeg=-1; refresh(); | |
| }); | |
| function renderClips(){ | |
| const wrap=$("clipsWrap"), list=$("clipList"); | |
| wrap.style.display = (!picMode && segments.length) ? "block" : "none"; | |
| if(picMode){ list.innerHTML=""; return; } | |
| $("clipsTitle").textContent = "Clip list ("+segments.length+")"; | |
| if(!segments.length){ list.innerHTML=""; return; } | |
| if(list.querySelector("input:focus")) return; // don't yank a field being typed in | |
| list.innerHTML=""; | |
| segments.forEach((s,i)=>{ | |
| const row=document.createElement("div"); | |
| row.className="crow"+(i===activeSeg?" on":""); | |
| const num=document.createElement("span"); num.className="cnum"; num.textContent=(i+1); | |
| const nameWrap=document.createElement("span"); nameWrap.className="cname"; | |
| const name=document.createElement("input"); | |
| name.type="text"; name.value=s.name||""; name.placeholder="name (optional)"; name.maxLength=40; | |
| name.addEventListener("input", ()=>{ s.name=name.value; }); | |
| name.addEventListener("keydown", ev=>{ ev.stopPropagation(); if(ev.key==="Enter") name.blur(); }); | |
| name.addEventListener("click", ev=>ev.stopPropagation()); | |
| nameWrap.appendChild(name); | |
| const times=document.createElement("span"); times.className="ctimes"; | |
| const bS=document.createElement("b"); bS.className="edit"; bS.textContent=tc(s.start); | |
| const bE=document.createElement("b"); bE.className="edit"; bE.textContent=tc(s.end); | |
| times.appendChild(bS); times.appendChild(document.createTextNode(" → ")); times.appendChild(bE); | |
| makeTimeEditor(bS, ()=>s.start, v=>{ s.start = clamp(v, 0, s.end-0.02); }); | |
| makeTimeEditor(bE, ()=>s.end, v=>{ s.end = clamp(v, s.start+0.02, dur); }); | |
| const d=document.createElement("span"); d.className="cdur"; d.textContent=tc(s.end-s.start); | |
| const btns=document.createElement("span"); btns.className="cbtns"; | |
| const prev=document.createElement("button"); | |
| prev.textContent="▶"; prev.title="Load this clip onto the timeline and play it"; | |
| prev.addEventListener("click", ev=>{ ev.stopPropagation(); | |
| if(exporting) return; | |
| activeSeg=i; inT=s.start; outT=s.end; V.currentTime=s.start; refresh(); | |
| V.play().catch(()=>{}); }); | |
| const use=document.createElement("button"); | |
| use.textContent="Set"; use.title="Replace this clip's start and end with the current timeline selection"; | |
| use.addEventListener("click", ev=>{ ev.stopPropagation(); | |
| if(exporting) return; | |
| s.start=inT; s.end=outT; activeSeg=i; refresh(); }); | |
| const del=document.createElement("button"); | |
| del.textContent="✕"; del.title="Remove this clip"; del.className="danger"; | |
| del.addEventListener("click", ev=>{ ev.stopPropagation(); | |
| if(exporting) return; | |
| segments.splice(i,1); | |
| if(activeSeg>=segments.length) activeSeg=segments.length-1; | |
| refresh(); }); | |
| btns.appendChild(prev); btns.appendChild(use); btns.appendChild(del); | |
| row.appendChild(num); row.appendChild(nameWrap); row.appendChild(times); | |
| row.appendChild(d); row.appendChild(btns); | |
| row.addEventListener("click", ()=>{ // preview this clip on the timeline | |
| if(exporting) return; | |
| activeSeg=i; inT=s.start; outT=s.end; V.currentTime=s.start; refresh(); | |
| }); | |
| list.appendChild(row); | |
| }); | |
| } | |
| function renderBand(){ | |
| const band=$("segBand"); band.innerHTML=""; | |
| if(!dur) return; | |
| segments.forEach((s,i)=>{ | |
| const el=document.createElement("div"); | |
| el.className="segbar"+(i===activeSeg?" on":""); | |
| el.style.left=(s.start/dur*100)+"%"; | |
| el.style.width=Math.max(0.4,(s.end-s.start)/dur*100)+"%"; | |
| el.textContent=(i+1); | |
| band.appendChild(el); | |
| }); | |
| } | |
| /* the list is what gets exported; with an empty list it's just the selection */ | |
| function exportList(){ | |
| return segments.length ? segments.map(s=>({start:s.start,end:s.end,name:s.name})) | |
| : [{start:inT,end:outT,name:""}]; | |
| } | |
| function totalLen(){ return exportList().reduce((a,s)=>a+Math.max(0,s.end-s.start),0); } | |
| /* ---------------- crop ---------------- | |
| Held in source pixels so it survives any output size change. Everything that | |
| sizes the output asks srcW()/srcH() rather than the video's own dimensions. */ | |
| let crop=null, cropAR=0, cropARLabel="free", curScale=1; | |
| function srcW(){ return crop ? crop.w : natW; } | |
| function srcH(){ return crop ? crop.h : natH; } | |
| /* The crop box is stored in source pixels, measured against the size the video | |
| reported when it loaded. Some files don't have their real size ready at | |
| loadedmetadata, so natW/natH can be stuck on the 640x360 fallback — and a crop | |
| rectangle in the wrong coordinate space lands outside the real frame. When | |
| that happens drawImage draws nothing at all: no error, no warning, just a | |
| black clip at full bitrate. Clamping against the live size stops it. */ | |
| function liveW(){ return V.videoWidth || natW || 640; } | |
| function liveH(){ return V.videoHeight || natH || 360; } | |
| function cropRect(){ | |
| if(!crop) return null; | |
| const sw=liveW(), sh=liveH(); | |
| const x=clamp(Math.round(crop.x), 0, Math.max(0,sw-2)); | |
| const y=clamp(Math.round(crop.y), 0, Math.max(0,sh-2)); | |
| return {x, y, w:clamp(Math.round(crop.w),2,sw-x), h:clamp(Math.round(crop.h),2,sh-y)}; | |
| } | |
| function drawSource(dctx, dw, dh){ | |
| dctx.imageSmoothingEnabled=true; dctx.imageSmoothingQuality="high"; | |
| const c = cropRect(); | |
| if(c) dctx.drawImage(V, c.x, c.y, c.w, c.h, 0, 0, dw, dh); | |
| else dctx.drawImage(V, 0, 0, dw, dh); | |
| } | |
| /* A video element fires "resize" when its intrinsic size first becomes known or | |
| changes. Nothing was listening, so a file that reported nothing useful at | |
| loadedmetadata kept the fallback size for the whole session. */ | |
| V.addEventListener("resize", ()=>{ | |
| const w=V.videoWidth, h=V.videoHeight; | |
| if(!w || !h || !loadedOnce || (w===natW && h===natH)) return; | |
| const sx = natW ? w/natW : 1, sy = natH ? h/natH : 1; | |
| natW=w; natH=h; | |
| if(crop){ crop={x:crop.x*sx, y:crop.y*sy, w:crop.w*sx, h:crop.h*sy}; cropChanged(true); } | |
| $("srcBadge").textContent = natW+"\u00d7"+natH+" \u00b7 "+Math.round(srcFps)+" fps \u00b7 "+tc(dur,false); | |
| layoutCrop(); estimate(); | |
| }); | |
| /* Is what we just drew completely black? Used as a safety net so a blank export | |
| gets reported instead of quietly handed over as a finished file. */ | |
| function canvasIsBlank(c){ | |
| try{ | |
| if(!probeCv){ | |
| probeCv=document.createElement("canvas"); probeCv.width=24; probeCv.height=14; | |
| probeCtx=probeCv.getContext("2d",{willReadFrequently:true}); | |
| } | |
| probeCtx.drawImage(c, 0, 0, 24, 14); | |
| const d=probeCtx.getImageData(0,0,24,14).data; | |
| for(let i=0;i<d.length;i+=4) if(d[i]>8||d[i+1]>8||d[i+2]>8) return false; | |
| return true; | |
| }catch(e){ return false; } | |
| } | |
| /* ---------------- seeking that actually lands ---------------- | |
| The black-first-frame bug: `seeked` only promises the video has moved, not | |
| that a picture is ready. Draw straight after it and the decoder can still be | |
| empty, which paints as black. Three things fix it — wait for a frame to be | |
| presented rather than just for the seek, handle the case where the video was | |
| already parked on the wanted time (no `seeked` ever fires), and check the | |
| result isn't blank before accepting it. */ | |
| let probeCv=null, probeCtx=null, probeDead=false; | |
| function frameIsBlank(){ | |
| if(probeDead || !V.videoWidth) return false; | |
| try{ | |
| if(!probeCv){ | |
| probeCv = document.createElement("canvas"); probeCv.width=24; probeCv.height=14; | |
| probeCtx = probeCv.getContext("2d", {willReadFrequently:true}); | |
| } | |
| probeCtx.drawImage(V, 0, 0, 24, 14); | |
| const d = probeCtx.getImageData(0,0,24,14).data; | |
| let mx=0; | |
| for(let i=0;i<d.length;i+=4){ | |
| const v = d[i]>d[i+1] ? (d[i]>d[i+2]?d[i]:d[i+2]) : (d[i+1]>d[i+2]?d[i+1]:d[i+2]); | |
| if(v>mx){ mx=v; if(mx>=8) return false; } | |
| } | |
| return true; | |
| }catch(e){ probeDead=true; return false; } // cross-origin video: can't look, don't try again | |
| } | |
| /* resolves once the next frame is on screen, or after a short wait */ | |
| function settleFrame(ms){ | |
| return new Promise(res=>{ | |
| let done=false; | |
| const fin=()=>{ if(!done){ done=true; res(); } }; | |
| if("requestVideoFrameCallback" in HTMLVideoElement.prototype){ | |
| try{ V.requestVideoFrameCallback(fin); }catch(e){} | |
| } | |
| setTimeout(fin, ms||90); | |
| }); | |
| } | |
| async function seekTo(time){ | |
| const target = clamp(time, 0, Math.max(0, dur-0.001)); | |
| const already = Math.abs(V.currentTime - target) < 1e-4; | |
| if(V.readyState < 2) await Promise.race([once(V,"loadeddata"), new Promise(r=>setTimeout(r,3000))]); | |
| V.currentTime = target; | |
| if(!already) await Promise.race([once(V,"seeked"), new Promise(r=>setTimeout(r,3000))]); | |
| await settleFrame(); | |
| // A frame that is blank because the decoder is behind fills in if you wait. | |
| // One that is blank because the video really is black stays blank, so give up | |
| // after two tries rather than stalling on a fade-in. | |
| for(let i=0;i<2 && frameIsBlank();i++) await settleFrame(110); | |
| } | |
| function videoBox(){ | |
| const st=$("stage").getBoundingClientRect(), v=V.getBoundingClientRect(); | |
| return {left:v.left-st.left, top:v.top-st.top, w:v.width, h:v.height}; | |
| } | |
| function layoutCrop(){ | |
| if(!crop || !natW) return; | |
| const L=$("cropLayer"), b=videoBox(); | |
| if(!b.w || !b.h) return; | |
| L.style.left=b.left+"px"; L.style.top=b.top+"px"; | |
| L.style.width=b.w+"px"; L.style.height=b.h+"px"; | |
| const sx=b.w/natW, sy=b.h/natH, B=$("cropBox"); | |
| B.style.left=(crop.x*sx)+"px"; B.style.top=(crop.y*sy)+"px"; | |
| B.style.width=(crop.w*sx)+"px"; B.style.height=(crop.h*sy)+"px"; | |
| $("cropDims").textContent = Math.round(crop.w)+" × "+Math.round(crop.h); | |
| } | |
| function cropChanged(finalise){ | |
| if(crop){ | |
| crop.x=Math.max(0,Math.round(crop.x)); crop.y=Math.max(0,Math.round(crop.y)); | |
| crop.w=Math.min(Math.round(crop.w), natW-crop.x); | |
| crop.h=Math.min(Math.round(crop.h), natH-crop.y); | |
| } | |
| layoutCrop(); | |
| $("cropInfo").textContent = crop | |
| ? "Keeping "+crop.w+" × "+crop.h+" ("+cropARLabel+") out of "+natW+" × "+natH+" · drag the box or its handles" | |
| : ""; | |
| if(finalise){ // only resize the output once the drag ends | |
| if(curScale){ W.value=even(srcW()*curScale); H.value=even(srcH()*curScale); markScale(curScale); } | |
| else if(lock.checked){ H.value=even((+W.value||srcW())*srcH()/srcW()); } | |
| } | |
| estimate(); | |
| } | |
| function setCrop(x,y,w,h){ | |
| w=Math.max(16,Math.min(w,natW)); h=Math.max(16,Math.min(h,natH)); | |
| crop={x:clamp(x,0,natW-w), y:clamp(y,0,natH-h), w, h}; | |
| } | |
| function snapToAR(ar){ | |
| if(!ar || !crop) return; | |
| const cx=crop.x+crop.w/2, cy=crop.y+crop.h/2; | |
| let w=Math.min(natW, Math.max(crop.w, crop.h*ar)), h=w/ar; | |
| if(h>natH){ h=natH; w=h*ar; } | |
| setCrop(cx-w/2, cy-h/2, w, h); | |
| } | |
| function biggestFit(ar){ | |
| let w=natW, h=natW/(ar||natW/natH); | |
| if(h>natH){ h=natH; w=h*(ar||natW/natH); } | |
| setCrop((natW-w)/2, (natH-h)/2, w, h); | |
| } | |
| function showCrop(on){ | |
| $("cropLayer").style.display = on ? "block" : "none"; | |
| $("cropSection").style.display = on ? "block" : "none"; | |
| $("cropBtn").classList.toggle("on", on); | |
| } | |
| $("cropBtn").addEventListener("click", toggleCrop); | |
| function toggleCrop(){ | |
| if(exporting || !dur) return; | |
| if(crop){ removeCrop(); return; } | |
| if(cropAR) biggestFit(cropAR); | |
| else setCrop(natW*0.1, natH*0.1, natW*0.8, natH*0.8); | |
| showCrop(true); markAR(); cropChanged(true); | |
| } | |
| function removeCrop(){ | |
| crop=null; cropAR=0; cropARLabel="free"; | |
| showCrop(false); | |
| if(curScale){ W.value=even(natW*curScale); H.value=even(natH*curScale); markScale(curScale); } | |
| $("cropInfo").textContent=""; | |
| estimate(); | |
| } | |
| $("cropMax").addEventListener("click", ()=>{ if(!crop) return; biggestFit(cropAR||natW/natH); cropChanged(true); }); | |
| function applyAR(ar, label){ | |
| cropAR = ar; cropARLabel = label; | |
| markAR(); | |
| if(cropAR) snapToAR(cropAR); | |
| cropChanged(true); | |
| } | |
| document.querySelectorAll(".arRow").forEach(row=>{ | |
| row.addEventListener("click", e=>{ | |
| const b=e.target.closest("button"); if(!b || !crop) return; | |
| const v=b.dataset.ar; | |
| applyAR(v==="src" ? natW/natH : parseFloat(v), v==="src" ? "original" : b.dataset.lbl); | |
| }); | |
| }); | |
| function useCustomAR(){ | |
| if(!crop) return; | |
| const a=+$("arW").value, b=+$("arH").value; | |
| if(!(a>0) || !(b>0)){ $("arW").focus(); return; } | |
| applyAR(a/b, a+":"+b); | |
| } | |
| $("arApply").addEventListener("click", useCustomAR); | |
| ["arW","arH"].forEach(id=>$(id).addEventListener("keydown", e=>{ | |
| e.stopPropagation(); | |
| if(e.key==="Enter"){ e.preventDefault(); useCustomAR(); } | |
| })); | |
| function markAR(){ | |
| let matched=false; | |
| document.querySelectorAll(".arRow button").forEach(b=>{ | |
| const v=b.dataset.ar, val = v==="src" ? (natW/natH) : parseFloat(v); | |
| const on = (!cropAR && v==="0") || (cropAR>0 && Math.abs(val-cropAR) < 0.004); | |
| b.classList.toggle("on", on); | |
| if(on) matched=true; | |
| }); | |
| $("arCustom").classList.toggle("on", !matched && cropAR>0); | |
| } | |
| let cropDrag=null; | |
| $("cropLayer").addEventListener("pointerdown", e=>{ | |
| const h=e.target.dataset.h; | |
| if(!h || exporting || !crop) return; | |
| e.preventDefault(); | |
| const b=videoBox(); | |
| cropDrag={h, s:{...crop}, px:e.clientX, py:e.clientY, sx:b.w/natW, sy:b.h/natH}; | |
| $("cropLayer").setPointerCapture(e.pointerId); | |
| }); | |
| $("cropLayer").addEventListener("pointermove", e=>{ | |
| if(!cropDrag) return; | |
| const dx=(e.clientX-cropDrag.px)/cropDrag.sx, dy=(e.clientY-cropDrag.py)/cropDrag.sy; | |
| const s=cropDrag.s, H=cropDrag.h, ar=cropAR; | |
| if(H==="move"){ | |
| setCrop(s.x+dx, s.y+dy, s.w, s.h); | |
| }else if(ar){ | |
| if(H==="n"||H==="s"){ | |
| let hh = H==="n" ? s.h-dy : s.h+dy; | |
| hh=Math.max(16,Math.min(hh,natH)); let ww=hh*ar; | |
| if(ww>natW){ ww=natW; hh=ww/ar; } | |
| const cx=s.x+s.w/2, y = H==="n" ? (s.y+s.h)-hh : s.y; | |
| setCrop(cx-ww/2, y, ww, hh); | |
| }else if(H==="e"||H==="w"){ | |
| let ww = H==="w" ? s.w-dx : s.w+dx; | |
| ww=Math.max(16,Math.min(ww,natW)); let hh=ww/ar; | |
| if(hh>natH){ hh=natH; ww=hh*ar; } | |
| const cy=s.y+s.h/2, x = H==="w" ? (s.x+s.w)-ww : s.x; | |
| setCrop(x, cy-hh/2, ww, hh); | |
| }else{ // corner: opposite corner stays put | |
| const west=H.indexOf("w")>=0, north=H.indexOf("n")>=0; | |
| const ax = west ? s.x+s.w : s.x, ay = north ? s.y+s.h : s.y; | |
| const availW = west ? ax : natW-ax, availH = north ? ay : natH-ay; | |
| let ww = Math.max(west ? s.w-dx : s.w+dx, (north ? s.h-dy : s.h+dy)*ar); | |
| ww = Math.max(16, Math.min(ww, availW, availH*ar)); | |
| const hh = ww/ar; | |
| setCrop(west ? ax-ww : ax, north ? ay-hh : ay, ww, hh); | |
| } | |
| }else{ | |
| let l=s.x, tp=s.y, r=s.x+s.w, bt=s.y+s.h; | |
| if(H.indexOf("w")>=0) l=clamp(s.x+dx, 0, r-16); | |
| if(H.indexOf("e")>=0) r=clamp(s.x+s.w+dx, l+16, natW); | |
| if(H.indexOf("n")>=0) tp=clamp(s.y+dy, 0, bt-16); | |
| if(H.indexOf("s")>=0) bt=clamp(s.y+s.h+dy, tp+16, natH); | |
| setCrop(l, tp, r-l, bt-tp); | |
| } | |
| cropChanged(false); | |
| }); | |
| ["pointerup","pointercancel"].forEach(ev=>$("cropLayer").addEventListener(ev, ()=>{ | |
| if(cropDrag){ cropDrag=null; cropChanged(true); } | |
| })); | |
| window.addEventListener("resize", layoutCrop); | |
| if(window.ResizeObserver) new ResizeObserver(layoutCrop).observe($("stage")); | |
| V.addEventListener("loadeddata", layoutCrop); | |
| /* ---------------- size ---------------- */ | |
| const W=$("w"), H=$("h"), lock=$("lockAr"); | |
| $("scaleSeg").addEventListener("click", e=>{ | |
| const b=e.target.closest("button"); if(!b) return; | |
| const s=parseFloat(b.dataset.s); | |
| W.value=even(srcW()*s); H.value=even(srcH()*s); markScale(s); estimate(); | |
| }); | |
| function markScale(s){ | |
| curScale = s; | |
| [...$("scaleSeg").children].forEach(b=>b.classList.toggle("on", parseFloat(b.dataset.s)===s)); | |
| } | |
| W.addEventListener("input", ()=>{ if(lock.checked && srcW()) H.value=even(W.value*srcH()/srcW()); markScale(null); estimate(); }); | |
| H.addEventListener("input", ()=>{ if(lock.checked && srcH()) W.value=even(H.value*srcW()/srcH()); markScale(null); estimate(); }); | |
| /* ---------------- fps ---------------- */ | |
| function buildFpsSeg(){ | |
| const seg=$("fpsSeg"); seg.innerHTML=""; | |
| const opts=[Math.round(srcFps),60,30,24,15,12].filter((v,i,a)=>v<=Math.max(60,Math.round(srcFps))&&a.indexOf(v)===i); | |
| opts.forEach(f=>{ | |
| const b=document.createElement("button"); | |
| b.textContent = (f===Math.round(srcFps)? "Same ("+f+")" : f); | |
| b.dataset.f=f; | |
| b.addEventListener("click",()=>{ $("fps").value=f; markFps(); estimate(); }); | |
| seg.appendChild(b); | |
| }); | |
| markFps(); | |
| } | |
| function markFps(){ | |
| const f=+$("fps").value; | |
| [...$("fpsSeg").children].forEach(b=>b.classList.toggle("on", +b.dataset.f===f)); | |
| } | |
| $("fps").addEventListener("input", ()=>{ markFps(); estimate(); }); | |
| /* ---------------- collapsible sections ---------------- */ | |
| $("sizeHead").addEventListener("click", ()=>{ | |
| const open = $("sizeHead").classList.toggle("open"); | |
| $("sizeHead").classList.toggle("shut", !open); | |
| $("sizeBody").style.display = open ? "block" : "none"; | |
| }); | |
| /* ---------------- tabs ---------------- */ | |
| let picMode = false; | |
| $("tabs").addEventListener("click", e=>{ | |
| const b=e.target.closest("button"); if(!b) return; | |
| const t=b.dataset.tab; | |
| [...$("tabs").children].forEach(x=>x.classList.toggle("on", x===b)); | |
| $("tabVideo").style.display = t==="video" ? "block" : "none"; | |
| $("tabPic").style.display = t==="pic" ? "block" : "none"; | |
| picMode = (t==="pic"); | |
| syncMode(); | |
| }); | |
| /* The two buttons under the timeline do different jobs depending on the tab: | |
| clips for video, individual frames for the manual storyboard. */ | |
| function syncMode(){ | |
| $("addClip").innerHTML = (picMode ? "+ Add snapshot" : "+ Add clip") | |
| + ' <span class="kbd" style="background:rgba(0,0,0,.15);border-color:rgba(0,0,0,.25)">A</span>'; | |
| $("resetSel").textContent = picMode ? "Reset storyboard" : "Reset to full clip"; | |
| $("keyA").textContent = picMode ? "add snapshot" : "add clip"; | |
| refresh(); | |
| } | |
| /* ---------------- compression / estimate ---------------- */ | |
| $("q").addEventListener("input", estimate); | |
| $("cfr").addEventListener("change", ()=>{ if(!$("cfr").checked) $("h3").checked=false; estimate(); }); | |
| $("h3").addEventListener("change", ()=>{ if($("h3").checked) $("cfr").checked=true; estimate(); }); | |
| $("keepAudio").addEventListener("change", estimate); | |
| ["sbCols","sbRows","imgMax","msCols","msRows"].forEach(id=>$(id).addEventListener("input", ()=>{ estimate(); renderSnaps(); })); | |
| /* bits per pixel per frame. The browser's recorder is a realtime encoder, so it | |
| needs noticeably more bitrate than an offline one to look the same. At the | |
| 78 default the curve gives 0.30 bpp, which is 8.3 Mbps for 720p30 and | |
| 18.7 Mbps for 1080p30. (The old comment here said 7 and 16 — it was written | |
| against an earlier curve and no longer matched the maths.) */ | |
| function qualityBpp(){ | |
| const q=+$("q").value/100; | |
| return 0.035 * Math.pow(0.55/0.035, q); // 0.035 → 0.55 | |
| } | |
| function targets(){ | |
| const w=even(clamp(+W.value||srcW(),16,7680)), h=even(clamp(+H.value||srcH(),16,4320)); | |
| const f=clamp(+$("fps").value||Math.round(srcFps),1,120); | |
| const bitrate=Math.round(clamp(w*h*f*qualityBpp(), 250000, 120000000)); | |
| return {w,h,f,bitrate}; | |
| } | |
| /* ---------------- fixed frame rate ---------------- | |
| Off, the browser samples the canvas whenever it feels like it, so frame | |
| spacing wanders. On, we hand the recorder one frame at a time on a wall-clock | |
| schedule and count them, which also makes an exact frame count possible. */ | |
| function cfrOn(){ return $("cfr").checked || $("h3").checked; } | |
| /* H3 wants 17n+5 frames in total: 5, 22, 39, 56, 73, 90 ... */ | |
| function h3Snap(f){ return 17*Math.max(0, Math.ceil((f-5)/17)) + 5; } | |
| /* How many frames a clip should contain, and how long it has to run to hold | |
| them. Padding comes from real video after the out point, so it is capped by | |
| what is actually left in the file — if there isn't room we drop to the next | |
| valid count down rather than inventing frames. */ | |
| function frameFit(start, end){ | |
| const fps = targets().f; | |
| let frames = Math.max(1, Math.round(Math.max(0, end-start)*fps)); | |
| let short = false; | |
| if($("h3").checked){ | |
| let want = h3Snap(frames); | |
| const room = Math.floor(Math.max(0, dur-start)*fps); | |
| while(want > room && want > 5){ want -= 17; short = true; } | |
| frames = Math.max(1, want); | |
| } | |
| return {fps, frames, len: frames/fps, short}; | |
| } | |
| /* What actually gets written, not what was selected. With a fixed frame rate | |
| the range is stretched (or clipped) to hold a whole number of frames, so the | |
| old estimate quietly used the wrong length. */ | |
| function exportLen(){ | |
| const list = exportList(); | |
| if(!cfrOn()) return list.reduce((a,s)=>a+Math.max(0,s.end-s.start),0); | |
| return list.reduce((a,s)=>a+frameFit(s.start, s.end).len, 0); | |
| } | |
| /* Container overhead — headers, the cue index, and a packet header per frame. | |
| Small, but it was missing entirely, and it grows with the frame rate. */ | |
| function muxOverhead(f){ return 1.012 + Math.min(0.010, f/12000); } | |
| function estBytes(){ | |
| const t = targets(), n = Math.max(1, exportList().length), len = exportLen(); | |
| const audio = wantAudio() ? 192000 : 0; // what the recorder is asked for | |
| return (t.bitrate + audio) * len / 8 * muxOverhead(t.f) + n*2048; | |
| } | |
| function estimate(){ | |
| const t=targets(), n=exportList().length, len=exportLen(); | |
| $("eSize").textContent = t.w+"×"+t.h; | |
| $("eRate").textContent = (t.bitrate/1e6).toFixed(2)+" Mbps"; | |
| $("eBytesLbl").textContent = n>1 ? "Est. size, all "+n : "Est. file size"; | |
| $("eCodec").textContent = codecLabel(); | |
| $("eBytes").textContent = bytes(estBytes()); | |
| $("eTime").textContent = len<1 ? "—" : (len<60? Math.ceil(len)+" s" : Math.ceil(len/60)+" min"); | |
| if($("h3").checked) $("cfr").checked = true; | |
| if(cfrOn()){ | |
| const seg = exportList()[0] || {start:inT, end:outT}; | |
| const ff = frameFit(seg.start, seg.end); | |
| $("cfrHint").textContent = "Canvas repainted strictly on a "+ff.fps+" fps grid." | |
| + ($("h3").checked | |
| ? " " + (n>1?"First clip: ":"") + ff.frames + " frames ("+ff.len.toFixed(2)+" s" | |
| + (ff.short ? ", trimmed down — not enough video after the out point" : ", out point pushed out to suit")+")." | |
| : "") | |
| + " The browser still does the encoding, so treat it as close rather than certain."; | |
| }else{ | |
| $("cfrHint").textContent = "Off, the browser samples the canvas when it likes and the frame spacing varies a little."; | |
| } | |
| const q=+$("q").value; | |
| $("qHint").textContent = (q<20?"Squeezed hard — smallest file, softest picture" | |
| : q<40?"Well compressed — easy to email" | |
| : q<60?"Balanced" | |
| : q<80?"Light compression — larger file, crisper picture" | |
| : "Barely compressed — largest file"); | |
| const gfit = fitWithin(t.w, t.h, imgCap()); | |
| $("imgMaxHint").textContent = "Pictures are scaled to fit inside this. A single frame comes out " | |
| + gfit.w + " × " + gfit.h + (gfit.scale<1 ? " (scaled down from "+t.w+" × "+t.h+")" : "") + "."; | |
| $("sizeHint").textContent = (crop ? "Cropped to "+crop.w+"×"+crop.h+" (from "+natW+"×"+natH+")." | |
| : "Source "+natW+"×"+natH+".")+" Width and height are rounded to even numbers."; | |
| $("exportBtn").textContent = n>1 ? "Export "+n+" clips" : (segments.length===1 ? "Export 1 clip" : "Export clip"); | |
| $("gifBtn").textContent = n>1 ? "Export "+n+" GIFs" : "Export as GIF"; | |
| const g = sbGrid(), cell = sbCell(t, g.c, g.r), frames = g.c*g.r; | |
| $("sbHint").textContent = frames+" frame"+(frames===1?"":"s")+" · sheet " | |
| + (g.c*cell.cw + (g.c-1)*SB_GAP) + " × " + (g.r*cell.ch + (g.r-1)*SB_GAP) + " px"; | |
| $("sbBtn").textContent = n>1 ? "Export "+n+" storyboards" : "Export storyboard"; | |
| const mg = msGrid(), mcell = sbCell(t, mg.c, mg.r), cells = mg.c*mg.r; | |
| $("msHint").textContent = snaps.length | |
| ? snaps.length+" snapshot"+(snaps.length===1?"":"s")+" · "+cells+" cell"+(cells===1?"":"s") | |
| + (snaps.length>cells ? " · "+(snaps.length-cells)+" won't fit" | |
| : snaps.length<cells ? " · "+(cells-snaps.length)+" left blank" : "") | |
| + " · sheet "+(mg.c*mcell.cw+(mg.c-1)*SB_GAP)+" × "+(mg.r*mcell.ch+(mg.r-1)*SB_GAP)+" px" | |
| : "No snapshots yet — press + Add snapshot under the video."; | |
| } | |
| /* ---------------- recording engine ---------------- */ | |
| /* H.264 High profile first — Baseline (the old default) has no CABAC and no | |
| B-frames, which is a big quality loss at the same bitrate. Level 5.2 so 4K | |
| is inside the profile limits. */ | |
| const MIMES = [ | |
| "video/mp4;codecs=avc1.640034,mp4a.40.2", // High 5.2 | |
| "video/mp4;codecs=avc1.64002A,mp4a.40.2", // High 4.2 | |
| "video/mp4;codecs=avc1.4D402A,mp4a.40.2", // Main 4.2 | |
| "video/mp4;codecs=avc1.640034", | |
| "video/mp4", | |
| "video/webm;codecs=vp9,opus", | |
| "video/webm;codecs=vp9", | |
| "video/webm;codecs=vp8,opus", | |
| "video/webm" | |
| ]; | |
| function pickMime(){ | |
| for(const m of MIMES) if(window.MediaRecorder && MediaRecorder.isTypeSupported(m)) return m; | |
| return ""; | |
| } | |
| /* isTypeSupported saying yes only means the string parses and the codecs exist. | |
| It does not promise the encoder will actually run: Chrome 152 accepts | |
| video/mp4;codecs=avc1.640034,mp4a.40.2 and then aborts the recorder a few | |
| milliseconds after start on some machines. So keep the whole list and work | |
| down it when one turns out not to record. */ | |
| function mimeChoices(){ | |
| if(!window.MediaRecorder) return []; | |
| const out=[], seen=new Set(); | |
| for(const m of MIMES){ | |
| if(seen.has(m) || !MediaRecorder.isTypeSupported(m)) continue; | |
| seen.add(m); out.push(m); | |
| } | |
| return out; | |
| } | |
| /* What to try after `failed` didn't record. When MP4 refuses, another H.264 | |
| profile is very likely to refuse the same way — the whole family goes through | |
| one encoder. Cross to WebM first and only come back to the siblings after, | |
| otherwise three retries get spent inside the family that already failed. */ | |
| function fallbackMimes(failed){ | |
| const fam = m => /mp4|avc1/.test(m) ? "mp4" : "webm"; | |
| const bad = fam(failed); | |
| const rest = mimeChoices().filter(m => m !== failed); | |
| return rest.filter(m => fam(m) !== bad).concat(rest.filter(m => fam(m) === bad)); | |
| } | |
| function shortMime(m){ | |
| if(!m) return "another format"; | |
| if(/avc1|mp4/.test(m)) return "MP4"; | |
| if(/vp9/.test(m)) return "WebM (VP9)"; | |
| if(/vp8/.test(m)) return "WebM (VP8)"; | |
| return "WebM"; | |
| } | |
| function codecLabel(){ | |
| const m = pickMime(); | |
| if(!m) return "none"; | |
| if(/avc1\.64/.test(m)) return "H.264 High"; | |
| if(/avc1\.4D/.test(m)) return "H.264 Main"; | |
| if(/avc1/.test(m)) return "H.264"; | |
| if(/mp4/.test(m)) return "MP4"; | |
| if(/vp9/.test(m)) return "VP9"; | |
| if(/vp8/.test(m)) return "VP8"; | |
| return m; | |
| } | |
| function wantAudio(){ const c=$("keepAudio"); return !c || c.checked; } | |
| /* createMediaElementSource can only ever be called once for a given <video>. | |
| The old version reset audioCtx to null on failure and then tried again on the | |
| next export, where that second call always throws — so one bad run poisoned | |
| audio for the rest of the session and clips came out with no audio track at | |
| all. Once the element is attached, that fact is remembered. */ | |
| let audioAttached=false, audioWhy=""; | |
| function setupAudio(){ | |
| if(audioCtx || audioAttached) return; | |
| try{ | |
| audioCtx = new (window.AudioContext||window.webkitAudioContext)(); | |
| mediaSrc = audioCtx.createMediaElementSource(V); | |
| audioAttached = true; | |
| monitorGain = audioCtx.createGain(); monitorGain.gain.value = 1; | |
| mediaSrc.connect(monitorGain); monitorGain.connect(audioCtx.destination); | |
| streamDest = audioCtx.createMediaStreamDestination(); | |
| mediaSrc.connect(streamDest); | |
| }catch(e){ | |
| audioWhy = e && e.message ? e.message : String(e); | |
| try{ if(audioCtx) audioCtx.close(); }catch(e2){} | |
| audioCtx=null; streamDest=null; monitorGain=null; | |
| } | |
| } | |
| /* records V from `a` to `z` onto the export canvas; resolves with {blob,ext,stalled,speed} */ | |
| /* A clock that keeps ticking when the tab is in the background. | |
| The bug this fixes: the whole export used to run inside requestAnimationFrame. | |
| Chrome stops firing rAF when a tab is hidden (or the window is fully covered), | |
| so the canvas was never repainted and the "we've reached the end, stop now" | |
| check never ran. Result: one frozen video frame plus audio, recording on until | |
| the source video ended. Web Audio runs on a realtime thread the browser does | |
| not throttle, so it drives things now; rAF and a timer are extra safety. */ | |
| function makeClock(fn){ | |
| const stops=[]; | |
| try{ | |
| if(audioCtx && audioCtx.createScriptProcessor){ | |
| const node = audioCtx.createScriptProcessor(1024, 1, 1); // ~21 ms, ~47 Hz | |
| const mute = audioCtx.createGain(); mute.gain.value = 0; | |
| node.onaudioprocess = ()=>fn(); | |
| node.connect(mute); mute.connect(audioCtx.destination); | |
| stops.push(()=>{ node.onaudioprocess=null; try{ node.disconnect(); mute.disconnect(); }catch(e){} }); | |
| } | |
| }catch(e){} | |
| if(typeof requestAnimationFrame === "function"){ | |
| let alive=true, raf=0; | |
| const loop=()=>{ if(!alive) return; fn(); raf=requestAnimationFrame(loop); }; | |
| raf=requestAnimationFrame(loop); | |
| stops.push(()=>{ alive=false; try{ cancelAnimationFrame(raf); }catch(e){} }); | |
| } | |
| const id=setInterval(fn, 60); | |
| stops.push(()=>clearInterval(id)); | |
| return ()=>stops.forEach(f=>{ try{ f(); }catch(e){} }); | |
| } | |
| let wakeLock=null; | |
| async function keepAwake(on){ | |
| try{ | |
| if(on && navigator.wakeLock && !wakeLock){ wakeLock = await navigator.wakeLock.request("screen"); } | |
| else if(!on && wakeLock){ await wakeLock.release(); wakeLock=null; } | |
| }catch(e){ wakeLock=null; } | |
| } | |
| /* ---------------- export log ---------------- | |
| Three attempts at diagnosing an export fault from screenshots got it wrong, | |
| so the export now records what it actually did. "Copy export log" in the | |
| result panel puts it on the clipboard. */ | |
| let xlog=[], xt0=0; | |
| function logStart(){ xlog=[]; xt0=performance.now(); } | |
| function log(msg){ | |
| if(xlog.length>400) return; | |
| xlog.push(((performance.now()-xt0)/1000).toFixed(3).padStart(8)+"s "+msg); | |
| } | |
| function logText(){ | |
| return ["Pocket Video Trimmer v3.6 export log", | |
| "source "+natW+"x"+natH+" @"+Math.round(srcFps)+"fps, duration "+dur.toFixed(2)+"s", | |
| "video element "+V.videoWidth+"x"+V.videoHeight+", readyState "+V.readyState, | |
| "browser "+navigator.userAgent, ""].concat(xlog).join("\n"); | |
| } | |
| /* Did this recording capture anything worth keeping? A single frame in a valid | |
| MP4 wrapper is roughly 2 KB, and its media span is one frame long. */ | |
| function isDud(r){ | |
| if(!r || !r.blob) return true; | |
| if(r.blob.size < 8192) return true; | |
| // 40% of what was asked for, with a floor so genuinely tiny clips aren't | |
| // rejected. Math.min here instead of Math.max let a clip that died after one | |
| // second of six sail through. | |
| if(r.span != null && r.want > 0.5 && r.span < Math.max(0.35, r.want*0.4)) return true; | |
| return false; | |
| } | |
| async function recordRange(a, z, t, mime, ctx, cv, onProg, forceFree){ | |
| // Fixed frame rate stretches (or shortens) the range so it holds a whole | |
| // number of frames, taking the extra from real video after the out point. | |
| const fixed = cfrOn(); | |
| const fit = fixed ? frameFit(a, z) : null; | |
| if(fit) z = Math.min(dur, a + fit.len); | |
| const len = Math.max(0.02, z-a); | |
| // captureStream(0) plus requestFrame() was how fixed frame rate used to work. | |
| // It is the correct API and it is also how clips ended up as a single frame in | |
| // a 2 KB file: when a browser doesn't honour the manual requests there is no | |
| // error, the track simply emits the one frame it started with and stops. The | |
| // canvas now always samples on its own clock, which cannot fail that way. | |
| // Fixed frame rate instead means "paint strictly on the 1/fps grid". | |
| const paced = fixed && !forceFree; | |
| const cStream = cv.captureStream(t.f); | |
| const vtrk = cStream.getVideoTracks()[0]; | |
| // Tell the encoder to favour a sharp picture over smoothing. Measured free: | |
| // +1.0 dB PSNR and it holds the target frame rate better, at the same file size. | |
| try{ if(vtrk) vtrk.contentHint = "detail"; }catch(e){} | |
| const wantFrames = paced && fit ? fit.frames : 0; | |
| let emitted = 0; | |
| const tracks = [...cStream.getVideoTracks()]; | |
| if(streamDest && wantAudio()) tracks.push(...streamDest.stream.getAudioTracks()); | |
| const stream = new MediaStream(tracks); | |
| let rec; | |
| try{ rec = new MediaRecorder(stream, {mimeType:mime, videoBitsPerSecond:t.bitrate, audioBitsPerSecond:192000}); } | |
| catch(e){ rec = new MediaRecorder(stream, {mimeType:mime}); } | |
| const chunks=[]; | |
| let recBytes=0, recErr=""; | |
| rec.ondataavailable = e => { if(e.data && e.data.size){ chunks.push(e.data); recBytes+=e.data.size; } }; | |
| rec.onerror = e => { | |
| const err = (e && e.error) || e; | |
| recErr = (err && (err.name || err.message)) ? ((err.name||"")+" "+(err.message||"")).trim() : "unknown"; | |
| log("rec.onerror: "+recErr); | |
| }; | |
| const stopped = new Promise(r => rec.onstop = ()=>{ log("rec.onstop fired (bytes="+recBytes+")"); r(); }); | |
| const paintFrame = ()=> drawSource(ctx, t.w, t.h); | |
| await seekTo(a); | |
| // Container duration is not always honest — plenty of files (AI-generated | |
| // clips especially) claim more than they hold. Seeking past the real end | |
| // silently lands short, playback fires "ended" straight away, and the recorder | |
| // gets one frame before stopping. That produced a ~2 KB file that the app | |
| // cheerfully saved. Catch it here instead. | |
| log("recordRange a="+a.toFixed(3)+" z="+z.toFixed(3)+" len="+len.toFixed(3) | |
| +" paced="+paced+" wantFrames="+wantFrames+" out="+t.w+"x"+t.h+"@"+t.f | |
| +" mime="+mime+" audioTrack="+(streamDest&&wantAudio()?"yes":"no")); | |
| const landed = V.currentTime; | |
| if(V.ended || landed < a - 0.3){ | |
| try{ cStream.getTracks().forEach(x=>x.stop()); }catch(e){} | |
| return { blob:new Blob([]), ext:(mime.indexOf("mp4")>=0?"mp4":"webm"), stalled:false, speed:1, | |
| aborted:false, blank:false, frames:0, fps:0, span:0, | |
| reason:"the video actually ends at "+tc(landed,false)+", before this clip starts (" | |
| +tc(a,false)+"), even though it reports a length of "+tc(dur,false) }; | |
| } | |
| drawSource(ctx, t.w, t.h); | |
| // Sampled a few times while recording. If every look is pure black but the | |
| // video itself isn't, something is drawing nothing and the clip is a write-off | |
| // — better to say so than to hand over a black file. | |
| let blankLooks=0, blankSeen=0, lastLook=0, dead=false; | |
| let stopClock=null, stopping=false, stalled=false, aborted=false, why=""; | |
| let wall0=0, media0=a, stopAt=z, speed=1, pausedTotal=0, pausedAt=0, hiddenPaused=false; | |
| let lastT=V.currentTime, stallMs=0, prevNow=performance.now(); | |
| const paintFloor = Math.max(16, 1000/Math.max(1,t.f)); | |
| let lastPaint = 0, vfcOn = false; | |
| const maxMs = len*1000*3 + 8000; // a clip can never run away, whatever else breaks | |
| const stop = (immediate)=>{ | |
| const el=(performance.now()-wall0-pausedTotal)/1000; | |
| if(el>0.2) speed=(V.currentTime-media0)/el; | |
| V.pause(); | |
| const finish = ()=>{ | |
| if(stopClock){ stopClock(); stopClock=null; } | |
| try{ rec.stop(); }catch(e){} | |
| }; | |
| // Hold on the last frame briefly before cutting the recorder. Without it the | |
| // final frame gets no screen time and a 10.000 s clip lands at 9.999 s — | |
| // which most players display as 0:09. | |
| if(immediate) finish(); | |
| else setTimeout(finish, Math.max(110, 2000/Math.max(1,t.f))); | |
| }; | |
| // Chrome may stop decoding video for a hidden tab, so recording while hidden | |
| // can't be trusted. Pause instead and pick up where we left off. | |
| const onVis = ()=>{ | |
| if(stopping) return; | |
| if(document.hidden && !hiddenPaused){ | |
| hiddenPaused=true; pausedAt=performance.now(); | |
| try{ V.pause(); }catch(e){} | |
| try{ if(rec.state==="recording") rec.pause(); }catch(e){} | |
| setStatus("Paused — come back to this tab to carry on exporting"); | |
| }else if(!document.hidden && hiddenPaused){ | |
| hiddenPaused=false; pausedTotal += performance.now()-pausedAt; | |
| prevNow=performance.now(); lastPaint=0; | |
| try{ if(rec.state==="paused") rec.resume(); }catch(e){} | |
| V.play().catch(()=>{}); | |
| keepAwake(true); | |
| } | |
| }; | |
| document.addEventListener("visibilitychange", onVis); | |
| const tick = ()=>{ | |
| if(stopping || hiddenPaused) return; | |
| const now=performance.now(); | |
| if(paced){ | |
| // Repaint only when the next slot on the frame grid is due, so the canvas | |
| // holds each frame for its full share of time instead of being redrawn | |
| // whenever the browser happens to ask. | |
| const due = Math.min(wantFrames, Math.floor((now-wall0-pausedTotal)/1000 * t.f) + 1); | |
| if(emitted < due){ paintFrame(); emitted = due; lastPaint = now; } | |
| }else if(!vfcOn || now-lastPaint > paintFloor){ paintFrame(); lastPaint = now; } | |
| // Manual capture asks the canvas for each frame by hand. When a browser | |
| // ignores those requests the track only ever emits the one frame it started | |
| // with, and the export ends up a ~2 KB file holding a single picture. There | |
| // is no error for this, so watch the encoder's output instead: frames going | |
| // out but almost no bytes coming back means the path isn't working. | |
| // Frames going by with almost nothing coming back from the encoder means | |
| // the recording is not happening, whatever the reason. | |
| if(!dead && now-wall0 > 1800 && recBytes < 4096){ | |
| dead=true; stopping=true; why="the encoder returned no data"; | |
| stop(true); return; | |
| } | |
| if(blankLooks<3 && now-wall0>350 && now-lastLook>700){ | |
| lastLook=now; blankLooks++; | |
| if(canvasIsBlank(cv) && !frameIsBlank()) blankSeen++; | |
| } | |
| if(Math.abs(V.currentTime-lastT)>0.001){ lastT=V.currentTime; stallMs=0; } else { stallMs += now-prevNow; } | |
| prevNow=now; | |
| if(onProg) onProg(clamp((V.currentTime-a)/len,0,1), Math.max(0,len-(V.currentTime-a))); | |
| if(cancelExport || (paced && emitted >= wantFrames && V.currentTime >= stopAt) || V.currentTime >= stopAt || V.ended || | |
| stallMs > 10000 || (now-wall0-pausedTotal) > maxMs){ | |
| stopping=true; | |
| log("stopping at t="+V.currentTime.toFixed(3)+" emitted="+emitted+" bytes="+recBytes | |
| +" stallMs="+Math.round(stallMs)); | |
| if(cancelExport) aborted=true; // half a clip is worse than none | |
| if(stallMs>10000 || (now-wall0-pausedTotal)>maxMs) stalled=true; | |
| why = cancelExport ? "stopped by you" | |
| : V.ended ? "the video ended" | |
| : V.currentTime>=stopAt ? "reached the out point" | |
| : stallMs>10000 ? "playback stalled" : "took too long"; | |
| stop(cancelExport || V.ended); | |
| } | |
| }; | |
| const onEnded = ()=>{ if(!stopping){ stopping=true; why="the video ended"; | |
| log("'ended' fired at t="+V.currentTime.toFixed(3)); stop(true); } }; | |
| V.addEventListener("ended", onEnded); | |
| // start playback FIRST, then the recorder — otherwise the frozen first frame | |
| // gets baked into the front of the clip and the output runs long | |
| V.playbackRate = 1; | |
| try{ await V.play(); } | |
| catch(e){ | |
| document.removeEventListener("visibilitychange", onVis); | |
| V.removeEventListener("ended",onEnded); | |
| try{ cStream.getTracks().forEach(x=>x.stop()); }catch(e2){} | |
| throw new Error("Couldn't start playback: "+e.message); | |
| } | |
| // Measure the end point from where the recorder actually began, not from the | |
| // mark. play() resolves a few ms after the video starts moving, and that gap | |
| // used to come straight off the length of the clip. | |
| wall0 = performance.now(); media0 = V.currentTime; | |
| stopAt = Math.min(dur, media0 + len); | |
| log("playing: media0="+media0.toFixed(3)+" stopAt="+stopAt.toFixed(3)+" paused="+V.paused+" ended="+V.ended); | |
| prevNow = wall0; lastPaint = 0; | |
| if(!paced && "requestVideoFrameCallback" in HTMLVideoElement.prototype){ | |
| vfcOn = true; | |
| const vfc = ()=>{ if(stopping) return; paintFrame(); lastPaint=performance.now(); | |
| try{ V.requestVideoFrameCallback(vfc); }catch(e){} }; | |
| try{ V.requestVideoFrameCallback(vfc); }catch(e){ vfcOn=false; } | |
| } | |
| rec.start(200); | |
| log("rec.start: state="+rec.state+" mime="+rec.mimeType+" streamActive="+stream.active | |
| +" tracks="+stream.getTracks().map(x=>x.kind+":"+x.readyState+(x.muted?"/muted":"")).join(",")); | |
| stopClock = makeClock(tick); | |
| try{ | |
| // A recorder that errors, or a stop that never lands, used to leave this | |
| // waiting forever with the clock still ticking and the video still playing. | |
| await Promise.race([stopped, new Promise(r=>setTimeout(r, maxMs+15000))]); | |
| }finally{ | |
| // Whatever happened above, nothing is left running. | |
| if(stopClock){ stopClock(); stopClock=null; } | |
| document.removeEventListener("visibilitychange", onVis); | |
| V.removeEventListener("ended", onEnded); | |
| try{ if(rec.state!=="inactive") rec.stop(); }catch(e){} | |
| try{ V.pause(); }catch(e){} | |
| try{ cStream.getTracks().forEach(x=>x.stop()); }catch(e){} | |
| } | |
| const type = mime.split(";")[0]; | |
| log("finished: span="+(V.currentTime-media0).toFixed(3)+"s of "+len.toFixed(3) | |
| +"s, bytes="+recBytes+", reason="+(why||"?")); | |
| return { blob:new Blob(chunks,{type}), ext:(type.indexOf("mp4")>=0?"mp4":"webm"), stalled, speed, aborted, | |
| blank: blankLooks>0 && blankSeen===blankLooks, | |
| span: Math.max(0, V.currentTime - media0), want: len, | |
| reason: why || (recErr ? "the encoder failed ("+recErr+")" : "the recorder stopped on its own"), | |
| recErr, mime, dead, | |
| usedManual: paced, bytes: recBytes, | |
| frames: paced ? emitted : 0, fps: paced ? t.f : 0 }; | |
| } | |
| /* ---------------- GIF encoder ---------------- | |
| Written out longhand so the file stays self-contained and works offline. | |
| GIF is limited to 256 colours per frame, so each frame gets its own palette | |
| (median cut) and Floyd-Steinberg dithering to stop gradients banding. */ | |
| const _hist=new Int32Array(32768), _rs=new Int32Array(32768), _gs=new Int32Array(32768), _bs=new Int32Array(32768); | |
| const _bins=new Int32Array(32768), _cache=new Int16Array(32768); | |
| function gifBytes(){ | |
| let b=new Uint8Array(1<<18), n=0; | |
| const grow=k=>{ if(n+k<=b.length) return; let L=b.length; while(L<n+k) L*=2; | |
| const t=new Uint8Array(L); t.set(b.subarray(0,n)); b=t; }; | |
| return { | |
| u8(v){ grow(1); b[n++]=v&255; }, | |
| u16(v){ grow(2); b[n++]=v&255; b[n++]=(v>>8)&255; }, | |
| str(s){ grow(s.length); for(let i=0;i<s.length;i++) b[n++]=s.charCodeAt(i); }, | |
| raw(a){ grow(a.length); b.set(a,n); n+=a.length; }, | |
| size(){ return n; }, | |
| out(){ return b.slice(0,n); } | |
| }; | |
| } | |
| function buildPalette(data, maxColors){ | |
| _hist.fill(0); _rs.fill(0); _gs.fill(0); _bs.fill(0); | |
| for(let i=0;i<data.length;i+=4){ | |
| const r=data[i], g=data[i+1], b=data[i+2]; | |
| const k=((r>>3)<<10)|((g>>3)<<5)|(b>>3); | |
| _hist[k]++; _rs[k]+=r; _gs[k]+=g; _bs[k]+=b; | |
| } | |
| let m=0; for(let k=0;k<32768;k++) if(_hist[k]) _bins[m++]=k; | |
| const stat=(s,e)=>{ | |
| let rmin=31,rmax=0,gmin=31,gmax=0,bmin=31,bmax=0; | |
| for(let i=s;i<e;i++){ const k=_bins[i], r=(k>>10)&31, g=(k>>5)&31, b=k&31; | |
| if(r<rmin)rmin=r; if(r>rmax)rmax=r; | |
| if(g<gmin)gmin=g; if(g>gmax)gmax=g; | |
| if(b<bmin)bmin=b; if(b>bmax)bmax=b; } | |
| const dr=rmax-rmin, dg=gmax-gmin, db=bmax-bmin; | |
| let chan=0, ext=dr; | |
| if(dg>ext){ chan=1; ext=dg; } | |
| if(db>ext){ chan=2; ext=db; } | |
| return {s,e,chan,ext}; | |
| }; | |
| let boxes=[stat(0,m)]; | |
| while(boxes.length<maxColors){ | |
| let bi=-1, best=0; | |
| for(let i=0;i<boxes.length;i++){ const B=boxes[i]; | |
| if(B.e-B.s<2) continue; | |
| if(B.ext>best){ best=B.ext; bi=i; } } | |
| if(bi<0) break; | |
| const B=boxes[bi], shift=B.chan===0?10:B.chan===1?5:0; | |
| const sub=Array.from(_bins.subarray(B.s,B.e)); | |
| sub.sort((x,y)=>(((x>>shift)&31)-((y>>shift)&31))); | |
| _bins.set(sub,B.s); | |
| let total=0; for(let i=B.s;i<B.e;i++) total+=_hist[_bins[i]]; | |
| let acc=0, mid=B.s+1; | |
| for(let i=B.s;i<B.e;i++){ acc+=_hist[_bins[i]]; if(acc*2>=total){ mid=i+1; break; } } | |
| if(mid<=B.s) mid=B.s+1; | |
| if(mid>=B.e) mid=B.e-1; | |
| boxes.splice(bi,1,stat(B.s,mid),stat(mid,B.e)); | |
| } | |
| const pal=new Uint8Array(768); | |
| for(let i=0;i<boxes.length;i++){ | |
| const B=boxes[i]; let c=0,R=0,G=0,Bl=0; | |
| for(let x=B.s;x<B.e;x++){ const k=_bins[x]; c+=_hist[k]; R+=_rs[k]; G+=_gs[k]; Bl+=_bs[k]; } | |
| if(c){ pal[i*3]=Math.round(R/c); pal[i*3+1]=Math.round(G/c); pal[i*3+2]=Math.round(Bl/c); } | |
| } | |
| return {pal, count:Math.max(1,boxes.length)}; | |
| } | |
| function mapToPalette(data, w, h, pal, palCount, dither){ | |
| const idx=new Uint8Array(w*h); | |
| _cache.fill(-1); | |
| const nearest=(r,g,b)=>{ | |
| const key=((r>>3)<<10)|((g>>3)<<5)|(b>>3); | |
| const hit=_cache[key]; if(hit>=0) return hit; | |
| let bd=Infinity, bi=0; | |
| for(let i=0;i<palCount;i++){ | |
| const dr=r-pal[i*3], dg=g-pal[i*3+1], db=b-pal[i*3+2]; | |
| const d=dr*dr*3+dg*dg*6+db*db; // roughly luma-weighted | |
| if(d<bd){ bd=d; bi=i; } | |
| } | |
| _cache[key]=bi; return bi; | |
| }; | |
| if(!dither){ | |
| for(let p=0,q=0;q<idx.length;q++,p+=4) idx[q]=nearest(data[p],data[p+1],data[p+2]); | |
| return idx; | |
| } | |
| const buf=new Float32Array(w*h*3); | |
| for(let p=0,q=0;q<w*h;q++,p+=4){ buf[q*3]=data[p]; buf[q*3+1]=data[p+1]; buf[q*3+2]=data[p+2]; } | |
| const cl=v=>v<0?0:(v>255?255:v); | |
| for(let y=0;y<h;y++){ | |
| for(let x=0;x<w;x++){ | |
| const q=y*w+x, o=q*3; | |
| const r=cl(buf[o]), g=cl(buf[o+1]), b=cl(buf[o+2]); | |
| const i=nearest(r|0,g|0,b|0); idx[q]=i; | |
| const er=(r-pal[i*3])*0.75, eg=(g-pal[i*3+1])*0.75, eb=(b-pal[i*3+2])*0.75; | |
| if(x+1<w){ const oo=(q+1)*3; buf[oo]+=er*0.4375; buf[oo+1]+=eg*0.4375; buf[oo+2]+=eb*0.4375; } | |
| if(y+1<h){ | |
| if(x>0){ const oo=(q+w-1)*3; buf[oo]+=er*0.1875; buf[oo+1]+=eg*0.1875; buf[oo+2]+=eb*0.1875; } | |
| { const oo=(q+w)*3; buf[oo]+=er*0.3125; buf[oo+1]+=eg*0.3125; buf[oo+2]+=eb*0.3125; } | |
| if(x+1<w){ const oo=(q+w+1)*3; buf[oo]+=er*0.0625; buf[oo+1]+=eg*0.0625; buf[oo+2]+=eb*0.0625; } | |
| } | |
| } | |
| } | |
| return idx; | |
| } | |
| function lzwWrite(minCodeSize, indices, g){ | |
| const CLEAR=1<<minCodeSize, EOI=CLEAR+1; | |
| let codeSize=minCodeSize+1, next=EOI+1; | |
| let dict=new Map(), cur=0, curBits=0; | |
| const block=[]; | |
| const flush=()=>{ if(block.length){ g.u8(block.length); for(let i=0;i<block.length;i++) g.u8(block[i]); block.length=0; } }; | |
| const emit=code=>{ | |
| cur |= code<<curBits; curBits += codeSize; | |
| while(curBits>=8){ block.push(cur&255); cur>>>=8; curBits-=8; if(block.length===255) flush(); } | |
| }; | |
| g.u8(minCodeSize); | |
| emit(CLEAR); | |
| let prefix=indices[0]; | |
| for(let i=1;i<indices.length;i++){ | |
| const k=indices[i], key=prefix*256+k, found=dict.get(key); | |
| if(found!==undefined){ prefix=found; continue; } | |
| emit(prefix); | |
| if(next===4096){ | |
| emit(CLEAR); dict=new Map(); next=EOI+1; codeSize=minCodeSize+1; | |
| }else{ | |
| // grow the code width BEFORE claiming the code, so the decoder — which | |
| // builds its table one code behind — switches width at the same place | |
| if(next >= (1<<codeSize) && codeSize<12) codeSize++; | |
| dict.set(key,next); next++; | |
| } | |
| prefix=k; | |
| } | |
| emit(prefix); emit(EOI); | |
| if(curBits>0){ block.push(cur&255); if(block.length===255) flush(); } | |
| flush(); | |
| g.u8(0); | |
| } | |
| function bgOn(){ const c=$("bgWork"); return !c || c.checked; } | |
| function whileVisible(){ | |
| if(!document.hidden || bgOn()) return Promise.resolve(); | |
| setStatus("Paused — come back to this tab to carry on"); | |
| return new Promise(res=>{ | |
| const h=()=>{ if(!document.hidden){ document.removeEventListener("visibilitychange",h); res(); } }; | |
| document.addEventListener("visibilitychange",h); | |
| }); | |
| } | |
| /* A yield that survives a hidden tab. setTimeout is clamped to about one second | |
| in background tabs (and once a minute after a few minutes), which would drag a | |
| 64-frame sheet out to over a minute of doing nothing. MessageChannel posts are | |
| not clamped that way, so the loop keeps its normal pace out of sight. */ | |
| const _yieldChan = (typeof MessageChannel === "function") ? new MessageChannel() : null; | |
| let _yieldQueue = []; | |
| if(_yieldChan) _yieldChan.port1.onmessage = ()=>{ const f=_yieldQueue.shift(); if(f) f(); }; | |
| function yieldTick(){ | |
| if(!_yieldChan) return new Promise(r=>setTimeout(r,0)); | |
| return new Promise(r=>{ _yieldQueue.push(r); _yieldChan.port2.postMessage(0); }); | |
| } | |
| /* Browsers are much less keen to freeze a tab that is making sound, so a silent | |
| tone runs for the length of a background-friendly export. It uses its own | |
| context rather than the one wired to the video, which is busy during exports. */ | |
| let bgCtx=null, bgNode=null; | |
| function bgKeepAlive(on){ | |
| try{ | |
| if(on){ | |
| if(bgCtx) return; | |
| // Browsers cap how many AudioContexts a page may hold (Chrome allows six). | |
| // Opening a fresh one per export used to creep towards that ceiling, and | |
| // once it was hit the export's own audio setup threw instead. | |
| if(audioCtx){ bgCtx = audioCtx; bgNode = null; return; } | |
| bgCtx = new (window.AudioContext||window.webkitAudioContext)(); | |
| const g = bgCtx.createGain(); g.gain.value = 0.0001; // inaudible, but not "silence" | |
| bgNode = bgCtx.createOscillator(); bgNode.frequency.value = 20; | |
| bgNode.connect(g); g.connect(bgCtx.destination); bgNode.start(); | |
| if(bgCtx.state === "suspended") bgCtx.resume().catch(()=>{}); | |
| }else if(bgCtx){ | |
| if(bgNode){ try{ bgNode.stop(); }catch(e){} } | |
| if(bgCtx !== audioCtx){ try{ bgCtx.close(); }catch(e){} } // never close the shared one | |
| bgCtx=null; bgNode=null; | |
| } | |
| }catch(e){ bgCtx=null; bgNode=null; } | |
| } | |
| /* Renders by seeking frame by frame rather than playing in real time, so it is | |
| exact and doesn't care how fast the machine is. */ | |
| async function renderGif(a, z, t, ctx, cv, onProg){ | |
| const len=Math.max(0.02, z-a); | |
| const gf = clamp(Math.round(+$("gfps").value||10), 1, 20); | |
| const delay = Math.max(2, Math.round(100/gf)); // GIF delays are in 1/100 s | |
| const fit = fitWithin(t.w, t.h, imgCap()); | |
| const w=fit.w, h=fit.h; | |
| const n=Math.max(1, Math.min(900, Math.round(len*100/delay))); | |
| const g=gifBytes(); | |
| g.str("GIF89a"); g.u16(w); g.u16(h); g.u8(0x70); g.u8(0); g.u8(0); | |
| g.u8(0x21); g.u8(0xFF); g.u8(11); g.str("NETSCAPE2.0"); g.u8(3); g.u8(1); g.u16(0); g.u8(0); // loop forever | |
| let aborted=false, t0=performance.now(); | |
| for(let i=0;i<n;i++){ | |
| if(cancelExport){ aborted=true; break; } | |
| await whileVisible(); | |
| await seekTo(Math.min(z-0.001, a + i*delay/100)); | |
| drawSource(ctx, w, h); | |
| const img=ctx.getImageData(0,0,w,h); | |
| const {pal,count}=buildPalette(img.data, 256); | |
| const idx=mapToPalette(img.data, w, h, pal, count, true); | |
| g.u8(0x21); g.u8(0xF9); g.u8(4); g.u8(0x04); g.u16(delay); g.u8(0); g.u8(0); | |
| g.u8(0x2C); g.u16(0); g.u16(0); g.u16(w); g.u16(h); g.u8(0x87); | |
| g.raw(pal); | |
| lzwWrite(8, idx, g); | |
| if(onProg){ | |
| const per=(performance.now()-t0)/(i+1)/1000; | |
| onProg((i+1)/n, Math.max(0,(n-i-1)*per)); | |
| } | |
| if((i&1)===0) await yieldTick(); // keep the page responsive | |
| } | |
| g.u8(0x3B); | |
| return { blob:new Blob([g.out()],{type:"image/gif"}), ext:"gif", stalled:false, speed:1, aborted, | |
| frames:n, fps:Math.round(1000/delay)/10 }; | |
| } | |
| /* ---------------- snapshot ---------------- | |
| Grabs whatever frame is currently on screen — same crop and output size as | |
| the video/GIF export — and saves it as a PNG. No recording involved, so | |
| it's instant. */ | |
| async function takeSnapshot(){ | |
| if(exporting || !dur) return; | |
| const t = targets(); | |
| const fit = fitWithin(t.w, t.h, imgCap()); | |
| const cv = $("exportCanvas"); cv.width=fit.w; cv.height=fit.h; | |
| const ctx = cv.getContext("2d", {alpha:false}); | |
| ctx.imageSmoothingEnabled=true; ctx.imageSmoothingQuality="high"; | |
| if(frameIsBlank()) await settleFrame(120); | |
| drawSource(ctx, fit.w, fit.h); | |
| cv.toBlob(blob=>{ | |
| if(!blob || !blob.size){ setStatus("Couldn't capture that frame."); return; } | |
| const base = safeName(srcName,60) || "clip"; | |
| const name = base+"_"+stamp(V.currentTime)+"_"+fit.w+"x"+fit.h+".png"; | |
| pushDownload(blob, name); | |
| setStatus("Saved "+name); | |
| }, "image/png"); | |
| } | |
| /* ---------------- extract audio ---------------- | |
| Decodes the file's own audio track and writes the selected span out as a WAV. | |
| WAV rather than MP3 because a browser can't make an MP3 without shipping an | |
| encoder alongside it, and this file stays self-contained and offline. */ | |
| function wavBytes(buf){ | |
| const ch = buf.numberOfChannels, n = buf.length, sr = buf.sampleRate; | |
| const data = new ArrayBuffer(44 + n*ch*2), view = new DataView(data); | |
| const str = (off,t)=>{ for(let i=0;i<t.length;i++) view.setUint8(off+i, t.charCodeAt(i)); }; | |
| str(0,"RIFF"); view.setUint32(4, 36 + n*ch*2, true); str(8,"WAVEfmt "); | |
| view.setUint32(16, 16, true); view.setUint16(20, 1, true); // PCM | |
| view.setUint16(22, ch, true); view.setUint32(24, sr, true); | |
| view.setUint32(28, sr*ch*2, true); view.setUint16(32, ch*2, true); | |
| view.setUint16(34, 16, true); str(36,"data"); view.setUint32(40, n*ch*2, true); | |
| const src = []; | |
| for(let c=0;c<ch;c++) src.push(buf.getChannelData(c)); | |
| let off = 44; | |
| for(let i=0;i<n;i++){ | |
| for(let c=0;c<ch;c++){ | |
| let v = src[c][i]; | |
| v = v<-1 ? -1 : v>1 ? 1 : v; | |
| view.setInt16(off, v<0 ? v*0x8000 : v*0x7FFF, true); | |
| off += 2; | |
| } | |
| } | |
| return new Blob([data], {type:"audio/wav"}); | |
| } | |
| async function extractAudio(){ | |
| if(exporting || !dur) return; | |
| if(!srcFile){ setStatus("Load the video again first — the original file isn't to hand."); return; } | |
| const btn = $("audioBtn"); | |
| btn.disabled = true; btn.textContent = "Reading audio…"; | |
| try{ | |
| const ctx = new (window.OfflineAudioContext||window.webkitOfflineAudioContext)(1, 1, 44100); | |
| const raw = await srcFile.arrayBuffer(); | |
| const full = await ctx.decodeAudioData(raw); | |
| if(!full || !full.length || !full.numberOfChannels) throw new Error("no audio track"); | |
| // Slice the selection out as plain views over the decoded samples — no copy, | |
| // and the WAV writer only ever asks for one channel at a time. | |
| const a = clamp(inT, 0, full.duration), z = clamp(outT, a, full.duration); | |
| const s0 = Math.floor(a*full.sampleRate); | |
| const n = Math.max(1, Math.min(full.length-s0, Math.floor((z-a)*full.sampleRate))); | |
| const out = { | |
| numberOfChannels: full.numberOfChannels, | |
| length: n, | |
| sampleRate: full.sampleRate, | |
| getChannelData: c => full.getChannelData(c).subarray(s0, s0+n) | |
| }; | |
| const blob = wavBytes(out); | |
| const base = safeName(srcName,60) || "clip"; | |
| const name = base+"_"+stamp(a)+"-"+stamp(z)+".wav"; | |
| pushDownload(blob, name); | |
| setStatus("Saved "+name+" · "+bytes(blob.size)); | |
| }catch(e){ | |
| setStatus("Couldn't pull the audio out — this browser may not be able to decode this file's audio track. Exporting a clip with audio still works."); | |
| }finally{ | |
| btn.disabled = false; btn.textContent = "Extract audio only"; | |
| } | |
| } | |
| $("audioBtn").addEventListener("click", extractAudio); | |
| /* ---------------- storyboard ---------------- | |
| Nine frames spread evenly from the start of the selection to its end, laid out | |
| left to right then down, with a 2px gap between them. Built by seeking rather | |
| than playing, so it takes a couple of seconds whatever the clip length. */ | |
| const SB_GAP = 2; | |
| /* One ceiling for every picture export: nothing comes out with a side longer | |
| than this, however big the video or the storyboard grid is. */ | |
| function imgCap(){ return clamp(Math.round(+$("imgMax").value||1920), 64, 8000); } | |
| function fitWithin(w, h, m){ | |
| const s = Math.min(1, m/Math.max(w,h)); | |
| return {w: Math.max(1,Math.round(w*s)), h: Math.max(1,Math.round(h*s)), scale:s}; | |
| } | |
| function sbGrid(){ | |
| const c = clamp(Math.round(+$("sbCols").value||3), 1, 10); | |
| const r = clamp(Math.round(+$("sbRows").value||3), 1, 10); | |
| return {c, r}; | |
| } | |
| function sbCell(t, cols, rows){ | |
| // Size the cells from the space left AFTER the gaps, so the finished sheet | |
| // lands inside the ceiling rather than a few pixels over it. | |
| const m = imgCap(), ar = Math.max(0.01, t.w/t.h); | |
| let cw = Math.min(t.w, 800); | |
| cw = Math.min(cw, Math.floor((m - (cols-1)*SB_GAP) / cols)); | |
| cw = Math.min(cw, Math.floor((m - (rows-1)*SB_GAP) / rows * ar)); | |
| cw = Math.max(8, cw); | |
| let ch = Math.max(8, Math.round(cw/ar)); | |
| for(let i=0;i<4 && rows*ch + (rows-1)*SB_GAP > m && ch > 8;i++){ | |
| ch = Math.max(8, ch - Math.ceil((rows*ch + (rows-1)*SB_GAP - m)/rows)); | |
| cw = Math.max(8, Math.round(ch*ar)); | |
| } | |
| return {cw, ch}; | |
| } | |
| /* A small numbered badge in the top-left of a cell. Drawn in cell-local | |
| coordinates, so it goes on after the frame while the canvas is still | |
| translated to that cell. Sized off the cell so it stays readable on any grid. */ | |
| function drawIndexBadge(g, n, cw, ch){ | |
| const fs = Math.max(9, Math.round(Math.min(cw, ch) * 0.11)); | |
| const pad = Math.max(3, Math.round(fs * 0.38)); | |
| const m = Math.max(3, Math.round(fs * 0.34)); | |
| const txt = String(n); | |
| g.save(); | |
| g.font = "700 " + fs + "px system-ui, -apple-system, Segoe UI, sans-serif"; | |
| g.textBaseline = "top"; | |
| g.textAlign = "left"; | |
| const bw = Math.round(g.measureText(txt).width) + pad*2, bh = fs + pad*2; | |
| g.fillStyle = "rgba(0,0,0,0.62)"; | |
| if(g.roundRect){ g.beginPath(); g.roundRect(m, m, bw, bh, Math.round(fs*0.32)); g.fill(); } | |
| else g.fillRect(m, m, bw, bh); | |
| g.fillStyle = "#ffffff"; | |
| g.fillText(txt, m + pad, m + pad); | |
| g.restore(); | |
| } | |
| /* Both storyboards paint the same kind of sheet — the only difference is where | |
| the frame times come from. */ | |
| async function composeSheet(times, COLS, ROWS, t, cv, onProg, number){ | |
| const {cw, ch} = sbCell(t, COLS, ROWS); | |
| const W = COLS*cw + (COLS-1)*SB_GAP, H = ROWS*ch + (ROWS-1)*SB_GAP; | |
| cv.width = W; cv.height = H; | |
| const g = cv.getContext("2d", {alpha:false}); | |
| g.imageSmoothingEnabled = true; g.imageSmoothingQuality = "high"; | |
| g.fillStyle = "#ffffff"; g.fillRect(0,0,W,H); // shows through as the 2px gaps | |
| const cells = Math.min(times.length, COLS*ROWS); | |
| for(let i=0;i<cells;i++){ | |
| if(cancelExport) return {aborted:true}; | |
| await whileVisible(); | |
| await seekTo(times[i]); | |
| const col = i % COLS, row = (i / COLS) | 0; // across first, then down | |
| g.save(); | |
| g.translate(col*(cw+SB_GAP), row*(ch+SB_GAP)); | |
| drawSource(g, cw, ch); | |
| if(number) drawIndexBadge(g, i+1, cw, ch); | |
| g.restore(); | |
| if(onProg) onProg((i+1)/cells, Math.max(0,(cells-i-1)*0.35)); | |
| await yieldTick(); | |
| } | |
| return { blob: await new Promise(res=>cv.toBlob(res, "image/png")) }; | |
| } | |
| /* evenly spaced from the start of the selection to its end */ | |
| async function renderStory(a, z, t, ctx, cv, onProg){ | |
| const {c:C, r:R} = sbGrid(), count = C*R, span = Math.max(0, z-a); | |
| const times = []; | |
| for(let i=0;i<count;i++) times.push(count>1 ? a + span*i/(count-1) : a); | |
| times[count-1] = Math.min(times[count-1], z-0.001); | |
| const out = await composeSheet(times, C, R, t, cv, onProg, $("sbNum").checked); | |
| return { blob: out.blob || new Blob([]), ext:"png", stalled:false, speed:1, aborted:!!out.aborted }; | |
| } | |
| /* exactly the frames you picked, in the order you picked them */ | |
| async function renderManual(a, z, t, ctx, cv, onProg){ | |
| const {c:C, r:R} = msGrid(); | |
| const out = await composeSheet(snaps.map(x=>x.t), C, R, t, cv, onProg, $("msNum").checked); | |
| return { blob: out.blob || new Blob([]), ext:"png", stalled:false, speed:1, aborted:!!out.aborted }; | |
| } | |
| /* One setting, two places to tick it — keep them agreeing with each other. */ | |
| $("sbNum").addEventListener("change", ()=>{ $("msNum").checked = $("sbNum").checked; }); | |
| $("msNum").addEventListener("change", ()=>{ $("sbNum").checked = $("msNum").checked; }); | |
| /* ---------------- export ---------------- */ | |
| $("exportBtn").addEventListener("click", ()=>doExport("video")); | |
| $("snapBtn").addEventListener("click", takeSnapshot); | |
| $("gifBtn").addEventListener("click", ()=>doExport("gif")); | |
| $("sbBtn").addEventListener("click", ()=>doExport("story")); | |
| $("msBtn").addEventListener("click", ()=>doExport("manual")); | |
| $("stopBtn").addEventListener("click", ()=>{ cancelExport=true; setStatus("Stopping…"); }); | |
| async function doExport(mode){ | |
| if(exporting || !dur) return; | |
| const isGif = mode === "gif"; | |
| const isStory = mode === "story"; | |
| const isManual = mode === "manual"; | |
| if(isManual && !snaps.length){ | |
| setStatus("Add some snapshots first — park the playhead and press + Add snapshot."); | |
| return; | |
| } | |
| sheetKind = isManual ? "manual" : "auto"; | |
| const t = targets(); | |
| const list = isManual ? [{start:inT, end:outT, name:""}] | |
| : exportList().filter(s => s.end - s.start >= 0.05); | |
| if(!list.length){ setStatus("Selection is too short."); return; } | |
| const multi = list.length > 1; | |
| let mime = isGif ? "image/gif" : (isStory||isManual) ? "image/png" : pickMime(); | |
| if(!mime){ setStatus("This browser can't record video. Try Chrome or Edge."); return; } | |
| if(isStory||isManual) lastExt = "png"; | |
| if(isGif){ // GIFs balloon quickly — say so before spending the time | |
| const gfps = Math.min(20, t.f); | |
| const frames = Math.round(list.reduce((x,sg)=>x+(sg.end-sg.start),0) * gfps); | |
| if(t.w*t.h*frames > 90e6 && | |
| !confirm("That's "+frames+" frames at "+t.w+"×"+t.h+", which will make a very large GIF and take a while.\n\nA smaller width or fewer frames per second would help.\n\nCarry on anyway?")) return; | |
| } | |
| // several files → put them in a folder of your choosing (must happen on the click) | |
| let dirHandle = null; | |
| if(multi && window.showDirectoryPicker){ | |
| try{ | |
| dirHandle = await window.showDirectoryPicker({id:"nugget-clips", mode:"readwrite", startIn:"videos"}); | |
| }catch(e){ | |
| if(e && e.name === "AbortError"){ setStatus("Export cancelled — no folder chosen."); return; } | |
| dirHandle = null; // not supported here; fall back to downloads | |
| } | |
| } | |
| logStart(); | |
| log("export mode="+mode+" clips="+list.length+" cfr="+$("cfr").checked+" h3="+$("h3").checked | |
| +" crop="+(crop? JSON.stringify(cropRect()) : "none")); | |
| exporting=true; cancelExport=false; | |
| clearResult(); clearSaved(); | |
| $("failBox").style.display="none"; | |
| $("exportBtn").disabled=true; $("gifBtn").disabled=true; $("snapBtn").disabled=true; | |
| $("sbBtn").disabled=true; $("msBtn").disabled=true; $("audioBtn").disabled=true; | |
| $("exportBtn").textContent = isGif ? "Building GIF…" : (isStory||isManual) ? "Building storyboard…" : "Exporting…"; | |
| $("stopBtn").style.display="block"; | |
| $("expNote").style.display="block"; | |
| $("bar").style.display="block"; $("barI").style.width="0%"; | |
| V.pause(); | |
| const cv=$("exportCanvas"); cv.width=t.w; cv.height=t.h; | |
| const ctx=cv.getContext("2d",{alpha:false}); | |
| ctx.imageSmoothingEnabled=true; ctx.imageSmoothingQuality="high"; | |
| ctx.fillStyle="#000"; ctx.fillRect(0,0,t.w,t.h); | |
| V.style.display="none"; cv.style.display="block"; $("cropLayer").style.display="none"; | |
| setupAudio(); | |
| log("audio: ctx="+(audioCtx?"ok":"none")+" dest="+(streamDest?"ok":"none")+(audioWhy?" err="+audioWhy:"")); | |
| if(audioCtx && audioCtx.state==="suspended"){ try{ await audioCtx.resume(); }catch(e){} } | |
| if(monitorGain) monitorGain.gain.value = 0; | |
| keepAwake(true); | |
| // storyboards and GIFs are seek-driven, so they can carry on out of sight | |
| if((isGif||isStory||isManual) && bgOn()) bgKeepAlive(true); | |
| const total = list.reduce((a,s)=>a+(s.end-s.start),0); | |
| let doneLen = 0, saved = [], slow = false, cut = false, failed = 0, frameCounts = [], blankOut = 0, duds = [], fellBack = false, switchedMime = ""; | |
| try{ | |
| for(let i=0;i<list.length;i++){ | |
| if(cancelExport) break; | |
| const s = list[i]; | |
| const len = s.end - s.start; | |
| const label = multi ? "Clip "+(i+1)+" of "+list.length+" · " : ""; | |
| const onProg = (p, left)=>{ | |
| const overall = (doneLen + p*len)/total; | |
| $("barI").style.width=(overall*100).toFixed(1)+"%"; | |
| setStatus(label+(isGif?"building GIF ":(isStory||isManual)?"grabbing frames ":"recording ")+Math.round(p*100)+"% · about "+Math.ceil(left)+"s left"); | |
| }; | |
| let r = isGif ? await renderGif(s.start, s.end, t, ctx, cv, onProg) | |
| : isManual ? await renderManual(s.start, s.end, t, ctx, cv, onProg) | |
| : isStory ? await renderStory(s.start, s.end, t, ctx, cv, onProg) | |
| : await recordRange(s.start, s.end, t, mime, ctx, cv, onProg); | |
| // A recorder that stops almost immediately leaves a valid-looking file | |
| // holding a single frame — about 2 KB. That used to be saved as a success. | |
| // One retry clears the intermittent version of it; anything still short is | |
| // reported rather than handed over. | |
| // A container the browser claims to support can still refuse to record. | |
| // Work down the list rather than repeating the one that just failed. | |
| if(!isGif && !isStory && !isManual && !cancelExport){ | |
| const others = fallbackMimes(mime); | |
| let tries = 0; | |
| while(!r.aborted && (r.dead || isDud(r)) && tries < 3){ | |
| const nextMime = (r.usedManual && tries===0) ? mime : others.shift(); | |
| if(!nextMime) break; | |
| const dropPacing = r.usedManual; | |
| setStatus(label + (dropPacing | |
| ? "that didn't record — retrying without fixed frame rate…" | |
| : "that didn't record — trying "+shortMime(nextMime)+"…")); | |
| log("retry "+(tries+1)+": mime="+nextMime+" pacing="+(dropPacing?"off":"as set")); | |
| r = await recordRange(s.start, s.end, t, nextMime, ctx, cv, onProg, dropPacing); | |
| if(!isDud(r)){ | |
| if(dropPacing) fellBack = true; | |
| if(nextMime !== mime){ switchedMime = shortMime(nextMime); mime = nextMime; } | |
| } | |
| tries++; | |
| } | |
| } | |
| doneLen += len; | |
| if(r.blank) blankOut++; | |
| if(r.frames) frameCounts.push(r.frames); | |
| if(r.ext) lastExt = r.ext; // may now differ from the first choice | |
| if(r.stalled) cut = true; | |
| if(!r.stalled && r.speed < 0.9) slow = true; | |
| if(r.aborted) break; // stopped part-way through this clip — discard it | |
| if(!r.blob.size || (!isGif && !isStory && !isManual && isDud(r))){ | |
| failed++; | |
| duds.push((s.name || "clip "+(i+1))+" — "+(r.reason || "the recorder stopped straight away") | |
| + (r.span!=null && r.want ? " (captured "+r.span.toFixed(2)+"s of "+r.want.toFixed(2)+"s)" : "")); | |
| continue; // don't save a file with nothing in it | |
| } | |
| const fname = clipFileName(i, s, t, multi); | |
| if(dirHandle){ | |
| setStatus(label+"saving…"); | |
| try{ | |
| const fh = await dirHandle.getFileHandle(fname, {create:true}); | |
| const ws = await fh.createWritable(); | |
| await ws.write(r.blob); await ws.close(); | |
| saved.push(fname+" ("+bytes(r.blob.size)+")"); | |
| }catch(e){ failed++; saved.push(fname+" — could not be written: "+e.message); } | |
| }else if(multi){ | |
| pushDownload(r.blob, fname); | |
| saved.push(fname+" ("+bytes(r.blob.size)+")"); | |
| await new Promise(r2=>setTimeout(r2,400)); // let the browser keep up | |
| }else{ | |
| lastBlob = r.blob; | |
| lastMeta = {w:t.w, h:t.h, f:t.f, at:s.start, name:s.name}; | |
| const url = URL.createObjectURL(lastBlob); | |
| if(isGif || isStory || isManual){ $("outImg").src=url; $("outImg").style.display="block"; $("outVid").style.display="none"; } | |
| else { $("outVid").src=url; $("outVid").style.display="block"; $("outImg").style.display="none"; } | |
| $("result").style.display="block"; | |
| log("saving "+fname+" size="+r.blob.size); | |
| const n = download(); | |
| saved.push((n||fname)+" ("+bytes(r.blob.size)+")"); | |
| } | |
| } | |
| }catch(err){ | |
| setStatus(err.message); | |
| }finally{ | |
| exporting=false; | |
| if(monitorGain) monitorGain.gain.value=1; | |
| cv.style.display="none"; V.style.display="block"; | |
| if(crop){ showCrop(true); layoutCrop(); } | |
| $("exportBtn").disabled=false; $("gifBtn").disabled=false; $("snapBtn").disabled=false; | |
| $("sbBtn").disabled=false; $("msBtn").disabled=false; $("audioBtn").disabled=false; | |
| $("stopBtn").style.display="none"; | |
| $("expNote").style.display="none"; | |
| $("bar").style.display="none"; | |
| keepAwake(false); | |
| bgKeepAlive(false); | |
| try{ V.pause(); }catch(e){} // an export that failed part-way used to | |
| V.currentTime=inT; // leave the video playing on by itself | |
| estimate(); | |
| } | |
| // ---- report ---- | |
| const notes=[]; | |
| if(lastExt==="webm" && !isGif && !isStory && !isManual) notes.push("Saved as .webm — this browser can't record MP4. Plays in Chrome, Edge, Firefox and VLC."); | |
| if(cut) notes.push("Playback stalled on at least one clip, so it may be cut short."); | |
| if(slow && !isGif && !isStory && !isManual) notes.push("Your computer couldn't play the video at full speed, so clips may run slightly slow. Try a smaller output size or lower quality."); | |
| if(failed) notes.push(failed+" clip(s) could not be saved."); | |
| duds.forEach(d=>notes.push("Nothing usable was recorded for "+d+".")); | |
| if(switchedMime) notes.push("This browser accepted the first format and then refused to record it, so the clip " | |
| + "was saved as "+switchedMime+" instead. Nothing else changed."); | |
| if(fellBack) notes.push("Fixed frame rate produced no video in this browser, so the clip was recorded " | |
| + "the normal way instead. The picture and length are right, but the frame timing isn't exact — " | |
| + "which also means the 17n+5 count isn't guaranteed."); | |
| if(wantAudio() && !streamDest && !isGif && !isStory && !isManual) | |
| notes.push("Audio was requested but couldn't be captured"+(audioWhy? " ("+audioWhy+")" : "")+", so the clip is silent."); | |
| if(blankOut) notes.push(blankOut+" clip(s) came out completely black even though the video was playing. " | |
| + "That usually means the crop box didn't line up with the frame, or this browser's encoder rejected " | |
| + t.w+"\u00d7"+t.h+". Try turning the crop off, or an output size that's a multiple of 16."); | |
| if(frameCounts.length && !fellBack){ | |
| const uniq = [...new Set(frameCounts)]; | |
| notes.push("Handed "+uniq.join(" / ")+" frame"+(frameCounts[0]===1?"":"s")+" to the encoder at "+t.f+" fps" | |
| + ($("h3").checked ? " (17n+5)" : "") | |
| + ". Check the finished file if the exact count matters — the browser does the encoding, not us."); | |
| } | |
| if(multi){ | |
| $("saved").style.display = saved.length ? "block" : "none"; | |
| $("savedWhere").textContent = cancelExport | |
| ? "Stopped — "+saved.length+" of "+list.length+" saved" | |
| : "Saved "+saved.length+" clip"+(saved.length===1?"":"s")+(dirHandle? " to “"+dirHandle.name+"”" : " to your Downloads folder"); | |
| $("savedList").innerHTML=""; | |
| saved.forEach(n=>{ const li=document.createElement("li"); li.textContent=n; $("savedList").appendChild(li); }); | |
| if(!dirHandle && saved.length>1) notes.push("Chrome asks permission the first time a page saves several files — choose Allow if you see that prompt."); | |
| $("savedNote").textContent = notes.join(" "); | |
| setStatus(cancelExport ? "Stopped." : "Done — "+saved.length+" clip"+(saved.length===1?"":"s")+"."); | |
| }else{ | |
| $("outNote").textContent = notes.join(" "); | |
| setStatus(cancelExport ? "Stopped." : (saved[0] ? "Saved "+saved[0] : "Nothing was saved.")); | |
| } | |
| // The reasons and the log button used to live inside the results panel, which | |
| // only appears when a file was produced — so a failed export said "Nothing was | |
| // saved." and gave you no way to find out why. They have their own box now. | |
| log("outcome: saved="+saved.length+" failed="+failed+" duds="+duds.length+" blank="+blankOut); | |
| duds.forEach(d=>log("dud: "+d)); | |
| const bad = !cancelExport && !saved.length; | |
| $("failBox").style.display = (bad || duds.length || blankOut) ? "block" : "none"; | |
| $("failNote").textContent = notes.length | |
| ? notes.join(" ") | |
| : "The export produced nothing and didn't say why. The log below has the detail."; | |
| } | |
| function clipFileName(i, s, t, multi){ | |
| const base = safeName(srcName,60) || "clip"; | |
| if(lastExt==="png"){ | |
| const lblS = safeName(s.name,40); | |
| const gg = sheetKind==="manual" ? msGrid() : sbGrid(); | |
| return base+"_storyboard_"+(sheetKind==="manual"?"manual_":"")+gg.c+"x"+gg.r+"_"+(multi? String(i+1).padStart(2,"0")+"_" : "")+(lblS || stamp(s.start))+".png"; | |
| } | |
| if(lastExt==="gif"){ | |
| const lbl0 = safeName(s.name,40); | |
| return base+"_"+(multi? String(i+1).padStart(2,"0")+"_" : "")+(lbl0 || stamp(s.start))+"_"+t.w+"px.gif"; | |
| } | |
| if(!multi) return base+"_"+stamp(s.start)+"_"+t.w+"x"+t.h+"_"+t.f+"fps."+lastExt; | |
| const lbl = safeName(s.name,40); | |
| return base+"_"+String(i+1).padStart(2,"0")+"_"+(lbl || stamp(s.start))+"."+lastExt; | |
| } | |
| function pushDownload(blob, name){ | |
| try{ | |
| const a=document.createElement("a"); | |
| a.href=URL.createObjectURL(blob); a.download=name; a.style.display="none"; | |
| document.body.appendChild(a); a.click(); | |
| setTimeout(()=>{ URL.revokeObjectURL(a.href); a.remove(); }, 12000); | |
| return name; | |
| }catch(e){ return null; } | |
| } | |
| function download(){ | |
| if(!lastBlob || !lastBlob.size) return null; | |
| const m = lastMeta || Object.assign(targets(), {at:inT, name:""}); | |
| const base = safeName(srcName,60) || "clip"; | |
| const lbl = safeName(m.name,40); | |
| const name = lastExt==="png" | |
| ? base+"_storyboard_"+(sheetKind==="manual"?"manual_":"") | |
| +(sheetKind==="manual"?msGrid().c:sbGrid().c)+"x"+(sheetKind==="manual"?msGrid().r:sbGrid().r) | |
| +"_"+(lbl || stamp(m.at))+".png" | |
| : lastExt==="gif" | |
| ? base+"_"+(lbl || stamp(m.at))+"_"+m.w+"px.gif" | |
| : base+"_"+(lbl || stamp(m.at))+"_"+m.w+"x"+m.h+"_"+m.f+"fps."+lastExt; | |
| return pushDownload(lastBlob, name); | |
| } | |
| $("dlBtn").addEventListener("click", ()=>{ download(); }); | |
| $("logBtn").addEventListener("click", async ()=>{ | |
| const txt = logText(); | |
| try{ | |
| await navigator.clipboard.writeText(txt); | |
| setStatus("Export log copied — paste it wherever you need it."); | |
| return; | |
| }catch(e){} | |
| // Clipboard access is refused on pages opened straight from disk in some | |
| // browsers, so fall back to something you can select by hand. | |
| try{ | |
| const ta=document.createElement("textarea"); | |
| ta.value=txt; ta.style.cssText="position:fixed;left:0;top:0;width:100%;height:60vh;z-index:99;font:12px monospace"; | |
| document.body.appendChild(ta); ta.focus(); ta.select(); | |
| const ok=document.execCommand&&document.execCommand("copy"); | |
| if(ok){ ta.remove(); setStatus("Export log copied — paste it wherever you need it."); return; } | |
| setStatus("Select the text above and copy it, then click Copy export log again to dismiss."); | |
| ta.addEventListener("blur", ()=>ta.remove(), {once:true}); | |
| }catch(e2){ | |
| console.log(txt); | |
| setStatus("The log is in the browser console — press F12, open Console, and copy it from there."); | |
| } | |
| }); | |
| $("clearRes").addEventListener("click", clearResult); | |
| function clearResult(){ | |
| lastBlob=null; lastMeta=null; $("result").style.display="none"; | |
| const ov=$("outVid"); if(ov.src){ URL.revokeObjectURL(ov.src); ov.removeAttribute("src"); } | |
| const oi=$("outImg"); if(oi.src){ URL.revokeObjectURL(oi.src); oi.removeAttribute("src"); } | |
| setStatus(""); | |
| } | |
| function clearSaved(){ $("saved").style.display="none"; $("savedList").innerHTML=""; $("savedNote").textContent=""; } | |
| function setStatus(s){ $("status").textContent=s; } | |
| window.__vt = { | |
| get state(){ return {ready:$("app").style.display==="block",loads:loadSeq,dur,inT,outT,natW,natH,srcFps, | |
| exporting,size:lastBlob&&lastBlob.size,ext:lastExt, | |
| clips:segments.map(s=>({start:+s.start.toFixed(3),end:+s.end.toFixed(3),name:s.name}))}; }, | |
| load, doExport, addClip, | |
| gif(){ return doExport("gif"); }, | |
| story(){ return doExport("story"); }, | |
| manual(){ return doExport("manual"); }, | |
| addSnap, snaps(){ return snaps.map(x=>+x.t.toFixed(3)); }, | |
| picMode(v){ picMode=v; syncMode(); }, | |
| set(a,b){ inT=a; outT=b; refresh(); }, | |
| shift(d){ nudge(d); }, | |
| shiftTo(t){ shiftTo(t); }, | |
| activeSeg(){ return activeSeg; }, | |
| pickClip(i){ const sg=segments[i]; if(!sg) return; activeSeg=i; inT=sg.start; outT=sg.end; V.currentTime=sg.start; refresh(); }, | |
| setCrop(x,y,w,h){ setCrop(x,y,w,h); showCrop(true); cropChanged(true); }, | |
| getCrop(){ return crop && {...crop}; }, | |
| setAR(v,l){ applyAR(v, l||String(v)); }, | |
| arLabel(){ return cropARLabel; }, | |
| setName(i,n){ segments[i].name=n; refresh(); }, | |
| clearClips(){ segments=[]; activeSeg=-1; refresh(); } | |
| }; | |
| })(); | |
| </script> | |
| </body> | |
| </html> | |