bytecut 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +140 -2
- package/dist/audit-PEKVNXKA.js +97 -0
- package/dist/check-BYNAWCDE.js +348 -0
- package/dist/chunk-3DBJKHOD.js +109 -0
- package/dist/chunk-3PF6BBFK.js +490 -0
- package/dist/chunk-4FIB6VNC.js +59 -0
- package/dist/chunk-4PWCQ4NF.js +620 -0
- package/dist/chunk-7DHE25UE.js +319 -0
- package/dist/chunk-AYYKUSRP.js +57 -0
- package/dist/chunk-CSFBJ3UH.js +259 -0
- package/dist/chunk-DJ45NQZD.js +29 -0
- package/dist/chunk-FY53B4NB.js +1439 -0
- package/dist/chunk-IZ4JEL3Q.js +212 -0
- package/dist/chunk-KN5QOB7R.js +186 -0
- package/dist/chunk-LTWUPQDT.js +26 -0
- package/dist/chunk-MV6J66AP.js +21 -0
- package/dist/chunk-P5PFI3AO.js +2170 -0
- package/dist/chunk-PQRBLHCC.js +188 -0
- package/dist/chunk-QOG2F4SF.js +536 -0
- package/dist/chunk-S3FQYUTI.js +59 -0
- package/dist/chunk-TCQFQ354.js +282 -0
- package/dist/chunk-WZ6MMRNB.js +238 -0
- package/dist/chunk-XUBQWBWH.js +382 -0
- package/dist/cli.js +138 -0
- package/dist/compare-MPPGTUC5.js +84 -0
- package/dist/doctor-PWCUEBD5.js +77 -0
- package/dist/explain-TS6XF5TX.js +267 -0
- package/dist/init-CWDWVJ55.js +92 -0
- package/dist/optimize-47YALM3D.js +216 -0
- package/dist/snippet-IIPVF6PS.js +114 -0
- package/dist/tui-CDF23I73.js +1691 -0
- package/package.json +46 -3
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
BytecutError
|
|
4
|
+
} from "./chunk-FY53B4NB.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
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// package.json
|
|
4
|
+
var package_default = {
|
|
5
|
+
name: "bytecut",
|
|
6
|
+
version: "0.1.0",
|
|
7
|
+
description: "Audit and optimize images and videos in web projects.",
|
|
8
|
+
type: "module",
|
|
9
|
+
license: "MIT",
|
|
10
|
+
bin: {
|
|
11
|
+
bytecut: "./dist/cli.js"
|
|
12
|
+
},
|
|
13
|
+
files: [
|
|
14
|
+
"dist"
|
|
15
|
+
],
|
|
16
|
+
engines: {
|
|
17
|
+
node: ">=22"
|
|
18
|
+
},
|
|
19
|
+
scripts: {
|
|
20
|
+
dev: "tsx src/cli/index.ts",
|
|
21
|
+
build: "tsup",
|
|
22
|
+
test: "vitest run",
|
|
23
|
+
"test:watch": "vitest",
|
|
24
|
+
lint: "eslint . && prettier --check .",
|
|
25
|
+
typecheck: "tsc --noEmit",
|
|
26
|
+
fixtures: "tsx scripts/make-fixtures.ts"
|
|
27
|
+
},
|
|
28
|
+
dependencies: {
|
|
29
|
+
"bytecut-ssimulacra2": "workspace:*",
|
|
30
|
+
citty: "^0.2.2",
|
|
31
|
+
ignore: "^7.0.12",
|
|
32
|
+
ink: "^8.0.0",
|
|
33
|
+
react: "^19.3.0",
|
|
34
|
+
sharp: "^0.35.5",
|
|
35
|
+
"smol-toml": "^1.9.1",
|
|
36
|
+
svgo: "^4.1.0",
|
|
37
|
+
zod: "^4.6.5"
|
|
38
|
+
},
|
|
39
|
+
devDependencies: {
|
|
40
|
+
"@eslint/js": "^10.0.1",
|
|
41
|
+
"@types/node": "^26.6.5",
|
|
42
|
+
"@types/react": "^19.3.0",
|
|
43
|
+
eslint: "^10.12.0",
|
|
44
|
+
"ink-testing-library": "^4.0.0",
|
|
45
|
+
prettier: "^3.9.9",
|
|
46
|
+
tsup: "^8.5.1",
|
|
47
|
+
tsx: "^4.23.15",
|
|
48
|
+
typescript: "^5.9.3",
|
|
49
|
+
"typescript-eslint": "^8.71.1",
|
|
50
|
+
vitest: "^5.0.3"
|
|
51
|
+
},
|
|
52
|
+
packageManager: "pnpm@10.18.1"
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export {
|
|
56
|
+
package_default
|
|
57
|
+
};
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/core/engines/image.ts
|
|
4
|
+
import sharp from "sharp";
|
|
5
|
+
|
|
6
|
+
// src/core/placeholders/blurhash.ts
|
|
7
|
+
var ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~";
|
|
8
|
+
function encode83(value, length) {
|
|
9
|
+
let out = "";
|
|
10
|
+
for (let i = 1; i <= length; i++) {
|
|
11
|
+
const digit = Math.floor(value / 83 ** (length - i)) % 83;
|
|
12
|
+
out += ALPHABET[digit];
|
|
13
|
+
}
|
|
14
|
+
return out;
|
|
15
|
+
}
|
|
16
|
+
function sRGBToLinear(value) {
|
|
17
|
+
const v = value / 255;
|
|
18
|
+
return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
|
19
|
+
}
|
|
20
|
+
function linearToSRGB(value) {
|
|
21
|
+
const v = Math.max(0, Math.min(1, value));
|
|
22
|
+
return v <= 31308e-7 ? Math.trunc(v * 12.92 * 255 + 0.5) : Math.trunc((1.055 * v ** (1 / 2.4) - 0.055) * 255 + 0.5);
|
|
23
|
+
}
|
|
24
|
+
var signPow = (value, exp) => Math.sign(value) * Math.abs(value) ** exp;
|
|
25
|
+
function basisFactor(pixels, width, height, xc, yc) {
|
|
26
|
+
let r = 0;
|
|
27
|
+
let g = 0;
|
|
28
|
+
let b = 0;
|
|
29
|
+
const normalisation = xc === 0 && yc === 0 ? 1 : 2;
|
|
30
|
+
const bytesPerRow = width * 4;
|
|
31
|
+
for (let x = 0; x < width; x++) {
|
|
32
|
+
for (let y = 0; y < height; y++) {
|
|
33
|
+
const basis = normalisation * Math.cos(Math.PI * xc * x / width) * Math.cos(Math.PI * yc * y / height);
|
|
34
|
+
r += basis * sRGBToLinear(pixels[4 * x + y * bytesPerRow]);
|
|
35
|
+
g += basis * sRGBToLinear(pixels[4 * x + 1 + y * bytesPerRow]);
|
|
36
|
+
b += basis * sRGBToLinear(pixels[4 * x + 2 + y * bytesPerRow]);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const scale = 1 / (width * height);
|
|
40
|
+
return [r * scale, g * scale, b * scale];
|
|
41
|
+
}
|
|
42
|
+
function encodeBlurhash(pixels, width, height, componentX, componentY) {
|
|
43
|
+
if (componentX < 1 || componentX > 9 || componentY < 1 || componentY > 9) {
|
|
44
|
+
throw new RangeError("BlurHash needs 1 to 9 components per axis");
|
|
45
|
+
}
|
|
46
|
+
if (pixels.length !== width * height * 4) {
|
|
47
|
+
throw new RangeError("Pixel buffer does not match the given width and height");
|
|
48
|
+
}
|
|
49
|
+
const factors = [];
|
|
50
|
+
for (let y = 0; y < componentY; y++) {
|
|
51
|
+
for (let x = 0; x < componentX; x++) {
|
|
52
|
+
factors.push(basisFactor(pixels, width, height, x, y));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const dc = factors[0];
|
|
56
|
+
const ac = factors.slice(1);
|
|
57
|
+
let hash = encode83(componentX - 1 + (componentY - 1) * 9, 1);
|
|
58
|
+
let maximumValue;
|
|
59
|
+
if (ac.length > 0) {
|
|
60
|
+
const actualMax = Math.max(...ac.flatMap((f) => f.map(Math.abs)));
|
|
61
|
+
const quantisedMax = Math.max(0, Math.min(82, Math.floor(actualMax * 166 - 0.5)));
|
|
62
|
+
maximumValue = (quantisedMax + 1) / 166;
|
|
63
|
+
hash += encode83(quantisedMax, 1);
|
|
64
|
+
} else {
|
|
65
|
+
maximumValue = 1;
|
|
66
|
+
hash += encode83(0, 1);
|
|
67
|
+
}
|
|
68
|
+
hash += encode83(
|
|
69
|
+
(linearToSRGB(dc[0]) << 16) + (linearToSRGB(dc[1]) << 8) + linearToSRGB(dc[2]),
|
|
70
|
+
4
|
|
71
|
+
);
|
|
72
|
+
for (const f of ac) {
|
|
73
|
+
const q = (v) => Math.max(0, Math.min(18, Math.floor(signPow(v / maximumValue, 0.5) * 9 + 9.5)));
|
|
74
|
+
hash += encode83(q(f[0]) * 19 * 19 + q(f[1]) * 19 + q(f[2]), 2);
|
|
75
|
+
}
|
|
76
|
+
return hash;
|
|
77
|
+
}
|
|
78
|
+
function componentsFor(width, height) {
|
|
79
|
+
if (width <= 0 || height <= 0) return [4, 3];
|
|
80
|
+
const ratio = width / height;
|
|
81
|
+
if (ratio > 1.25) return [4, 3];
|
|
82
|
+
if (ratio < 0.8) return [3, 4];
|
|
83
|
+
return [4, 4];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// src/core/engines/exif.ts
|
|
87
|
+
function hasGpsIfd(exif) {
|
|
88
|
+
const base = exif.subarray(0, 6).toString("latin1") === "Exif\0\0" ? 6 : 0;
|
|
89
|
+
if (exif.length < base + 8) return false;
|
|
90
|
+
const order = exif.toString("latin1", base, base + 2);
|
|
91
|
+
const little = order === "II";
|
|
92
|
+
if (!little && order !== "MM") return false;
|
|
93
|
+
const u16 = (at) => little ? exif.readUInt16LE(at) : exif.readUInt16BE(at);
|
|
94
|
+
const u32 = (at) => little ? exif.readUInt32LE(at) : exif.readUInt32BE(at);
|
|
95
|
+
const ifd0 = base + u32(base + 4);
|
|
96
|
+
if (ifd0 + 2 > exif.length) return false;
|
|
97
|
+
const count = u16(ifd0);
|
|
98
|
+
for (let i = 0; i < count; i++) {
|
|
99
|
+
const entry = ifd0 + 2 + i * 12;
|
|
100
|
+
if (entry + 12 > exif.length) return false;
|
|
101
|
+
if (u16(entry) === 34853) return true;
|
|
102
|
+
}
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// src/core/engines/image.ts
|
|
107
|
+
function sourceFormat(format, compression) {
|
|
108
|
+
switch (format) {
|
|
109
|
+
case "jpeg":
|
|
110
|
+
case "png":
|
|
111
|
+
case "webp":
|
|
112
|
+
case "gif":
|
|
113
|
+
return format;
|
|
114
|
+
case "heif":
|
|
115
|
+
return compression === "av1" ? "avif" : "other";
|
|
116
|
+
default:
|
|
117
|
+
return "other";
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
var SAMPLE_EDGE = 64;
|
|
121
|
+
async function sampleColors(input) {
|
|
122
|
+
const { data, info } = await sharp(input).rotate().resize(SAMPLE_EDGE, SAMPLE_EDGE, { fit: "inside", kernel: "nearest" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
123
|
+
const seen = /* @__PURE__ */ new Set();
|
|
124
|
+
for (let i = 0; i + 3 < data.length; i += 4) {
|
|
125
|
+
const a = data[i + 3];
|
|
126
|
+
const key = a === 0 ? 4294967295 : data[i] >> 3 << 15 | data[i + 1] >> 3 << 10 | data[i + 2] >> 3 << 5 | a >> 6;
|
|
127
|
+
seen.add(key);
|
|
128
|
+
}
|
|
129
|
+
return { colorCount: seen.size, sampleSize: info.width * info.height };
|
|
130
|
+
}
|
|
131
|
+
function encoderNames() {
|
|
132
|
+
const v = sharp.versions;
|
|
133
|
+
return {
|
|
134
|
+
jpg: `mozjpeg ${v.mozjpeg ?? "unknown"}`,
|
|
135
|
+
png: `libpng ${v.png ?? "unknown"}`,
|
|
136
|
+
avif: `libaom-av1 ${v.aom ?? "unknown"}`,
|
|
137
|
+
webp: `libwebp ${v.webp ?? "unknown"}`
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
var LQIP_EDGE = 16;
|
|
141
|
+
var BLURHASH_EDGE = 32;
|
|
142
|
+
async function makePlaceholder(input, kind) {
|
|
143
|
+
switch (kind) {
|
|
144
|
+
case "dominant-color": {
|
|
145
|
+
const { dominant } = await sharp(input).rotate().stats();
|
|
146
|
+
const hex = (n) => n.toString(16).padStart(2, "0");
|
|
147
|
+
return { kind, value: `#${hex(dominant.r)}${hex(dominant.g)}${hex(dominant.b)}` };
|
|
148
|
+
}
|
|
149
|
+
case "lqip": {
|
|
150
|
+
const webp = await sharp(input).rotate().resize(LQIP_EDGE, LQIP_EDGE, { fit: "inside" }).webp({ quality: 40, effort: 6 }).toBuffer();
|
|
151
|
+
return { kind, value: `data:image/webp;base64,${webp.toString("base64")}` };
|
|
152
|
+
}
|
|
153
|
+
case "blurhash": {
|
|
154
|
+
const { data, info } = await sharp(input).rotate().resize(BLURHASH_EDGE, BLURHASH_EDGE, { fit: "inside" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
155
|
+
const [cx, cy] = componentsFor(info.width, info.height);
|
|
156
|
+
return { kind, value: encodeBlurhash(data, info.width, info.height, cx, cy) };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function encodeAs(image, r) {
|
|
161
|
+
switch (r.format) {
|
|
162
|
+
case "jpg":
|
|
163
|
+
return image.jpeg({ quality: r.quality, mozjpeg: true });
|
|
164
|
+
case "png":
|
|
165
|
+
return image.png({ compressionLevel: 9, effort: Math.max(1, Math.min(10, r.effort)) });
|
|
166
|
+
case "avif":
|
|
167
|
+
return image.avif({ quality: r.quality, effort: r.effort, lossless: r.lossless });
|
|
168
|
+
case "webp":
|
|
169
|
+
return image.webp({ quality: r.quality, effort: r.effort, lossless: r.lossless });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
var sharpImageEngine = {
|
|
173
|
+
encoders: encoderNames,
|
|
174
|
+
placeholder: makePlaceholder,
|
|
175
|
+
versions() {
|
|
176
|
+
const v = sharp.versions;
|
|
177
|
+
return { sharp: v.sharp ?? "unknown", vips: v.vips ?? "unknown" };
|
|
178
|
+
},
|
|
179
|
+
async analyze(input) {
|
|
180
|
+
const meta = await sharp(input).metadata();
|
|
181
|
+
const swap = (meta.orientation ?? 1) >= 5;
|
|
182
|
+
const width = (swap ? meta.height : meta.width) ?? 0;
|
|
183
|
+
const height = (swap ? meta.width : meta.height) ?? 0;
|
|
184
|
+
const format = sourceFormat(meta.format, meta.compression);
|
|
185
|
+
const hasAlpha = meta.hasAlpha ?? false;
|
|
186
|
+
let alphaUsed = false;
|
|
187
|
+
if (hasAlpha && format !== "other") {
|
|
188
|
+
const { channels } = await sharp(input).stats();
|
|
189
|
+
const alpha = channels[channels.length - 1];
|
|
190
|
+
alphaUsed = alpha !== void 0 && alpha.min < 255;
|
|
191
|
+
}
|
|
192
|
+
const { colorCount, sampleSize } = format === "other" ? { colorCount: 0, sampleSize: 0 } : await sampleColors(input);
|
|
193
|
+
return {
|
|
194
|
+
width,
|
|
195
|
+
height,
|
|
196
|
+
format,
|
|
197
|
+
hasAlpha,
|
|
198
|
+
alphaUsed,
|
|
199
|
+
animated: (meta.pages ?? 1) > 1,
|
|
200
|
+
space: meta.space ?? "unknown",
|
|
201
|
+
depth: meta.depth ?? "unknown",
|
|
202
|
+
colorCount,
|
|
203
|
+
sampleSize,
|
|
204
|
+
metadata: {
|
|
205
|
+
exif: meta.exif?.byteLength ?? 0,
|
|
206
|
+
xmp: meta.xmp?.byteLength ?? 0,
|
|
207
|
+
iptc: meta.iptc?.byteLength ?? 0,
|
|
208
|
+
icc: meta.icc?.byteLength ?? 0,
|
|
209
|
+
gps: meta.exif ? hasGpsIfd(meta.exif) : false
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
},
|
|
213
|
+
async decodeRaw(input, options) {
|
|
214
|
+
let image = sharp(input).rotate();
|
|
215
|
+
if (options.width) image = image.resize({ width: options.width, withoutEnlargement: true });
|
|
216
|
+
const { data, info } = await image.toColourspace("srgb").ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
217
|
+
return {
|
|
218
|
+
data: new Uint8Array(data.buffer, data.byteOffset, data.byteLength),
|
|
219
|
+
width: info.width,
|
|
220
|
+
height: info.height,
|
|
221
|
+
channels: 4
|
|
222
|
+
};
|
|
223
|
+
},
|
|
224
|
+
async encodeRaw(image, request) {
|
|
225
|
+
const raw = sharp(image.data, {
|
|
226
|
+
raw: { width: image.width, height: image.height, channels: image.channels }
|
|
227
|
+
});
|
|
228
|
+
const encoded = encodeAs(raw, {
|
|
229
|
+
format: request.format,
|
|
230
|
+
quality: request.quality,
|
|
231
|
+
effort: request.effort,
|
|
232
|
+
lossless: false
|
|
233
|
+
});
|
|
234
|
+
const { data, info } = await encoded.toBuffer({ resolveWithObject: true });
|
|
235
|
+
return {
|
|
236
|
+
data,
|
|
237
|
+
width: info.width,
|
|
238
|
+
height: info.height,
|
|
239
|
+
encoder: encoderNames()[request.format]
|
|
240
|
+
};
|
|
241
|
+
},
|
|
242
|
+
async encode(input, request) {
|
|
243
|
+
const meta = await sharp(input).metadata();
|
|
244
|
+
let image = sharp(input).rotate().resize({ width: request.width, withoutEnlargement: true });
|
|
245
|
+
image = meta.space === "cmyk" ? image.toColourspace("srgb") : image.keepIccProfile();
|
|
246
|
+
image = encodeAs(image, request);
|
|
247
|
+
const { data, info } = await image.toBuffer({ resolveWithObject: true });
|
|
248
|
+
return {
|
|
249
|
+
data,
|
|
250
|
+
width: info.width,
|
|
251
|
+
height: info.height,
|
|
252
|
+
encoder: encoderNames()[request.format]
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
export {
|
|
258
|
+
sharpImageEngine
|
|
259
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/core/scan/gitignore.ts
|
|
4
|
+
import { readFile } from "fs/promises";
|
|
5
|
+
import { join, relative, sep } from "path";
|
|
6
|
+
import ignore from "ignore";
|
|
7
|
+
async function isGitIgnored(projectRoot, absolute) {
|
|
8
|
+
const rel = relative(projectRoot, absolute);
|
|
9
|
+
if (rel === "" || rel.startsWith("..")) return false;
|
|
10
|
+
const parts = rel.split(sep);
|
|
11
|
+
let dir = projectRoot;
|
|
12
|
+
const scopes = [];
|
|
13
|
+
for (let i = 0; i < parts.length; i++) {
|
|
14
|
+
const text = await readFile(join(dir, ".gitignore"), "utf8").catch(() => null);
|
|
15
|
+
if (text !== null) scopes.push({ base: dir, matcher: ignore().add(text) });
|
|
16
|
+
dir = join(dir, parts[i]);
|
|
17
|
+
for (let s = scopes.length - 1; s >= 0; s--) {
|
|
18
|
+
const scope = scopes[s];
|
|
19
|
+
const verdict = scope.matcher.test(`${relative(scope.base, dir).split(sep).join("/")}/`);
|
|
20
|
+
if (verdict.ignored) return true;
|
|
21
|
+
if (verdict.unignored) break;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export {
|
|
28
|
+
isGitIgnored
|
|
29
|
+
};
|