PoopMan333 commited on
Commit
c70a048
·
verified ·
1 Parent(s): 2091be5

Upload old/Nugget Video Trimmer v1.9.html

Browse files
Files changed (1) hide show
  1. old/Nugget Video Trimmer v1.9.html +1767 -0
old/Nugget Video Trimmer v1.9.html ADDED
@@ -0,0 +1,1767 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ button.danger{color:#ffb4ae;border-color:#5a2b2b}
39
+ button.danger:hover:not(:disabled){background:#341c1c;border-color:#7a3a3a}
40
+ .icobtn{padding:7px 11px;font-variant-numeric:tabular-nums}
41
+ .small{padding:5px 10px;font-size:12.5px}
42
+
43
+ /* ---------- drop zone ---------- */
44
+ #drop{
45
+ border:2px dashed var(--line);border-radius:14px;padding:64px 24px;text-align:center;
46
+ background:var(--panel);cursor:pointer;transition:.15s;
47
+ }
48
+ #drop:hover,#drop.over{border-color:var(--accent);background:#12212b}
49
+ #drop h2{margin:0 0 6px;font-size:18px;font-weight:600}
50
+ #drop p{margin:0;color:var(--dim);font-size:13px}
51
+ #drop .big{font-size:38px;line-height:1;margin-bottom:14px;opacity:.65}
52
+
53
+ /* ---------- layout ---------- */
54
+ #app{display:none}
55
+ .grid{display:grid;grid-template-columns:1fr 316px;gap:18px;align-items:start}
56
+ @media(max-width:900px){.grid{grid-template-columns:1fr}}
57
+
58
+ .card{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);padding:14px}
59
+ .card h3{margin:0 0 12px;font-size:12px;text-transform:uppercase;letter-spacing:.7px;color:var(--dim);font-weight:600}
60
+
61
+ .stage{background:#000;border-radius:var(--r);overflow:hidden;display:flex;align-items:center;justify-content:center;position:relative;border:1px solid var(--line)}
62
+ .stage video,.stage canvas{max-width:100%;max-height:56vh;display:block}
63
+ #exportCanvas{display:none}
64
+ .badge{position:absolute;top:10px;left:10px;z-index:3;background:rgba(0,0,0,.66);backdrop-filter:blur(4px);
65
+ padding:4px 9px;border-radius:6px;font-size:11.5px;color:#cfe;letter-spacing:.3px}
66
+
67
+ /* ---------- crop overlay ---------- */
68
+ #cropLayer{position:absolute;display:none;touch-action:none;z-index:2}
69
+ #cropBox{position:absolute;box-shadow:0 0 0 9999px rgba(4,7,11,.62);
70
+ border:1px solid rgba(255,255,255,.92);cursor:move;box-sizing:border-box}
71
+ #cropBox::after{content:"";position:absolute;inset:0;pointer-events:none;
72
+ background:
73
+ linear-gradient(to right,transparent calc(33.33% - 0.5px),rgba(255,255,255,.3) calc(33.33% - 0.5px),rgba(255,255,255,.3) calc(33.33% + 0.5px),transparent calc(33.33% + 0.5px),transparent calc(66.66% - 0.5px),rgba(255,255,255,.3) calc(66.66% - 0.5px),rgba(255,255,255,.3) calc(66.66% + 0.5px),transparent calc(66.66% + 0.5px)),
74
+ linear-gradient(to bottom,transparent calc(33.33% - 0.5px),rgba(255,255,255,.3) calc(33.33% - 0.5px),rgba(255,255,255,.3) calc(33.33% + 0.5px),transparent calc(33.33% + 0.5px),transparent calc(66.66% - 0.5px),rgba(255,255,255,.3) calc(66.66% - 0.5px),rgba(255,255,255,.3) calc(66.66% + 0.5px),transparent calc(66.66% + 0.5px));}
75
+ .ch{position:absolute;width:16px;height:16px;background:#fff;border:1px solid rgba(0,0,0,.65);border-radius:3px;z-index:2}
76
+ .ch:hover{background:var(--accent)}
77
+ .ch[data-h=nw]{left:-8px;top:-8px;cursor:nwse-resize}
78
+ .ch[data-h=ne]{right:-8px;top:-8px;cursor:nesw-resize}
79
+ .ch[data-h=sw]{left:-8px;bottom:-8px;cursor:nesw-resize}
80
+ .ch[data-h=se]{right:-8px;bottom:-8px;cursor:nwse-resize}
81
+ .ch[data-h=n]{left:calc(50% - 8px);top:-8px;cursor:ns-resize}
82
+ .ch[data-h=s]{left:calc(50% - 8px);bottom:-8px;cursor:ns-resize}
83
+ .ch[data-h=w]{left:-8px;top:calc(50% - 8px);cursor:ew-resize}
84
+ .ch[data-h=e]{right:-8px;top:calc(50% - 8px);cursor:ew-resize}
85
+ .cropdims{position:absolute;left:50%;transform:translateX(-50%);bottom:5px;
86
+ background:rgba(0,0,0,.75);color:#dff;padding:2px 8px;border-radius:5px;
87
+ font-size:11px;font-variant-numeric:tabular-nums;white-space:nowrap;pointer-events:none}
88
+
89
+ #cropBtn.on{background:var(--accent);border-color:var(--accent);color:#04121c;font-weight:600}
90
+ #cropBtn.on .kbd{background:rgba(0,0,0,.15);border-color:rgba(0,0,0,.25);color:#04121c}
91
+ .arlab{color:var(--dim);font-size:11px;text-transform:uppercase;letter-spacing:.6px;margin:2px 0 5px}
92
+ .arRow{margin-bottom:8px}
93
+ .arcustom{display:flex;align-items:center;gap:6px;margin-bottom:4px}
94
+ .arcustom input{width:100%;min-width:0;text-align:center}
95
+ .arcustom span{color:var(--dim)}
96
+ .arcustom.on input{border-color:var(--accent)}
97
+
98
+ /* ---------- transport ---------- */
99
+ .transport{display:flex;align-items:center;gap:8px;margin-top:12px;flex-wrap:wrap}
100
+ .tc{font-variant-numeric:tabular-nums;font-size:13px;color:var(--dim)}
101
+ .tc b{color:var(--txt);font-weight:600}
102
+
103
+ /* ---------- timeline ---------- */
104
+ .tl-wrap{margin-top:14px;user-select:none}
105
+ .tl{position:relative;height:70px;background:var(--panel2);border:1px solid var(--line);border-radius:8px;overflow:hidden;touch-action:none;cursor:pointer}
106
+ #strip{position:absolute;inset:0;display:flex;opacity:.9}
107
+ #strip canvas{height:100%;flex:1 1 0;min-width:0;object-fit:cover}
108
+ .mask{position:absolute;top:0;bottom:0;background:rgba(6,9,13,.72);pointer-events:none}
109
+ .sel{position:absolute;top:0;bottom:0;border-left:2px solid var(--accent);border-right:2px solid var(--accent);
110
+ box-shadow:inset 0 0 0 1px rgba(76,194,255,.25);pointer-events:none}
111
+ .handle{position:absolute;top:0;bottom:0;width:16px;margin-left:-8px;cursor:ew-resize;touch-action:none;z-index:5}
112
+ .handle::after{content:"";position:absolute;left:5px;top:50%;transform:translateY(-50%);
113
+ width:6px;height:26px;border-radius:3px;background:var(--accent);box-shadow:0 0 0 1px rgba(0,0,0,.4)}
114
+ .handle:hover::after{background:#8bd8ff}
115
+ .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)}
116
+ .ruler{display:flex;justify-content:space-between;margin-top:5px;color:var(--dim);font-size:11px;font-variant-numeric:tabular-nums}
117
+
118
+ /* saved clips drawn along the bottom of the timeline */
119
+ #segBand{position:absolute;left:0;right:0;bottom:0;height:16px;pointer-events:none;z-index:4}
120
+ .segbar{position:absolute;bottom:0;height:16px;min-width:3px;background:rgba(63,185,80,.42);
121
+ border:1px solid rgba(63,185,80,.95);border-radius:3px;overflow:hidden;
122
+ font-size:9.5px;line-height:14px;text-align:center;color:#dfffe6;font-weight:600}
123
+ .segbar.on{background:rgba(76,194,255,.5);border-color:#8bd8ff;color:#eaf7ff}
124
+
125
+ /* ---------- shift bar ---------- */
126
+ .shiftwrap{margin-top:12px}
127
+ .shift-lbl{color:var(--dim);font-size:11.5px;margin-bottom:5px}
128
+ .shift-lbl b{color:var(--accent);font-weight:600}
129
+ .shiftbar{position:relative;height:26px;background:var(--panel2);border:1px solid var(--line);
130
+ border-radius:7px;overflow:hidden;touch-action:none;cursor:pointer}
131
+ .shiftbar::before{content:"";position:absolute;inset:0;
132
+ background:repeating-linear-gradient(90deg,transparent 0 24px,rgba(255,255,255,.05) 24px 25px)}
133
+ .shiftblk{position:absolute;top:0;bottom:0;min-width:8px;background:rgba(76,194,255,.34);
134
+ border:1px solid var(--accent);border-radius:6px;cursor:grab}
135
+ .shiftblk:active{cursor:grabbing}
136
+ .shiftblk::after{content:"";position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);
137
+ width:16px;height:12px;border-radius:1px;
138
+ background:repeating-linear-gradient(90deg,var(--accent) 0 2px,transparent 2px 5px)}
139
+ .shiftbtns{display:flex;gap:6px;margin-top:7px;align-items:center;flex-wrap:wrap}
140
+ .shiftbtns button{padding:4px 10px;font-size:12px;border-radius:6px;font-variant-numeric:tabular-nums}
141
+ .shiftbtns .sp{color:var(--dim);font-size:11.5px;margin-left:2px}
142
+
143
+ .marks{display:flex;gap:8px;margin-top:12px;flex-wrap:wrap;align-items:center}
144
+ .pill{background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:6px 10px;font-size:12.5px;font-variant-numeric:tabular-nums}
145
+ .pill span{color:var(--dim)}
146
+ .pill b{font-weight:600}
147
+ .pill.dur b{color:var(--accent)}
148
+ .edit{cursor:text;border-bottom:1px dotted #4a5663;padding-bottom:1px}
149
+ .edit:hover{border-bottom-color:var(--accent);color:var(--accent)}
150
+ .edit input{width:88px;padding:0 3px;border:1px solid var(--accent);border-radius:4px;
151
+ background:#0b0f14;color:var(--txt);font:inherit;font-weight:600;text-align:center}
152
+
153
+ /* ---------- clip list ---------- */
154
+ #clipsWrap{display:none;margin-top:18px}
155
+ .clips-head{display:flex;align-items:center;gap:10px;margin-bottom:8px}
156
+ .clips-head h3{margin:0;font-size:12px;text-transform:uppercase;letter-spacing:.7px;color:var(--dim);font-weight:600}
157
+ #clipList{border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--panel)}
158
+ .crow{display:flex;align-items:center;gap:10px;padding:7px 10px;border-top:1px solid var(--line);font-size:12.5px}
159
+ .crow:first-child{border-top:none}
160
+ .crow:hover{background:var(--panel2)}
161
+ .crow.on{background:#12212b;box-shadow:inset 2px 0 0 var(--accent)}
162
+ .cnum{width:20px;color:var(--dim);font-variant-numeric:tabular-nums;flex:none}
163
+ .cname{flex:1 1 90px;min-width:60px;max-width:230px}
164
+ .cname input{width:100%;background:transparent;border:1px solid transparent;border-radius:6px;
165
+ padding:2px 6px;color:var(--txt);font:inherit}
166
+ .cname input:hover{border-color:var(--line)}
167
+ .cname input:focus{outline:none;border-color:var(--accent);background:#0b0f14}
168
+ .cname input::placeholder{color:#5a6572}
169
+ .ctimes{font-variant-numeric:tabular-nums;color:var(--dim);flex:none}
170
+ .ctimes b{color:var(--txt);font-weight:600}
171
+ .cdur{font-variant-numeric:tabular-nums;color:var(--accent);font-weight:600;width:64px;text-align:right;flex:none}
172
+ .cbtns{display:flex;gap:4px;flex:none}
173
+ .cbtns button{padding:3px 8px;font-size:11.5px;border-radius:6px}
174
+ @media(max-width:760px){ .ctimes{display:none} }
175
+
176
+ /* ---------- settings ---------- */
177
+ .row{display:flex;align-items:center;gap:8px;margin-bottom:10px}
178
+ .row label{flex:0 0 78px;color:var(--dim);font-size:12.5px}
179
+ input[type=text],input[type=number],select{
180
+ font:inherit;background:var(--panel2);color:var(--txt);border:1px solid var(--line);
181
+ border-radius:7px;padding:6px 8px;width:100%;min-width:0;
182
+ }
183
+ input:focus,select:focus{outline:none;border-color:var(--accent)}
184
+ input[type=range]{width:100%;accent-color:var(--accent)}
185
+ .seg{display:flex;gap:5px;flex-wrap:wrap;margin-bottom:10px}
186
+ .seg button{padding:5px 9px;font-size:12.5px;border-radius:7px}
187
+ .seg button.on{background:var(--accent);border-color:var(--accent);color:#04121c;font-weight:600}
188
+ .hint{color:var(--dim);font-size:11.5px;margin:-4px 0 12px}
189
+ .chk{display:flex;align-items:center;gap:7px;color:var(--dim);font-size:12.5px;margin-bottom:10px;cursor:pointer}
190
+ hr{border:none;border-top:1px solid var(--line);margin:14px 0}
191
+
192
+ .est{background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:9px 11px;font-size:12.5px;margin-bottom:12px}
193
+ .est div{display:flex;justify-content:space-between;margin:2px 0}
194
+ .est span{color:var(--dim)}
195
+ .est b{font-variant-numeric:tabular-nums;font-weight:600}
196
+
197
+ #exportBtn{width:100%;padding:11px}
198
+ #snapBtn{width:100%;padding:9px;margin-top:8px}
199
+ #gifBtn{display:block;width:auto;margin:10px auto 0;padding:2px 0;background:transparent;
200
+ border:none;border-radius:0;color:var(--accent);text-decoration:underline;font-size:12.5px;text-align:center}
201
+ #gifBtn:hover:not(:disabled){background:transparent;color:#8bd8ff;border-color:transparent}
202
+ #gifBtn:disabled{opacity:.4;text-decoration:none}
203
+ #stopBtn{width:100%;padding:9px;margin-top:8px;display:none}
204
+ .bar{height:6px;background:var(--panel2);border-radius:99px;overflow:hidden;margin-top:10px;display:none}
205
+ .bar i{display:block;height:100%;width:0;background:var(--accent);transition:width .2s}
206
+ #status{font-size:12px;color:var(--dim);margin-top:8px;text-align:center;min-height:16px}
207
+
208
+ #result{display:none;margin-top:14px}
209
+ #result video{width:100%;border-radius:8px;background:#000;border:1px solid var(--line)}
210
+ #saved{display:none;margin-top:14px;border:1px solid var(--line);border-radius:8px;background:var(--panel2);padding:10px 12px}
211
+ #saved h4{margin:0 0 6px;font-size:12px;text-transform:uppercase;letter-spacing:.7px;color:var(--dim)}
212
+ #savedList{margin:0;padding-left:16px;font-size:12px;color:var(--txt);line-height:1.7;max-height:190px;overflow:auto}
213
+ .note{font-size:11.5px;color:var(--warn);margin-top:8px;line-height:1.4}
214
+ .kbd{display:inline-block;background:var(--panel2);border:1px solid var(--line);border-bottom-width:2px;
215
+ border-radius:5px;padding:0 5px;font-size:11px;font-family:ui-monospace,Menlo,Consolas,monospace}
216
+ .keys{color:var(--dim);font-size:11.5px;margin-top:12px;line-height:1.9}
217
+ </style>
218
+ </head>
219
+ <body>
220
+ <div class="wrap">
221
+
222
+ <header>
223
+ <h1>Nugget Video Trimmer</h1>
224
+ <span class="sub">trim &middot; resize &middot; fps &middot; quality</span>
225
+ <span class="spacer"></span>
226
+ <button id="newBtn" class="ghost" style="display:none">Load another video</button>
227
+ <span class="ver">v1.9</span>
228
+ </header>
229
+
230
+ <div id="drop">
231
+ <div class="big">&#9634;</div>
232
+ <h2>Drop a video here</h2>
233
+ <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>
234
+ <input type="file" id="file" accept="video/*" hidden>
235
+ </div>
236
+
237
+ <div id="app">
238
+ <div class="grid">
239
+
240
+ <!-- LEFT -->
241
+ <div>
242
+ <div class="stage" id="stage">
243
+ <video id="video" playsinline></video>
244
+ <canvas id="exportCanvas"></canvas>
245
+ <div id="cropLayer">
246
+ <div id="cropBox" data-h="move">
247
+ <span class="ch" data-h="nw"></span><span class="ch" data-h="n"></span><span class="ch" data-h="ne"></span>
248
+ <span class="ch" data-h="w"></span><span class="ch" data-h="e"></span>
249
+ <span class="ch" data-h="sw"></span><span class="ch" data-h="s"></span><span class="ch" data-h="se"></span>
250
+ <span class="cropdims" id="cropDims"></span>
251
+ </div>
252
+ </div>
253
+ <div class="badge" id="srcBadge"></div>
254
+ </div>
255
+
256
+ <div class="transport">
257
+ <button id="playBtn" class="icobtn" title="Play / pause (Space)">&#9654;</button>
258
+ <button id="prevF" class="icobtn" title="Previous frame (&larr;)">&#8676;</button>
259
+ <button id="nextF" class="icobtn" title="Next frame (&rarr;)">&#8677;</button>
260
+ <span class="tc"><b id="curTime">0:00.00</b> / <span id="totTime">0:00.00</span></span>
261
+ <span class="spacer"></span>
262
+ <button id="cropBtn" title="Show or hide the crop box (C)">Crop &nbsp;<span class="kbd">C</span></button>
263
+ <button id="setIn">Set start &nbsp;<span class="kbd">I</span></button>
264
+ <button id="setOut">Set end &nbsp;<span class="kbd">O</span></button>
265
+ <button id="loopBtn" class="ghost" title="Loop the selection">Loop: off</button>
266
+ </div>
267
+
268
+ <div class="tl-wrap">
269
+ <div class="tl" id="tl">
270
+ <div id="strip"></div>
271
+ <div class="mask" id="maskL"></div>
272
+ <div class="mask" id="maskR"></div>
273
+ <div class="sel" id="sel"></div>
274
+ <div id="segBand"></div>
275
+ <div class="playhead" id="ph"></div>
276
+ <div class="handle" id="hIn"></div>
277
+ <div class="handle" id="hOut"></div>
278
+ </div>
279
+ <div class="ruler"><span>0:00</span><span id="rulMid"></span><span id="rulEnd"></span></div>
280
+
281
+ <div class="shiftwrap">
282
+ <div class="shift-lbl" id="shiftLbl"></div>
283
+ <div class="shiftbar" id="shiftBar"><div class="shiftblk" id="shiftBlk"></div></div>
284
+ <div class="shiftbtns" id="shiftBtns">
285
+ <button data-d="-1">&#8722;1 s</button>
286
+ <button data-d="-f">&#8722;1 frame</button>
287
+ <button data-d="f">+1 frame</button>
288
+ <button data-d="1">+1 s</button>
289
+ <span class="sp">or drag the blue block &nbsp;·&nbsp; <span class="kbd">[</span> <span class="kbd">]</span> nudge, hold <span class="kbd">Shift</span> for a second</span>
290
+ </div>
291
+ </div>
292
+
293
+ <div class="marks">
294
+ <span class="pill"><span>Start</span> <b id="pIn" class="edit" title="Click to type an exact time">0:00.00</b></span>
295
+ <span class="pill"><span>End</span> <b id="pOut" class="edit" title="Click to type an exact time">0:00.00</b></span>
296
+ <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>
297
+ <button id="addClip" class="primary small" title="Save this selection to the clip list (A)">+ Add clip &nbsp;<span class="kbd" style="background:rgba(0,0,0,.15);border-color:rgba(0,0,0,.25)">A</span></button>
298
+ <button id="resetSel" class="ghost small">Reset to full clip</button>
299
+ </div>
300
+
301
+ <div class="keys">
302
+ <span class="kbd">Space</span> play/pause &nbsp; <span class="kbd">&larr;</span><span class="kbd">&rarr;</span> step 1 frame &nbsp;
303
+ <span class="kbd">Shift</span>+<span class="kbd">&larr;</span><span class="kbd">&rarr;</span> step 1 sec &nbsp;
304
+ <span class="kbd">I</span> set start &nbsp; <span class="kbd">O</span> set end &nbsp; <span class="kbd">A</span> add clip &nbsp; <span class="kbd">C</span> crop &nbsp; <span class="kbd">S</span> snapshot &nbsp; <span class="kbd">Home</span>/<span class="kbd">End</span> jump
305
+ <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>).
306
+ </div>
307
+
308
+ <!-- clip list: only appears once you add something -->
309
+ <div id="clipsWrap">
310
+ <div class="clips-head">
311
+ <h3 id="clipsTitle">Clip list</h3>
312
+ <span class="spacer"></span>
313
+ <button id="clearClips" class="ghost small">Clear all</button>
314
+ </div>
315
+ <div id="clipList"></div>
316
+ <div class="hint" style="margin-top:8px">
317
+ Export uses this list — the timeline selection is only used for adding new clips.
318
+ Use ▶ to preview a clip, Set to overwrite it with the current timeline selection, ✕ to remove it. Click its times to type exact values.
319
+ Names are optional and become the file name.
320
+ </div>
321
+ </div>
322
+ </div>
323
+ </div>
324
+
325
+ <!-- RIGHT -->
326
+ <div>
327
+ <div class="card">
328
+ <div id="cropSection" style="display:none">
329
+ <h3>Crop &amp; aspect ratio</h3>
330
+ <div class="seg arRow">
331
+ <button data-ar="0" data-lbl="free">Free</button>
332
+ <button data-ar="src" data-lbl="original">Original</button>
333
+ <button data-ar="1" data-lbl="1:1">1:1 square</button>
334
+ </div>
335
+ <div class="arlab">Landscape</div>
336
+ <div class="seg arRow">
337
+ <button data-ar="2.33333" data-lbl="21:9">21:9</button>
338
+ <button data-ar="1.77778" data-lbl="16:9">16:9</button>
339
+ <button data-ar="1.5" data-lbl="3:2">3:2</button>
340
+ <button data-ar="1.33333" data-lbl="4:3">4:3</button>
341
+ </div>
342
+ <div class="arlab">Portrait</div>
343
+ <div class="seg arRow">
344
+ <button data-ar="0.5625" data-lbl="9:16">9:16</button>
345
+ <button data-ar="0.5" data-lbl="1:2">1:2</button>
346
+ <button data-ar="0.66667" data-lbl="2:3">2:3</button>
347
+ <button data-ar="0.75" data-lbl="3:4">3:4</button>
348
+ <button data-ar="0.8" data-lbl="4:5">4:5</button>
349
+ </div>
350
+ <div class="arlab">Custom</div>
351
+ <div class="arcustom" id="arCustom">
352
+ <input type="number" id="arW" min="1" max="9999" step="1" placeholder="9">
353
+ <span>:</span>
354
+ <input type="number" id="arH" min="1" max="9999" step="1" placeholder="16">
355
+ <button id="arApply" class="small">Use</button>
356
+ </div>
357
+ <div class="hint" id="cropInfo" style="margin:8px 0"></div>
358
+ <button id="cropMax" class="small" style="width:100%">Biggest box that fits</button>
359
+ <hr>
360
+ </div>
361
+
362
+ <h3>Size</h3>
363
+ <div class="seg" id="scaleSeg">
364
+ <button data-s="1">100%</button>
365
+ <button data-s="0.75">75%</button>
366
+ <button data-s="0.5">50%</button>
367
+ <button data-s="0.25">25%</button>
368
+ </div>
369
+ <div class="row">
370
+ <label>Width</label><input type="number" id="w" min="16" step="2">
371
+ </div>
372
+ <div class="row">
373
+ <label>Height</label><input type="number" id="h" min="16" step="2">
374
+ </div>
375
+ <label class="chk"><input type="checkbox" id="lockAr" checked> Lock aspect ratio</label>
376
+ <div class="hint" id="sizeHint"></div>
377
+
378
+ <hr>
379
+ <h3>Frame rate</h3>
380
+ <div class="seg" id="fpsSeg"></div>
381
+ <div class="row">
382
+ <label>Custom</label><input type="number" id="fps" min="1" max="120" step="1">
383
+ </div>
384
+ <div class="hint">Higher than the source frame rate won't add detail.</div>
385
+
386
+ <hr>
387
+ <h3>Quality</h3>
388
+ <input type="range" id="q" min="0" max="100" value="78">
389
+ <div class="hint" id="qHint"></div>
390
+ <label class="chk"><input type="checkbox" id="keepAudio" checked> Include audio</label>
391
+
392
+ <div class="est">
393
+ <div><span>Output</span><b id="eSize">—</b></div>
394
+ <div><span>Bitrate</span><b id="eRate">—</b></div>
395
+ <div><span>Encoder</span><b id="eCodec">—</b></div>
396
+ <div><span id="eBytesLbl">Est. file size</span><b id="eBytes">—</b></div>
397
+ <div><span>Export takes about</span><b id="eTime">—</b></div>
398
+ </div>
399
+
400
+ <button id="exportBtn" class="primary">Export clip</button>
401
+ <button id="snapBtn" title="Save the current frame as an image (S)">Take snapshot</button>
402
+ <button id="gifBtn">Export as GIF</button>
403
+ <div class="hint" id="gifHint" style="margin:6px 0 0">GIF has only 256 colours and no sound, and files get large fast. A smaller width and 10–15 fps work best.</div>
404
+ <button id="stopBtn" class="danger">Stop</button>
405
+ <div class="bar" id="bar"><i id="barI"></i></div>
406
+ <div id="status"></div>
407
+ <div class="hint" id="expNote" style="display:none;margin:8px 0 0;text-align:center">
408
+ Leave this tab on screen — if you switch away, the export pauses and picks up when you come back.
409
+ </div>
410
+
411
+ <div id="result">
412
+ <video id="outVid" controls></video>
413
+ <img id="outImg" alt="" style="display:none;width:100%;border-radius:8px;background:#000;border:1px solid var(--line)">
414
+ <div style="display:flex;gap:8px;margin-top:10px">
415
+ <button id="dlBtn" class="primary" style="flex:1">Download again</button>
416
+ <button id="clearRes" class="ghost">Clear</button>
417
+ </div>
418
+ <div class="note" id="outNote"></div>
419
+ </div>
420
+
421
+ <div id="saved">
422
+ <h4 id="savedWhere">Saved</h4>
423
+ <ol id="savedList"></ol>
424
+ <div class="note" id="savedNote"></div>
425
+ </div>
426
+ </div>
427
+ </div>
428
+
429
+ </div>
430
+ </div>
431
+ </div>
432
+
433
+ <script>
434
+ "use strict";
435
+ (function(){
436
+
437
+ /* ---------------- helpers ---------------- */
438
+ const $ = id => document.getElementById(id);
439
+ const clamp = (v,a,b) => Math.min(b, Math.max(a, v));
440
+ function tc(t, frac){ // 1:23.45 / 1:05:03.20
441
+ if(!isFinite(t)||t<0) t=0;
442
+ const h = Math.floor(t/3600);
443
+ const m = Math.floor((t-h*3600)/60), s = t - h*3600 - m*60;
444
+ const mm = h ? String(m).padStart(2,"0") : String(m);
445
+ const hh = h ? h+":" : "";
446
+ return frac===false ? hh+mm+":"+String(Math.floor(s)).padStart(2,"0")
447
+ : hh+mm+":"+(s<10?"0":"")+s.toFixed(2);
448
+ }
449
+ function stamp(t){ t=Math.max(0,t||0); return Math.floor(t/60)+"m"+String(Math.floor(t%60)).padStart(2,"0")+"s"; }
450
+ function bytes(n){
451
+ if(!isFinite(n)) return "—";
452
+ if(n < 1024*1024) return (n/1024).toFixed(0)+" KB";
453
+ if(n < 1024*1024*1024) return (n/1048576).toFixed(1)+" MB";
454
+ return (n/1073741824).toFixed(2)+" GB";
455
+ }
456
+ function even(n){ n = Math.round(n); return n%2 ? n+1 : n; }
457
+ function safeName(s, n){ return String(s||"").replace(/[\\/:*?"<>|\x00-\x1f]+/g,"-").replace(/^\.+/,"").trim().slice(0,n||60); }
458
+ function once(el,ev){ return new Promise(r=>el.addEventListener(ev,r,{once:true})); }
459
+
460
+ /* ---------------- state ---------------- */
461
+ const V = $("video");
462
+ let srcUrl=null, srcName="clip", dur=0, natW=0, natH=0, srcFps=30;
463
+ let inT=0, outT=0, looping=false, dragging=null, exporting=false, cancelExport=false;
464
+ let audioCtx=null, mediaSrc=null, monitorGain=null, streamDest=null;
465
+ let lastBlob=null, lastExt="webm", lastMeta=null;
466
+ let segments=[], activeSeg=-1, segId=0;
467
+
468
+ /* ---------------- file loading ---------------- */
469
+ const drop=$("drop"), fileInput=$("file");
470
+ fileInput.addEventListener("click", e=>e.stopPropagation());
471
+ drop.addEventListener("click", ()=>fileInput.click());
472
+ fileInput.addEventListener("change", e=>{ if(e.target.files[0]) load(e.target.files[0]); e.target.value=""; });
473
+ ["dragenter","dragover"].forEach(ev=>drop.addEventListener(ev,e=>{e.preventDefault();drop.classList.add("over")}));
474
+ ["dragleave","drop"].forEach(ev=>drop.addEventListener(ev,e=>{e.preventDefault();drop.classList.remove("over")}));
475
+ // ONE drop handler only, on the window — two handlers meant a drop started the load twice
476
+ window.addEventListener("dragover", e=>e.preventDefault());
477
+ window.addEventListener("drop", e=>{
478
+ e.preventDefault(); drop.classList.remove("over");
479
+ const f = e.dataTransfer && e.dataTransfer.files[0];
480
+ if(f) load(f);
481
+ });
482
+ $("newBtn").addEventListener("click", ()=>fileInput.click());
483
+
484
+ const DROP_HTML = drop.innerHTML;
485
+ let loadSeq = 0; // only the newest load() is allowed to touch the UI
486
+ let loadedOnce = false;
487
+
488
+ async function load(file){
489
+ if(exporting){ alert("Hang on until the export finishes, then load the next video."); return; }
490
+ if(segments.length && loadedOnce &&
491
+ !confirm("Loading a new video clears the clip list ("+segments.length+" clips). Continue?")) return;
492
+
493
+ const myId = ++loadSeq;
494
+ const newUrl = URL.createObjectURL(file);
495
+ const prevUrl = srcUrl;
496
+
497
+ const bail = (msg)=>{
498
+ URL.revokeObjectURL(newUrl);
499
+ if(myId !== loadSeq) return; // a newer load took over — stay quiet
500
+ srcUrl = prevUrl;
501
+ if(loadedOnce){ // don't destroy a session that's already open
502
+ if(V.src !== prevUrl && prevUrl){ V.src = prevUrl; }
503
+ alert(msg+"\n\nYour current video is still open.");
504
+ }else{
505
+ drop.innerHTML = DROP_HTML; drop.style.display="block"; $("app").style.display="none";
506
+ alert(msg);
507
+ }
508
+ };
509
+
510
+ srcName = (file.name||"clip").replace(/\.[^.]+$/,"") || "clip";
511
+ srcUrl = newUrl;
512
+ clearResult(); clearSaved();
513
+ V.src = newUrl;
514
+ V.muted = false;
515
+
516
+ const how = await new Promise(res=>{
517
+ let settled=false, timer=0;
518
+ const done = v => { if(settled) return; settled=true; clearTimeout(timer);
519
+ V.onloadedmetadata=null; V.onerror=null; res(v); };
520
+ V.onloadedmetadata = ()=>done("ok");
521
+ V.onerror = ()=>done("err");
522
+ timer = setTimeout(()=>done("slow"), 120000);
523
+ });
524
+ if(myId !== loadSeq){ URL.revokeObjectURL(newUrl); return; }
525
+
526
+ 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).");
527
+ 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.");
528
+
529
+ dur = V.duration;
530
+ if(!isFinite(dur) || dur<=0){ // some WebM/MKV files report no duration until you seek past the end
531
+ try{
532
+ V.currentTime = 1e7;
533
+ await Promise.race([once(V,"seeked"), new Promise(r=>setTimeout(r,2500))]);
534
+ dur = isFinite(V.duration) ? V.duration : V.currentTime;
535
+ V.currentTime = 0;
536
+ await Promise.race([once(V,"seeked"), new Promise(r=>setTimeout(r,1500))]);
537
+ }catch(e){}
538
+ }
539
+ if(myId !== loadSeq){ URL.revokeObjectURL(newUrl); return; }
540
+ 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.");
541
+
542
+ if(prevUrl) URL.revokeObjectURL(prevUrl); // safe now: the new file is definitely in
543
+ natW = V.videoWidth || 640; natH = V.videoHeight || 360;
544
+ inT = 0; outT = dur;
545
+ segments = []; activeSeg = -1;
546
+ removeCrop();
547
+
548
+ if(!loadedOnce) drop.innerHTML = '<div class="big">&#9634;</div><h2>Reading video…</h2><p>one moment</p>';
549
+ srcFps = 30;
550
+ await detectFps();
551
+ if(myId !== loadSeq) return;
552
+
553
+ loadedOnce = true;
554
+ drop.style.display="none"; $("app").style.display="block"; $("newBtn").style.display="inline-block";
555
+
556
+ $("w").value = natW; $("h").value = natH;
557
+ $("totTime").textContent = tc(dur);
558
+ $("rulEnd").textContent = tc(dur,false);
559
+ $("rulMid").textContent = tc(dur/2,false);
560
+
561
+ buildFpsSeg();
562
+ $("fps").value = Math.round(srcFps);
563
+ $("srcBadge").textContent = natW+"×"+natH+" · "+Math.round(srcFps)+" fps · "+tc(dur,false);
564
+ document.title = srcName+" — Nugget Video Trimmer";
565
+ markScale(1);
566
+ refresh(); update();
567
+ buildStrip(newUrl, myId);
568
+ }
569
+
570
+ /* estimate source fps by counting presented frames over a short muted sample */
571
+ async function detectFps(){
572
+ if(!("requestVideoFrameCallback" in HTMLVideoElement.prototype)) return;
573
+ const prevMuted = V.muted;
574
+ V.muted = true;
575
+ let stop = false;
576
+ try{
577
+ V.currentTime = Math.min(0.2, dur/4);
578
+ await Promise.race([once(V,"seeked"), new Promise(r=>setTimeout(r,1500))]);
579
+ let first=null, last=null, n=0;
580
+ const done = new Promise(res=>{
581
+ const finish = ()=>{ stop=true; res(); };
582
+ const cb=(now,md)=>{
583
+ if(stop) return;
584
+ if(first===null) first=md.mediaTime; else { last=md.mediaTime; n++; }
585
+ if(n>=12 || (last!==null && last-first>0.5)) return finish();
586
+ V.requestVideoFrameCallback(cb);
587
+ };
588
+ V.requestVideoFrameCallback(cb);
589
+ setTimeout(finish, 1400); // rVFC never fired (or is throttled) — give up
590
+ });
591
+ await V.play().catch(()=>{});
592
+ await done;
593
+ if(n>2 && last>first){
594
+ const f = n/(last-first);
595
+ if(f>4 && f<125) srcFps = Math.round(f*100)/100;
596
+ }
597
+ }catch(e){}
598
+ stop = true;
599
+ try{ V.pause(); }catch(e){}
600
+ V.muted = prevMuted;
601
+ V.currentTime = 0;
602
+ await Promise.race([once(V,"seeked"), new Promise(r=>setTimeout(r,800))]);
603
+ }
604
+
605
+ /* ---------------- filmstrip ---------------- */
606
+ async function buildStrip(url, id){
607
+ const strip=$("strip"); strip.innerHTML="";
608
+ const N=12, tw=160, th=90, total=dur;
609
+ const v=document.createElement("video");
610
+ v.src=url; v.muted=true; v.playsInline=true;
611
+ try{
612
+ await Promise.race([once(v,"loadeddata"), once(v,"error"), new Promise(r=>setTimeout(r,15000))]);
613
+ }catch(e){ return; }
614
+ for(let i=0;i<N;i++){
615
+ if(id !== loadSeq){ v.removeAttribute("src"); return; }
616
+ const c=document.createElement("canvas"); c.width=tw; c.height=th;
617
+ strip.appendChild(c);
618
+ try{
619
+ v.currentTime = Math.min(total-0.03, (i+0.5)*total/N);
620
+ await Promise.race([once(v,"seeked"), new Promise(r=>setTimeout(r,1500))]);
621
+ c.getContext("2d").drawImage(v,0,0,tw,th);
622
+ }catch(e){}
623
+ }
624
+ v.removeAttribute("src");
625
+ }
626
+
627
+ /* ---------------- transport ---------------- */
628
+ $("playBtn").addEventListener("click", togglePlay);
629
+ function togglePlay(){
630
+ if(exporting) return;
631
+ if(V.paused){ if(V.currentTime<inT-0.01||V.currentTime>=outT-0.01) V.currentTime=inT; V.play(); }
632
+ else V.pause();
633
+ }
634
+ V.addEventListener("play", ()=>$("playBtn").innerHTML="&#10073;&#10073;");
635
+ V.addEventListener("pause",()=>$("playBtn").innerHTML="&#9654;");
636
+ V.addEventListener("timeupdate", update);
637
+ V.addEventListener("seeked", update);
638
+
639
+ function rafLoop(){
640
+ if(!V.paused && !exporting){
641
+ if(V.currentTime>=outT-0.02){ if(looping){ V.currentTime=inT; } else { V.pause(); V.currentTime=outT; } }
642
+ update();
643
+ }
644
+ requestAnimationFrame(rafLoop);
645
+ }
646
+ requestAnimationFrame(rafLoop);
647
+
648
+ $("prevF").addEventListener("click", ()=>step(-1));
649
+ $("nextF").addEventListener("click", ()=>step(1));
650
+ function step(dir, big){
651
+ V.pause();
652
+ const d = big ? 1 : 1/(srcFps||30);
653
+ V.currentTime = clamp(V.currentTime + dir*d, 0, dur);
654
+ }
655
+ /* Setting a start later than the current end used to clamp the start back onto
656
+ the end. Marking a new clip after the previous one is the normal way to work,
657
+ so the other mark gets out of the way instead. */
658
+ function setIn(t){
659
+ inT = clamp(t, 0, Math.max(0, dur-0.02));
660
+ if(outT <= inT + 0.02) outT = dur; // start moved past the end — open the end up
661
+ refresh();
662
+ }
663
+ function setOut(t){
664
+ outT = clamp(t, 0.02, dur);
665
+ if(inT >= outT - 0.02) inT = 0; // end moved before the start — open the start up
666
+ refresh();
667
+ }
668
+ $("setIn").addEventListener("click", ()=>setIn(V.currentTime));
669
+ $("setOut").addEventListener("click", ()=>setOut(V.currentTime));
670
+ $("resetSel").addEventListener("click", ()=>{ inT=0; outT=dur; refresh(); });
671
+ $("loopBtn").addEventListener("click", e=>{ looping=!looping; e.target.textContent="Loop: "+(looping?"on":"off"); e.target.classList.toggle("ghost",!looping); });
672
+
673
+ /* ---------------- click-to-type time fields ---------------- */
674
+ function parseTime(s){ // "1:23.4", "0:05", "83.4", "1:02:03"
675
+ s = String(s).trim(); if(!s) return null;
676
+ const parts = s.split(":").map(x=>parseFloat(x));
677
+ if(parts.some(x=>isNaN(x))) return null;
678
+ return parts.reduce((a,x)=>a*60+x, 0);
679
+ }
680
+ function makeTimeEditor(el, get, set){
681
+ el.addEventListener("click", function(e){
682
+ e.stopPropagation();
683
+ if(exporting || el.querySelector("input")) return;
684
+ const inp = document.createElement("input");
685
+ inp.value = tc(get()); el.textContent=""; el.appendChild(inp);
686
+ inp.focus(); inp.select();
687
+ let done=false;
688
+ const commit = ok => {
689
+ if(done) return; done=true;
690
+ const v = ok ? parseTime(inp.value) : null;
691
+ inp.remove();
692
+ if(v !== null && isFinite(v)) set(v);
693
+ refresh();
694
+ };
695
+ inp.addEventListener("blur", ()=>commit(true));
696
+ inp.addEventListener("click", ev=>ev.stopPropagation());
697
+ inp.addEventListener("keydown", ev=>{
698
+ ev.stopPropagation();
699
+ if(ev.key==="Enter"){ ev.preventDefault(); commit(true); }
700
+ if(ev.key==="Escape"){ ev.preventDefault(); commit(false); }
701
+ });
702
+ });
703
+ }
704
+ makeTimeEditor($("pIn"), ()=>inT, v=>{ setIn(v); V.currentTime=inT; });
705
+ makeTimeEditor($("pOut"), ()=>outT, v=>{ setOut(v); V.currentTime=outT; });
706
+ makeTimeEditor($("pDur"), ()=>outT-inT, v=>{
707
+ const len = clamp(v, 0.02, dur);
708
+ if(inT + len <= dur){ outT = inT + len; }
709
+ else { outT = dur; inT = Math.max(0, dur - len); }
710
+ V.currentTime = inT;
711
+ });
712
+
713
+ document.addEventListener("keydown", e=>{
714
+ if(exporting) return;
715
+ const t=e.target.tagName;
716
+ if(t==="INPUT"||t==="SELECT"||t==="TEXTAREA") return;
717
+ const k=e.key;
718
+ if(k===" "){ e.preventDefault(); togglePlay(); }
719
+ else if(k==="ArrowLeft"){ e.preventDefault(); step(-1, e.shiftKey); }
720
+ else if(k==="ArrowRight"){ e.preventDefault(); step(1, e.shiftKey); }
721
+ else if(k==="i"||k==="I"){ setIn(V.currentTime); }
722
+ else if(k==="o"||k==="O"){ setOut(V.currentTime); }
723
+ else if(k==="a"||k==="A"){ e.preventDefault(); addClip(); }
724
+ else if(k==="c"||k==="C"){ e.preventDefault(); toggleCrop(); }
725
+ // holding Shift turns "[" into "{" on most layouts, so accept both
726
+ else if(k==="["||k==="{"){ e.preventDefault(); nudge(e.shiftKey ? -1 : -1/(srcFps||30)); }
727
+ else if(k==="]"||k==="}"){ e.preventDefault(); nudge(e.shiftKey ? 1 : 1/(srcFps||30)); }
728
+ else if(k==="s"||k==="S"){ e.preventDefault(); takeSnapshot(); }
729
+ else if(k==="Home"){ V.currentTime=inT; }
730
+ else if(k==="End"){ V.currentTime=outT; }
731
+ });
732
+
733
+ /* ---------------- timeline drag ---------------- */
734
+ const tl=$("tl");
735
+ function posToTime(clientX){
736
+ const r=tl.getBoundingClientRect();
737
+ return clamp((clientX-r.left)/r.width,0,1)*dur;
738
+ }
739
+ tl.addEventListener("pointerdown", e=>{
740
+ if(exporting||!dur) return;
741
+ if(e.target===$("hIn")) dragging="in";
742
+ else if(e.target===$("hOut")) dragging="out";
743
+ else { dragging="seek"; V.pause(); V.currentTime=posToTime(e.clientX); }
744
+ tl.setPointerCapture(e.pointerId);
745
+ });
746
+ tl.addEventListener("pointermove", e=>{
747
+ if(!dragging) return;
748
+ const t=posToTime(e.clientX);
749
+ if(dragging==="in"){ inT=clamp(t,0,outT-0.02); V.currentTime=inT; }
750
+ else if(dragging==="out"){ outT=clamp(t,inT+0.02,dur); V.currentTime=outT; }
751
+ else V.currentTime=t;
752
+ refresh();
753
+ });
754
+ ["pointerup","pointercancel"].forEach(ev=>tl.addEventListener(ev,()=>dragging=null));
755
+
756
+ function refresh(){
757
+ const a = dur? inT/dur*100 : 0, b = dur? outT/dur*100 : 100;
758
+ $("maskL").style.left="0"; $("maskL").style.width=a+"%";
759
+ $("maskR").style.left=b+"%"; $("maskR").style.width=(100-b)+"%";
760
+ $("sel").style.left=a+"%"; $("sel").style.width=(b-a)+"%";
761
+ $("hIn").style.left=a+"%"; $("hOut").style.left=b+"%";
762
+ if(activeSeg>=0){ // highlight only while the selection still matches
763
+ const sg=segments[activeSeg];
764
+ if(!sg || Math.abs(sg.start-inT)>0.001 || Math.abs(sg.end-outT)>0.001) activeSeg=-1;
765
+ }
766
+ if(!$("pIn").querySelector("input")) $("pIn").textContent=tc(inT);
767
+ if(!$("pOut").querySelector("input")) $("pOut").textContent=tc(outT);
768
+ if(!$("pDur").querySelector("input")) $("pDur").textContent=tc(outT-inT);
769
+ renderClips(); renderBand(); renderShift(); estimate();
770
+ }
771
+ function update(){
772
+ $("curTime").textContent=tc(V.currentTime);
773
+ $("ph").style.left=(dur? V.currentTime/dur*100 : 0)+"%";
774
+ }
775
+
776
+ /* ---------------- shift the whole selection ----------------
777
+ Moves the start and end together so the length stays put. If the selection
778
+ currently matches a saved clip, that clip moves with it. */
779
+ const shiftBar=$("shiftBar"), shiftBlk=$("shiftBlk");
780
+ let shiftDrag=null;
781
+
782
+ function renderShift(){
783
+ if(!dur) return;
784
+ shiftBlk.style.left=(inT/dur*100)+"%";
785
+ shiftBlk.style.width=(Math.max(0.3,(outT-inT)/dur*100))+"%";
786
+ $("shiftLbl").innerHTML = (activeSeg>=0 && segments[activeSeg])
787
+ ? "Shifting <b>clip "+(activeSeg+1)+"</b> — keeps its length of "+tc(outT-inT)
788
+ : "Shift the selection — keeps its length of "+tc(outT-inT);
789
+ }
790
+ function shiftTo(newStart){
791
+ const len = outT-inT;
792
+ const a = clamp(newStart, 0, Math.max(0, dur-len));
793
+ inT = a; outT = a+len;
794
+ if(activeSeg>=0 && segments[activeSeg]){ // carry the saved clip along
795
+ segments[activeSeg].start = inT;
796
+ segments[activeSeg].end = outT;
797
+ }
798
+ V.currentTime = inT;
799
+ refresh();
800
+ }
801
+ function nudge(sec){ if(!exporting && dur) shiftTo(inT+sec); }
802
+
803
+ shiftBar.addEventListener("pointerdown", e=>{
804
+ if(exporting || !dur) return;
805
+ e.preventDefault();
806
+ const r=shiftBar.getBoundingClientRect();
807
+ if(e.target !== shiftBlk){ // clicked the track — centre the block there
808
+ shiftTo(clamp((e.clientX-r.left)/r.width,0,1)*dur - (outT-inT)/2);
809
+ }
810
+ shiftDrag={px:e.clientX, start:inT, w:r.width};
811
+ shiftBar.setPointerCapture(e.pointerId);
812
+ });
813
+ shiftBar.addEventListener("pointermove", e=>{
814
+ if(!shiftDrag) return;
815
+ shiftTo(shiftDrag.start + (e.clientX-shiftDrag.px)/shiftDrag.w*dur);
816
+ });
817
+ ["pointerup","pointercancel"].forEach(ev=>shiftBar.addEventListener(ev, ()=>shiftDrag=null));
818
+ $("shiftBtns").addEventListener("click", e=>{
819
+ const b=e.target.closest("button"); if(!b) return;
820
+ const d=b.dataset.d, f=1/(srcFps||30);
821
+ nudge(d==="f" ? f : d==="-f" ? -f : parseFloat(d));
822
+ });
823
+
824
+ /* ---------------- clip list ---------------- */
825
+ $("addClip").addEventListener("click", addClip);
826
+ function addClip(){
827
+ if(exporting || !dur) return;
828
+ if(outT - inT < 0.05){ setStatus("Selection is too short to add."); return; }
829
+ segments.push({id:++segId, start:inT, end:outT, name:""});
830
+ activeSeg = segments.length-1;
831
+ setStatus("");
832
+ refresh();
833
+ }
834
+ $("clearClips").addEventListener("click", ()=>{
835
+ if(exporting || !segments.length) return;
836
+ if(segments.length>1 && !confirm("Remove all "+segments.length+" clips from the list?")) return;
837
+ segments=[]; activeSeg=-1; refresh();
838
+ });
839
+
840
+ function renderClips(){
841
+ const wrap=$("clipsWrap"), list=$("clipList");
842
+ wrap.style.display = segments.length ? "block" : "none";
843
+ $("clipsTitle").textContent = "Clip list ("+segments.length+")";
844
+ if(!segments.length){ list.innerHTML=""; return; }
845
+ if(list.querySelector("input:focus")) return; // don't yank a field being typed in
846
+
847
+ list.innerHTML="";
848
+ segments.forEach((s,i)=>{
849
+ const row=document.createElement("div");
850
+ row.className="crow"+(i===activeSeg?" on":"");
851
+
852
+ const num=document.createElement("span"); num.className="cnum"; num.textContent=(i+1);
853
+
854
+ const nameWrap=document.createElement("span"); nameWrap.className="cname";
855
+ const name=document.createElement("input");
856
+ name.type="text"; name.value=s.name||""; name.placeholder="name (optional)"; name.maxLength=40;
857
+ name.addEventListener("input", ()=>{ s.name=name.value; });
858
+ name.addEventListener("keydown", ev=>{ ev.stopPropagation(); if(ev.key==="Enter") name.blur(); });
859
+ name.addEventListener("click", ev=>ev.stopPropagation());
860
+ nameWrap.appendChild(name);
861
+
862
+ const times=document.createElement("span"); times.className="ctimes";
863
+ const bS=document.createElement("b"); bS.className="edit"; bS.textContent=tc(s.start);
864
+ const bE=document.createElement("b"); bE.className="edit"; bE.textContent=tc(s.end);
865
+ times.appendChild(bS); times.appendChild(document.createTextNode(" → ")); times.appendChild(bE);
866
+ makeTimeEditor(bS, ()=>s.start, v=>{ s.start = clamp(v, 0, s.end-0.02); });
867
+ makeTimeEditor(bE, ()=>s.end, v=>{ s.end = clamp(v, s.start+0.02, dur); });
868
+
869
+ const d=document.createElement("span"); d.className="cdur"; d.textContent=tc(s.end-s.start);
870
+
871
+ const btns=document.createElement("span"); btns.className="cbtns";
872
+ const prev=document.createElement("button");
873
+ prev.textContent="▶"; prev.title="Load this clip onto the timeline and play it";
874
+ prev.addEventListener("click", ev=>{ ev.stopPropagation();
875
+ if(exporting) return;
876
+ activeSeg=i; inT=s.start; outT=s.end; V.currentTime=s.start; refresh();
877
+ V.play().catch(()=>{}); });
878
+ const use=document.createElement("button");
879
+ use.textContent="Set"; use.title="Replace this clip's start and end with the current timeline selection";
880
+ use.addEventListener("click", ev=>{ ev.stopPropagation();
881
+ if(exporting) return;
882
+ s.start=inT; s.end=outT; activeSeg=i; refresh(); });
883
+ const del=document.createElement("button");
884
+ del.textContent="✕"; del.title="Remove this clip"; del.className="danger";
885
+ del.addEventListener("click", ev=>{ ev.stopPropagation();
886
+ if(exporting) return;
887
+ segments.splice(i,1);
888
+ if(activeSeg>=segments.length) activeSeg=segments.length-1;
889
+ refresh(); });
890
+ btns.appendChild(prev); btns.appendChild(use); btns.appendChild(del);
891
+
892
+ row.appendChild(num); row.appendChild(nameWrap); row.appendChild(times);
893
+ row.appendChild(d); row.appendChild(btns);
894
+ row.addEventListener("click", ()=>{ // preview this clip on the timeline
895
+ if(exporting) return;
896
+ activeSeg=i; inT=s.start; outT=s.end; V.currentTime=s.start; refresh();
897
+ });
898
+ list.appendChild(row);
899
+ });
900
+ }
901
+
902
+ function renderBand(){
903
+ const band=$("segBand"); band.innerHTML="";
904
+ if(!dur) return;
905
+ segments.forEach((s,i)=>{
906
+ const el=document.createElement("div");
907
+ el.className="segbar"+(i===activeSeg?" on":"");
908
+ el.style.left=(s.start/dur*100)+"%";
909
+ el.style.width=Math.max(0.4,(s.end-s.start)/dur*100)+"%";
910
+ el.textContent=(i+1);
911
+ band.appendChild(el);
912
+ });
913
+ }
914
+
915
+ /* the list is what gets exported; with an empty list it's just the selection */
916
+ function exportList(){
917
+ return segments.length ? segments.map(s=>({start:s.start,end:s.end,name:s.name}))
918
+ : [{start:inT,end:outT,name:""}];
919
+ }
920
+ function totalLen(){ return exportList().reduce((a,s)=>a+Math.max(0,s.end-s.start),0); }
921
+
922
+ /* ---------------- crop ----------------
923
+ Held in source pixels so it survives any output size change. Everything that
924
+ sizes the output asks srcW()/srcH() rather than the video's own dimensions. */
925
+ let crop=null, cropAR=0, cropARLabel="free", curScale=1;
926
+ function srcW(){ return crop ? crop.w : natW; }
927
+ function srcH(){ return crop ? crop.h : natH; }
928
+ function drawSource(dctx, dw, dh){
929
+ dctx.imageSmoothingEnabled=true; dctx.imageSmoothingQuality="high";
930
+ if(crop) dctx.drawImage(V, crop.x, crop.y, crop.w, crop.h, 0, 0, dw, dh);
931
+ else dctx.drawImage(V, 0, 0, dw, dh);
932
+ }
933
+
934
+ function videoBox(){
935
+ const st=$("stage").getBoundingClientRect(), v=V.getBoundingClientRect();
936
+ return {left:v.left-st.left, top:v.top-st.top, w:v.width, h:v.height};
937
+ }
938
+ function layoutCrop(){
939
+ if(!crop || !natW) return;
940
+ const L=$("cropLayer"), b=videoBox();
941
+ if(!b.w || !b.h) return;
942
+ L.style.left=b.left+"px"; L.style.top=b.top+"px";
943
+ L.style.width=b.w+"px"; L.style.height=b.h+"px";
944
+ const sx=b.w/natW, sy=b.h/natH, B=$("cropBox");
945
+ B.style.left=(crop.x*sx)+"px"; B.style.top=(crop.y*sy)+"px";
946
+ B.style.width=(crop.w*sx)+"px"; B.style.height=(crop.h*sy)+"px";
947
+ $("cropDims").textContent = Math.round(crop.w)+" × "+Math.round(crop.h);
948
+ }
949
+ function cropChanged(finalise){
950
+ if(crop){
951
+ crop.x=Math.max(0,Math.round(crop.x)); crop.y=Math.max(0,Math.round(crop.y));
952
+ crop.w=Math.min(Math.round(crop.w), natW-crop.x);
953
+ crop.h=Math.min(Math.round(crop.h), natH-crop.y);
954
+ }
955
+ layoutCrop();
956
+ $("cropInfo").textContent = crop
957
+ ? "Keeping "+crop.w+" × "+crop.h+" ("+cropARLabel+") out of "+natW+" × "+natH+" · drag the box or its handles"
958
+ : "";
959
+ if(finalise){ // only resize the output once the drag ends
960
+ if(curScale){ W.value=even(srcW()*curScale); H.value=even(srcH()*curScale); markScale(curScale); }
961
+ else if(lock.checked){ H.value=even((+W.value||srcW())*srcH()/srcW()); }
962
+ }
963
+ estimate();
964
+ }
965
+ function setCrop(x,y,w,h){
966
+ w=Math.max(16,Math.min(w,natW)); h=Math.max(16,Math.min(h,natH));
967
+ crop={x:clamp(x,0,natW-w), y:clamp(y,0,natH-h), w, h};
968
+ }
969
+ function snapToAR(ar){
970
+ if(!ar || !crop) return;
971
+ const cx=crop.x+crop.w/2, cy=crop.y+crop.h/2;
972
+ let w=Math.min(natW, Math.max(crop.w, crop.h*ar)), h=w/ar;
973
+ if(h>natH){ h=natH; w=h*ar; }
974
+ setCrop(cx-w/2, cy-h/2, w, h);
975
+ }
976
+ function biggestFit(ar){
977
+ let w=natW, h=natW/(ar||natW/natH);
978
+ if(h>natH){ h=natH; w=h*(ar||natW/natH); }
979
+ setCrop((natW-w)/2, (natH-h)/2, w, h);
980
+ }
981
+
982
+ function showCrop(on){
983
+ $("cropLayer").style.display = on ? "block" : "none";
984
+ $("cropSection").style.display = on ? "block" : "none";
985
+ $("cropBtn").classList.toggle("on", on);
986
+ }
987
+ $("cropBtn").addEventListener("click", toggleCrop);
988
+ function toggleCrop(){
989
+ if(exporting || !dur) return;
990
+ if(crop){ removeCrop(); return; }
991
+ if(cropAR) biggestFit(cropAR);
992
+ else setCrop(natW*0.1, natH*0.1, natW*0.8, natH*0.8);
993
+ showCrop(true); markAR(); cropChanged(true);
994
+ }
995
+ function removeCrop(){
996
+ crop=null; cropAR=0; cropARLabel="free";
997
+ showCrop(false);
998
+ if(curScale){ W.value=even(natW*curScale); H.value=even(natH*curScale); markScale(curScale); }
999
+ $("cropInfo").textContent="";
1000
+ estimate();
1001
+ }
1002
+ $("cropMax").addEventListener("click", ()=>{ if(!crop) return; biggestFit(cropAR||natW/natH); cropChanged(true); });
1003
+
1004
+ function applyAR(ar, label){
1005
+ cropAR = ar; cropARLabel = label;
1006
+ markAR();
1007
+ if(cropAR) snapToAR(cropAR);
1008
+ cropChanged(true);
1009
+ }
1010
+ document.querySelectorAll(".arRow").forEach(row=>{
1011
+ row.addEventListener("click", e=>{
1012
+ const b=e.target.closest("button"); if(!b || !crop) return;
1013
+ const v=b.dataset.ar;
1014
+ applyAR(v==="src" ? natW/natH : parseFloat(v), v==="src" ? "original" : b.dataset.lbl);
1015
+ });
1016
+ });
1017
+ function useCustomAR(){
1018
+ if(!crop) return;
1019
+ const a=+$("arW").value, b=+$("arH").value;
1020
+ if(!(a>0) || !(b>0)){ $("arW").focus(); return; }
1021
+ applyAR(a/b, a+":"+b);
1022
+ }
1023
+ $("arApply").addEventListener("click", useCustomAR);
1024
+ ["arW","arH"].forEach(id=>$(id).addEventListener("keydown", e=>{
1025
+ e.stopPropagation();
1026
+ if(e.key==="Enter"){ e.preventDefault(); useCustomAR(); }
1027
+ }));
1028
+ function markAR(){
1029
+ let matched=false;
1030
+ document.querySelectorAll(".arRow button").forEach(b=>{
1031
+ const v=b.dataset.ar, val = v==="src" ? (natW/natH) : parseFloat(v);
1032
+ const on = (!cropAR && v==="0") || (cropAR>0 && Math.abs(val-cropAR) < 0.004);
1033
+ b.classList.toggle("on", on);
1034
+ if(on) matched=true;
1035
+ });
1036
+ $("arCustom").classList.toggle("on", !matched && cropAR>0);
1037
+ }
1038
+
1039
+ let cropDrag=null;
1040
+ $("cropLayer").addEventListener("pointerdown", e=>{
1041
+ const h=e.target.dataset.h;
1042
+ if(!h || exporting || !crop) return;
1043
+ e.preventDefault();
1044
+ const b=videoBox();
1045
+ cropDrag={h, s:{...crop}, px:e.clientX, py:e.clientY, sx:b.w/natW, sy:b.h/natH};
1046
+ $("cropLayer").setPointerCapture(e.pointerId);
1047
+ });
1048
+ $("cropLayer").addEventListener("pointermove", e=>{
1049
+ if(!cropDrag) return;
1050
+ const dx=(e.clientX-cropDrag.px)/cropDrag.sx, dy=(e.clientY-cropDrag.py)/cropDrag.sy;
1051
+ const s=cropDrag.s, H=cropDrag.h, ar=cropAR;
1052
+ if(H==="move"){
1053
+ setCrop(s.x+dx, s.y+dy, s.w, s.h);
1054
+ }else if(ar){
1055
+ if(H==="n"||H==="s"){
1056
+ let hh = H==="n" ? s.h-dy : s.h+dy;
1057
+ hh=Math.max(16,Math.min(hh,natH)); let ww=hh*ar;
1058
+ if(ww>natW){ ww=natW; hh=ww/ar; }
1059
+ const cx=s.x+s.w/2, y = H==="n" ? (s.y+s.h)-hh : s.y;
1060
+ setCrop(cx-ww/2, y, ww, hh);
1061
+ }else if(H==="e"||H==="w"){
1062
+ let ww = H==="w" ? s.w-dx : s.w+dx;
1063
+ ww=Math.max(16,Math.min(ww,natW)); let hh=ww/ar;
1064
+ if(hh>natH){ hh=natH; ww=hh*ar; }
1065
+ const cy=s.y+s.h/2, x = H==="w" ? (s.x+s.w)-ww : s.x;
1066
+ setCrop(x, cy-hh/2, ww, hh);
1067
+ }else{ // corner: opposite corner stays put
1068
+ const west=H.indexOf("w")>=0, north=H.indexOf("n")>=0;
1069
+ const ax = west ? s.x+s.w : s.x, ay = north ? s.y+s.h : s.y;
1070
+ const availW = west ? ax : natW-ax, availH = north ? ay : natH-ay;
1071
+ let ww = Math.max(west ? s.w-dx : s.w+dx, (north ? s.h-dy : s.h+dy)*ar);
1072
+ ww = Math.max(16, Math.min(ww, availW, availH*ar));
1073
+ const hh = ww/ar;
1074
+ setCrop(west ? ax-ww : ax, north ? ay-hh : ay, ww, hh);
1075
+ }
1076
+ }else{
1077
+ let l=s.x, tp=s.y, r=s.x+s.w, bt=s.y+s.h;
1078
+ if(H.indexOf("w")>=0) l=clamp(s.x+dx, 0, r-16);
1079
+ if(H.indexOf("e")>=0) r=clamp(s.x+s.w+dx, l+16, natW);
1080
+ if(H.indexOf("n")>=0) tp=clamp(s.y+dy, 0, bt-16);
1081
+ if(H.indexOf("s")>=0) bt=clamp(s.y+s.h+dy, tp+16, natH);
1082
+ setCrop(l, tp, r-l, bt-tp);
1083
+ }
1084
+ cropChanged(false);
1085
+ });
1086
+ ["pointerup","pointercancel"].forEach(ev=>$("cropLayer").addEventListener(ev, ()=>{
1087
+ if(cropDrag){ cropDrag=null; cropChanged(true); }
1088
+ }));
1089
+ window.addEventListener("resize", layoutCrop);
1090
+ if(window.ResizeObserver) new ResizeObserver(layoutCrop).observe($("stage"));
1091
+ V.addEventListener("loadeddata", layoutCrop);
1092
+
1093
+ /* ---------------- size ---------------- */
1094
+ const W=$("w"), H=$("h"), lock=$("lockAr");
1095
+ $("scaleSeg").addEventListener("click", e=>{
1096
+ const b=e.target.closest("button"); if(!b) return;
1097
+ const s=parseFloat(b.dataset.s);
1098
+ W.value=even(srcW()*s); H.value=even(srcH()*s); markScale(s); estimate();
1099
+ });
1100
+ function markScale(s){
1101
+ curScale = s;
1102
+ [...$("scaleSeg").children].forEach(b=>b.classList.toggle("on", parseFloat(b.dataset.s)===s));
1103
+ }
1104
+ W.addEventListener("input", ()=>{ if(lock.checked && srcW()) H.value=even(W.value*srcH()/srcW()); markScale(null); estimate(); });
1105
+ H.addEventListener("input", ()=>{ if(lock.checked && srcH()) W.value=even(H.value*srcW()/srcH()); markScale(null); estimate(); });
1106
+
1107
+ /* ---------------- fps ---------------- */
1108
+ function buildFpsSeg(){
1109
+ const seg=$("fpsSeg"); seg.innerHTML="";
1110
+ 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);
1111
+ opts.forEach(f=>{
1112
+ const b=document.createElement("button");
1113
+ b.textContent = (f===Math.round(srcFps)? "Same ("+f+")" : f);
1114
+ b.dataset.f=f;
1115
+ b.addEventListener("click",()=>{ $("fps").value=f; markFps(); estimate(); });
1116
+ seg.appendChild(b);
1117
+ });
1118
+ markFps();
1119
+ }
1120
+ function markFps(){
1121
+ const f=+$("fps").value;
1122
+ [...$("fpsSeg").children].forEach(b=>b.classList.toggle("on", +b.dataset.f===f));
1123
+ }
1124
+ $("fps").addEventListener("input", ()=>{ markFps(); estimate(); });
1125
+
1126
+ /* ---------------- quality / estimate ---------------- */
1127
+ $("q").addEventListener("input", estimate);
1128
+ $("keepAudio").addEventListener("change", estimate);
1129
+ /* bits per pixel per frame. The browser's recorder is a realtime encoder, so it
1130
+ needs noticeably more bitrate than an offline one to look the same. At the
1131
+ 78 default this is ~7 Mbps for 720p30 and ~16 Mbps for 1080p30. */
1132
+ function qualityBpp(){
1133
+ const q=+$("q").value/100;
1134
+ return 0.035 * Math.pow(0.55/0.035, q); // 0.035 → 0.55
1135
+ }
1136
+ function targets(){
1137
+ const w=even(clamp(+W.value||srcW(),16,7680)), h=even(clamp(+H.value||srcH(),16,4320));
1138
+ const f=clamp(+$("fps").value||Math.round(srcFps),1,120);
1139
+ const bitrate=Math.round(clamp(w*h*f*qualityBpp(), 250000, 120000000));
1140
+ return {w,h,f,bitrate};
1141
+ }
1142
+ function estimate(){
1143
+ const t=targets(), n=exportList().length, len=totalLen();
1144
+ $("eSize").textContent = t.w+"×"+t.h;
1145
+ $("eRate").textContent = (t.bitrate/1e6).toFixed(2)+" Mbps";
1146
+ $("eBytesLbl").textContent = n>1 ? "Est. size, all "+n : "Est. file size";
1147
+ $("eCodec").textContent = codecLabel();
1148
+ $("eBytes").textContent = bytes(t.bitrate*len/8 + (wantAudio()?192000:0)*len/8);
1149
+ $("eTime").textContent = len<1 ? "—" : (len<60? Math.ceil(len)+" s" : Math.ceil(len/60)+" min");
1150
+ const q=+$("q").value;
1151
+ $("qHint").textContent = (q<25?"Small file — fine for quick previews"
1152
+ : q<50?"Balanced — good for email and chat"
1153
+ : q<72?"High — good for web and presentations"
1154
+ : q<90?"Very high — hard to tell from the original"
1155
+ : "Maximum — biggest file, best detail");
1156
+ $("sizeHint").textContent = (crop ? "Cropped to "+crop.w+"×"+crop.h+" (from "+natW+"×"+natH+")."
1157
+ : "Source "+natW+"×"+natH+".")+" Width and height are rounded to even numbers.";
1158
+ $("exportBtn").textContent = n>1 ? "Export "+n+" clips" : (segments.length===1 ? "Export 1 clip" : "Export clip");
1159
+ $("gifBtn").textContent = n>1 ? "Export "+n+" GIFs" : "Export as GIF";
1160
+ }
1161
+
1162
+ /* ---------------- recording engine ---------------- */
1163
+ /* H.264 High profile first — Baseline (the old default) has no CABAC and no
1164
+ B-frames, which is a big quality loss at the same bitrate. Level 5.2 so 4K
1165
+ is inside the profile limits. */
1166
+ const MIMES = [
1167
+ "video/mp4;codecs=avc1.640034,mp4a.40.2", // High 5.2
1168
+ "video/mp4;codecs=avc1.64002A,mp4a.40.2", // High 4.2
1169
+ "video/mp4;codecs=avc1.4D402A,mp4a.40.2", // Main 4.2
1170
+ "video/mp4;codecs=avc1.640034",
1171
+ "video/mp4",
1172
+ "video/webm;codecs=vp9,opus",
1173
+ "video/webm;codecs=vp9",
1174
+ "video/webm;codecs=vp8,opus",
1175
+ "video/webm"
1176
+ ];
1177
+ function pickMime(){
1178
+ for(const m of MIMES) if(window.MediaRecorder && MediaRecorder.isTypeSupported(m)) return m;
1179
+ return "";
1180
+ }
1181
+ function codecLabel(){
1182
+ const m = pickMime();
1183
+ if(!m) return "none";
1184
+ if(/avc1\.64/.test(m)) return "H.264 High";
1185
+ if(/avc1\.4D/.test(m)) return "H.264 Main";
1186
+ if(/avc1/.test(m)) return "H.264";
1187
+ if(/mp4/.test(m)) return "MP4";
1188
+ if(/vp9/.test(m)) return "VP9";
1189
+ if(/vp8/.test(m)) return "VP8";
1190
+ return m;
1191
+ }
1192
+ function wantAudio(){ const c=$("keepAudio"); return !c || c.checked; }
1193
+ function setupAudio(){
1194
+ if(audioCtx) return;
1195
+ try{
1196
+ audioCtx = new (window.AudioContext||window.webkitAudioContext)();
1197
+ mediaSrc = audioCtx.createMediaElementSource(V);
1198
+ monitorGain = audioCtx.createGain(); monitorGain.gain.value = 1;
1199
+ mediaSrc.connect(monitorGain); monitorGain.connect(audioCtx.destination);
1200
+ streamDest = audioCtx.createMediaStreamDestination();
1201
+ mediaSrc.connect(streamDest);
1202
+ }catch(e){ audioCtx=null; }
1203
+ }
1204
+
1205
+ /* records V from `a` to `z` onto the export canvas; resolves with {blob,ext,stalled,speed} */
1206
+ /* A clock that keeps ticking when the tab is in the background.
1207
+
1208
+ The bug this fixes: the whole export used to run inside requestAnimationFrame.
1209
+ Chrome stops firing rAF when a tab is hidden (or the window is fully covered),
1210
+ so the canvas was never repainted and the "we've reached the end, stop now"
1211
+ check never ran. Result: one frozen video frame plus audio, recording on until
1212
+ the source video ended. Web Audio runs on a realtime thread the browser does
1213
+ not throttle, so it drives things now; rAF and a timer are extra safety. */
1214
+ function makeClock(fn){
1215
+ const stops=[];
1216
+ try{
1217
+ if(audioCtx && audioCtx.createScriptProcessor){
1218
+ const node = audioCtx.createScriptProcessor(1024, 1, 1); // ~21 ms, ~47 Hz
1219
+ const mute = audioCtx.createGain(); mute.gain.value = 0;
1220
+ node.onaudioprocess = ()=>fn();
1221
+ node.connect(mute); mute.connect(audioCtx.destination);
1222
+ stops.push(()=>{ node.onaudioprocess=null; try{ node.disconnect(); mute.disconnect(); }catch(e){} });
1223
+ }
1224
+ }catch(e){}
1225
+ if(typeof requestAnimationFrame === "function"){
1226
+ let alive=true, raf=0;
1227
+ const loop=()=>{ if(!alive) return; fn(); raf=requestAnimationFrame(loop); };
1228
+ raf=requestAnimationFrame(loop);
1229
+ stops.push(()=>{ alive=false; try{ cancelAnimationFrame(raf); }catch(e){} });
1230
+ }
1231
+ const id=setInterval(fn, 60);
1232
+ stops.push(()=>clearInterval(id));
1233
+ return ()=>stops.forEach(f=>{ try{ f(); }catch(e){} });
1234
+ }
1235
+
1236
+ let wakeLock=null;
1237
+ async function keepAwake(on){
1238
+ try{
1239
+ if(on && navigator.wakeLock && !wakeLock){ wakeLock = await navigator.wakeLock.request("screen"); }
1240
+ else if(!on && wakeLock){ await wakeLock.release(); wakeLock=null; }
1241
+ }catch(e){ wakeLock=null; }
1242
+ }
1243
+
1244
+ async function recordRange(a, z, t, mime, ctx, cv, onProg){
1245
+ const len = Math.max(0.02, z-a);
1246
+
1247
+ // Let the canvas sample on its own clock. Driving frames by hand with
1248
+ // requestFrame() was tried and measured worse: roughly one run in three came
1249
+ // out badly short (0.85 s for a 2.00 s clip), whereas this is consistent.
1250
+ const cStream = cv.captureStream(t.f);
1251
+
1252
+ const tracks = [...cStream.getVideoTracks()];
1253
+ if(streamDest && wantAudio()) tracks.push(...streamDest.stream.getAudioTracks());
1254
+ const stream = new MediaStream(tracks);
1255
+
1256
+ let rec;
1257
+ try{ rec = new MediaRecorder(stream, {mimeType:mime, videoBitsPerSecond:t.bitrate, audioBitsPerSecond:192000}); }
1258
+ catch(e){ rec = new MediaRecorder(stream, {mimeType:mime}); }
1259
+
1260
+ const chunks=[];
1261
+ rec.ondataavailable = e => { if(e.data && e.data.size) chunks.push(e.data); };
1262
+ const stopped = new Promise(r => rec.onstop = r);
1263
+
1264
+ const paintFrame = ()=> drawSource(ctx, t.w, t.h);
1265
+
1266
+ V.currentTime = a;
1267
+ await Promise.race([once(V,"seeked"), new Promise(r=>setTimeout(r,5000))]);
1268
+ drawSource(ctx, t.w, t.h);
1269
+
1270
+ let stopClock=null, stopping=false, stalled=false, aborted=false;
1271
+ let wall0=0, media0=a, stopAt=z, speed=1, pausedTotal=0, pausedAt=0, hiddenPaused=false;
1272
+ let lastT=V.currentTime, stallMs=0, prevNow=performance.now();
1273
+ const frameMs = 1000/Math.max(1,t.f);
1274
+ let nextFrameAt = 0;
1275
+ const maxMs = len*1000*3 + 8000; // a clip can never run away, whatever else breaks
1276
+
1277
+ const stop = (immediate)=>{
1278
+ const el=(performance.now()-wall0-pausedTotal)/1000;
1279
+ if(el>0.2) speed=(V.currentTime-media0)/el;
1280
+ V.pause();
1281
+ const finish = ()=>{
1282
+ if(stopClock){ stopClock(); stopClock=null; }
1283
+ try{ rec.stop(); }catch(e){}
1284
+ };
1285
+ // Hold on the last frame briefly before cutting the recorder. Without it the
1286
+ // final frame gets no screen time and a 10.000 s clip lands at 9.999 s —
1287
+ // which most players display as 0:09.
1288
+ if(immediate) finish();
1289
+ else setTimeout(finish, Math.max(110, 2000/Math.max(1,t.f)));
1290
+ };
1291
+
1292
+ // Chrome may stop decoding video for a hidden tab, so recording while hidden
1293
+ // can't be trusted. Pause instead and pick up where we left off.
1294
+ const onVis = ()=>{
1295
+ if(stopping) return;
1296
+ if(document.hidden && !hiddenPaused){
1297
+ hiddenPaused=true; pausedAt=performance.now();
1298
+ try{ V.pause(); }catch(e){}
1299
+ try{ if(rec.state==="recording") rec.pause(); }catch(e){}
1300
+ setStatus("Paused — come back to this tab to carry on exporting");
1301
+ }else if(!document.hidden && hiddenPaused){
1302
+ hiddenPaused=false; pausedTotal += performance.now()-pausedAt;
1303
+ prevNow=performance.now(); nextFrameAt=0;
1304
+ try{ if(rec.state==="paused") rec.resume(); }catch(e){}
1305
+ V.play().catch(()=>{});
1306
+ keepAwake(true);
1307
+ }
1308
+ };
1309
+ document.addEventListener("visibilitychange", onVis);
1310
+
1311
+ const tick = ()=>{
1312
+ if(stopping || hiddenPaused) return;
1313
+ const now=performance.now();
1314
+ if(now >= nextFrameAt){
1315
+ paintFrame();
1316
+ nextFrameAt = (nextFrameAt && now - nextFrameAt < frameMs) ? nextFrameAt + frameMs : now + frameMs;
1317
+ }
1318
+ if(Math.abs(V.currentTime-lastT)>0.001){ lastT=V.currentTime; stallMs=0; } else { stallMs += now-prevNow; }
1319
+ prevNow=now;
1320
+ if(onProg) onProg(clamp((V.currentTime-a)/len,0,1), Math.max(0,len-(V.currentTime-a)));
1321
+ if(cancelExport || V.currentTime >= stopAt || V.ended ||
1322
+ stallMs > 10000 || (now-wall0-pausedTotal) > maxMs){
1323
+ stopping=true;
1324
+ if(cancelExport) aborted=true; // half a clip is worse than none
1325
+ if(stallMs>10000 || (now-wall0-pausedTotal)>maxMs) stalled=true;
1326
+ stop(cancelExport || V.ended);
1327
+ }
1328
+ };
1329
+ const onEnded = ()=>{ if(!stopping){ stopping=true; stop(true); } };
1330
+ V.addEventListener("ended", onEnded);
1331
+
1332
+ // start playback FIRST, then the recorder — otherwise the frozen first frame
1333
+ // gets baked into the front of the clip and the output runs long
1334
+ V.playbackRate = 1;
1335
+ try{ await V.play(); }
1336
+ catch(e){
1337
+ document.removeEventListener("visibilitychange", onVis);
1338
+ V.removeEventListener("ended",onEnded);
1339
+ try{ cStream.getTracks().forEach(x=>x.stop()); }catch(e2){}
1340
+ throw new Error("Couldn't start playback: "+e.message);
1341
+ }
1342
+ // Measure the end point from where the recorder actually began, not from the
1343
+ // mark. play() resolves a few ms after the video starts moving, and that gap
1344
+ // used to come straight off the length of the clip.
1345
+ wall0 = performance.now(); media0 = V.currentTime;
1346
+ stopAt = Math.min(dur, media0 + len);
1347
+ prevNow = wall0; nextFrameAt = 0;
1348
+ rec.start(200);
1349
+ stopClock = makeClock(tick);
1350
+
1351
+ await stopped;
1352
+ if(stopClock){ stopClock(); stopClock=null; }
1353
+ document.removeEventListener("visibilitychange", onVis);
1354
+ V.removeEventListener("ended", onEnded);
1355
+ try{ cStream.getTracks().forEach(x=>x.stop()); }catch(e){}
1356
+
1357
+ const type = mime.split(";")[0];
1358
+ return { blob:new Blob(chunks,{type}), ext:(type.indexOf("mp4")>=0?"mp4":"webm"), stalled, speed, aborted };
1359
+ }
1360
+
1361
+ /* ---------------- GIF encoder ----------------
1362
+ Written out longhand so the file stays self-contained and works offline.
1363
+ GIF is limited to 256 colours per frame, so each frame gets its own palette
1364
+ (median cut) and Floyd-Steinberg dithering to stop gradients banding. */
1365
+ const _hist=new Int32Array(32768), _rs=new Int32Array(32768), _gs=new Int32Array(32768), _bs=new Int32Array(32768);
1366
+ const _bins=new Int32Array(32768), _cache=new Int16Array(32768);
1367
+
1368
+ function gifBytes(){
1369
+ let b=new Uint8Array(1<<18), n=0;
1370
+ const grow=k=>{ if(n+k<=b.length) return; let L=b.length; while(L<n+k) L*=2;
1371
+ const t=new Uint8Array(L); t.set(b.subarray(0,n)); b=t; };
1372
+ return {
1373
+ u8(v){ grow(1); b[n++]=v&255; },
1374
+ u16(v){ grow(2); b[n++]=v&255; b[n++]=(v>>8)&255; },
1375
+ str(s){ grow(s.length); for(let i=0;i<s.length;i++) b[n++]=s.charCodeAt(i); },
1376
+ raw(a){ grow(a.length); b.set(a,n); n+=a.length; },
1377
+ size(){ return n; },
1378
+ out(){ return b.slice(0,n); }
1379
+ };
1380
+ }
1381
+
1382
+ function buildPalette(data, maxColors){
1383
+ _hist.fill(0); _rs.fill(0); _gs.fill(0); _bs.fill(0);
1384
+ for(let i=0;i<data.length;i+=4){
1385
+ const r=data[i], g=data[i+1], b=data[i+2];
1386
+ const k=((r>>3)<<10)|((g>>3)<<5)|(b>>3);
1387
+ _hist[k]++; _rs[k]+=r; _gs[k]+=g; _bs[k]+=b;
1388
+ }
1389
+ let m=0; for(let k=0;k<32768;k++) if(_hist[k]) _bins[m++]=k;
1390
+ const stat=(s,e)=>{
1391
+ let rmin=31,rmax=0,gmin=31,gmax=0,bmin=31,bmax=0;
1392
+ for(let i=s;i<e;i++){ const k=_bins[i], r=(k>>10)&31, g=(k>>5)&31, b=k&31;
1393
+ if(r<rmin)rmin=r; if(r>rmax)rmax=r;
1394
+ if(g<gmin)gmin=g; if(g>gmax)gmax=g;
1395
+ if(b<bmin)bmin=b; if(b>bmax)bmax=b; }
1396
+ const dr=rmax-rmin, dg=gmax-gmin, db=bmax-bmin;
1397
+ let chan=0, ext=dr;
1398
+ if(dg>ext){ chan=1; ext=dg; }
1399
+ if(db>ext){ chan=2; ext=db; }
1400
+ return {s,e,chan,ext};
1401
+ };
1402
+ let boxes=[stat(0,m)];
1403
+ while(boxes.length<maxColors){
1404
+ let bi=-1, best=0;
1405
+ for(let i=0;i<boxes.length;i++){ const B=boxes[i];
1406
+ if(B.e-B.s<2) continue;
1407
+ if(B.ext>best){ best=B.ext; bi=i; } }
1408
+ if(bi<0) break;
1409
+ const B=boxes[bi], shift=B.chan===0?10:B.chan===1?5:0;
1410
+ const sub=Array.from(_bins.subarray(B.s,B.e));
1411
+ sub.sort((x,y)=>(((x>>shift)&31)-((y>>shift)&31)));
1412
+ _bins.set(sub,B.s);
1413
+ let total=0; for(let i=B.s;i<B.e;i++) total+=_hist[_bins[i]];
1414
+ let acc=0, mid=B.s+1;
1415
+ for(let i=B.s;i<B.e;i++){ acc+=_hist[_bins[i]]; if(acc*2>=total){ mid=i+1; break; } }
1416
+ if(mid<=B.s) mid=B.s+1;
1417
+ if(mid>=B.e) mid=B.e-1;
1418
+ boxes.splice(bi,1,stat(B.s,mid),stat(mid,B.e));
1419
+ }
1420
+ const pal=new Uint8Array(768);
1421
+ for(let i=0;i<boxes.length;i++){
1422
+ const B=boxes[i]; let c=0,R=0,G=0,Bl=0;
1423
+ for(let x=B.s;x<B.e;x++){ const k=_bins[x]; c+=_hist[k]; R+=_rs[k]; G+=_gs[k]; Bl+=_bs[k]; }
1424
+ if(c){ pal[i*3]=Math.round(R/c); pal[i*3+1]=Math.round(G/c); pal[i*3+2]=Math.round(Bl/c); }
1425
+ }
1426
+ return {pal, count:Math.max(1,boxes.length)};
1427
+ }
1428
+
1429
+ function mapToPalette(data, w, h, pal, palCount, dither){
1430
+ const idx=new Uint8Array(w*h);
1431
+ _cache.fill(-1);
1432
+ const nearest=(r,g,b)=>{
1433
+ const key=((r>>3)<<10)|((g>>3)<<5)|(b>>3);
1434
+ const hit=_cache[key]; if(hit>=0) return hit;
1435
+ let bd=Infinity, bi=0;
1436
+ for(let i=0;i<palCount;i++){
1437
+ const dr=r-pal[i*3], dg=g-pal[i*3+1], db=b-pal[i*3+2];
1438
+ const d=dr*dr*3+dg*dg*6+db*db; // roughly luma-weighted
1439
+ if(d<bd){ bd=d; bi=i; }
1440
+ }
1441
+ _cache[key]=bi; return bi;
1442
+ };
1443
+ if(!dither){
1444
+ for(let p=0,q=0;q<idx.length;q++,p+=4) idx[q]=nearest(data[p],data[p+1],data[p+2]);
1445
+ return idx;
1446
+ }
1447
+ const buf=new Float32Array(w*h*3);
1448
+ for(let p=0,q=0;q<w*h;q++,p+=4){ buf[q*3]=data[p]; buf[q*3+1]=data[p+1]; buf[q*3+2]=data[p+2]; }
1449
+ const cl=v=>v<0?0:(v>255?255:v);
1450
+ for(let y=0;y<h;y++){
1451
+ for(let x=0;x<w;x++){
1452
+ const q=y*w+x, o=q*3;
1453
+ const r=cl(buf[o]), g=cl(buf[o+1]), b=cl(buf[o+2]);
1454
+ const i=nearest(r|0,g|0,b|0); idx[q]=i;
1455
+ const er=(r-pal[i*3])*0.75, eg=(g-pal[i*3+1])*0.75, eb=(b-pal[i*3+2])*0.75;
1456
+ if(x+1<w){ const oo=(q+1)*3; buf[oo]+=er*0.4375; buf[oo+1]+=eg*0.4375; buf[oo+2]+=eb*0.4375; }
1457
+ if(y+1<h){
1458
+ if(x>0){ const oo=(q+w-1)*3; buf[oo]+=er*0.1875; buf[oo+1]+=eg*0.1875; buf[oo+2]+=eb*0.1875; }
1459
+ { const oo=(q+w)*3; buf[oo]+=er*0.3125; buf[oo+1]+=eg*0.3125; buf[oo+2]+=eb*0.3125; }
1460
+ if(x+1<w){ const oo=(q+w+1)*3; buf[oo]+=er*0.0625; buf[oo+1]+=eg*0.0625; buf[oo+2]+=eb*0.0625; }
1461
+ }
1462
+ }
1463
+ }
1464
+ return idx;
1465
+ }
1466
+
1467
+ function lzwWrite(minCodeSize, indices, g){
1468
+ const CLEAR=1<<minCodeSize, EOI=CLEAR+1;
1469
+ let codeSize=minCodeSize+1, next=EOI+1;
1470
+ let dict=new Map(), cur=0, curBits=0;
1471
+ const block=[];
1472
+ const flush=()=>{ if(block.length){ g.u8(block.length); for(let i=0;i<block.length;i++) g.u8(block[i]); block.length=0; } };
1473
+ const emit=code=>{
1474
+ cur |= code<<curBits; curBits += codeSize;
1475
+ while(curBits>=8){ block.push(cur&255); cur>>>=8; curBits-=8; if(block.length===255) flush(); }
1476
+ };
1477
+ g.u8(minCodeSize);
1478
+ emit(CLEAR);
1479
+ let prefix=indices[0];
1480
+ for(let i=1;i<indices.length;i++){
1481
+ const k=indices[i], key=prefix*256+k, found=dict.get(key);
1482
+ if(found!==undefined){ prefix=found; continue; }
1483
+ emit(prefix);
1484
+ if(next===4096){
1485
+ emit(CLEAR); dict=new Map(); next=EOI+1; codeSize=minCodeSize+1;
1486
+ }else{
1487
+ // grow the code width BEFORE claiming the code, so the decoder — which
1488
+ // builds its table one code behind — switches width at the same place
1489
+ if(next >= (1<<codeSize) && codeSize<12) codeSize++;
1490
+ dict.set(key,next); next++;
1491
+ }
1492
+ prefix=k;
1493
+ }
1494
+ emit(prefix); emit(EOI);
1495
+ if(curBits>0){ block.push(cur&255); if(block.length===255) flush(); }
1496
+ flush();
1497
+ g.u8(0);
1498
+ }
1499
+
1500
+ function whileVisible(){
1501
+ if(!document.hidden) return Promise.resolve();
1502
+ setStatus("Paused — come back to this tab to carry on");
1503
+ return new Promise(res=>{
1504
+ const h=()=>{ if(!document.hidden){ document.removeEventListener("visibilitychange",h); res(); } };
1505
+ document.addEventListener("visibilitychange",h);
1506
+ });
1507
+ }
1508
+
1509
+ /* Renders by seeking frame by frame rather than playing in real time, so it is
1510
+ exact and doesn't care how fast the machine is. */
1511
+ async function renderGif(a, z, t, ctx, cv, onProg){
1512
+ const len=Math.max(0.02, z-a);
1513
+ const delay=Math.max(2, Math.round(100/Math.min(20, t.f))); // GIF delays are in 1/100 s
1514
+ const w=t.w, h=t.h;
1515
+ const n=Math.max(1, Math.min(900, Math.round(len*100/delay)));
1516
+ const g=gifBytes();
1517
+ g.str("GIF89a"); g.u16(w); g.u16(h); g.u8(0x70); g.u8(0); g.u8(0);
1518
+ g.u8(0x21); g.u8(0xFF); g.u8(11); g.str("NETSCAPE2.0"); g.u8(3); g.u8(1); g.u16(0); g.u8(0); // loop forever
1519
+
1520
+ let aborted=false, t0=performance.now();
1521
+ for(let i=0;i<n;i++){
1522
+ if(cancelExport){ aborted=true; break; }
1523
+ await whileVisible();
1524
+ V.currentTime = Math.min(z-0.001, a + i*delay/100);
1525
+ await Promise.race([once(V,"seeked"), new Promise(r=>setTimeout(r,3000))]);
1526
+ drawSource(ctx, w, h);
1527
+ const img=ctx.getImageData(0,0,w,h);
1528
+ const {pal,count}=buildPalette(img.data, 256);
1529
+ const idx=mapToPalette(img.data, w, h, pal, count, true);
1530
+ g.u8(0x21); g.u8(0xF9); g.u8(4); g.u8(0x04); g.u16(delay); g.u8(0); g.u8(0);
1531
+ g.u8(0x2C); g.u16(0); g.u16(0); g.u16(w); g.u16(h); g.u8(0x87);
1532
+ g.raw(pal);
1533
+ lzwWrite(8, idx, g);
1534
+ if(onProg){
1535
+ const per=(performance.now()-t0)/(i+1)/1000;
1536
+ onProg((i+1)/n, Math.max(0,(n-i-1)*per));
1537
+ }
1538
+ if((i&1)===0) await new Promise(r=>setTimeout(r,0)); // keep the page responsive
1539
+ }
1540
+ g.u8(0x3B);
1541
+ return { blob:new Blob([g.out()],{type:"image/gif"}), ext:"gif", stalled:false, speed:1, aborted,
1542
+ frames:n, fps:Math.round(1000/delay)/10 };
1543
+ }
1544
+
1545
+ /* ---------------- snapshot ----------------
1546
+ Grabs whatever frame is currently on screen — same crop and output size as
1547
+ the video/GIF export — and saves it as a PNG. No recording involved, so
1548
+ it's instant. */
1549
+ function takeSnapshot(){
1550
+ if(exporting || !dur) return;
1551
+ const t = targets();
1552
+ const cv = $("exportCanvas"); cv.width=t.w; cv.height=t.h;
1553
+ const ctx = cv.getContext("2d", {alpha:false});
1554
+ ctx.imageSmoothingEnabled=true; ctx.imageSmoothingQuality="high";
1555
+ drawSource(ctx, t.w, t.h);
1556
+ cv.toBlob(blob=>{
1557
+ if(!blob || !blob.size){ setStatus("Couldn't capture that frame."); return; }
1558
+ const base = safeName(srcName,60) || "clip";
1559
+ const name = base+"_"+stamp(V.currentTime)+"_"+t.w+"x"+t.h+".png";
1560
+ pushDownload(blob, name);
1561
+ setStatus("Saved "+name);
1562
+ }, "image/png");
1563
+ }
1564
+
1565
+ /* ---------------- export ---------------- */
1566
+ $("exportBtn").addEventListener("click", ()=>doExport("video"));
1567
+ $("snapBtn").addEventListener("click", takeSnapshot);
1568
+ $("gifBtn").addEventListener("click", ()=>doExport("gif"));
1569
+ $("stopBtn").addEventListener("click", ()=>{ cancelExport=true; setStatus("Stopping…"); });
1570
+
1571
+ async function doExport(mode){
1572
+ if(exporting || !dur) return;
1573
+ const isGif = mode === "gif";
1574
+ const t = targets();
1575
+ const list = exportList().filter(s => s.end - s.start >= 0.05);
1576
+ if(!list.length){ setStatus("Selection is too short."); return; }
1577
+ const multi = list.length > 1;
1578
+ const mime = isGif ? "image/gif" : pickMime();
1579
+ if(!mime){ setStatus("This browser can't record video. Try Chrome or Edge."); return; }
1580
+
1581
+ if(isGif){ // GIFs balloon quickly — say so before spending the time
1582
+ const gfps = Math.min(20, t.f);
1583
+ const frames = Math.round(list.reduce((x,sg)=>x+(sg.end-sg.start),0) * gfps);
1584
+ if(t.w*t.h*frames > 90e6 &&
1585
+ !confirm("That's "+frames+" frames at "+t.w+"×"+t.h+", which will make a very large GIF and take a while.\n\nA smaller width or fewer frames per second would help.\n\nCarry on anyway?")) return;
1586
+ }
1587
+
1588
+ // several files → put them in a folder of your choosing (must happen on the click)
1589
+ let dirHandle = null;
1590
+ if(multi && window.showDirectoryPicker){
1591
+ try{
1592
+ dirHandle = await window.showDirectoryPicker({id:"nugget-clips", mode:"readwrite", startIn:"videos"});
1593
+ }catch(e){
1594
+ if(e && e.name === "AbortError"){ setStatus("Export cancelled — no folder chosen."); return; }
1595
+ dirHandle = null; // not supported here; fall back to downloads
1596
+ }
1597
+ }
1598
+
1599
+ exporting=true; cancelExport=false;
1600
+ clearResult(); clearSaved();
1601
+ $("exportBtn").disabled=true; $("gifBtn").disabled=true; $("snapBtn").disabled=true;
1602
+ $("exportBtn").textContent = isGif ? "Building GIF…" : "Exporting…";
1603
+ $("stopBtn").style.display="block";
1604
+ $("expNote").style.display="block";
1605
+ $("bar").style.display="block"; $("barI").style.width="0%";
1606
+ V.pause();
1607
+
1608
+ const cv=$("exportCanvas"); cv.width=t.w; cv.height=t.h;
1609
+ const ctx=cv.getContext("2d",{alpha:false});
1610
+ ctx.imageSmoothingEnabled=true; ctx.imageSmoothingQuality="high";
1611
+ ctx.fillStyle="#000"; ctx.fillRect(0,0,t.w,t.h);
1612
+ V.style.display="none"; cv.style.display="block"; $("cropLayer").style.display="none";
1613
+
1614
+ setupAudio();
1615
+ if(audioCtx && audioCtx.state==="suspended"){ try{ await audioCtx.resume(); }catch(e){} }
1616
+ if(monitorGain) monitorGain.gain.value = 0;
1617
+ keepAwake(true);
1618
+
1619
+ const total = list.reduce((a,s)=>a+(s.end-s.start),0);
1620
+ let doneLen = 0, saved = [], slow = false, cut = false, failed = 0;
1621
+
1622
+ try{
1623
+ for(let i=0;i<list.length;i++){
1624
+ if(cancelExport) break;
1625
+ const s = list[i];
1626
+ const len = s.end - s.start;
1627
+ const label = multi ? "Clip "+(i+1)+" of "+list.length+" · " : "";
1628
+
1629
+ const onProg = (p, left)=>{
1630
+ const overall = (doneLen + p*len)/total;
1631
+ $("barI").style.width=(overall*100).toFixed(1)+"%";
1632
+ setStatus(label+(isGif?"building GIF ":"recording ")+Math.round(p*100)+"% · about "+Math.ceil(left)+"s left");
1633
+ };
1634
+ const r = isGif ? await renderGif(s.start, s.end, t, ctx, cv, onProg)
1635
+ : await recordRange(s.start, s.end, t, mime, ctx, cv, onProg);
1636
+ doneLen += len;
1637
+ lastExt = r.ext;
1638
+ if(r.stalled) cut = true;
1639
+ if(!r.stalled && r.speed < 0.9) slow = true;
1640
+
1641
+ if(r.aborted) break; // stopped part-way through this clip — discard it
1642
+ if(!r.blob.size){ failed++; continue; }
1643
+
1644
+ const fname = clipFileName(i, s, t, multi);
1645
+ if(dirHandle){
1646
+ setStatus(label+"saving…");
1647
+ try{
1648
+ const fh = await dirHandle.getFileHandle(fname, {create:true});
1649
+ const ws = await fh.createWritable();
1650
+ await ws.write(r.blob); await ws.close();
1651
+ saved.push(fname+" ("+bytes(r.blob.size)+")");
1652
+ }catch(e){ failed++; saved.push(fname+" — could not be written: "+e.message); }
1653
+ }else if(multi){
1654
+ pushDownload(r.blob, fname);
1655
+ saved.push(fname+" ("+bytes(r.blob.size)+")");
1656
+ await new Promise(r2=>setTimeout(r2,400)); // let the browser keep up
1657
+ }else{
1658
+ lastBlob = r.blob;
1659
+ lastMeta = {w:t.w, h:t.h, f:t.f, at:s.start, name:s.name};
1660
+ const url = URL.createObjectURL(lastBlob);
1661
+ if(isGif){ $("outImg").src=url; $("outImg").style.display="block"; $("outVid").style.display="none"; }
1662
+ else { $("outVid").src=url; $("outVid").style.display="block"; $("outImg").style.display="none"; }
1663
+ $("result").style.display="block";
1664
+ const n = download();
1665
+ saved.push((n||fname)+" ("+bytes(r.blob.size)+")");
1666
+ }
1667
+ }
1668
+ }catch(err){
1669
+ setStatus(err.message);
1670
+ }finally{
1671
+ exporting=false;
1672
+ if(monitorGain) monitorGain.gain.value=1;
1673
+ cv.style.display="none"; V.style.display="block";
1674
+ if(crop){ showCrop(true); layoutCrop(); }
1675
+ $("exportBtn").disabled=false; $("gifBtn").disabled=false; $("snapBtn").disabled=false; $("stopBtn").style.display="none";
1676
+ $("expNote").style.display="none";
1677
+ $("bar").style.display="none";
1678
+ keepAwake(false);
1679
+ V.currentTime=inT;
1680
+ estimate();
1681
+ }
1682
+
1683
+ // ---- report ----
1684
+ const notes=[];
1685
+ if(lastExt==="webm" && !isGif) notes.push("Saved as .webm — this browser can't record MP4. Plays in Chrome, Edge, Firefox and VLC.");
1686
+ if(cut) notes.push("Playback stalled on at least one clip, so it may be cut short.");
1687
+ if(slow && !isGif) notes.push("Your computer couldn't play the video at full speed, so clips may run slightly slow. Try a smaller output size or lower quality.");
1688
+ if(failed) notes.push(failed+" clip(s) could not be saved.");
1689
+
1690
+ if(multi){
1691
+ $("saved").style.display = saved.length ? "block" : "none";
1692
+ $("savedWhere").textContent = cancelExport
1693
+ ? "Stopped — "+saved.length+" of "+list.length+" saved"
1694
+ : "Saved "+saved.length+" clip"+(saved.length===1?"":"s")+(dirHandle? " to “"+dirHandle.name+"”" : " to your Downloads folder");
1695
+ $("savedList").innerHTML="";
1696
+ saved.forEach(n=>{ const li=document.createElement("li"); li.textContent=n; $("savedList").appendChild(li); });
1697
+ if(!dirHandle && saved.length>1) notes.push("Chrome asks permission the first time a page saves several files — choose Allow if you see that prompt.");
1698
+ $("savedNote").textContent = notes.join(" ");
1699
+ setStatus(cancelExport ? "Stopped." : "Done — "+saved.length+" clip"+(saved.length===1?"":"s")+".");
1700
+ }else{
1701
+ $("outNote").textContent = notes.join(" ");
1702
+ setStatus(cancelExport ? "Stopped." : (saved[0] ? "Saved "+saved[0] : "Nothing was saved."));
1703
+ }
1704
+ }
1705
+
1706
+ function clipFileName(i, s, t, multi){
1707
+ const base = safeName(srcName,60) || "clip";
1708
+ if(lastExt==="gif"){
1709
+ const lbl0 = safeName(s.name,40);
1710
+ return base+"_"+(multi? String(i+1).padStart(2,"0")+"_" : "")+(lbl0 || stamp(s.start))+"_"+t.w+"px.gif";
1711
+ }
1712
+ if(!multi) return base+"_"+stamp(s.start)+"_"+t.w+"x"+t.h+"_"+t.f+"fps."+lastExt;
1713
+ const lbl = safeName(s.name,40);
1714
+ return base+"_"+String(i+1).padStart(2,"0")+"_"+(lbl || stamp(s.start))+"."+lastExt;
1715
+ }
1716
+ function pushDownload(blob, name){
1717
+ try{
1718
+ const a=document.createElement("a");
1719
+ a.href=URL.createObjectURL(blob); a.download=name; a.style.display="none";
1720
+ document.body.appendChild(a); a.click();
1721
+ setTimeout(()=>{ URL.revokeObjectURL(a.href); a.remove(); }, 12000);
1722
+ return name;
1723
+ }catch(e){ return null; }
1724
+ }
1725
+ function download(){
1726
+ if(!lastBlob || !lastBlob.size) return null;
1727
+ const m = lastMeta || Object.assign(targets(), {at:inT, name:""});
1728
+ const base = safeName(srcName,60) || "clip";
1729
+ const lbl = safeName(m.name,40);
1730
+ const name = lastExt==="gif"
1731
+ ? base+"_"+(lbl || stamp(m.at))+"_"+m.w+"px.gif"
1732
+ : base+"_"+(lbl || stamp(m.at))+"_"+m.w+"x"+m.h+"_"+m.f+"fps."+lastExt;
1733
+ return pushDownload(lastBlob, name);
1734
+ }
1735
+ $("dlBtn").addEventListener("click", ()=>{ download(); });
1736
+ $("clearRes").addEventListener("click", clearResult);
1737
+ function clearResult(){
1738
+ lastBlob=null; lastMeta=null; $("result").style.display="none";
1739
+ const ov=$("outVid"); if(ov.src){ URL.revokeObjectURL(ov.src); ov.removeAttribute("src"); }
1740
+ const oi=$("outImg"); if(oi.src){ URL.revokeObjectURL(oi.src); oi.removeAttribute("src"); }
1741
+ setStatus("");
1742
+ }
1743
+ function clearSaved(){ $("saved").style.display="none"; $("savedList").innerHTML=""; $("savedNote").textContent=""; }
1744
+ function setStatus(s){ $("status").textContent=s; }
1745
+
1746
+ window.__vt = {
1747
+ get state(){ return {ready:$("app").style.display==="block",loads:loadSeq,dur,inT,outT,natW,natH,srcFps,
1748
+ exporting,size:lastBlob&&lastBlob.size,ext:lastExt,
1749
+ clips:segments.map(s=>({start:+s.start.toFixed(3),end:+s.end.toFixed(3),name:s.name}))}; },
1750
+ load, doExport, addClip,
1751
+ gif(){ return doExport("gif"); },
1752
+ set(a,b){ inT=a; outT=b; refresh(); },
1753
+ shift(d){ nudge(d); },
1754
+ shiftTo(t){ shiftTo(t); },
1755
+ activeSeg(){ return activeSeg; },
1756
+ pickClip(i){ const sg=segments[i]; if(!sg) return; activeSeg=i; inT=sg.start; outT=sg.end; V.currentTime=sg.start; refresh(); },
1757
+ setCrop(x,y,w,h){ setCrop(x,y,w,h); showCrop(true); cropChanged(true); },
1758
+ getCrop(){ return crop && {...crop}; },
1759
+ setAR(v,l){ applyAR(v, l||String(v)); },
1760
+ arLabel(){ return cropARLabel; },
1761
+ setName(i,n){ segments[i].name=n; refresh(); },
1762
+ clearClips(){ segments=[]; activeSeg=-1; refresh(); }
1763
+ };
1764
+ })();
1765
+ </script>
1766
+ </body>
1767
+ </html>