bytecut 0.0.0-stage → 0.0.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.
@@ -0,0 +1,319 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ BytecutError
4
+ } from "./chunk-TWI2OZJZ.js";
5
+
6
+ // src/core/compare/server.ts
7
+ import { randomBytes } from "crypto";
8
+ import { createReadStream } from "fs";
9
+ import { realpath, stat } from "fs/promises";
10
+ import { createServer } from "http";
11
+ import { extname, isAbsolute, relative, resolve } from "path";
12
+
13
+ // src/core/compare/page.ts
14
+ function comparePage() {
15
+ return `<!doctype html>
16
+ <html lang="en">
17
+ <head>
18
+ <meta charset="utf-8">
19
+ <meta name="viewport" content="width=device-width, initial-scale=1">
20
+ <meta name="referrer" content="no-referrer">
21
+ <title>bytecut compare</title>
22
+ <style>
23
+ :root{color-scheme:light dark;--bg:#fff;--fg:#1c1c1e;--muted:#6b6b70;--line:#d8d8dc;--accent:#0a6cff;--panel:#f5f5f7}
24
+ @media (prefers-color-scheme:dark){:root{--bg:#161618;--fg:#f2f2f4;--muted:#9b9ba1;--line:#38383c;--accent:#5aa0ff;--panel:#212124}}
25
+ *{box-sizing:border-box}
26
+ body{margin:0;font:15px/1.5 system-ui,sans-serif;background:var(--bg);color:var(--fg)}
27
+ header{padding:16px;border-bottom:1px solid var(--line);display:flex;flex-wrap:wrap;gap:8px 24px;align-items:baseline}
28
+ h1{font-size:16px;margin:0;font-weight:600}
29
+ .muted{color:var(--muted)}
30
+ main{padding:16px;max-width:1400px;margin:0 auto}
31
+ .controls{display:flex;flex-wrap:wrap;gap:12px;align-items:center;margin-bottom:16px}
32
+ select,button{font:inherit;background:var(--panel);color:var(--fg);border:1px solid var(--line);border-radius:6px;padding:6px 10px}
33
+ .stage{position:relative;display:inline-block;max-width:100%;background:var(--panel);line-height:0;user-select:none;touch-action:none}
34
+ .stage img{display:block;max-width:100%;height:auto}
35
+ .stage .after{position:absolute;inset:0;width:100%;height:100%;object-fit:contain}
36
+ .handle{position:absolute;top:0;bottom:0;width:2px;background:var(--accent);transform:translateX(-1px);cursor:ew-resize}
37
+ .handle::after{content:"";position:absolute;top:50%;left:50%;width:28px;height:28px;margin:-14px;border-radius:50%;background:var(--accent);opacity:.9}
38
+ .labels{display:flex;justify-content:space-between;margin-top:8px;gap:16px;flex-wrap:wrap}
39
+ .pair{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:16px}
40
+ .pair video{width:100%;background:#000}
41
+ table{border-collapse:collapse;margin-top:24px;width:100%;max-width:720px}
42
+ th,td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--line);font-variant-numeric:tabular-nums}
43
+ th{color:var(--muted);font-weight:500}
44
+ tr.on td{background:var(--panel)}
45
+ .err{padding:16px;color:#c0392b}
46
+ </style>
47
+ </head>
48
+ <body>
49
+ <header><h1 id="title">bytecut compare</h1><span class="muted" id="sub"></span></header>
50
+ <main id="app"><p class="muted">Loading\u2026</p></main>
51
+ <script>
52
+ (function(){
53
+ var base=location.pathname.replace(/[^\\/]*$/,'');
54
+ var app=document.getElementById('app');
55
+ function fmt(n){if(n<1024)return n+' B';if(n<1048576)return (n/1024).toFixed(1)+' KB';return (n/1048576).toFixed(2)+' MB'}
56
+ function el(tag,attrs,kids){var e=document.createElement(tag);for(var k in attrs||{}){if(k==='text')e.textContent=attrs[k];else e.setAttribute(k,attrs[k])}(kids||[]).forEach(function(c){e.appendChild(c)});return e}
57
+ fetch(base+'data.json').then(function(r){return r.json()}).then(render).catch(function(e){app.innerHTML='';app.appendChild(el('p',{class:'err',text:'Could not load: '+e}))});
58
+
59
+ function render(d){
60
+ document.getElementById('title').textContent=d.source.path;
61
+ document.getElementById('sub').textContent=fmt(d.source.bytes)+(d.source.width?' \xB7 '+d.source.width+'\xD7'+d.source.height:'')+' original';
62
+ app.innerHTML='';
63
+ var shown=d.outputs.filter(function(o){return o.role!=='poster'});
64
+ if(!shown.length){app.appendChild(el('p',{class:'err',text:'This file has no outputs to compare.'}));return}
65
+ var picker=el('select',{'aria-label':'Output'});
66
+ shown.forEach(function(o,i){
67
+ var label=o.format+(o.codec?' ('+o.codec+')':'')+' \xB7 '+o.width+'w \xB7 '+fmt(o.bytes);
68
+ picker.appendChild(el('option',{value:String(i),text:label}));
69
+ });
70
+ var controls=el('div',{class:'controls'},[el('label',{text:'Compare with '},[picker])]);
71
+ var stage=el('div');
72
+ app.appendChild(controls);app.appendChild(stage);
73
+ var table=el('table');app.appendChild(table);
74
+
75
+ function row(label,value){return el('tr',{},[el('th',{text:label}),el('td',{text:value})])}
76
+ function details(o){
77
+ table.innerHTML='';
78
+ var saved=d.source.bytes>0?Math.round((1-o.bytes/d.source.bytes)*100):0;
79
+ table.appendChild(row('File',o.path));
80
+ table.appendChild(row('Size',fmt(o.bytes)+' ('+(saved>=0?'-':'+')+Math.abs(saved)+'% vs original)'));
81
+ table.appendChild(row('Dimensions',o.width+'\xD7'+o.height));
82
+ if(o.encoder)table.appendChild(row('Encoder',o.encoder));
83
+ if(o.quality!=null)table.appendChild(row('Quality',String(o.quality)));
84
+ if(o.crf!=null)table.appendChild(row('CRF',String(o.crf)));
85
+ if(o.score!=null&&o.search)table.appendChild(row(o.search.metric,o.score.toFixed(1)+' (target '+o.search.target+(o.scorer?', '+o.scorer:'')+')'));
86
+ }
87
+
88
+ function slider(o){
89
+ stage.innerHTML='';
90
+ var wrap=el('div',{class:'stage'});
91
+ var before=el('img',{src:base+'file/original',alt:'Original'});
92
+ var after=el('img',{src:base+'file/'+o.index,alt:'Optimized',class:'after'});
93
+ var handle=el('div',{class:'handle'});
94
+ wrap.appendChild(before);wrap.appendChild(after);wrap.appendChild(handle);
95
+ var pos=50;
96
+ function set(p){pos=Math.max(0,Math.min(100,p));after.style.clipPath='inset(0 0 0 '+pos+'%)';handle.style.left=pos+'%'}
97
+ set(50);
98
+ function move(ev){var r=wrap.getBoundingClientRect();set((ev.clientX-r.left)/r.width*100)}
99
+ wrap.addEventListener('pointerdown',function(ev){wrap.setPointerCapture(ev.pointerId);move(ev);wrap.onpointermove=move});
100
+ wrap.addEventListener('pointerup',function(){wrap.onpointermove=null});
101
+ stage.appendChild(wrap);
102
+ stage.appendChild(el('div',{class:'labels'},[
103
+ el('span',{class:'muted',text:'Original \xB7 '+fmt(d.source.bytes)}),
104
+ el('span',{class:'muted',text:'Optimized \xB7 '+fmt(o.bytes)})]));
105
+ }
106
+
107
+ function pair(o){
108
+ stage.innerHTML='';
109
+ var a=el('video',{src:base+'file/original',controls:'',muted:'',loop:'',playsinline:'',preload:'metadata'});
110
+ var b=el('video',{src:base+'file/'+o.index,controls:'',muted:'',loop:'',playsinline:'',preload:'metadata'});
111
+ a.muted=b.muted=true;
112
+ if(d.poster){b.poster=base+'file/'+d.poster.index}
113
+ var lock=false;
114
+ function link(x,y){
115
+ x.addEventListener('play',function(){y.play().catch(function(){})});
116
+ x.addEventListener('pause',function(){y.pause()});
117
+ x.addEventListener('seeking',function(){if(!lock){lock=true;y.currentTime=x.currentTime;setTimeout(function(){lock=false},50)}});
118
+ }
119
+ link(a,b);link(b,a);
120
+ stage.appendChild(el('div',{class:'pair'},[
121
+ el('figure',{style:'margin:0'},[a,el('figcaption',{class:'muted',text:'Original \xB7 '+fmt(d.source.bytes)})]),
122
+ el('figure',{style:'margin:0'},[b,el('figcaption',{class:'muted',text:'Optimized \xB7 '+fmt(o.bytes)})])]));
123
+ }
124
+
125
+ function show(){
126
+ var o=shown[Number(picker.value)];
127
+ details(o);
128
+ if(d.source.kind==='video')pair(o);else slider(o);
129
+ }
130
+ picker.addEventListener('change',show);
131
+ show();
132
+ }
133
+ })();
134
+ </script>
135
+ </body>
136
+ </html>
137
+ `;
138
+ }
139
+
140
+ // src/core/compare/server.ts
141
+ var TYPES = {
142
+ ".avif": "image/avif",
143
+ ".webp": "image/webp",
144
+ ".jpg": "image/jpeg",
145
+ ".jpeg": "image/jpeg",
146
+ ".png": "image/png",
147
+ ".gif": "image/gif",
148
+ ".svg": "image/svg+xml",
149
+ ".mp4": "video/mp4",
150
+ ".webm": "video/webm",
151
+ ".mov": "video/quicktime"
152
+ };
153
+ var VIDEO_EXT = /* @__PURE__ */ new Set([".mp4", ".webm", ".mov", ".m4v", ".gif"]);
154
+ function describe(o, index) {
155
+ const video = "role" in o ? o : null;
156
+ return {
157
+ index,
158
+ path: o.path,
159
+ role: video?.role ?? "image",
160
+ format: o.format,
161
+ ...video?.codec ? { codec: video.codec } : {},
162
+ width: o.width,
163
+ height: o.height,
164
+ bytes: o.bytes,
165
+ encoder: o.encoder,
166
+ ..."quality" in o && o.quality !== void 0 ? { quality: o.quality } : {},
167
+ ...video?.crf !== void 0 ? { crf: video.crf } : {},
168
+ ...o.search ? { search: o.search } : {},
169
+ ...o.score !== void 0 ? { score: o.score } : {},
170
+ ...o.scorer ? { scorer: o.scorer } : {}
171
+ };
172
+ }
173
+ async function within(root, candidate) {
174
+ try {
175
+ const [r, c] = await Promise.all([realpath(root), realpath(candidate)]);
176
+ const rel = relative(r, c);
177
+ if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) return null;
178
+ return c;
179
+ } catch {
180
+ return null;
181
+ }
182
+ }
183
+ async function startCompareServer(options) {
184
+ const { manifest, source } = options;
185
+ const image = manifest.sources[source];
186
+ const video = manifest.videos[source];
187
+ const entry = image ?? video;
188
+ if (!entry) {
189
+ throw new BytecutError(
190
+ "INPUT_NOT_FOUND",
191
+ `${source} has not been optimized yet. Run \`bytecut optimize -y\` first.`
192
+ );
193
+ }
194
+ const outDir = resolve(options.projectRoot, options.outDir);
195
+ const sourceFile = await within(options.projectRoot, resolve(options.projectRoot, source));
196
+ if (!sourceFile) {
197
+ throw new BytecutError("INPUT_NOT_FOUND", `${source} is not a readable file in this project.`);
198
+ }
199
+ const files = /* @__PURE__ */ new Map();
200
+ const outputs = [];
201
+ let posterIndex;
202
+ const list = entry.outputs;
203
+ for (const out of list) {
204
+ if (out.status !== "written") continue;
205
+ const file = await within(outDir, resolve(outDir, ...out.path.split("/")));
206
+ if (!file) continue;
207
+ const index = outputs.length;
208
+ files.set(String(index), { file });
209
+ outputs.push(describe(out, index));
210
+ if ("role" in out && out.role === "poster" && posterIndex === void 0) posterIndex = index;
211
+ }
212
+ files.set("original", { file: sourceFile });
213
+ const token = options.token ?? randomBytes(16).toString("hex");
214
+ const isVideo = video !== void 0 || VIDEO_EXT.has(extname(source).toLowerCase());
215
+ const data = JSON.stringify({
216
+ source: {
217
+ path: source,
218
+ bytes: entry.bytes,
219
+ width: entry.width,
220
+ height: entry.height,
221
+ kind: isVideo ? "video" : "image"
222
+ },
223
+ outputs,
224
+ ...posterIndex !== void 0 ? { poster: { index: posterIndex } } : {}
225
+ });
226
+ const page = comparePage();
227
+ const prefix = `/${token}/`;
228
+ function send(res, status, body, type = "text/plain") {
229
+ res.writeHead(status, {
230
+ "content-type": `${type}; charset=utf-8`,
231
+ "cache-control": "no-store",
232
+ "x-content-type-options": "nosniff",
233
+ "content-security-policy": "default-src 'none'; img-src 'self'; media-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'"
234
+ });
235
+ res.end(body);
236
+ }
237
+ async function sendFile(req, res, file) {
238
+ let size;
239
+ try {
240
+ size = (await stat(file)).size;
241
+ } catch {
242
+ return send(res, 404, "Not found");
243
+ }
244
+ const type = TYPES[extname(file).toLowerCase()] ?? "application/octet-stream";
245
+ const headers = {
246
+ "content-type": type,
247
+ "cache-control": "no-store",
248
+ "accept-ranges": "bytes",
249
+ "x-content-type-options": "nosniff"
250
+ };
251
+ const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range ?? "");
252
+ if (range && (range[1] || range[2])) {
253
+ let start = range[1] ? Number(range[1]) : size - Number(range[2]);
254
+ let end = range[1] && range[2] ? Number(range[2]) : size - 1;
255
+ start = Math.max(0, start);
256
+ end = Math.min(size - 1, end);
257
+ if (start > end || start >= size) {
258
+ res.writeHead(416, { "content-range": `bytes */${size}` });
259
+ return res.end();
260
+ }
261
+ res.writeHead(206, {
262
+ ...headers,
263
+ "content-range": `bytes ${start}-${end}/${size}`,
264
+ "content-length": end - start + 1
265
+ });
266
+ if (req.method === "HEAD") return res.end();
267
+ createReadStream(file, { start, end }).pipe(res);
268
+ return;
269
+ }
270
+ res.writeHead(200, { ...headers, "content-length": size });
271
+ if (req.method === "HEAD") return res.end();
272
+ createReadStream(file).pipe(res);
273
+ }
274
+ let port = 0;
275
+ const hostOk = (host) => host === `127.0.0.1:${port}` || host === `localhost:${port}`;
276
+ const server = createServer((req, res) => {
277
+ void (async () => {
278
+ if (req.method !== "GET" && req.method !== "HEAD") {
279
+ res.setHeader("allow", "GET, HEAD");
280
+ return send(res, 405, "Method not allowed");
281
+ }
282
+ if (!hostOk(req.headers.host)) return send(res, 403, "Forbidden");
283
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
284
+ if (!url.pathname.startsWith(prefix)) return send(res, 404, "Not found");
285
+ const rest = url.pathname.slice(prefix.length);
286
+ if (rest === "") return send(res, 200, page, "text/html");
287
+ if (rest === "data.json") return send(res, 200, data, "application/json");
288
+ const m = /^file\/(original|\d+)$/.exec(rest);
289
+ const target = m ? files.get(m[1]) : void 0;
290
+ if (!target) return send(res, 404, "Not found");
291
+ return sendFile(req, res, target.file);
292
+ })().catch(() => {
293
+ if (!res.headersSent) send(res, 500, "Internal error");
294
+ else res.destroy();
295
+ });
296
+ });
297
+ await new Promise((resolveListen, reject) => {
298
+ server.once("error", reject);
299
+ server.listen(options.port ?? 0, "127.0.0.1", () => resolveListen());
300
+ }).catch((error) => {
301
+ if (error.code === "EADDRINUSE") {
302
+ throw new BytecutError("PORT_IN_USE", `Port ${options.port} is already in use.`);
303
+ }
304
+ throw error;
305
+ });
306
+ port = server.address().port;
307
+ return {
308
+ url: `http://127.0.0.1:${port}${prefix}`,
309
+ port,
310
+ close: () => new Promise((resolveClose) => {
311
+ server.closeAllConnections();
312
+ server.close(() => resolveClose());
313
+ })
314
+ };
315
+ }
316
+
317
+ export {
318
+ startCompareServer
319
+ };