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,370 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ outputWidth,
4
+ toPosix
5
+ } from "./chunk-WE7AEDVI.js";
6
+ import {
7
+ BUILTIN_POLICY,
8
+ getVideoPreset
9
+ } from "./chunk-YBEC5QGV.js";
10
+ import {
11
+ BytecutError
12
+ } from "./chunk-TWI2OZJZ.js";
13
+
14
+ // src/core/classify/classify.ts
15
+ var FLAT_COLOR_LIMIT = 64;
16
+ var ICON_MAX_EDGE = 1024;
17
+ var HERO_NAME = /(^|[^a-z])(hero|banner|cover|header|splash|masthead|jumbotron)([^a-z]|$)/i;
18
+ var ICON_NAME = /(^|[^a-z])(icon|logo|favicon|sprite|glyph)([^a-z]|$)/i;
19
+ function baseName(source) {
20
+ const name = source.slice(source.lastIndexOf("/") + 1);
21
+ const dot = name.lastIndexOf(".");
22
+ return dot > 0 ? name.slice(0, dot) : name;
23
+ }
24
+ function classify(source, a) {
25
+ const name = baseName(source);
26
+ const longEdge = Math.max(a.width, a.height);
27
+ const flat = a.sampleSize > 0 && a.colorCount <= FLAT_COLOR_LIMIT;
28
+ if (flat && longEdge <= ICON_MAX_EDGE) {
29
+ const named = ICON_NAME.test(name);
30
+ return {
31
+ preset: "icon",
32
+ confidence: named || a.alphaUsed || longEdge <= 512 ? "high" : "medium",
33
+ reason: `flat art (${a.colorCount} colours in sample${a.alphaUsed ? ", uses transparency" : ""}${named ? `, name "${name}"` : ""})`
34
+ };
35
+ }
36
+ if (!flat && ICON_NAME.test(name) && longEdge <= 256) {
37
+ return {
38
+ preset: "icon",
39
+ confidence: "medium",
40
+ reason: `small image named "${name}" (${longEdge}px)`
41
+ };
42
+ }
43
+ if (HERO_NAME.test(name)) {
44
+ return { preset: "hero", confidence: "high", reason: `name "${name}" looks like a hero image` };
45
+ }
46
+ if (a.width >= 1920 && a.width / Math.max(1, a.height) >= 1.6) {
47
+ return {
48
+ preset: "hero",
49
+ confidence: "medium",
50
+ reason: `large wide image (${a.width}x${a.height})`
51
+ };
52
+ }
53
+ const confident = a.colorCount > FLAT_COLOR_LIMIT * 2;
54
+ return {
55
+ preset: "photo",
56
+ confidence: confident ? "high" : "low",
57
+ reason: confident ? "continuous-tone image" : "ambiguous content; using the conservative preset"
58
+ };
59
+ }
60
+
61
+ // src/core/hash.ts
62
+ import { createHash } from "crypto";
63
+ import { createReadStream } from "fs";
64
+ function sha256(data) {
65
+ return createHash("sha256").update(data).digest("hex");
66
+ }
67
+ function sha256File(path) {
68
+ return new Promise((resolve, reject) => {
69
+ const hash = createHash("sha256");
70
+ const stream = createReadStream(path);
71
+ stream.on("error", reject);
72
+ stream.on("data", (chunk) => hash.update(chunk));
73
+ stream.on("end", () => resolve(hash.digest("hex")));
74
+ });
75
+ }
76
+
77
+ // src/core/plan/build.ts
78
+ import { posix, relative } from "path";
79
+ function targetWidths(presetWidths, sourceWidth) {
80
+ const widths = presetWidths.filter((w) => w <= sourceWidth);
81
+ const largest = Math.max(...presetWidths);
82
+ if (sourceWidth < largest && !widths.includes(sourceWidth)) widths.push(sourceWidth);
83
+ return [...new Set(widths)].sort((a, b) => a - b);
84
+ }
85
+ function mirroredDir(source, staticRoot) {
86
+ const dir = posix.dirname(source);
87
+ if (staticRoot !== "." && staticRoot !== "") {
88
+ if (dir === staticRoot) return "";
89
+ if (dir.startsWith(`${staticRoot}/`)) return dir.slice(staticRoot.length + 1);
90
+ }
91
+ return dir === "." ? "" : dir;
92
+ }
93
+ var DEFAULT_PUBLIC_PATH = "/_bytecut";
94
+ var ANIMATED_GIF_REASON = "animated GIF";
95
+ async function buildPlan(options) {
96
+ const items = [];
97
+ const claimed = /* @__PURE__ */ new Map();
98
+ const staticRoot = options.staticRoot ?? ".";
99
+ const analyze = options.analyze ?? ((data) => options.engine.analyze(data));
100
+ const files = options.files.map((file) => ({ file, source: toPosix(relative(options.projectRoot, file)) })).sort((a, b) => a.source < b.source ? -1 : a.source > b.source ? 1 : 0);
101
+ for (const { file, source } of files) {
102
+ if (source.startsWith("../") || source === "..") {
103
+ throw new BytecutError(
104
+ "SOURCE_OUTSIDE_PROJECT",
105
+ `${file} is outside the project root (${options.projectRoot}); run bytecut from a parent folder.`
106
+ );
107
+ }
108
+ const data = await options.readSource(file);
109
+ const digest = sha256(data);
110
+ const base = {
111
+ source,
112
+ sourceBytes: data.byteLength,
113
+ sourceSha256: digest,
114
+ sourceWidth: 0,
115
+ sourceHeight: 0
116
+ };
117
+ let analysis;
118
+ try {
119
+ analysis = await analyze(data, digest);
120
+ } catch {
121
+ items.push({
122
+ ...base,
123
+ preset: "-",
124
+ outputs: [],
125
+ skip: { reason: "unreadable or corrupt image" }
126
+ });
127
+ continue;
128
+ }
129
+ const sized = { ...base, sourceWidth: analysis.width, sourceHeight: analysis.height };
130
+ if (analysis.format === "other") {
131
+ items.push({
132
+ ...sized,
133
+ preset: "-",
134
+ outputs: [],
135
+ skip: { reason: "unsupported image format" }
136
+ });
137
+ continue;
138
+ }
139
+ if (analysis.animated) {
140
+ const reason = analysis.format === "gif" ? `${ANIMATED_GIF_REASON} (video conversion arrives with ffmpeg support in M5)` : "animated image";
141
+ items.push({ ...sized, preset: "-", outputs: [], skip: { reason } });
142
+ continue;
143
+ }
144
+ const choice = options.choosePreset(source, analysis);
145
+ const { preset } = choice;
146
+ const dir = mirroredDir(source, staticRoot);
147
+ const name = posix.basename(source, posix.extname(source));
148
+ const outputs = [];
149
+ for (const width of targetWidths(preset.widths, analysis.width)) {
150
+ for (const format of preset.formats) {
151
+ const lossless = preset.lossless ?? (analysis.format === "png" && format === "webp");
152
+ const path = posix.join(dir, name, `${name}-${width}.${format}`);
153
+ const owner = claimed.get(path);
154
+ if (owner !== void 0) {
155
+ throw new BytecutError(
156
+ "OUTPUT_COLLISION",
157
+ `${source} and ${owner} would both write ${path}. Rename one of them.`
158
+ );
159
+ }
160
+ claimed.set(path, source);
161
+ outputs.push({
162
+ path,
163
+ format,
164
+ width,
165
+ lossless,
166
+ ...preset.encoders[format],
167
+ // A lossless output is exact; there is nothing to search for.
168
+ ...preset.search && !lossless ? { search: { ...preset.search } } : {}
169
+ });
170
+ }
171
+ }
172
+ items.push({
173
+ ...sized,
174
+ preset: preset.name,
175
+ classification: {
176
+ reason: choice.reason,
177
+ ...choice.confidence ? { confidence: choice.confidence } : {}
178
+ },
179
+ outputs
180
+ });
181
+ }
182
+ return {
183
+ version: 1,
184
+ outDir: toPosix(relative(options.projectRoot, options.outDir)) || ".",
185
+ publicPath: options.publicPath ?? DEFAULT_PUBLIC_PATH,
186
+ items
187
+ };
188
+ }
189
+
190
+ // src/core/classify/video.ts
191
+ var BACKGROUND_NAME = /(^|[^a-z])(bg|background|backdrop|loop|ambient|hero|banner|header|splash|texture|cover)([^a-z]|$)/i;
192
+ var BACKGROUND_MAX_SECONDS = 45;
193
+ function baseName2(source) {
194
+ const name = source.slice(source.lastIndexOf("/") + 1);
195
+ const dot = name.lastIndexOf(".");
196
+ return dot > 0 ? name.slice(0, dot) : name;
197
+ }
198
+ function classifyVideo(source, probe) {
199
+ const name = baseName2(source);
200
+ const named = BACKGROUND_NAME.test(name);
201
+ const short = probe.durationSec > 0 && probe.durationSec <= BACKGROUND_MAX_SECONDS;
202
+ if (!probe.hasAudio && short) {
203
+ return {
204
+ preset: "background-video",
205
+ confidence: named ? "high" : "medium",
206
+ reason: `silent ${probe.durationSec.toFixed(0)}s clip${named ? `, name "${name}"` : ""}`
207
+ };
208
+ }
209
+ if (named && short && probe.hasAudio) {
210
+ return {
211
+ preset: "background-video",
212
+ confidence: "medium",
213
+ reason: `name "${name}" and ${probe.durationSec.toFixed(0)}s long; its audio track will be removed`
214
+ };
215
+ }
216
+ return {
217
+ preset: "content-video",
218
+ confidence: probe.hasAudio ? "high" : "low",
219
+ reason: probe.hasAudio ? "has an audio track, so it is treated as content" : "long silent video; keeping resolution and frame rate"
220
+ };
221
+ }
222
+
223
+ // src/core/plan/video.ts
224
+ import { stat } from "fs/promises";
225
+ import { posix as posix2, relative as relative2 } from "path";
226
+ function skipped(source, bytes, sha, reason, extra = {}) {
227
+ return {
228
+ source,
229
+ sourceBytes: bytes,
230
+ sourceSha256: sha,
231
+ sourceWidth: 0,
232
+ sourceHeight: 0,
233
+ durationSec: 0,
234
+ fps: 0,
235
+ hasAudio: false,
236
+ videoCodec: "unknown",
237
+ preset: "-",
238
+ outputs: [],
239
+ skip: { reason },
240
+ ...extra
241
+ };
242
+ }
243
+ function plannedVideoOutputs(source, staticRoot, preset, probe) {
244
+ const dir = mirroredDir(source, staticRoot);
245
+ const name = posix2.basename(source, posix2.extname(source));
246
+ const width = outputWidth(probe.width, preset.maxWidth);
247
+ const outputs = [];
248
+ for (const codec of preset.codecs) {
249
+ outputs.push({
250
+ role: "video",
251
+ path: posix2.join(dir, name, `${name}-${width}.${codec}.mp4`),
252
+ codec,
253
+ width,
254
+ crf: preset.crf[codec],
255
+ speed: preset.speed[codec],
256
+ maxWidth: preset.maxWidth,
257
+ maxFps: preset.maxFps,
258
+ audio: preset.audio,
259
+ audioBitrate: preset.audioBitrate,
260
+ ...preset.search ? { search: { ...preset.search } } : {}
261
+ });
262
+ }
263
+ outputs.push({
264
+ role: "poster",
265
+ path: posix2.join(dir, name, `${name}-poster.avif`),
266
+ format: "avif",
267
+ width,
268
+ quality: preset.poster.quality,
269
+ effort: preset.poster.effort,
270
+ at: preset.poster.at
271
+ });
272
+ return outputs;
273
+ }
274
+ async function buildVideoItems(options) {
275
+ const staticRoot = options.staticRoot ?? ".";
276
+ const claimed = options.claimed ?? /* @__PURE__ */ new Set();
277
+ const items = [];
278
+ const files = options.files.map((file) => ({ file, source: toPosix(relative2(options.projectRoot, file)) })).sort((a, b) => a.source < b.source ? -1 : a.source > b.source ? 1 : 0);
279
+ const caps = options.engine ? await options.engine.capabilities() : null;
280
+ for (const { file, source } of files) {
281
+ if (source.startsWith("../") || source === "..") {
282
+ throw new BytecutError(
283
+ "SOURCE_OUTSIDE_PROJECT",
284
+ `${file} is outside the project root (${options.projectRoot}); run bytecut from a parent folder.`
285
+ );
286
+ }
287
+ const [info, sha] = await Promise.all([stat(file), sha256File(file)]);
288
+ if (!options.engine || !caps) {
289
+ items.push(skipped(source, info.size, sha, `needs ffmpeg: ${options.needsFfmpeg}`));
290
+ continue;
291
+ }
292
+ let probe;
293
+ try {
294
+ probe = options.probe ? await options.probe(file, sha) : await options.engine.probe(file);
295
+ } catch {
296
+ items.push(skipped(source, info.size, sha, "could not be read by ffprobe"));
297
+ continue;
298
+ }
299
+ if (!probe || probe.width === 0 || probe.height === 0) {
300
+ items.push(skipped(source, info.size, sha, "no video stream"));
301
+ continue;
302
+ }
303
+ const media = {
304
+ sourceWidth: probe.width,
305
+ sourceHeight: probe.height,
306
+ durationSec: probe.durationSec,
307
+ fps: probe.fps,
308
+ hasAudio: probe.hasAudio,
309
+ videoCodec: probe.videoCodec
310
+ };
311
+ const policy = options.policy ?? BUILTIN_POLICY;
312
+ const override = options.presetOverrides?.[source];
313
+ const forced = override ? getVideoPreset(override, policy.catalog) : void 0;
314
+ const matched = policy.assetFor(source);
315
+ const byAssets = matched ? getVideoPreset(matched.preset, policy.catalog) : void 0;
316
+ let preset;
317
+ let why;
318
+ if (forced) {
319
+ preset = forced;
320
+ why = { reason: `set to ${forced.name} for this file` };
321
+ } else if (byAssets && matched) {
322
+ preset = byAssets;
323
+ why = {
324
+ reason: `set to ${byAssets.name} by assets "${matched.pattern}"`,
325
+ confidence: "high"
326
+ };
327
+ } else if (posix2.extname(source).toLowerCase() === ".gif") {
328
+ preset = getVideoPreset("gif-to-video", policy.catalog);
329
+ why = { reason: "animated GIF", confidence: "high" };
330
+ } else {
331
+ const verdict = classifyVideo(source, probe);
332
+ preset = getVideoPreset(verdict.preset, policy.catalog);
333
+ why = { reason: verdict.reason, confidence: verdict.confidence };
334
+ }
335
+ const outputs = plannedVideoOutputs(source, staticRoot, preset, probe);
336
+ for (const out of outputs) {
337
+ if (claimed.has(out.path)) {
338
+ throw new BytecutError(
339
+ "OUTPUT_COLLISION",
340
+ `${source} would write ${out.path}, which another source already writes. Rename one of them.`
341
+ );
342
+ }
343
+ claimed.add(out.path);
344
+ }
345
+ items.push({
346
+ source,
347
+ sourceBytes: info.size,
348
+ sourceSha256: sha,
349
+ ...media,
350
+ preset: preset.name,
351
+ classification: why,
352
+ outputs
353
+ });
354
+ }
355
+ return items;
356
+ }
357
+
358
+ export {
359
+ classify,
360
+ sha256,
361
+ sha256File,
362
+ targetWidths,
363
+ mirroredDir,
364
+ DEFAULT_PUBLIC_PATH,
365
+ ANIMATED_GIF_REASON,
366
+ buildPlan,
367
+ classifyVideo,
368
+ plannedVideoOutputs,
369
+ buildVideoItems
370
+ };