| <!doctype html> |
| <html lang="en"> |
| <head> |
| <meta charset="utf-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1"> |
| <title>Nugget 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 .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)} |
| .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} |
| .icobtn{padding:7px 11px;font-variant-numeric:tabular-nums} |
|
|
| /* ---------- 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} |
|
|
| .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;background:rgba(0,0,0,.66);backdrop-filter:blur(4px); |
| padding:4px 9px;border-radius:6px;font-size:11.5px;color:#cfe;letter-spacing:.3px} |
|
|
| /* ---------- transport ---------- */ |
| .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:64px;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;object-fit:cover} |
| .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:5px;color:var(--dim);font-size:11px;font-variant-numeric:tabular-nums} |
|
|
| .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} |
|
|
| /* ---------- 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} |
| .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)} |
| .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>Nugget Video Trimmer</h1> |
| <span class="sub">trim · resize · fps · quality</span> |
| <span class="spacer"></span> |
| <button id="newBtn" class="ghost" style="display:none">Load another video</button> |
| <span class="ver">v1.0</span> |
| </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"> |
|
|
| |
| <div> |
| <div class="stage"> |
| <video id="video" playsinline></video> |
| <canvas id="exportCanvas"></canvas> |
| <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="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 class="playhead" id="ph"></div> |
| <div class="handle" id="hIn"></div> |
| <div class="handle" id="hOut"></div> |
| </div> |
| <div class="ruler"><span>0:00</span><span id="rulMid"></span><span id="rulEnd"></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="resetSel" class="ghost" style="padding:5px 10px;font-size:12.5px">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">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> |
| </div> |
| </div> |
|
|
| |
| <div> |
| <div class="card"> |
| <h3>Size</h3> |
| <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> |
|
|
| <hr> |
| <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> |
|
|
| <hr> |
| <h3>Quality</h3> |
| <input type="range" id="q" min="0" max="100" value="55"> |
| <div class="hint" id="qHint"></div> |
|
|
| <div class="est"> |
| <div><span>Output</span><b id="eSize">—</b></div> |
| <div><span>Bitrate</span><b id="eRate">—</b></div> |
| <div><span>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> |
| <div class="bar" id="bar"><i id="barI"></i></div> |
| <div id="status"></div> |
|
|
| <div id="result"> |
| <video id="outVid" controls></video> |
| <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> |
| </div> |
|
|
| </div> |
| </div> |
| </div> |
|
|
| <script> |
| "use strict"; |
| (function(){ |
| |
| |
| const $ = id => document.getElementById(id); |
| const clamp = (v,a,b) => Math.min(b, Math.max(a, v)); |
| function tc(t, frac){ |
| 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 bytes(n){ |
| if(!isFinite(n)) return "—"; |
| if(n < 1024*1024) return (n/1024).toFixed(0)+" KB"; |
| if(n < 1024*1024*1024) return (n/1048576).toFixed(1)+" MB"; |
| return (n/1073741824).toFixed(2)+" GB"; |
| } |
| function even(n){ n = Math.round(n); return n%2 ? n+1 : n; } |
| |
| |
| const V = $("video"); |
| let srcUrl=null, srcName="clip", dur=0, natW=0, natH=0, srcFps=30, hasAudio=true; |
| let inT=0, outT=0, looping=false, dragging=null, exporting=false; |
| let audioCtx=null, mediaSrc=null, monitorGain=null, streamDest=null; |
| let lastBlob=null, lastExt="webm", lastMeta=null; |
| |
| |
| 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")})); |
| |
| |
| 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; |
| let loadedOnce = false; |
| |
| async function load(file){ |
| if(exporting){ alert("Hang on until the export finishes, then load the next video."); return; } |
| const myId = ++loadSeq; |
| const newUrl = URL.createObjectURL(file); |
| const prevUrl = srcUrl; |
| |
| const bail = (msg)=>{ |
| URL.revokeObjectURL(newUrl); |
| if(myId !== loadSeq) return; |
| srcUrl = prevUrl; |
| if(loadedOnce){ |
| 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"; |
| srcUrl = newUrl; |
| clearResult(); |
| 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){ |
| 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); |
| natW = V.videoWidth || 640; natH = V.videoHeight || 360; |
| inT = 0; outT = dur; |
| |
| |
| 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+" — Nugget Video Trimmer"; |
| markScale(1); |
| refresh(); update(); |
| buildStrip(newUrl, myId); |
| } |
| |
| |
| 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); |
| }); |
| 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))]); |
| } |
| function once(el,ev){ return new Promise(r=>el.addEventListener(ev,r,{once:true})); } |
| |
| |
| async function buildStrip(url, id){ |
| const strip=$("strip"); strip.innerHTML=""; |
| const N=12, tw=160, th=90, 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; } |
| for(let i=0;i<N;i++){ |
| if(id !== loadSeq){ v.removeAttribute("src"); return; } |
| const c=document.createElement("canvas"); c.width=tw; c.height=th; |
| 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))]); |
| c.getContext("2d").drawImage(v,0,0,tw,th); |
| }catch(e){} |
| } |
| v.removeAttribute("src"); |
| } |
| |
| |
| $("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); |
| } |
| $("setIn").addEventListener("click", ()=>{ inT=clamp(V.currentTime,0,outT-0.02); refresh(); }); |
| $("setOut").addEventListener("click", ()=>{ outT=clamp(V.currentTime,inT+0.02,dur); refresh(); }); |
| $("resetSel").addEventListener("click", ()=>{ inT=0; outT=dur; refresh(); }); |
| |
| |
| function parseTime(s){ |
| 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); |
| } |
| [["pIn","in"],["pOut","out"],["pDur","dur"]].forEach(([id,which])=>{ |
| $(id).addEventListener("click", function(){ |
| if(exporting || this.querySelector("input")) return; |
| const cur = which==="in" ? inT : which==="out" ? outT : outT-inT; |
| const inp = document.createElement("input"); |
| inp.value = tc(cur); this.textContent=""; this.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)){ |
| if(which==="in"){ inT = clamp(v, 0, outT-0.02); V.currentTime = inT; } |
| else if(which==="out"){ outT = clamp(v, inT+0.02, dur); V.currentTime = outT; } |
| else { |
| |
| 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; |
| } |
| } |
| refresh(); |
| }; |
| inp.addEventListener("blur", ()=>commit(true)); |
| inp.addEventListener("keydown", e=>{ |
| e.stopPropagation(); |
| if(e.key==="Enter"){ e.preventDefault(); commit(true); } |
| if(e.key==="Escape"){ e.preventDefault(); commit(false); } |
| }); |
| }); |
| }); |
| $("loopBtn").addEventListener("click", e=>{ looping=!looping; e.target.textContent="Loop: "+(looping?"on":"off"); e.target.classList.toggle("ghost",!looping); }); |
| |
| 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"){ inT=clamp(V.currentTime,0,outT-0.02); refresh(); } |
| else if(k==="o"||k==="O"){ outT=clamp(V.currentTime,inT+0.02,dur); refresh(); } |
| else if(k==="Home"){ V.currentTime=inT; } |
| else if(k==="End"){ V.currentTime=outT; } |
| }); |
| |
| |
| 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(!$("pIn").querySelector("input")) $("pIn").textContent=tc(inT); |
| if(!$("pOut").querySelector("input")) $("pOut").textContent=tc(outT); |
| if(!$("pDur").querySelector("input")) $("pDur").textContent=tc(outT-inT); |
| estimate(); |
| } |
| function update(){ |
| $("curTime").textContent=tc(V.currentTime); |
| $("ph").style.left=(dur? V.currentTime/dur*100 : 0)+"%"; |
| } |
| |
| |
| 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(natW*s); H.value=even(natH*s); markScale(s); estimate(); |
| }); |
| function markScale(s){ |
| [...$("scaleSeg").children].forEach(b=>b.classList.toggle("on", parseFloat(b.dataset.s)===s)); |
| } |
| W.addEventListener("input", ()=>{ if(lock.checked && natW) H.value=even(W.value*natH/natW); markScale(null); estimate(); }); |
| H.addEventListener("input", ()=>{ if(lock.checked && natH) W.value=even(H.value*natW/natH); markScale(null); estimate(); }); |
| |
| |
| 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(); }); |
| |
| |
| $("q").addEventListener("input", estimate); |
| function qualityBpp(){ |
| const q=+$("q").value/100; |
| return 0.018 * Math.pow(0.20/0.018, q); |
| } |
| function targets(){ |
| const w=even(clamp(+W.value||natW,16,7680)), h=even(clamp(+H.value||natH,16,4320)); |
| const f=clamp(+$("fps").value||Math.round(srcFps),1,120); |
| const bitrate=Math.round(clamp(w*h*f*qualityBpp(), 120000, 60000000)); |
| return {w,h,f,bitrate,len:Math.max(0,outT-inT)}; |
| } |
| function estimate(){ |
| const t=targets(); |
| $("eSize").textContent = t.w+"×"+t.h; |
| $("eRate").textContent = (t.bitrate/1e6).toFixed(2)+" Mbps"; |
| $("eBytes").textContent = bytes(t.bitrate*t.len/8 + 128000*t.len/8); |
| $("eTime").textContent = t.len<1 ? "—" : (t.len<60? Math.ceil(t.len)+" s" : Math.ceil(t.len/60)+" min"); |
| const q=+$("q").value; |
| $("qHint").textContent = (q<25?"Small file — fine for quick previews" |
| : q<50?"Balanced — good for email and chat" |
| : q<78?"High — good for web and presentations" |
| : "Maximum — largest file, closest to the original"); |
| $("sizeHint").textContent = "Source "+natW+"×"+natH+". Width and height are rounded to even numbers."; |
| } |
| |
| |
| function pickMime(){ |
| const c=["video/mp4;codecs=avc1.42E01E,mp4a.40.2","video/mp4","video/webm;codecs=vp9,opus","video/webm;codecs=vp8,opus","video/webm"]; |
| for(const m of c) if(window.MediaRecorder && MediaRecorder.isTypeSupported(m)) return m; |
| return ""; |
| } |
| function setupAudio(){ |
| if(audioCtx) return; |
| try{ |
| audioCtx = new (window.AudioContext||window.webkitAudioContext)(); |
| mediaSrc = audioCtx.createMediaElementSource(V); |
| monitorGain = audioCtx.createGain(); monitorGain.gain.value = 1; |
| mediaSrc.connect(monitorGain); monitorGain.connect(audioCtx.destination); |
| streamDest = audioCtx.createMediaStreamDestination(); |
| mediaSrc.connect(streamDest); |
| }catch(e){ audioCtx=null; } |
| } |
| |
| $("exportBtn").addEventListener("click", doExport); |
| |
| async function doExport(){ |
| if(exporting || !dur) return; |
| const t=targets(); |
| if(t.len < 0.05){ setStatus("Selection is too short."); return; } |
| const mime=pickMime(); |
| if(!mime){ setStatus("This browser can't record video. Try Chrome or Edge."); return; } |
| |
| exporting=true; clearResult(); |
| $("exportBtn").disabled=true; $("exportBtn").textContent="Exporting…"; |
| $("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.fillStyle="#000"; ctx.fillRect(0,0,t.w,t.h); |
| V.style.display="none"; cv.style.display="block"; |
| |
| setupAudio(); |
| if(audioCtx && audioCtx.state==="suspended"){ try{ await audioCtx.resume(); }catch(e){} } |
| if(monitorGain) monitorGain.gain.value = 0; |
| |
| const cStream = cv.captureStream(t.f); |
| const tracks = [...cStream.getVideoTracks()]; |
| if(streamDest) tracks.push(...streamDest.stream.getAudioTracks()); |
| const stream = new MediaStream(tracks); |
| |
| let rec; |
| try{ |
| rec = new MediaRecorder(stream, {mimeType:mime, videoBitsPerSecond:t.bitrate, audioBitsPerSecond:128000}); |
| }catch(e){ |
| try{ rec = new MediaRecorder(stream, {mimeType:mime}); } |
| catch(e2){ finishUI(); setStatus("Recorder failed to start: "+e2.message); return; } |
| } |
| |
| const chunks=[]; |
| rec.ondataavailable = e => { if(e.data && e.data.size) chunks.push(e.data); }; |
| const stopped = new Promise(r => rec.onstop = r); |
| |
| V.currentTime = inT; |
| await Promise.race([once(V,"seeked"), new Promise(r=>setTimeout(r,3000))]); |
| ctx.drawImage(V,0,0,t.w,t.h); |
| let wall0=0, media0=inT, realSpeed=1; |
| |
| let raf=0, stopping=false, lastT=V.currentTime, stallMs=0, prevNow=performance.now(); |
| const paint = () => { |
| ctx.drawImage(V,0,0,t.w,t.h); |
| const now=performance.now(); |
| if(Math.abs(V.currentTime-lastT) > 0.001){ lastT=V.currentTime; stallMs=0; } else { stallMs += now-prevNow; } |
| prevNow=now; |
| const p = clamp((V.currentTime-inT)/t.len,0,1); |
| $("barI").style.width=(p*100).toFixed(1)+"%"; |
| const left = Math.max(0, t.len-(V.currentTime-inT)); |
| setStatus("Recording… "+Math.round(p*100)+"% · about "+Math.ceil(left)+"s left"); |
| if(!stopping && (V.currentTime >= outT-0.005 || V.ended || stallMs > 10000)){ |
| stopping=true; if(stallMs>10000) stalled=true; stop(); return; |
| } |
| raf=requestAnimationFrame(paint); |
| }; |
| let stalled=false; |
| function stop(){ |
| cancelAnimationFrame(raf); |
| const el = (performance.now()-wall0)/1000; |
| if(el>0.2) realSpeed = (V.currentTime-media0)/el; |
| V.pause(); |
| try{ rec.stop(); }catch(e){} |
| } |
| const onEnded = ()=>{ if(!stopping){ stopping=true; stop(); } }; |
| V.addEventListener("ended", onEnded); |
| |
| |
| |
| V.playbackRate = 1; |
| try{ await V.play(); } |
| catch(e){ finishUI(); setStatus("Couldn't start playback: "+e.message); return; } |
| wall0 = performance.now(); media0 = V.currentTime; |
| rec.start(200); |
| raf=requestAnimationFrame(paint); |
| |
| await stopped; |
| V.removeEventListener("ended", onEnded); |
| cStream.getTracks().forEach(tr=>tr.stop()); |
| |
| const type = mime.split(";")[0]; |
| lastExt = type.indexOf("mp4")>=0 ? "mp4" : "webm"; |
| lastBlob = new Blob(chunks,{type}); |
| finishUI(); |
| |
| if(!lastBlob.size){ setStatus("Export produced an empty file — try a different quality setting."); return; } |
| $("outVid").src = URL.createObjectURL(lastBlob); |
| $("result").style.display="block"; |
| |
| const notes=[]; |
| if(lastExt==="webm") notes.push("Saved as .webm — this browser can't record MP4. Plays in Chrome, Edge, Firefox and VLC."); |
| if(stalled) notes.push("Playback stalled, so the clip may be cut short."); |
| else if(realSpeed < 0.9) notes.push("Your computer couldn't play the video at full speed ("+Math.round(realSpeed*100)+"%), so the clip may run slightly slow. Try a smaller output size or lower quality."); |
| $("outNote").textContent = notes.join(" "); |
| lastMeta = {w:t.w, h:t.h, f:t.f, at:inT}; |
| setStatus("Done — "+bytes(lastBlob.size)+" · "+tc(t.len)+" · "+t.w+"×"+t.h+" · "+t.f+" fps"); |
| |
| const name = download(); |
| if(name) setStatus("Saved "+name+" — "+bytes(lastBlob.size)+" · "+tc(t.len)+" · "+t.w+"×"+t.h+" · "+t.f+" fps"); |
| |
| function finishUI(){ |
| exporting=false; |
| if(monitorGain) monitorGain.gain.value=1; |
| cv.style.display="none"; V.style.display="block"; |
| $("exportBtn").disabled=false; $("exportBtn").textContent="Export clip"; |
| $("bar").style.display="none"; |
| V.currentTime=inT; |
| } |
| } |
| |
| function download(){ |
| if(!lastBlob || !lastBlob.size) return null; |
| const m = lastMeta || Object.assign(targets(), {at:inT}); |
| const safe = srcName.replace(/[\\/:*?"<>|]+/g,"-").slice(0,80) || "clip"; |
| |
| const at = Math.floor((m.at||0)/60)+"m"+String(Math.floor((m.at||0)%60)).padStart(2,"0")+"s"; |
| const name = safe+"_"+at+"_"+m.w+"x"+m.h+"_"+m.f+"fps."+lastExt; |
| try{ |
| const a=document.createElement("a"); |
| a.href=URL.createObjectURL(lastBlob); |
| a.download=name; a.style.display="none"; |
| document.body.appendChild(a); |
| a.click(); |
| setTimeout(()=>{ URL.revokeObjectURL(a.href); a.remove(); }, 8000); |
| return name; |
| }catch(e){ return null; } |
| } |
| $("dlBtn").addEventListener("click", ()=>{ download(); }); |
| $("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"); } |
| setStatus(""); |
| } |
| 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}; }, |
| load, doExport, set(a,b){inT=a;outT=b;refresh();} }; |
| })(); |
| </script> |
| </body> |
| </html> |
|
|