halfsize 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/web/index.html ADDED
@@ -0,0 +1,557 @@
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>halfsize — fit any video into any limit</title>
7
+ <style>
8
+ :root {
9
+ --bg: #07080a;
10
+ --panel: #0d0f13;
11
+ --panel-2: #11141a;
12
+ --line: #1e222b;
13
+ --text: #eef1f6;
14
+ --muted: #8b93a7;
15
+ --dim: #5d6577;
16
+ --accent: #b6f36a;
17
+ --accent-2: #61d9a4;
18
+ --warn: #ffb547;
19
+ --bad: #ff6b6b;
20
+ --radius: 18px;
21
+ }
22
+ * { box-sizing: border-box; }
23
+ html, body { margin: 0; height: 100%; }
24
+ body {
25
+ background:
26
+ radial-gradient(900px 500px at 15% -10%, rgba(182,243,106,.09), transparent 60%),
27
+ radial-gradient(700px 400px at 100% 0%, rgba(97,217,164,.06), transparent 55%),
28
+ var(--bg);
29
+ color: var(--text);
30
+ font: 15px/1.55 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Inter, "PingFang SC", "Microsoft YaHei", sans-serif;
31
+ -webkit-font-smoothing: antialiased;
32
+ }
33
+ a { color: var(--accent); text-decoration: none; }
34
+ .wrap { max-width: 1080px; margin: 0 auto; padding: 28px 24px 64px; }
35
+
36
+ header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 34px; }
37
+ .brand { display: flex; align-items: center; gap: 11px; font-weight: 700; letter-spacing: -.02em; font-size: 19px; }
38
+ .mark { width: 26px; height: 26px; border-radius: 8px; background: linear-gradient(135deg, var(--accent), var(--accent-2)); position: relative; }
39
+ .mark::after { content: ""; position: absolute; inset: 7px; border-radius: 3px; background: #07080a; }
40
+ .mark::before { content: ""; position: absolute; inset: 11px; border-radius: 2px; background: var(--accent); }
41
+ .pill { font-size: 12px; color: var(--muted); border: 1px solid var(--line); padding: 6px 11px; border-radius: 999px; background: rgba(255,255,255,.02); }
42
+
43
+ h1 { font-size: clamp(30px, 4.4vw, 46px); line-height: 1.08; letter-spacing: -.035em; margin: 0 0 14px; font-weight: 700; }
44
+ h1 em { font-style: normal; background: linear-gradient(90deg, var(--accent), var(--accent-2)); -webkit-background-clip: text; background-clip: text; color: transparent; }
45
+ .sub { color: var(--muted); max-width: 620px; margin: 0 0 30px; font-size: 16px; }
46
+ .sub b { color: var(--text); font-weight: 600; }
47
+
48
+ .card {
49
+ background: linear-gradient(180deg, rgba(255,255,255,.025), transparent 40%), var(--panel);
50
+ border: 1px solid var(--line);
51
+ border-radius: var(--radius);
52
+ padding: 22px;
53
+ }
54
+
55
+ .drop {
56
+ border: 1.5px dashed #2a2f3a;
57
+ border-radius: 14px;
58
+ padding: 44px 24px;
59
+ text-align: center;
60
+ cursor: pointer;
61
+ transition: .18s ease;
62
+ background: radial-gradient(600px 200px at 50% 0%, rgba(182,243,106,.04), transparent 70%);
63
+ }
64
+ .drop:hover, .drop.over { border-color: var(--accent); background: rgba(182,243,106,.05); }
65
+ .drop .big { font-size: 17px; font-weight: 600; margin-bottom: 6px; }
66
+ .drop .small { color: var(--muted); font-size: 13px; }
67
+ .drop .icon { width: 42px; height: 42px; margin: 0 auto 14px; border-radius: 12px; border: 1px solid var(--line); display: grid; place-items: center; color: var(--accent); }
68
+ .drop input { display: none; }
69
+
70
+ .layout { display: grid; grid-template-columns: 1.1fr .9fr; gap: 22px; align-items: start; }
71
+ @media (max-width: 820px) { .layout { grid-template-columns: 1fr; } }
72
+
73
+ .video-box { border-radius: 12px; overflow: hidden; border: 1px solid var(--line); background: #000; aspect-ratio: 16/9; display: grid; place-items: center; }
74
+ .video-box video { width: 100%; height: 100%; object-fit: contain; }
75
+ .meta { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
76
+ .tag { font-size: 12px; color: var(--muted); border: 1px solid var(--line); background: var(--panel-2); padding: 5px 9px; border-radius: 7px; font-variant-numeric: tabular-nums; }
77
+ .fname { font-size: 13px; color: var(--dim); margin-top: 12px; word-break: break-all; }
78
+
79
+ .label { font-size: 12px; text-transform: uppercase; letter-spacing: .08em; color: var(--dim); margin: 0 0 10px; font-weight: 600; }
80
+ .chips { display: flex; flex-wrap: wrap; gap: 8px; }
81
+ .chip {
82
+ border: 1px solid var(--line); background: var(--panel-2); color: var(--text);
83
+ padding: 9px 13px; border-radius: 10px; cursor: pointer; font: inherit; font-size: 13px;
84
+ transition: .15s ease;
85
+ }
86
+ .chip:hover { border-color: #39404e; }
87
+ .chip.on { border-color: var(--accent); color: var(--accent); background: rgba(182,243,106,.08); }
88
+ .chip small { display: block; color: var(--dim); font-size: 11px; }
89
+ .chip.on small { color: var(--accent-2); }
90
+
91
+ .row { display: flex; gap: 10px; align-items: center; }
92
+ .row + .row { margin-top: 18px; }
93
+ input[type=text] {
94
+ flex: 1; background: var(--panel-2); border: 1px solid var(--line); color: var(--text);
95
+ border-radius: 10px; padding: 10px 12px; font: inherit; font-size: 14px; outline: none;
96
+ }
97
+ input[type=text]:focus { border-color: var(--accent); }
98
+
99
+ button.primary {
100
+ width: 100%; margin-top: 20px; border: 0; border-radius: 11px; cursor: pointer;
101
+ padding: 14px 18px; font: inherit; font-weight: 700; font-size: 15px; color: #0a1206;
102
+ background: linear-gradient(135deg, var(--accent), var(--accent-2));
103
+ transition: .15s ease;
104
+ }
105
+ button.primary:hover { filter: brightness(1.06); }
106
+ button.primary:disabled { opacity: .45; cursor: not-allowed; }
107
+ button.ghost {
108
+ border: 1px solid var(--line); background: transparent; color: var(--text);
109
+ border-radius: 10px; padding: 11px 15px; font: inherit; font-size: 14px; cursor: pointer;
110
+ }
111
+ button.ghost:hover { border-color: #39404e; }
112
+
113
+ .progress { margin-top: 20px; display: none; }
114
+ .progress.on { display: block; }
115
+ .bar { height: 8px; border-radius: 99px; background: #171b22; overflow: hidden; }
116
+ .bar i { display: block; height: 100%; width: 0%; background: linear-gradient(90deg, var(--accent), var(--accent-2)); transition: width .2s ease; }
117
+ .pmeta { display: flex; justify-content: space-between; color: var(--muted); font-size: 12px; margin-top: 8px; font-variant-numeric: tabular-nums; }
118
+
119
+ .result { display: none; margin-top: 26px; }
120
+ .result.on { display: block; }
121
+ .big-num { font-size: clamp(44px, 8vw, 76px); font-weight: 800; letter-spacing: -.05em; line-height: 1; background: linear-gradient(90deg, var(--accent), var(--accent-2)); -webkit-background-clip: text; background-clip: text; color: transparent; }
122
+ .big-sub { color: var(--muted); margin-top: 8px; font-size: 16px; }
123
+ .big-sub b { color: var(--text); }
124
+ .stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-top: 20px; }
125
+ .stat { border: 1px solid var(--line); background: var(--panel-2); border-radius: 12px; padding: 13px 14px; }
126
+ .stat .k { font-size: 11px; color: var(--dim); text-transform: uppercase; letter-spacing: .07em; }
127
+ .stat .v { font-size: 18px; font-weight: 700; margin-top: 4px; font-variant-numeric: tabular-nums; }
128
+ .verdict { display: inline-flex; align-items: center; gap: 7px; font-size: 13px; padding: 6px 11px; border-radius: 999px; margin-top: 16px; }
129
+ .verdict.good { color: var(--accent); background: rgba(182,243,106,.1); }
130
+ .verdict.ok { color: var(--accent-2); background: rgba(97,217,164,.1); }
131
+ .verdict.warn { color: var(--warn); background: rgba(255,181,71,.1); }
132
+ .verdict.bad { color: var(--bad); background: rgba(255,107,107,.1); }
133
+ .actions { display: flex; gap: 10px; margin-top: 20px; flex-wrap: wrap; }
134
+ .actions button { flex: 1 1 0; min-width: 0; white-space: nowrap; }
135
+ .actions button.primary { width: auto; margin: 0; flex-grow: 1.6; }
136
+
137
+ .error { display: none; margin-top: 16px; border: 1px solid rgba(255,107,107,.3); background: rgba(255,107,107,.07); color: #ffb3b3; padding: 12px 14px; border-radius: 10px; font-size: 13px; }
138
+ .error.on { display: block; }
139
+
140
+ footer { margin-top: 46px; color: var(--dim); font-size: 12px; display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
141
+ .kbd { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: var(--muted); background: var(--panel-2); border: 1px solid var(--line); padding: 3px 7px; border-radius: 6px; }
142
+ .hidden { display: none !important; }
143
+ </style>
144
+ </head>
145
+ <body>
146
+ <div class="wrap">
147
+ <header>
148
+ <div class="brand"><span class="mark"></span> halfsize</div>
149
+ <div class="pill" data-i18n="pill">本地运行 · 不上传 · 无账号</div>
150
+ </header>
151
+
152
+ <h1 data-i18n-html="title">把任何视频,塞进任何限制。<br><em>并且证明画质没变。</em></h1>
153
+ <p class="sub" data-i18n-html="sub">微信只让发 25 MB,Discord 只让发 10 MB。选一个数字,剩下的交给它:算码率、降分辨率、两遍编码,最后用 <b>VMAF</b> 告诉你画质掉没掉。</p>
154
+
155
+ <div class="card" id="card">
156
+ <div id="drop" class="drop" role="button" tabindex="0">
157
+ <div class="icon">
158
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 16V4m0 0L7 9m5-5l5 5"/><path d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2"/></svg>
159
+ </div>
160
+ <div class="big" data-i18n="dropBig">把视频拖进来,或点击选择</div>
161
+ <div class="small" data-i18n="dropSmall">MP4 · MOV · MKV · AVI · WebM · 不超过 8 GB</div>
162
+ <input type="file" id="file" accept="video/*">
163
+ </div>
164
+
165
+ <div id="editor" class="hidden">
166
+ <div class="layout">
167
+ <div>
168
+ <div class="video-box"><video id="preview" controls playsinline></video></div>
169
+ <div class="meta" id="meta"></div>
170
+ <div class="fname" id="fname"></div>
171
+ </div>
172
+ <div>
173
+ <div class="label" data-i18n="target">目标大小</div>
174
+ <div class="chips" id="presets">
175
+ <button class="chip on" data-size="25mb">25 MB<small data-i18n="wechat">微信</small></button>
176
+ <button class="chip" data-size="10mb">10 MB<small>Discord</small></button>
177
+ <button class="chip" data-size="20mb">20 MB<small data-i18n="email">邮件</small></button>
178
+ <button class="chip" data-size="5mb">5 MB<small data-i18n="web">网页</small></button>
179
+ </div>
180
+ <div class="row">
181
+ <input type="text" id="custom" placeholder="自定义,例如 48mb" data-i18n-ph="custom">
182
+ </div>
183
+
184
+ <div class="label" style="margin-top:20px" data-i18n="codec">编码</div>
185
+ <div class="chips" id="codecs">
186
+ <button class="chip on" data-codec="h264">H.264<small data-i18n="compat">兼容性最好</small></button>
187
+ <button class="chip" data-codec="h265">H.265<small data-i18n="smaller">再小 30%</small></button>
188
+ </div>
189
+
190
+ <button class="primary" id="go" data-i18n="go">开始压缩</button>
191
+
192
+ <div class="progress" id="progress">
193
+ <div class="bar"><i id="fill"></i></div>
194
+ <div class="pmeta"><span id="pct">0%</span><span id="speed"></span></div>
195
+ </div>
196
+ </div>
197
+ </div>
198
+
199
+ <div class="result" id="result">
200
+ <div class="big-num" id="ratio">—</div>
201
+ <div class="big-sub" id="sizes"></div>
202
+ <div class="verdict good" id="verdict"></div>
203
+ <div class="stats">
204
+ <div class="stat"><div class="k">VMAF</div><div class="v" id="vmaf">—</div></div>
205
+ <div class="stat"><div class="k">SSIM</div><div class="v" id="ssim">—</div></div>
206
+ <div class="stat"><div class="k" data-i18n="time">耗时</div><div class="v" id="elapsed">—</div></div>
207
+ </div>
208
+ <div class="actions">
209
+ <button class="primary" id="download" data-i18n="download">下载压缩后的视频</button>
210
+ <button class="ghost" id="share" data-i18n="share">生成分享图</button>
211
+ </div>
212
+ </div>
213
+
214
+ <div class="error" id="error"></div>
215
+ </div>
216
+ </div>
217
+
218
+ <footer>
219
+ <div><span class="kbd">npx halfsize video.mp4 --to 25mb</span></div>
220
+ <div data-i18n="footer">本地 ffmpeg · 零上传 · MIT</div>
221
+ </footer>
222
+ </div>
223
+
224
+ <script>
225
+ const I18N = {
226
+ zh: {
227
+ pill: "本地运行 · 不上传 · 无账号",
228
+ title: "把任何视频,塞进任何限制。<br><em>并且证明画质没变。</em>",
229
+ sub: "微信只让发 25 MB,Discord 只让发 10 MB。选一个数字,剩下的交给它:算码率、降分辨率、两遍编码,最后用 <b>VMAF</b> 告诉你画质掉没掉。",
230
+ dropBig: "把视频拖进来,或点击选择",
231
+ dropSmall: "MP4 · MOV · MKV · AVI · WebM · 不超过 8 GB",
232
+ target: "目标大小",
233
+ custom: "自定义,例如 48mb",
234
+ codec: "编码",
235
+ compat: "兼容性最好",
236
+ smaller: "再小 30%",
237
+ go: "开始压缩",
238
+ time: "耗时",
239
+ download: "下载压缩后的视频",
240
+ share: "生成分享图",
241
+ footer: "本地 ffmpeg · 零上传 · MIT",
242
+ verifying: "正在测画质…",
243
+ done: "完成",
244
+ uploading: "上传中",
245
+ wechat: "微信",
246
+ email: "邮件",
247
+ web: "网页",
248
+ smallerLabel: "更小",
249
+ accuracyUnder: (p) => `比目标小 ${p}%`,
250
+ accuracyOver: (p) => `超出目标 ${p}%`,
251
+ alreadySmall: (a, b) => `这个文件已经只有 ${a},小于 ${b} 的目标。换一个更小的目标。`,
252
+ badSize: "看不懂这个大小。试试 25mb、700k 或 1.5gb。",
253
+ failed: "压缩失败",
254
+ audioLabel: "有声音",
255
+ silentLabel: "无声音",
256
+ verdicts: {
257
+ indistinguishable: "几乎看不出差别",
258
+ same: "看起来一样",
259
+ slight: "轻微变软",
260
+ visible: "能看出损失",
261
+ degraded: "明显变差",
262
+ none: "未测量",
263
+ },
264
+ },
265
+ en: {
266
+ pill: "runs locally · no upload · no account",
267
+ title: "Fit any video into any limit.<br><em>And prove nothing changed.</em>",
268
+ sub: "WeChat caps at 25 MB. Discord caps at 10 MB. Pick the number. It does the rest: bitrate math, resolution, two-pass encoding, then measures the result with <b>VMAF</b>.",
269
+ dropBig: "Drop a video, or click to choose",
270
+ dropSmall: "MP4 · MOV · MKV · AVI · WebM · up to 8 GB",
271
+ target: "Target size",
272
+ custom: "Custom, e.g. 48mb",
273
+ codec: "Codec",
274
+ compat: "best compatibility",
275
+ smaller: "30% smaller",
276
+ go: "Compress",
277
+ time: "Time",
278
+ download: "Download the smaller file",
279
+ share: "Share card",
280
+ footer: "local ffmpeg · zero upload · MIT",
281
+ verifying: "measuring quality…",
282
+ done: "done",
283
+ uploading: "uploading",
284
+ wechat: "WeChat",
285
+ email: "email",
286
+ web: "web",
287
+ smallerLabel: "smaller",
288
+ accuracyUnder: (p) => `${p}% under target`,
289
+ accuracyOver: (p) => `${p}% over target`,
290
+ alreadySmall: (a, b) => `This file is already ${a}, smaller than the ${b} target. Pick a smaller target.`,
291
+ badSize: "Can't read that size. Try 25mb, 700k or 1.5gb.",
292
+ failed: "compression failed",
293
+ audioLabel: "audio",
294
+ silentLabel: "silent",
295
+ verdicts: {
296
+ indistinguishable: "indistinguishable",
297
+ same: "looks the same",
298
+ slight: "slight softening",
299
+ visible: "visible loss",
300
+ degraded: "clearly degraded",
301
+ none: "not measured",
302
+ },
303
+ },
304
+ };
305
+
306
+ const forcedLang = new URLSearchParams(location.search).get("lang");
307
+ const lang = forcedLang === "zh" || forcedLang === "en"
308
+ ? forcedLang
309
+ : (navigator.language || "en").toLowerCase().startsWith("zh") ? "zh" : "en";
310
+ const strings = I18N[lang];
311
+ const t = (key) => I18N[lang][key] ?? key;
312
+ document.querySelectorAll("[data-i18n]").forEach((el) => (el.textContent = t(el.dataset.i18n)));
313
+ document.querySelectorAll("[data-i18n-html]").forEach((el) => (el.innerHTML = t(el.dataset.i18nHtml)));
314
+ document.querySelectorAll("[data-i18n-ph]").forEach((el) => (el.placeholder = t(el.dataset.i18nPh)));
315
+
316
+ const $ = (id) => document.getElementById(id);
317
+ const drop = $("drop"), fileInput = $("file"), editor = $("editor"), preview = $("preview");
318
+ const meta = $("meta"), fname = $("fname"), go = $("go"), progress = $("progress");
319
+ const fill = $("fill"), pct = $("pct"), speed = $("speed"), result = $("result"), error = $("error");
320
+
321
+ let state = { id: null, target: "25mb", codec: "h264", data: null };
322
+
323
+ function fmtBytes(n) {
324
+ if (!n && n !== 0) return "—";
325
+ const u = ["B", "KB", "MB", "GB"];
326
+ let i = 0, v = n;
327
+ while (v >= 1000 && i < u.length - 1) { v /= 1000; i++; }
328
+ return `${v.toFixed(v >= 100 ? 0 : v >= 10 ? 1 : 2)} ${u[i]}`;
329
+ }
330
+ function fmtDur(s) {
331
+ if (!s) return "—";
332
+ const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), x = Math.round(s % 60);
333
+ return h ? `${h}h ${String(m).padStart(2, "0")}m` : m ? `${m}m ${String(x).padStart(2, "0")}s` : `${x}s`;
334
+ }
335
+ function showError(msg) { error.textContent = msg; error.classList.add("on"); }
336
+
337
+ function parseTarget(value) {
338
+ const match = String(value).trim().toLowerCase().match(/^([0-9]*\.?[0-9]+)\s*(b|kb|k|mb|m|gb|g)?$/);
339
+ if (!match) return null;
340
+ const unit = match[2] || "mb";
341
+ const scale = { b: 1, k: 1e3, kb: 1e3, m: 1e6, mb: 1e6, g: 1e9, gb: 1e9 }[unit];
342
+ return Math.round(Number(match[1]) * scale);
343
+ }
344
+
345
+ drop.addEventListener("click", () => fileInput.click());
346
+ drop.addEventListener("keydown", (e) => {
347
+ if (e.key === "Enter" || e.key === " ") {
348
+ e.preventDefault();
349
+ fileInput.click();
350
+ }
351
+ });
352
+ drop.addEventListener("dragover", (e) => { e.preventDefault(); drop.classList.add("over"); });
353
+ drop.addEventListener("dragleave", () => drop.classList.remove("over"));
354
+ drop.addEventListener("drop", (e) => {
355
+ e.preventDefault(); drop.classList.remove("over");
356
+ const file = e.dataTransfer.files[0];
357
+ if (file) upload(file);
358
+ });
359
+ fileInput.addEventListener("change", () => { if (fileInput.files[0]) upload(fileInput.files[0]); });
360
+
361
+ function upload(file) {
362
+ error.classList.remove("on");
363
+ drop.querySelector(".big").textContent = `${t("uploading")} ${file.name}`;
364
+ const xhr = new XMLHttpRequest();
365
+ xhr.open("POST", "/api/upload");
366
+ xhr.setRequestHeader("x-filename", encodeURIComponent(file.name));
367
+ xhr.upload.onprogress = (e) => {
368
+ if (e.lengthComputable) {
369
+ const p = Math.round((e.loaded / e.total) * 100);
370
+ drop.querySelector(".small").textContent = `${p}%`;
371
+ }
372
+ };
373
+ xhr.onload = () => {
374
+ if (xhr.status !== 200) { showError(xhr.responseText || "upload failed"); return; }
375
+ const data = JSON.parse(xhr.responseText);
376
+ state.id = data.id;
377
+ state.data = data;
378
+ enterEditor(data);
379
+ };
380
+ xhr.onerror = () => showError("upload failed");
381
+ xhr.send(file);
382
+ }
383
+
384
+ function enterEditor(data) {
385
+ drop.classList.add("hidden");
386
+ editor.classList.remove("hidden");
387
+ preview.src = `/api/media/${data.id}/in`;
388
+ fname.textContent = data.name;
389
+ meta.innerHTML = [
390
+ fmtBytes(data.size),
391
+ fmtDur(data.duration),
392
+ `${data.width}×${data.height}`,
393
+ `${Number(data.fps).toFixed(2)} fps`,
394
+ data.codec.toUpperCase(),
395
+ data.hasAudio ? t("audioLabel") : t("silentLabel"),
396
+ ].map((x) => `<span class="tag">${x}</span>`).join("");
397
+ }
398
+
399
+ document.querySelectorAll("#presets .chip").forEach((chip) => {
400
+ chip.addEventListener("click", () => {
401
+ document.querySelectorAll("#presets .chip").forEach((c) => c.classList.remove("on"));
402
+ chip.classList.add("on");
403
+ state.target = chip.dataset.size;
404
+ $("custom").value = "";
405
+ });
406
+ });
407
+ document.querySelectorAll("#codecs .chip").forEach((chip) => {
408
+ chip.addEventListener("click", () => {
409
+ document.querySelectorAll("#codecs .chip").forEach((c) => c.classList.remove("on"));
410
+ chip.classList.add("on");
411
+ state.codec = chip.dataset.codec;
412
+ });
413
+ });
414
+ $("custom").addEventListener("input", (e) => {
415
+ if (e.target.value.trim()) {
416
+ document.querySelectorAll("#presets .chip").forEach((c) => c.classList.remove("on"));
417
+ state.target = e.target.value.trim();
418
+ } else {
419
+ const first = document.querySelector("#presets .chip");
420
+ first.classList.add("on");
421
+ state.target = first.dataset.size;
422
+ }
423
+ });
424
+
425
+ go.addEventListener("click", async () => {
426
+ if (!state.id) return;
427
+ error.classList.remove("on");
428
+
429
+ const targetBytes = parseTarget(state.target);
430
+ if (!targetBytes) {
431
+ showError(strings.badSize);
432
+ return;
433
+ }
434
+ if (targetBytes >= state.data.size) {
435
+ showError(strings.alreadySmall(fmtBytes(state.data.size), fmtBytes(targetBytes)));
436
+ return;
437
+ }
438
+
439
+ result.classList.remove("on");
440
+ progress.classList.add("on");
441
+ go.disabled = true;
442
+ fill.style.width = "0%";
443
+ pct.textContent = "0%";
444
+ speed.textContent = "";
445
+
446
+ const res = await fetch("/api/compress", {
447
+ method: "POST",
448
+ headers: { "content-type": "application/json" },
449
+ body: JSON.stringify({ id: state.id, target: state.target, codec: state.codec }),
450
+ });
451
+ if (!res.ok) {
452
+ showError((await res.json()).error || t("failed"));
453
+ go.disabled = false;
454
+ progress.classList.remove("on");
455
+ return;
456
+ }
457
+
458
+ const es = new EventSource(`/api/events/${state.id}`);
459
+ es.addEventListener("progress", (ev) => {
460
+ const p = JSON.parse(ev.data);
461
+ const percent = Math.round((p.ratio || 0) * 100);
462
+ fill.style.width = `${percent}%`;
463
+ pct.textContent = `${percent}%`;
464
+ speed.textContent = `${p.speed || ""} eta ${fmtDur(p.eta)}`;
465
+ });
466
+ es.addEventListener("phase", () => {
467
+ fill.style.width = "100%";
468
+ pct.textContent = t("verifying");
469
+ });
470
+ es.addEventListener("done", (ev) => {
471
+ es.close();
472
+ progress.classList.remove("on");
473
+ go.disabled = false;
474
+ pct.textContent = "100%";
475
+ renderResult(JSON.parse(ev.data));
476
+ });
477
+ es.addEventListener("failed", (ev) => {
478
+ es.close();
479
+ progress.classList.remove("on");
480
+ go.disabled = false;
481
+ try { showError(JSON.parse(ev.data).message); } catch { showError(t("failed")); }
482
+ });
483
+ });
484
+
485
+ function renderResult(r) {
486
+ state.result = r;
487
+ result.classList.add("on");
488
+ $("ratio").textContent = `${r.ratio >= 10 ? Math.round(r.ratio) : r.ratio.toFixed(1)}×`;
489
+ $("sizes").innerHTML = `${t("smallerLabel")} · <b>${fmtBytes(r.inputBytes)}</b> → <b>${fmtBytes(r.outputBytes)}</b>`;
490
+ const v = $("verdict");
491
+ v.className = `verdict ${r.verdict.tone}`;
492
+ const label = strings.verdicts[r.verdict.key] || r.verdict.label;
493
+ let acc = "";
494
+ if (r.accuracy !== null) {
495
+ const pct = Math.abs(r.accuracy * 100).toFixed(1);
496
+ acc = ` · ${r.accuracy > 0 ? strings.accuracyOver(pct) : strings.accuracyUnder(pct)}`;
497
+ }
498
+ v.textContent = label + acc;
499
+ $("vmaf").textContent = r.quality.vmaf !== null ? r.quality.vmaf.toFixed(1) : "—";
500
+ $("ssim").textContent = r.quality.ssim !== null ? r.quality.ssim.toFixed(3) : "—";
501
+ $("elapsed").textContent = fmtDur(r.elapsedMs / 1000);
502
+ $("download").onclick = () => { window.location.href = r.download; };
503
+ result.scrollIntoView({ behavior: "smooth", block: "nearest" });
504
+ }
505
+
506
+ $("share").addEventListener("click", () => {
507
+ const r = state.result;
508
+ if (!r) return;
509
+ const c = document.createElement("canvas");
510
+ c.width = 1200; c.height = 630;
511
+ const x = c.getContext("2d");
512
+ const g = x.createLinearGradient(0, 0, 1200, 630);
513
+ g.addColorStop(0, "#07080a"); g.addColorStop(1, "#10140f");
514
+ x.fillStyle = g; x.fillRect(0, 0, 1200, 630);
515
+ const glow = x.createRadialGradient(950, 80, 0, 950, 80, 600);
516
+ glow.addColorStop(0, "rgba(182,243,106,.20)"); glow.addColorStop(1, "rgba(182,243,106,0)");
517
+ x.fillStyle = glow; x.fillRect(0, 0, 1200, 630);
518
+
519
+ x.fillStyle = "#b6f36a"; x.font = "700 26px ui-sans-serif, system-ui, sans-serif";
520
+ x.fillText("halfsize", 80, 96);
521
+ x.fillStyle = "#8b93a7"; x.font = "400 20px ui-sans-serif, system-ui, sans-serif";
522
+ x.fillText("fit any video into any limit", 80, 132);
523
+
524
+ x.fillStyle = "#eef1f6"; x.font = "800 150px ui-sans-serif, system-ui, sans-serif";
525
+ const ratioText = `${r.ratio >= 10 ? Math.round(r.ratio) : r.ratio.toFixed(1)}×`;
526
+ x.fillText(ratioText, 72, 330);
527
+ const ratioWidth = x.measureText(ratioText).width;
528
+ x.fillStyle = "#8b93a7"; x.font = "400 34px ui-sans-serif, system-ui, sans-serif";
529
+ x.fillText(t("smallerLabel"), 72 + ratioWidth + 28, 330);
530
+
531
+ x.fillStyle = "#eef1f6"; x.font = "600 40px ui-sans-serif, system-ui, sans-serif";
532
+ x.fillText(`${fmtBytes(r.inputBytes)} → ${fmtBytes(r.outputBytes)}`, 80, 410);
533
+ x.fillStyle = "#8b93a7"; x.font = "400 28px ui-sans-serif, system-ui, sans-serif";
534
+ const q = r.quality.vmaf !== null ? `VMAF ${r.quality.vmaf.toFixed(1)}` : r.quality.ssim !== null ? `SSIM ${r.quality.ssim.toFixed(3)}` : "";
535
+ const vLabel = strings.verdicts[r.verdict.key] || r.verdict.label;
536
+ x.fillText([q, vLabel].filter(Boolean).join(" · "), 80, 464);
537
+
538
+ x.fillStyle = "#5d6577"; x.font = "400 22px ui-monospace, monospace";
539
+ x.fillText("npx halfsize video.mp4 --to " + (r.targetBytes ? `${Math.round(r.targetBytes / 1e6)}mb` : "25mb"), 80, 556);
540
+ x.textAlign = "right";
541
+ x.fillStyle = "#b6f36a"; x.font = "500 22px ui-monospace, monospace";
542
+ x.fillText("github.com/YKLovePython/halfsize", 1120, 556);
543
+ x.textAlign = "left";
544
+
545
+ x.strokeStyle = "rgba(255,255,255,.08)"; x.strokeRect(24, 24, 1152, 582);
546
+
547
+ c.toBlob((blob) => {
548
+ const a = document.createElement("a");
549
+ a.href = URL.createObjectURL(blob);
550
+ a.download = "halfsize-result.png";
551
+ a.click();
552
+ setTimeout(() => URL.revokeObjectURL(a.href), 4000);
553
+ });
554
+ });
555
+ </script>
556
+ </body>
557
+ </html>