Video_Tools / Nugget Video Trimmer v1.0.html
PoopMan333's picture
Upload Nugget Video Trimmer v1.0.html
9f950a7 verified
Raw
History Blame
35.8 kB
<!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 &middot; resize &middot; fps &middot; 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">&#9634;</div>
<h2>Drop a video here</h2>
<p>or click to choose a file &nbsp;&middot;&nbsp; MP4, MOV, WebM, MKV &nbsp;&middot;&nbsp; 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">
<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)">&#9654;</button>
<button id="prevF" class="icobtn" title="Previous frame (&larr;)">&#8676;</button>
<button id="nextF" class="icobtn" title="Next frame (&rarr;)">&#8677;</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 &nbsp;<span class="kbd">I</span></button>
<button id="setOut">Set end &nbsp;<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 &nbsp; <span class="kbd">&larr;</span><span class="kbd">&rarr;</span> step 1 frame &nbsp;
<span class="kbd">Shift</span>+<span class="kbd">&larr;</span><span class="kbd">&rarr;</span> step 1 sec &nbsp;
<span class="kbd">I</span> set start &nbsp; <span class="kbd">O</span> set end &nbsp; <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>
<!-- RIGHT -->
<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(){
/* ---------------- 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 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; }
/* ---------------- state ---------------- */
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;
/* ---------------- file loading ---------------- */
const drop=$("drop"), fileInput=$("file");
// the hidden input lives inside #drop, so its own click must not bubble back
// into the drop handler (that reopened the file picker twice)
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. A second handler on #drop meant a drop
// onto the zone bubbled up and 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; }
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";
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){ // 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;
// read the frame rate before handing the UI over, so nothing fights the probe
if(!loadedOnce) drop.innerHTML = '<div class="big">&#9634;</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);
}
/* 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))]);
}
function once(el,ev){ return new Promise(r=>el.addEventListener(ev,r,{once:true})); }
/* ---------------- filmstrip ---------------- */
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; } // a newer video is loading
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");
}
/* ---------------- 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="&#10073;&#10073;");
V.addEventListener("pause",()=>$("playBtn").innerHTML="&#9654;");
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(); });
/* click a Start/End readout to type an exact time: 1:23.4, 0:05, 83.4 or 1:02:03 */
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(); // put the plain text back before redrawing
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 {
// set the length: push the end out, and if it won't fit, pull the start back
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; }
});
/* ---------------- 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(!$("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)+"%";
}
/* ---------------- 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(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(); });
/* ---------------- 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(); });
/* ---------------- quality / estimate ---------------- */
$("q").addEventListener("input", estimate);
function qualityBpp(){
const q=+$("q").value/100; // bits per pixel per frame
return 0.018 * Math.pow(0.20/0.018, q); // 0.018 → 0.20
}
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.";
}
/* ---------------- export ---------------- */
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; // silent while exporting
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);
// 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){ 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}; // name the file after what was actually exported
setStatus("Done — "+bytes(lastBlob.size)+" · "+tc(t.len)+" · "+t.w+"×"+t.h+" · "+t.f+" fps");
const name = download(); // save it without waiting to be asked
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";
// include where the cut starts, so several clips from one video don't collide
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; } // browser blocked it — the Download button is still there
}
$("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>