PoopMan333 commited on
Commit
9f950a7
·
verified ·
1 Parent(s): 54c8c47

Upload Nugget Video Trimmer v1.0.html

Browse files
Files changed (1) hide show
  1. Nugget Video Trimmer v1.0.html +793 -0
Nugget Video Trimmer v1.0.html ADDED
@@ -0,0 +1,793 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Nugget Video Trimmer</title>
7
+ <style>
8
+ :root{
9
+ --bg:#0e1116; --panel:#161b22; --panel2:#1c232d; --line:#2a323d;
10
+ --txt:#e6edf3; --dim:#8b98a5; --accent:#4cc2ff; --accent2:#2a9fd6;
11
+ --ok:#3fb950; --warn:#d29922; --danger:#f85149;
12
+ --r:10px;
13
+ }
14
+ *{box-sizing:border-box}
15
+ html,body{margin:0;height:100%}
16
+ body{
17
+ background:var(--bg); color:var(--txt);
18
+ font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
19
+ -webkit-font-smoothing:antialiased;
20
+ }
21
+ .wrap{max-width:1180px;margin:0 auto;padding:20px 20px 60px}
22
+ header{display:flex;align-items:center;gap:12px;margin-bottom:18px}
23
+ header h1{font-size:17px;margin:0;font-weight:600;letter-spacing:.2px}
24
+ header .sub{color:var(--dim);font-size:12.5px}
25
+ header .ver{color:var(--dim);font-size:12px;font-variant-numeric:tabular-nums;
26
+ border:1px solid var(--line);border-radius:99px;padding:3px 10px;background:var(--panel)}
27
+ .spacer{flex:1}
28
+
29
+ button{
30
+ font:inherit;color:var(--txt);background:var(--panel2);border:1px solid var(--line);
31
+ padding:7px 13px;border-radius:8px;cursor:pointer;transition:.12s;
32
+ }
33
+ button:hover:not(:disabled){background:#242d39;border-color:#3a4553}
34
+ button:disabled{opacity:.4;cursor:not-allowed}
35
+ button.primary{background:var(--accent);border-color:var(--accent);color:#04121c;font-weight:600}
36
+ button.primary:hover:not(:disabled){background:#6bcfff}
37
+ button.ghost{background:transparent}
38
+ .icobtn{padding:7px 11px;font-variant-numeric:tabular-nums}
39
+
40
+ /* ---------- drop zone ---------- */
41
+ #drop{
42
+ border:2px dashed var(--line);border-radius:14px;padding:64px 24px;text-align:center;
43
+ background:var(--panel);cursor:pointer;transition:.15s;
44
+ }
45
+ #drop:hover,#drop.over{border-color:var(--accent);background:#12212b}
46
+ #drop h2{margin:0 0 6px;font-size:18px;font-weight:600}
47
+ #drop p{margin:0;color:var(--dim);font-size:13px}
48
+ #drop .big{font-size:38px;line-height:1;margin-bottom:14px;opacity:.65}
49
+
50
+ /* ---------- layout ---------- */
51
+ #app{display:none}
52
+ .grid{display:grid;grid-template-columns:1fr 316px;gap:18px;align-items:start}
53
+ @media(max-width:900px){.grid{grid-template-columns:1fr}}
54
+
55
+ .card{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);padding:14px}
56
+ .card h3{margin:0 0 12px;font-size:12px;text-transform:uppercase;letter-spacing:.7px;color:var(--dim);font-weight:600}
57
+
58
+ .stage{background:#000;border-radius:var(--r);overflow:hidden;display:flex;align-items:center;justify-content:center;position:relative;border:1px solid var(--line)}
59
+ .stage video,.stage canvas{max-width:100%;max-height:56vh;display:block}
60
+ #exportCanvas{display:none}
61
+ .badge{position:absolute;top:10px;left:10px;background:rgba(0,0,0,.66);backdrop-filter:blur(4px);
62
+ padding:4px 9px;border-radius:6px;font-size:11.5px;color:#cfe;letter-spacing:.3px}
63
+
64
+ /* ---------- transport ---------- */
65
+ .transport{display:flex;align-items:center;gap:8px;margin-top:12px;flex-wrap:wrap}
66
+ .tc{font-variant-numeric:tabular-nums;font-size:13px;color:var(--dim)}
67
+ .tc b{color:var(--txt);font-weight:600}
68
+
69
+ /* ---------- timeline ---------- */
70
+ .tl-wrap{margin-top:14px;user-select:none}
71
+ .tl{position:relative;height:64px;background:var(--panel2);border:1px solid var(--line);border-radius:8px;overflow:hidden;touch-action:none;cursor:pointer}
72
+ #strip{position:absolute;inset:0;display:flex;opacity:.9}
73
+ #strip canvas{height:100%;flex:1 1 0;min-width:0;object-fit:cover}
74
+ .mask{position:absolute;top:0;bottom:0;background:rgba(6,9,13,.72);pointer-events:none}
75
+ .sel{position:absolute;top:0;bottom:0;border-left:2px solid var(--accent);border-right:2px solid var(--accent);
76
+ box-shadow:inset 0 0 0 1px rgba(76,194,255,.25);pointer-events:none}
77
+ .handle{position:absolute;top:0;bottom:0;width:16px;margin-left:-8px;cursor:ew-resize;touch-action:none;z-index:5}
78
+ .handle::after{content:"";position:absolute;left:5px;top:50%;transform:translateY(-50%);
79
+ width:6px;height:26px;border-radius:3px;background:var(--accent);box-shadow:0 0 0 1px rgba(0,0,0,.4)}
80
+ .handle:hover::after{background:#8bd8ff}
81
+ .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)}
82
+ .ruler{display:flex;justify-content:space-between;margin-top:5px;color:var(--dim);font-size:11px;font-variant-numeric:tabular-nums}
83
+
84
+ .marks{display:flex;gap:8px;margin-top:12px;flex-wrap:wrap;align-items:center}
85
+ .pill{background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:6px 10px;font-size:12.5px;font-variant-numeric:tabular-nums}
86
+ .pill span{color:var(--dim)}
87
+ .pill b{font-weight:600}
88
+ .pill.dur b{color:var(--accent)}
89
+ .edit{cursor:text;border-bottom:1px dotted #4a5663;padding-bottom:1px}
90
+ .edit:hover{border-bottom-color:var(--accent);color:var(--accent)}
91
+ .edit input{width:88px;padding:0 3px;border:1px solid var(--accent);border-radius:4px;
92
+ background:#0b0f14;color:var(--txt);font:inherit;font-weight:600;text-align:center}
93
+
94
+ /* ---------- settings ---------- */
95
+ .row{display:flex;align-items:center;gap:8px;margin-bottom:10px}
96
+ .row label{flex:0 0 78px;color:var(--dim);font-size:12.5px}
97
+ input[type=text],input[type=number],select{
98
+ font:inherit;background:var(--panel2);color:var(--txt);border:1px solid var(--line);
99
+ border-radius:7px;padding:6px 8px;width:100%;min-width:0;
100
+ }
101
+ input:focus,select:focus{outline:none;border-color:var(--accent)}
102
+ input[type=range]{width:100%;accent-color:var(--accent)}
103
+ .seg{display:flex;gap:5px;flex-wrap:wrap;margin-bottom:10px}
104
+ .seg button{padding:5px 9px;font-size:12.5px;border-radius:7px}
105
+ .seg button.on{background:var(--accent);border-color:var(--accent);color:#04121c;font-weight:600}
106
+ .hint{color:var(--dim);font-size:11.5px;margin:-4px 0 12px}
107
+ .chk{display:flex;align-items:center;gap:7px;color:var(--dim);font-size:12.5px;margin-bottom:10px;cursor:pointer}
108
+ hr{border:none;border-top:1px solid var(--line);margin:14px 0}
109
+
110
+ .est{background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:9px 11px;font-size:12.5px;margin-bottom:12px}
111
+ .est div{display:flex;justify-content:space-between;margin:2px 0}
112
+ .est span{color:var(--dim)}
113
+ .est b{font-variant-numeric:tabular-nums;font-weight:600}
114
+
115
+ #exportBtn{width:100%;padding:11px}
116
+ .bar{height:6px;background:var(--panel2);border-radius:99px;overflow:hidden;margin-top:10px;display:none}
117
+ .bar i{display:block;height:100%;width:0;background:var(--accent);transition:width .2s}
118
+ #status{font-size:12px;color:var(--dim);margin-top:8px;text-align:center;min-height:16px}
119
+
120
+ #result{display:none;margin-top:14px}
121
+ #result video{width:100%;border-radius:8px;background:#000;border:1px solid var(--line)}
122
+ .note{font-size:11.5px;color:var(--warn);margin-top:8px;line-height:1.4}
123
+ .kbd{display:inline-block;background:var(--panel2);border:1px solid var(--line);border-bottom-width:2px;
124
+ border-radius:5px;padding:0 5px;font-size:11px;font-family:ui-monospace,Menlo,Consolas,monospace}
125
+ .keys{color:var(--dim);font-size:11.5px;margin-top:12px;line-height:1.9}
126
+ </style>
127
+ </head>
128
+ <body>
129
+ <div class="wrap">
130
+
131
+ <header>
132
+ <h1>Nugget Video Trimmer</h1>
133
+ <span class="sub">trim &middot; resize &middot; fps &middot; quality</span>
134
+ <span class="spacer"></span>
135
+ <button id="newBtn" class="ghost" style="display:none">Load another video</button>
136
+ <span class="ver">v1.0</span>
137
+ </header>
138
+
139
+ <div id="drop">
140
+ <div class="big">&#9634;</div>
141
+ <h2>Drop a video here</h2>
142
+ <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>
143
+ <input type="file" id="file" accept="video/*" hidden>
144
+ </div>
145
+
146
+ <div id="app">
147
+ <div class="grid">
148
+
149
+ <!-- LEFT -->
150
+ <div>
151
+ <div class="stage">
152
+ <video id="video" playsinline></video>
153
+ <canvas id="exportCanvas"></canvas>
154
+ <div class="badge" id="srcBadge"></div>
155
+ </div>
156
+
157
+ <div class="transport">
158
+ <button id="playBtn" class="icobtn" title="Play / pause (Space)">&#9654;</button>
159
+ <button id="prevF" class="icobtn" title="Previous frame (&larr;)">&#8676;</button>
160
+ <button id="nextF" class="icobtn" title="Next frame (&rarr;)">&#8677;</button>
161
+ <span class="tc"><b id="curTime">0:00.00</b> / <span id="totTime">0:00.00</span></span>
162
+ <span class="spacer"></span>
163
+ <button id="setIn">Set start &nbsp;<span class="kbd">I</span></button>
164
+ <button id="setOut">Set end &nbsp;<span class="kbd">O</span></button>
165
+ <button id="loopBtn" class="ghost" title="Loop the selection">Loop: off</button>
166
+ </div>
167
+
168
+ <div class="tl-wrap">
169
+ <div class="tl" id="tl">
170
+ <div id="strip"></div>
171
+ <div class="mask" id="maskL"></div>
172
+ <div class="mask" id="maskR"></div>
173
+ <div class="sel" id="sel"></div>
174
+ <div class="playhead" id="ph"></div>
175
+ <div class="handle" id="hIn"></div>
176
+ <div class="handle" id="hOut"></div>
177
+ </div>
178
+ <div class="ruler"><span>0:00</span><span id="rulMid"></span><span id="rulEnd"></span></div>
179
+
180
+ <div class="marks">
181
+ <span class="pill"><span>Start</span> <b id="pIn" class="edit" title="Click to type an exact time">0:00.00</b></span>
182
+ <span class="pill"><span>End</span> <b id="pOut" class="edit" title="Click to type an exact time">0:00.00</b></span>
183
+ <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>
184
+ <button id="resetSel" class="ghost" style="padding:5px 10px;font-size:12.5px">Reset to full clip</button>
185
+ </div>
186
+
187
+ <div class="keys">
188
+ <span class="kbd">Space</span> play/pause &nbsp; <span class="kbd">&larr;</span><span class="kbd">&rarr;</span> step 1 frame &nbsp;
189
+ <span class="kbd">Shift</span>+<span class="kbd">&larr;</span><span class="kbd">&rarr;</span> step 1 sec &nbsp;
190
+ <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
191
+ <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>).
192
+ </div>
193
+ </div>
194
+ </div>
195
+
196
+ <!-- RIGHT -->
197
+ <div>
198
+ <div class="card">
199
+ <h3>Size</h3>
200
+ <div class="seg" id="scaleSeg">
201
+ <button data-s="1">100%</button>
202
+ <button data-s="0.75">75%</button>
203
+ <button data-s="0.5">50%</button>
204
+ <button data-s="0.25">25%</button>
205
+ </div>
206
+ <div class="row">
207
+ <label>Width</label><input type="number" id="w" min="16" step="2">
208
+ </div>
209
+ <div class="row">
210
+ <label>Height</label><input type="number" id="h" min="16" step="2">
211
+ </div>
212
+ <label class="chk"><input type="checkbox" id="lockAr" checked> Lock aspect ratio</label>
213
+ <div class="hint" id="sizeHint"></div>
214
+
215
+ <hr>
216
+ <h3>Frame rate</h3>
217
+ <div class="seg" id="fpsSeg"></div>
218
+ <div class="row">
219
+ <label>Custom</label><input type="number" id="fps" min="1" max="120" step="1">
220
+ </div>
221
+ <div class="hint">Higher than the source frame rate won't add detail.</div>
222
+
223
+ <hr>
224
+ <h3>Quality</h3>
225
+ <input type="range" id="q" min="0" max="100" value="55">
226
+ <div class="hint" id="qHint"></div>
227
+
228
+ <div class="est">
229
+ <div><span>Output</span><b id="eSize">—</b></div>
230
+ <div><span>Bitrate</span><b id="eRate">—</b></div>
231
+ <div><span>Est. file size</span><b id="eBytes">—</b></div>
232
+ <div><span>Export takes about</span><b id="eTime">—</b></div>
233
+ </div>
234
+
235
+ <button id="exportBtn" class="primary">Export clip</button>
236
+ <div class="bar" id="bar"><i id="barI"></i></div>
237
+ <div id="status"></div>
238
+
239
+ <div id="result">
240
+ <video id="outVid" controls></video>
241
+ <div style="display:flex;gap:8px;margin-top:10px">
242
+ <button id="dlBtn" class="primary" style="flex:1">Download again</button>
243
+ <button id="clearRes" class="ghost">Clear</button>
244
+ </div>
245
+ <div class="note" id="outNote"></div>
246
+ </div>
247
+ </div>
248
+ </div>
249
+
250
+ </div>
251
+ </div>
252
+ </div>
253
+
254
+ <script>
255
+ "use strict";
256
+ (function(){
257
+
258
+ /* ---------------- helpers ---------------- */
259
+ const $ = id => document.getElementById(id);
260
+ const clamp = (v,a,b) => Math.min(b, Math.max(a, v));
261
+ function tc(t, frac){ // 1:23.45 / 1:05:03.20
262
+ if(!isFinite(t)||t<0) t=0;
263
+ const h = Math.floor(t/3600);
264
+ const m = Math.floor((t-h*3600)/60), s = t - h*3600 - m*60;
265
+ const mm = h ? String(m).padStart(2,"0") : String(m);
266
+ const hh = h ? h+":" : "";
267
+ return frac===false ? hh+mm+":"+String(Math.floor(s)).padStart(2,"0")
268
+ : hh+mm+":"+(s<10?"0":"")+s.toFixed(2);
269
+ }
270
+ function bytes(n){
271
+ if(!isFinite(n)) return "—";
272
+ if(n < 1024*1024) return (n/1024).toFixed(0)+" KB";
273
+ if(n < 1024*1024*1024) return (n/1048576).toFixed(1)+" MB";
274
+ return (n/1073741824).toFixed(2)+" GB";
275
+ }
276
+ function even(n){ n = Math.round(n); return n%2 ? n+1 : n; }
277
+
278
+ /* ---------------- state ---------------- */
279
+ const V = $("video");
280
+ let srcUrl=null, srcName="clip", dur=0, natW=0, natH=0, srcFps=30, hasAudio=true;
281
+ let inT=0, outT=0, looping=false, dragging=null, exporting=false;
282
+ let audioCtx=null, mediaSrc=null, monitorGain=null, streamDest=null;
283
+ let lastBlob=null, lastExt="webm", lastMeta=null;
284
+
285
+ /* ---------------- file loading ---------------- */
286
+ const drop=$("drop"), fileInput=$("file");
287
+ // the hidden input lives inside #drop, so its own click must not bubble back
288
+ // into the drop handler (that reopened the file picker twice)
289
+ fileInput.addEventListener("click", e=>e.stopPropagation());
290
+ drop.addEventListener("click", ()=>fileInput.click());
291
+ fileInput.addEventListener("change", e=>{ if(e.target.files[0]) load(e.target.files[0]); e.target.value=""; });
292
+ ["dragenter","dragover"].forEach(ev=>drop.addEventListener(ev,e=>{e.preventDefault();drop.classList.add("over")}));
293
+ ["dragleave","drop"].forEach(ev=>drop.addEventListener(ev,e=>{e.preventDefault();drop.classList.remove("over")}));
294
+ // ONE drop handler only, on the window. A second handler on #drop meant a drop
295
+ // onto the zone bubbled up and started the load twice.
296
+ window.addEventListener("dragover", e=>e.preventDefault());
297
+ window.addEventListener("drop", e=>{
298
+ e.preventDefault();
299
+ drop.classList.remove("over");
300
+ const f = e.dataTransfer && e.dataTransfer.files[0];
301
+ if(f) load(f);
302
+ });
303
+ $("newBtn").addEventListener("click", ()=>fileInput.click());
304
+
305
+ const DROP_HTML = drop.innerHTML;
306
+ let loadSeq = 0; // only the newest load() is allowed to touch the UI
307
+ let loadedOnce = false;
308
+
309
+ async function load(file){
310
+ if(exporting){ alert("Hang on until the export finishes, then load the next video."); return; }
311
+ const myId = ++loadSeq;
312
+ const newUrl = URL.createObjectURL(file);
313
+ const prevUrl = srcUrl;
314
+
315
+ const bail = (msg)=>{
316
+ URL.revokeObjectURL(newUrl);
317
+ if(myId !== loadSeq) return; // a newer load took over — stay quiet
318
+ srcUrl = prevUrl;
319
+ if(loadedOnce){ // don't destroy a session that's already open
320
+ if(V.src !== prevUrl && prevUrl){ V.src = prevUrl; }
321
+ alert(msg+"\n\nYour current video is still open.");
322
+ }else{
323
+ drop.innerHTML = DROP_HTML; drop.style.display="block"; $("app").style.display="none";
324
+ alert(msg);
325
+ }
326
+ };
327
+
328
+ srcName = (file.name||"clip").replace(/\.[^.]+$/,"") || "clip";
329
+ srcUrl = newUrl;
330
+ clearResult();
331
+ V.src = newUrl;
332
+ V.muted = false;
333
+
334
+ const how = await new Promise(res=>{
335
+ let settled=false, timer=0;
336
+ const done = v => { if(settled) return; settled=true; clearTimeout(timer);
337
+ V.onloadedmetadata=null; V.onerror=null; res(v); };
338
+ V.onloadedmetadata = ()=>done("ok");
339
+ V.onerror = ()=>done("err");
340
+ timer = setTimeout(()=>done("slow"), 120000);
341
+ });
342
+ if(myId !== loadSeq){ URL.revokeObjectURL(newUrl); return; }
343
+
344
+ 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).");
345
+ 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.");
346
+
347
+ dur = V.duration;
348
+ if(!isFinite(dur) || dur<=0){ // some WebM/MKV files report no duration until you seek past the end
349
+ try{
350
+ V.currentTime = 1e7;
351
+ await Promise.race([once(V,"seeked"), new Promise(r=>setTimeout(r,2500))]);
352
+ dur = isFinite(V.duration) ? V.duration : V.currentTime;
353
+ V.currentTime = 0;
354
+ await Promise.race([once(V,"seeked"), new Promise(r=>setTimeout(r,1500))]);
355
+ }catch(e){}
356
+ }
357
+ if(myId !== loadSeq){ URL.revokeObjectURL(newUrl); return; }
358
+ 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.");
359
+
360
+ if(prevUrl) URL.revokeObjectURL(prevUrl); // safe now: the new file is definitely in
361
+ natW = V.videoWidth || 640; natH = V.videoHeight || 360;
362
+ inT = 0; outT = dur;
363
+
364
+ // read the frame rate before handing the UI over, so nothing fights the probe
365
+ if(!loadedOnce) drop.innerHTML = '<div class="big">&#9634;</div><h2>Reading video…</h2><p>one moment</p>';
366
+ srcFps = 30;
367
+ await detectFps();
368
+ if(myId !== loadSeq) return;
369
+
370
+ loadedOnce = true;
371
+ drop.style.display="none"; $("app").style.display="block"; $("newBtn").style.display="inline-block";
372
+
373
+ $("w").value = natW; $("h").value = natH;
374
+ $("totTime").textContent = tc(dur);
375
+ $("rulEnd").textContent = tc(dur,false);
376
+ $("rulMid").textContent = tc(dur/2,false);
377
+
378
+ buildFpsSeg();
379
+ $("fps").value = Math.round(srcFps);
380
+ $("srcBadge").textContent = natW+"×"+natH+" · "+Math.round(srcFps)+" fps · "+tc(dur,false);
381
+ document.title = srcName+" — Nugget Video Trimmer";
382
+ markScale(1);
383
+ refresh(); update();
384
+ buildStrip(newUrl, myId);
385
+ }
386
+
387
+ /* estimate source fps by counting presented frames over a short muted sample */
388
+ async function detectFps(){
389
+ if(!("requestVideoFrameCallback" in HTMLVideoElement.prototype)) return;
390
+ const prevMuted = V.muted;
391
+ V.muted = true;
392
+ let stop = false;
393
+ try{
394
+ V.currentTime = Math.min(0.2, dur/4);
395
+ await Promise.race([once(V,"seeked"), new Promise(r=>setTimeout(r,1500))]);
396
+ let first=null, last=null, n=0;
397
+ const done = new Promise(res=>{
398
+ const finish = ()=>{ stop=true; res(); };
399
+ const cb=(now,md)=>{
400
+ if(stop) return;
401
+ if(first===null) first=md.mediaTime; else { last=md.mediaTime; n++; }
402
+ if(n>=12 || (last!==null && last-first>0.5)) return finish();
403
+ V.requestVideoFrameCallback(cb);
404
+ };
405
+ V.requestVideoFrameCallback(cb);
406
+ setTimeout(finish, 1400); // rVFC never fired (or is throttled) — give up
407
+ });
408
+ await V.play().catch(()=>{});
409
+ await done;
410
+ if(n>2 && last>first){
411
+ const f = n/(last-first);
412
+ if(f>4 && f<125) srcFps = Math.round(f*100)/100;
413
+ }
414
+ }catch(e){}
415
+ stop = true;
416
+ try{ V.pause(); }catch(e){}
417
+ V.muted = prevMuted;
418
+ V.currentTime = 0;
419
+ await Promise.race([once(V,"seeked"), new Promise(r=>setTimeout(r,800))]);
420
+ }
421
+ function once(el,ev){ return new Promise(r=>el.addEventListener(ev,r,{once:true})); }
422
+
423
+ /* ---------------- filmstrip ---------------- */
424
+ async function buildStrip(url, id){
425
+ const strip=$("strip"); strip.innerHTML="";
426
+ const N=12, tw=160, th=90, total=dur;
427
+ const v=document.createElement("video");
428
+ v.src=url; v.muted=true; v.playsInline=true;
429
+ try{
430
+ await Promise.race([once(v,"loadeddata"), once(v,"error"), new Promise(r=>setTimeout(r,15000))]);
431
+ }catch(e){ return; }
432
+ for(let i=0;i<N;i++){
433
+ if(id !== loadSeq){ v.removeAttribute("src"); return; } // a newer video is loading
434
+ const c=document.createElement("canvas"); c.width=tw; c.height=th;
435
+ strip.appendChild(c);
436
+ try{
437
+ v.currentTime = Math.min(total-0.03, (i+0.5)*total/N);
438
+ await Promise.race([once(v,"seeked"), new Promise(r=>setTimeout(r,1500))]);
439
+ c.getContext("2d").drawImage(v,0,0,tw,th);
440
+ }catch(e){}
441
+ }
442
+ v.removeAttribute("src");
443
+ }
444
+
445
+ /* ---------------- transport ---------------- */
446
+ $("playBtn").addEventListener("click", togglePlay);
447
+ function togglePlay(){
448
+ if(exporting) return;
449
+ if(V.paused){ if(V.currentTime<inT-0.01||V.currentTime>=outT-0.01) V.currentTime=inT; V.play(); }
450
+ else V.pause();
451
+ }
452
+ V.addEventListener("play", ()=>$("playBtn").innerHTML="&#10073;&#10073;");
453
+ V.addEventListener("pause",()=>$("playBtn").innerHTML="&#9654;");
454
+ V.addEventListener("timeupdate", update);
455
+ V.addEventListener("seeked", update);
456
+
457
+ function rafLoop(){
458
+ if(!V.paused && !exporting){
459
+ if(V.currentTime>=outT-0.02){ if(looping){ V.currentTime=inT; } else { V.pause(); V.currentTime=outT; } }
460
+ update();
461
+ }
462
+ requestAnimationFrame(rafLoop);
463
+ }
464
+ requestAnimationFrame(rafLoop);
465
+
466
+ $("prevF").addEventListener("click", ()=>step(-1));
467
+ $("nextF").addEventListener("click", ()=>step(1));
468
+ function step(dir, big){
469
+ V.pause();
470
+ const d = big ? 1 : 1/(srcFps||30);
471
+ V.currentTime = clamp(V.currentTime + dir*d, 0, dur);
472
+ }
473
+ $("setIn").addEventListener("click", ()=>{ inT=clamp(V.currentTime,0,outT-0.02); refresh(); });
474
+ $("setOut").addEventListener("click", ()=>{ outT=clamp(V.currentTime,inT+0.02,dur); refresh(); });
475
+ $("resetSel").addEventListener("click", ()=>{ inT=0; outT=dur; refresh(); });
476
+
477
+ /* click a Start/End readout to type an exact time: 1:23.4, 0:05, 83.4 or 1:02:03 */
478
+ function parseTime(s){
479
+ s = String(s).trim(); if(!s) return null;
480
+ const parts = s.split(":").map(x=>parseFloat(x));
481
+ if(parts.some(x=>isNaN(x))) return null;
482
+ return parts.reduce((a,x)=>a*60+x, 0);
483
+ }
484
+ [["pIn","in"],["pOut","out"],["pDur","dur"]].forEach(([id,which])=>{
485
+ $(id).addEventListener("click", function(){
486
+ if(exporting || this.querySelector("input")) return;
487
+ const cur = which==="in" ? inT : which==="out" ? outT : outT-inT;
488
+ const inp = document.createElement("input");
489
+ inp.value = tc(cur); this.textContent=""; this.appendChild(inp);
490
+ inp.focus(); inp.select();
491
+ let done = false;
492
+ const commit = ok => {
493
+ if(done) return; done = true;
494
+ const v = ok ? parseTime(inp.value) : null;
495
+ inp.remove(); // put the plain text back before redrawing
496
+ if(v !== null && isFinite(v)){
497
+ if(which==="in"){ inT = clamp(v, 0, outT-0.02); V.currentTime = inT; }
498
+ else if(which==="out"){ outT = clamp(v, inT+0.02, dur); V.currentTime = outT; }
499
+ else {
500
+ // set the length: push the end out, and if it won't fit, pull the start back
501
+ const len = clamp(v, 0.02, dur);
502
+ if(inT + len <= dur){ outT = inT + len; }
503
+ else { outT = dur; inT = Math.max(0, dur - len); }
504
+ V.currentTime = inT;
505
+ }
506
+ }
507
+ refresh();
508
+ };
509
+ inp.addEventListener("blur", ()=>commit(true));
510
+ inp.addEventListener("keydown", e=>{
511
+ e.stopPropagation();
512
+ if(e.key==="Enter"){ e.preventDefault(); commit(true); }
513
+ if(e.key==="Escape"){ e.preventDefault(); commit(false); }
514
+ });
515
+ });
516
+ });
517
+ $("loopBtn").addEventListener("click", e=>{ looping=!looping; e.target.textContent="Loop: "+(looping?"on":"off"); e.target.classList.toggle("ghost",!looping); });
518
+
519
+ document.addEventListener("keydown", e=>{
520
+ if(exporting) return;
521
+ const t=e.target.tagName;
522
+ if(t==="INPUT"||t==="SELECT"||t==="TEXTAREA") return;
523
+ const k=e.key;
524
+ if(k===" "){ e.preventDefault(); togglePlay(); }
525
+ else if(k==="ArrowLeft"){ e.preventDefault(); step(-1, e.shiftKey); }
526
+ else if(k==="ArrowRight"){ e.preventDefault(); step(1, e.shiftKey); }
527
+ else if(k==="i"||k==="I"){ inT=clamp(V.currentTime,0,outT-0.02); refresh(); }
528
+ else if(k==="o"||k==="O"){ outT=clamp(V.currentTime,inT+0.02,dur); refresh(); }
529
+ else if(k==="Home"){ V.currentTime=inT; }
530
+ else if(k==="End"){ V.currentTime=outT; }
531
+ });
532
+
533
+ /* ---------------- timeline drag ---------------- */
534
+ const tl=$("tl");
535
+ function posToTime(clientX){
536
+ const r=tl.getBoundingClientRect();
537
+ return clamp((clientX-r.left)/r.width,0,1)*dur;
538
+ }
539
+ tl.addEventListener("pointerdown", e=>{
540
+ if(exporting||!dur) return;
541
+ if(e.target===$("hIn")) dragging="in";
542
+ else if(e.target===$("hOut")) dragging="out";
543
+ else { dragging="seek"; V.pause(); V.currentTime=posToTime(e.clientX); }
544
+ tl.setPointerCapture(e.pointerId);
545
+ });
546
+ tl.addEventListener("pointermove", e=>{
547
+ if(!dragging) return;
548
+ const t=posToTime(e.clientX);
549
+ if(dragging==="in"){ inT=clamp(t,0,outT-0.02); V.currentTime=inT; }
550
+ else if(dragging==="out"){ outT=clamp(t,inT+0.02,dur); V.currentTime=outT; }
551
+ else V.currentTime=t;
552
+ refresh();
553
+ });
554
+ ["pointerup","pointercancel"].forEach(ev=>tl.addEventListener(ev,()=>dragging=null));
555
+
556
+ function refresh(){
557
+ const a = dur? inT/dur*100 : 0, b = dur? outT/dur*100 : 100;
558
+ $("maskL").style.left="0"; $("maskL").style.width=a+"%";
559
+ $("maskR").style.left=b+"%"; $("maskR").style.width=(100-b)+"%";
560
+ $("sel").style.left=a+"%"; $("sel").style.width=(b-a)+"%";
561
+ $("hIn").style.left=a+"%"; $("hOut").style.left=b+"%";
562
+ if(!$("pIn").querySelector("input")) $("pIn").textContent=tc(inT);
563
+ if(!$("pOut").querySelector("input")) $("pOut").textContent=tc(outT);
564
+ if(!$("pDur").querySelector("input")) $("pDur").textContent=tc(outT-inT);
565
+ estimate();
566
+ }
567
+ function update(){
568
+ $("curTime").textContent=tc(V.currentTime);
569
+ $("ph").style.left=(dur? V.currentTime/dur*100 : 0)+"%";
570
+ }
571
+
572
+ /* ---------------- size ---------------- */
573
+ const W=$("w"), H=$("h"), lock=$("lockAr");
574
+ $("scaleSeg").addEventListener("click", e=>{
575
+ const b=e.target.closest("button"); if(!b) return;
576
+ const s=parseFloat(b.dataset.s);
577
+ W.value=even(natW*s); H.value=even(natH*s); markScale(s); estimate();
578
+ });
579
+ function markScale(s){
580
+ [...$("scaleSeg").children].forEach(b=>b.classList.toggle("on", parseFloat(b.dataset.s)===s));
581
+ }
582
+ W.addEventListener("input", ()=>{ if(lock.checked && natW) H.value=even(W.value*natH/natW); markScale(null); estimate(); });
583
+ H.addEventListener("input", ()=>{ if(lock.checked && natH) W.value=even(H.value*natW/natH); markScale(null); estimate(); });
584
+
585
+ /* ---------------- fps ---------------- */
586
+ function buildFpsSeg(){
587
+ const seg=$("fpsSeg"); seg.innerHTML="";
588
+ 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);
589
+ opts.forEach(f=>{
590
+ const b=document.createElement("button");
591
+ b.textContent = (f===Math.round(srcFps)? "Same ("+f+")" : f);
592
+ b.dataset.f=f;
593
+ b.addEventListener("click",()=>{ $("fps").value=f; markFps(); estimate(); });
594
+ seg.appendChild(b);
595
+ });
596
+ markFps();
597
+ }
598
+ function markFps(){
599
+ const f=+$("fps").value;
600
+ [...$("fpsSeg").children].forEach(b=>b.classList.toggle("on", +b.dataset.f===f));
601
+ }
602
+ $("fps").addEventListener("input", ()=>{ markFps(); estimate(); });
603
+
604
+ /* ---------------- quality / estimate ---------------- */
605
+ $("q").addEventListener("input", estimate);
606
+ function qualityBpp(){
607
+ const q=+$("q").value/100; // bits per pixel per frame
608
+ return 0.018 * Math.pow(0.20/0.018, q); // 0.018 → 0.20
609
+ }
610
+ function targets(){
611
+ const w=even(clamp(+W.value||natW,16,7680)), h=even(clamp(+H.value||natH,16,4320));
612
+ const f=clamp(+$("fps").value||Math.round(srcFps),1,120);
613
+ const bitrate=Math.round(clamp(w*h*f*qualityBpp(), 120000, 60000000));
614
+ return {w,h,f,bitrate,len:Math.max(0,outT-inT)};
615
+ }
616
+ function estimate(){
617
+ const t=targets();
618
+ $("eSize").textContent = t.w+"×"+t.h;
619
+ $("eRate").textContent = (t.bitrate/1e6).toFixed(2)+" Mbps";
620
+ $("eBytes").textContent = bytes(t.bitrate*t.len/8 + 128000*t.len/8);
621
+ $("eTime").textContent = t.len<1 ? "—" : (t.len<60? Math.ceil(t.len)+" s" : Math.ceil(t.len/60)+" min");
622
+ const q=+$("q").value;
623
+ $("qHint").textContent = (q<25?"Small file — fine for quick previews"
624
+ : q<50?"Balanced — good for email and chat"
625
+ : q<78?"High — good for web and presentations"
626
+ : "Maximum — largest file, closest to the original");
627
+ $("sizeHint").textContent = "Source "+natW+"×"+natH+". Width and height are rounded to even numbers.";
628
+ }
629
+
630
+ /* ---------------- export ---------------- */
631
+ function pickMime(){
632
+ const c=["video/mp4;codecs=avc1.42E01E,mp4a.40.2","video/mp4","video/webm;codecs=vp9,opus","video/webm;codecs=vp8,opus","video/webm"];
633
+ for(const m of c) if(window.MediaRecorder && MediaRecorder.isTypeSupported(m)) return m;
634
+ return "";
635
+ }
636
+ function setupAudio(){
637
+ if(audioCtx) return;
638
+ try{
639
+ audioCtx = new (window.AudioContext||window.webkitAudioContext)();
640
+ mediaSrc = audioCtx.createMediaElementSource(V);
641
+ monitorGain = audioCtx.createGain(); monitorGain.gain.value = 1;
642
+ mediaSrc.connect(monitorGain); monitorGain.connect(audioCtx.destination);
643
+ streamDest = audioCtx.createMediaStreamDestination();
644
+ mediaSrc.connect(streamDest);
645
+ }catch(e){ audioCtx=null; }
646
+ }
647
+
648
+ $("exportBtn").addEventListener("click", doExport);
649
+
650
+ async function doExport(){
651
+ if(exporting || !dur) return;
652
+ const t=targets();
653
+ if(t.len < 0.05){ setStatus("Selection is too short."); return; }
654
+ const mime=pickMime();
655
+ if(!mime){ setStatus("This browser can't record video. Try Chrome or Edge."); return; }
656
+
657
+ exporting=true; clearResult();
658
+ $("exportBtn").disabled=true; $("exportBtn").textContent="Exporting…";
659
+ $("bar").style.display="block"; $("barI").style.width="0%";
660
+ V.pause();
661
+
662
+ const cv=$("exportCanvas"); cv.width=t.w; cv.height=t.h;
663
+ const ctx=cv.getContext("2d",{alpha:false});
664
+ ctx.fillStyle="#000"; ctx.fillRect(0,0,t.w,t.h);
665
+ V.style.display="none"; cv.style.display="block";
666
+
667
+ setupAudio();
668
+ if(audioCtx && audioCtx.state==="suspended"){ try{ await audioCtx.resume(); }catch(e){} }
669
+ if(monitorGain) monitorGain.gain.value = 0; // silent while exporting
670
+
671
+ const cStream = cv.captureStream(t.f);
672
+ const tracks = [...cStream.getVideoTracks()];
673
+ if(streamDest) tracks.push(...streamDest.stream.getAudioTracks());
674
+ const stream = new MediaStream(tracks);
675
+
676
+ let rec;
677
+ try{
678
+ rec = new MediaRecorder(stream, {mimeType:mime, videoBitsPerSecond:t.bitrate, audioBitsPerSecond:128000});
679
+ }catch(e){
680
+ try{ rec = new MediaRecorder(stream, {mimeType:mime}); }
681
+ catch(e2){ finishUI(); setStatus("Recorder failed to start: "+e2.message); return; }
682
+ }
683
+
684
+ const chunks=[];
685
+ rec.ondataavailable = e => { if(e.data && e.data.size) chunks.push(e.data); };
686
+ const stopped = new Promise(r => rec.onstop = r);
687
+
688
+ V.currentTime = inT;
689
+ await Promise.race([once(V,"seeked"), new Promise(r=>setTimeout(r,3000))]);
690
+ ctx.drawImage(V,0,0,t.w,t.h);
691
+ let wall0=0, media0=inT, realSpeed=1;
692
+
693
+ let raf=0, stopping=false, lastT=V.currentTime, stallMs=0, prevNow=performance.now();
694
+ const paint = () => {
695
+ ctx.drawImage(V,0,0,t.w,t.h);
696
+ const now=performance.now();
697
+ if(Math.abs(V.currentTime-lastT) > 0.001){ lastT=V.currentTime; stallMs=0; } else { stallMs += now-prevNow; }
698
+ prevNow=now;
699
+ const p = clamp((V.currentTime-inT)/t.len,0,1);
700
+ $("barI").style.width=(p*100).toFixed(1)+"%";
701
+ const left = Math.max(0, t.len-(V.currentTime-inT));
702
+ setStatus("Recording… "+Math.round(p*100)+"% · about "+Math.ceil(left)+"s left");
703
+ if(!stopping && (V.currentTime >= outT-0.005 || V.ended || stallMs > 10000)){
704
+ stopping=true; if(stallMs>10000) stalled=true; stop(); return;
705
+ }
706
+ raf=requestAnimationFrame(paint);
707
+ };
708
+ let stalled=false;
709
+ function stop(){
710
+ cancelAnimationFrame(raf);
711
+ const el = (performance.now()-wall0)/1000;
712
+ if(el>0.2) realSpeed = (V.currentTime-media0)/el;
713
+ V.pause();
714
+ try{ rec.stop(); }catch(e){}
715
+ }
716
+ const onEnded = ()=>{ if(!stopping){ stopping=true; stop(); } };
717
+ V.addEventListener("ended", onEnded);
718
+
719
+ // start playback FIRST, then the recorder — otherwise the frozen first frame
720
+ // gets baked into the front of the clip and the output runs long
721
+ V.playbackRate = 1;
722
+ try{ await V.play(); }
723
+ catch(e){ finishUI(); setStatus("Couldn't start playback: "+e.message); return; }
724
+ wall0 = performance.now(); media0 = V.currentTime;
725
+ rec.start(200);
726
+ raf=requestAnimationFrame(paint);
727
+
728
+ await stopped;
729
+ V.removeEventListener("ended", onEnded);
730
+ cStream.getTracks().forEach(tr=>tr.stop());
731
+
732
+ const type = mime.split(";")[0];
733
+ lastExt = type.indexOf("mp4")>=0 ? "mp4" : "webm";
734
+ lastBlob = new Blob(chunks,{type});
735
+ finishUI();
736
+
737
+ if(!lastBlob.size){ setStatus("Export produced an empty file — try a different quality setting."); return; }
738
+ $("outVid").src = URL.createObjectURL(lastBlob);
739
+ $("result").style.display="block";
740
+
741
+ const notes=[];
742
+ if(lastExt==="webm") notes.push("Saved as .webm — this browser can't record MP4. Plays in Chrome, Edge, Firefox and VLC.");
743
+ if(stalled) notes.push("Playback stalled, so the clip may be cut short.");
744
+ 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.");
745
+ $("outNote").textContent = notes.join(" ");
746
+ lastMeta = {w:t.w, h:t.h, f:t.f, at:inT}; // name the file after what was actually exported
747
+ setStatus("Done — "+bytes(lastBlob.size)+" · "+tc(t.len)+" · "+t.w+"×"+t.h+" · "+t.f+" fps");
748
+
749
+ const name = download(); // save it without waiting to be asked
750
+ if(name) setStatus("Saved "+name+" — "+bytes(lastBlob.size)+" · "+tc(t.len)+" · "+t.w+"×"+t.h+" · "+t.f+" fps");
751
+
752
+ function finishUI(){
753
+ exporting=false;
754
+ if(monitorGain) monitorGain.gain.value=1;
755
+ cv.style.display="none"; V.style.display="block";
756
+ $("exportBtn").disabled=false; $("exportBtn").textContent="Export clip";
757
+ $("bar").style.display="none";
758
+ V.currentTime=inT;
759
+ }
760
+ }
761
+
762
+ function download(){
763
+ if(!lastBlob || !lastBlob.size) return null;
764
+ const m = lastMeta || Object.assign(targets(), {at:inT});
765
+ const safe = srcName.replace(/[\\/:*?"<>|]+/g,"-").slice(0,80) || "clip";
766
+ // include where the cut starts, so several clips from one video don't collide
767
+ const at = Math.floor((m.at||0)/60)+"m"+String(Math.floor((m.at||0)%60)).padStart(2,"0")+"s";
768
+ const name = safe+"_"+at+"_"+m.w+"x"+m.h+"_"+m.f+"fps."+lastExt;
769
+ try{
770
+ const a=document.createElement("a");
771
+ a.href=URL.createObjectURL(lastBlob);
772
+ a.download=name; a.style.display="none";
773
+ document.body.appendChild(a);
774
+ a.click();
775
+ setTimeout(()=>{ URL.revokeObjectURL(a.href); a.remove(); }, 8000);
776
+ return name;
777
+ }catch(e){ return null; } // browser blocked it — the Download button is still there
778
+ }
779
+ $("dlBtn").addEventListener("click", ()=>{ download(); });
780
+ $("clearRes").addEventListener("click", clearResult);
781
+ function clearResult(){
782
+ lastBlob=null; lastMeta=null; $("result").style.display="none";
783
+ const ov=$("outVid"); if(ov.src){ URL.revokeObjectURL(ov.src); ov.removeAttribute("src"); }
784
+ setStatus("");
785
+ }
786
+ function setStatus(s){ $("status").textContent=s; }
787
+
788
+ 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}; },
789
+ load, doExport, set(a,b){inT=a;outT=b;refresh();} };
790
+ })();
791
+ </script>
792
+ </body>
793
+ </html>