@datamoc/mw_games 0.10.0 → 0.11.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/dist/version.d.ts CHANGED
@@ -9,5 +9,5 @@
9
9
  * this from `package.json` automatically, chosen because this project's own build already
10
10
  * has no step before `tsc`/`vite` that this would need to run ahead of.
11
11
  */
12
- export declare const version = "0.10.0";
12
+ export declare const version = "0.11.0";
13
13
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -9,5 +9,5 @@
9
9
  * this from `package.json` automatically, chosen because this project's own build already
10
10
  * has no step before `tsc`/`vite` that this would need to run ahead of.
11
11
  */
12
- export const version = '0.10.0';
12
+ export const version = '0.11.0';
13
13
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@datamoc/mw_games",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "A local-file game framework: renderer-free game logic, drawn through PixiJS in 2D or Babylon.js in 3D, with mobile and desktop packaging.",
5
5
  "license": "MPL-2.0",
6
6
  "repository": {
@@ -39,6 +39,9 @@
39
39
  "files": [
40
40
  "dist",
41
41
  "tools/compile-resources.mjs",
42
+ "tools/compile-resources.d.mts",
43
+ "tools/webp-convert.mjs",
44
+ "tools/webp-convert.d.mts",
42
45
  "tools/extract-html.mjs",
43
46
  "tools/extract-html.d.mts",
44
47
  "tools/compress-dist.mjs",
@@ -133,6 +136,7 @@
133
136
  },
134
137
  "./tools/compile-resources": "./tools/compile-resources.mjs",
135
138
  "./tools/compress-dist": "./tools/compress-dist.mjs",
139
+ "./tools/webp-convert": "./tools/webp-convert.mjs",
136
140
  "./two-d": {
137
141
  "types": "./dist/two-d/index.d.ts",
138
142
  "import": "./dist/two-d/index.js"
@@ -292,6 +296,7 @@
292
296
  "pixi.js": "^8.20.1",
293
297
  "playwright-core": "^1.62.1",
294
298
  "prettier": "^3.9.6",
299
+ "sharp": "^0.35.4",
295
300
  "typescript": "^7.0.2",
296
301
  "vite": "^8.2.2",
297
302
  "ws": "^8.21.3"
@@ -0,0 +1,29 @@
1
+ export const DEFAULT_MIME: Record<string, string>;
2
+
3
+ export function walk(dir: string): AsyncGenerator<string>;
4
+
5
+ export interface CompileResourcesOptions {
6
+ from: string;
7
+ to: string;
8
+ mime?: Record<string, string>;
9
+ groupBy?: (key: string) => string;
10
+ toWebp?: boolean;
11
+ webpLossless?: boolean;
12
+ webpQuality?: number;
13
+ }
14
+
15
+ export interface CompileResourcesGroup {
16
+ name: string;
17
+ count: number;
18
+ size: number;
19
+ }
20
+
21
+ export interface CompileResourcesResult {
22
+ groups: CompileResourcesGroup[];
23
+ rawBytes: number;
24
+ embeddedBytes: number;
25
+ webpConverted: number;
26
+ skipped: number;
27
+ }
28
+
29
+ export function compileResources(options: CompileResourcesOptions): Promise<CompileResourcesResult>;
@@ -1,5 +1,6 @@
1
1
  import { readdir, readFile, mkdir, writeFile, rm } from 'node:fs/promises';
2
2
  import { join, extname, relative, sep, dirname, basename } from 'node:path';
3
+ import { WEBP_CONVERTIBLE_EXTENSIONS } from './webp-convert.mjs';
3
4
 
4
5
  /**
5
6
  * Turns a folder of assets into scripts a `file://` page can load.
@@ -56,9 +57,27 @@ export async function* walk(dir) {
56
57
  * @param options.to folder to write the generated scripts into
57
58
  * @param options.mime extension to MIME type, merged over the defaults
58
59
  * @param options.groupBy how to name the script an asset lands in; defaults to its top folder
60
+ * @param options.toWebp convert `.png`/`.jpg`/`.jpeg` assets to WebP before embedding
61
+ * (default false - see `tools/webp-convert.mjs`). The asset's *key* never changes (a game
62
+ * still calls `load('tiles.png')` in both dev and built modes; dev mode serves the literal
63
+ * file from disk, unconverted), only the embedded `data:` URI's bytes and MIME type do, and
64
+ * only when the WebP result is actually smaller - a conversion that would not save
65
+ * anything is silently skipped rather than embedded anyway.
66
+ * @param options.webpLossless use WebP's lossless mode when converting (default true - a
67
+ * pixel-art tileset's exact colours survive; set false only with `webpQuality` to opt into
68
+ * lossy re-encoding for photographic source art)
69
+ * @param options.webpQuality 0-100, used only when `webpLossless` is false (default 90)
59
70
  * @returns the group names, in the order the page should load them
60
71
  */
61
- export async function compileResources({ from, to, mime = {}, groupBy } = {}) {
72
+ export async function compileResources({
73
+ from,
74
+ to,
75
+ mime = {},
76
+ groupBy,
77
+ toWebp: convertToWebp = false,
78
+ webpLossless = true,
79
+ webpQuality = 90,
80
+ } = {}) {
62
81
  if (!from || !to) throw new Error('compileResources needs both `from` and `to`');
63
82
 
64
83
  const types = { ...DEFAULT_MIME, ...mime };
@@ -70,9 +89,16 @@ export async function compileResources({ from, to, mime = {}, groupBy } = {}) {
70
89
  const groups = new Map();
71
90
  let skipped = 0;
72
91
  let rawBytes = 0;
92
+ let embeddedBytes = 0;
93
+ let webpConverted = 0;
94
+
95
+ // only paid when toWebp is actually used, so a build that never asks for it never imports
96
+ // (or requires installing) sharp
97
+ const toWebp = convertToWebp ? (await import('./webp-convert.mjs')).toWebp : null;
73
98
 
74
99
  for await (const file of walk(from)) {
75
- const type = types[extname(file).toLowerCase()];
100
+ const ext = extname(file).toLowerCase();
101
+ const type = types[ext];
76
102
  if (!type) {
77
103
  skipped++;
78
104
  continue;
@@ -81,10 +107,22 @@ export async function compileResources({ from, to, mime = {}, groupBy } = {}) {
81
107
  const key = relative(from, file).split(sep).join('/');
82
108
  const data = await readFile(file);
83
109
 
110
+ let mimeType = type;
111
+ let bytes = data;
112
+ if (toWebp && WEBP_CONVERTIBLE_EXTENSIONS.has(ext)) {
113
+ const webp = await toWebp(data, { lossless: webpLossless, quality: webpQuality });
114
+ if (webp.length < data.length) {
115
+ mimeType = 'image/webp';
116
+ bytes = webp;
117
+ webpConverted++;
118
+ }
119
+ }
120
+
84
121
  const name = group(key);
85
122
  if (!groups.has(name)) groups.set(name, {});
86
- groups.get(name)[key] = `data:${type};base64,${data.toString('base64')}`;
123
+ groups.get(name)[key] = `data:${mimeType};base64,${bytes.toString('base64')}`;
87
124
  rawBytes += data.length;
125
+ embeddedBytes += bytes.length;
88
126
  }
89
127
 
90
128
  const written = [];
@@ -98,7 +136,7 @@ export async function compileResources({ from, to, mime = {}, groupBy } = {}) {
98
136
  written.push({ name, count: Object.keys(assets).length, size: js.length });
99
137
  }
100
138
 
101
- return { groups: written, rawBytes, skipped };
139
+ return { groups: written, rawBytes, embeddedBytes, webpConverted, skipped };
102
140
  }
103
141
 
104
142
  // ---------------------------------------------------------------- CLI
@@ -106,17 +144,27 @@ export async function compileResources({ from, to, mime = {}, groupBy } = {}) {
106
144
  const isMain = process.argv[1] && import.meta.url.endsWith(basename(process.argv[1]));
107
145
 
108
146
  if (isMain) {
109
- const [from, to] = process.argv.slice(2);
147
+ const args = process.argv.slice(2);
148
+ const [from, to] = args.filter((a) => !a.startsWith('--'));
110
149
  if (!from || !to) {
111
- console.error('usage: node compile-resources.mjs <asset folder> <output folder>');
150
+ console.error(
151
+ 'usage: node compile-resources.mjs <asset folder> <output folder> [--to-webp] [--webp-lossy[=quality]]',
152
+ );
112
153
  process.exit(1);
113
154
  }
155
+ const toWebp = args.includes('--to-webp');
156
+ const lossyFlag = args.find((a) => a === '--webp-lossy' || a.startsWith('--webp-lossy='));
157
+ const webpLossless = !lossyFlag;
158
+ const webpQuality = lossyFlag && lossyFlag.includes('=') ? Number(lossyFlag.split('=')[1]) : 90;
114
159
 
115
- const { groups, rawBytes, skipped } = await compileResources({
160
+ const { groups, rawBytes, embeddedBytes, webpConverted, skipped } = await compileResources({
116
161
  from,
117
162
  to,
118
163
  //a flat folder has no subdirectories to group by, so everything lands in one file
119
164
  groupBy: (key) => (key.includes('/') ? key.slice(0, key.indexOf('/')) : 'assets'),
165
+ toWebp,
166
+ webpLossless,
167
+ webpQuality,
120
168
  });
121
169
 
122
170
  const kb = (n) => (n / 1024).toFixed(1).padStart(8) + ' KB';
@@ -124,6 +172,12 @@ if (isMain) {
124
172
  console.log(` ${join(relative(process.cwd(), to), name + '.js').padEnd(40)}${kb(size)} (${count} files)`);
125
173
  }
126
174
  console.log(` ${groups.length} script(s), ${kb(rawBytes)} of assets` + (skipped ? `, ${skipped} skipped` : ''));
175
+ if (toWebp) {
176
+ console.log(
177
+ ` ${webpConverted} image(s) converted to WebP (${webpLossless ? 'lossless' : `lossy, quality ${webpQuality}`}), ` +
178
+ `${kb(rawBytes)} -> ${kb(embeddedBytes)} embedded`,
179
+ );
180
+ }
127
181
  }
128
182
 
129
183
  export { dirname };
@@ -1,6 +1,7 @@
1
1
  export interface SingleFileOptions {
2
2
  dist: string;
3
3
  compress?: boolean;
4
+ algorithm?: 'gzip' | 'brotli';
4
5
  level?: number;
5
6
  splash?: boolean;
6
7
  output?: string;
@@ -1,6 +1,6 @@
1
1
  import { readFile, writeFile } from 'node:fs/promises';
2
2
  import { join, basename, resolve } from 'node:path';
3
- import { gzipSync } from 'node:zlib';
3
+ import { gzipSync, brotliCompressSync, constants } from 'node:zlib';
4
4
  import { fileURLToPath } from 'node:url';
5
5
 
6
6
  /**
@@ -14,16 +14,27 @@ import { fileURLToPath } from 'node:url';
14
14
  * loads directly into the page, in the same order, so the folder can be reduced to one file
15
15
  * before it is shared or archived.
16
16
  *
17
- * Compression (`compress: true`) gzips each script's source before it is base64-embedded, and
18
- * unpacks it at load with the browser's own `DecompressionStream('gzip')` - a real Web
19
- * Streams API, not a shipped decoder. That beats a pure-JS decompressor such as `js-lzma` (the
20
- * roadmap item's other candidate) on every axis that matters here: it adds zero bytes to the
21
- * page, needs no license or maintenance, and gzip is exactly what `tools/compress-dist.mjs`
22
- * already uses for the server-negotiated path, so the project only carries one compression
23
- * story. The one real cost is browser support: `DecompressionStream` is unavailable on Safari
24
- * before 16.4 and a handful of older engines, where the bootstrap throws a clear error rather
25
- * than silently failing - `compress: false` (the default) has no such requirement, since it is
26
- * just base64, which every browser this project targets can decode.
17
+ * Compression (`compress: true`) compresses each script's source before it is
18
+ * base64-embedded, and unpacks it at load with the browser's own `DecompressionStream` - a
19
+ * real Web Streams API, not a shipped decoder. That beats a pure-JS decompressor such as
20
+ * `js-lzma` (the roadmap item's other candidate) on every axis that matters here: it adds
21
+ * zero bytes to the page, needs no license or maintenance, and both algorithms below are
22
+ * exactly what `tools/compress-dist.mjs` already writes for the server-negotiated path, so the
23
+ * project only carries one compression story either way.
24
+ *
25
+ * `algorithm: 'gzip'` (the default) is the safer choice for browser reach: `compress: false`
26
+ * has no requirement beyond base64, which every browser this project targets can decode, and
27
+ * gzip's `DecompressionStream('gzip')` has been available since May 2023. `algorithm:
28
+ * 'brotli'` compresses further for the same reason `compress-dist.mjs` prefers it over gzip
29
+ * when a server can negotiate it, and needs `DecompressionStream('br')` - an option this
30
+ * repo's roadmap (item 350) took up expecting broad support, but a real Chrome (153, tested
31
+ * directly in this repo's own verification pass) still throws
32
+ * `Unsupported compression format: 'br'` for both `'br'` and `'brotli'`, so treat brotli here
33
+ * as an advanced, narrower-reach option rather than a safe default until that is re-verified
34
+ * against current browsers - `algorithm: 'gzip'` stays the one every browser this project
35
+ * targets can actually decode today. Either way, the bootstrap throws a clear, actionable
36
+ * error rather than silently failing when the browser's `DecompressionStream` does not support
37
+ * the chosen format.
27
38
  *
28
39
  * Decoding is async even when uncompressed (kept uniform, and `atob` on a very large payload
29
40
  * still benefits from not blocking the parser), so a splash screen (`splash: true`, the
@@ -33,15 +44,28 @@ import { fileURLToPath } from 'node:url';
33
44
  *
34
45
  * @param {object} options
35
46
  * @param {string} options.dist path to the folder `emit-page.mjs` already wrote
36
- * @param {boolean} [options.compress] gzip each inlined script (default false)
37
- * @param {number} [options.level] gzip level 1-9 when `compress` is set (default 9)
47
+ * @param {boolean} [options.compress] compress each inlined script (default false)
48
+ * @param {'gzip'|'brotli'} [options.algorithm] compression algorithm when `compress` is set
49
+ * (default 'gzip')
50
+ * @param {number} [options.level] gzip level 1-9, or brotli quality 0-11, when `compress` is
51
+ * set (default 9 for gzip, 11 for brotli)
38
52
  * @param {boolean} [options.splash] show a splash screen while scripts decode (default true)
39
53
  * @param {string} [options.output] output file name, written inside `dist` (default
40
54
  * `standalone.html`) - additive, so the existing multi-file `index.html` is untouched
41
55
  * @returns {Promise<{path: string, scripts: number, rawBytes: number, embeddedBytes: number}>}
42
56
  */
43
- export async function buildSingleFile({ dist, compress = false, level = 9, splash = true, output = 'standalone.html' } = {}) {
57
+ export async function buildSingleFile({
58
+ dist,
59
+ compress = false,
60
+ algorithm = 'gzip',
61
+ level,
62
+ splash = true,
63
+ output = 'standalone.html',
64
+ } = {}) {
44
65
  if (!dist) throw new Error('buildSingleFile needs a `dist` folder');
66
+ if (algorithm !== 'gzip' && algorithm !== 'brotli') {
67
+ throw new Error(`buildSingleFile: algorithm must be 'gzip' or 'brotli', got ${JSON.stringify(algorithm)}`);
68
+ }
45
69
 
46
70
  const htmlPath = join(dist, 'index.html');
47
71
  const html = await readFile(htmlPath, 'utf8');
@@ -58,6 +82,11 @@ export async function buildSingleFile({ dist, compress = false, level = 9, splas
58
82
  throw new Error(`${htmlPath}: no <script src="..."> tags found - is this an emit-page.mjs output folder?`);
59
83
  }
60
84
 
85
+ const compressBytes = (buf) =>
86
+ algorithm === 'brotli'
87
+ ? brotliCompressSync(buf, { params: { [constants.BROTLI_PARAM_QUALITY]: level ?? 11 } })
88
+ : gzipSync(buf, { level: level ?? 9 });
89
+
61
90
  let rawBytes = 0;
62
91
  let embeddedBytes = 0;
63
92
  const payloads = [];
@@ -65,14 +94,14 @@ export async function buildSingleFile({ dist, compress = false, level = 9, splas
65
94
  const filePath = join(dist, src.replace(/^\.\//, ''));
66
95
  const code = await readFile(filePath, 'utf8');
67
96
  rawBytes += Buffer.byteLength(code, 'utf8');
68
- const bytes = compress ? gzipSync(Buffer.from(code, 'utf8'), { level }) : Buffer.from(code, 'utf8');
97
+ const bytes = compress ? compressBytes(Buffer.from(code, 'utf8')) : Buffer.from(code, 'utf8');
69
98
  const b64 = bytes.toString('base64');
70
99
  embeddedBytes += b64.length;
71
100
  payloads.push({ name: basename(src), b64 });
72
101
  }
73
102
 
74
103
  const withoutScripts = html.replace(scriptTag, '').replace(/\n{3,}/g, '\n\n');
75
- const bootstrap = renderBootstrap(payloads, compress);
104
+ const bootstrap = renderBootstrap(payloads, compress ? algorithm : 'none');
76
105
  const splashCss = splash ? renderSplashStyle() : '';
77
106
  const splashHtml = splash ? renderSplashMarkup() : '';
78
107
 
@@ -103,8 +132,10 @@ function renderSplashMarkup() {
103
132
  return '\t<div id="mwg-splash"><div class="mwg-spinner"></div><div>Loading...</div></div>\n';
104
133
  }
105
134
 
106
- function renderBootstrap(payloads, compress) {
135
+ function renderBootstrap(payloads, algorithm) {
107
136
  const json = JSON.stringify(payloads);
137
+ // 'none' | 'gzip' | 'brotli' -> the DecompressionStream format string, or '' for uncompressed
138
+ const streamFormat = algorithm === 'brotli' ? 'br' : algorithm === 'gzip' ? 'gzip' : '';
108
139
  return (
109
140
  '<script>\n' +
110
141
  '(function(){\n' +
@@ -120,16 +151,25 @@ function renderBootstrap(payloads, compress) {
120
151
  // game bundle that reads them executes.
121
152
  '\twindow.__MWG_ASSETS__=window.__MWG_ASSETS__||{};\n' +
122
153
  `\tvar PAYLOADS=${json};\n` +
123
- `\tvar COMPRESSED=${compress ? 'true' : 'false'};\n` +
154
+ `\tvar FORMAT=${JSON.stringify(streamFormat)};\n` +
124
155
  '\tfunction b64ToBytes(b64){var bin=atob(b64);var bytes=new Uint8Array(bin.length);' +
125
156
  'for(var i=0;i<bin.length;i++)bytes[i]=bin.charCodeAt(i);return bytes;}\n' +
126
157
  '\tfunction decodeText(bytes){\n' +
127
- '\t\tif(!COMPRESSED)return Promise.resolve(new TextDecoder().decode(bytes));\n' +
128
- '\t\tif(typeof DecompressionStream==="undefined")return Promise.reject(new Error(' +
158
+ '\t\tif(!FORMAT)return Promise.resolve(new TextDecoder().decode(bytes));\n' +
159
+ // `new DecompressionStream(FORMAT)` throws *synchronously* (a TypeError) when the browser
160
+ // does not support the requested format - it does not reject a promise. A brotli build on
161
+ // a browser without DecompressionStream("br") support hits exactly this: without the
162
+ // try/catch here that throw would escape decodeText's caller entirely, skip the
163
+ // .catch(...) below, and leave the splash screen stuck forever instead of failing visibly.
164
+ '\t\ttry{\n' +
165
+ '\t\t\tif(typeof DecompressionStream==="undefined")throw new Error(' +
129
166
  '"mwg standalone build: this browser has no DecompressionStream, needed to unpack the compressed bundle. ' +
130
- 'Use the multi-file build instead."));\n' +
131
- '\t\tvar stream=new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip"));\n' +
132
- '\t\treturn new Response(stream).arrayBuffer().then(function(buf){return new TextDecoder().decode(buf);});\n' +
167
+ 'Use the multi-file build instead.");\n' +
168
+ '\t\t\tvar stream=new Blob([bytes]).stream().pipeThrough(new DecompressionStream(FORMAT));\n' +
169
+ '\t\t\treturn new Response(stream).arrayBuffer().then(function(buf){return new TextDecoder().decode(buf);});\n' +
170
+ '\t\t}catch(err){\n' +
171
+ '\t\t\treturn Promise.reject(err);\n' +
172
+ '\t\t}\n' +
133
173
  '\t}\n' +
134
174
  '\tfunction removeSplash(){var el=document.getElementById("mwg-splash");if(el&&el.parentNode)el.parentNode.removeChild(el);}\n' +
135
175
  '\tfunction runNext(i){\n' +
@@ -158,23 +198,27 @@ if (isMain) {
158
198
  const dist = args[0];
159
199
  if (!dist || dist.startsWith('--')) {
160
200
  console.error(
161
- 'usage: node tools/single-file.mjs <dist folder> [--compress[=level]] [--no-splash] [--output name.html]',
201
+ 'usage: node tools/single-file.mjs <dist folder> [--compress[=level]] [--brotli[=quality]] ' +
202
+ '[--no-splash] [--output name.html]',
162
203
  );
163
204
  process.exit(1);
164
205
  }
206
+ const brotliFlag = args.find((a) => a === '--brotli' || a.startsWith('--brotli='));
165
207
  const compressFlag = args.find((a) => a === '--compress' || a.startsWith('--compress='));
166
- const compress = Boolean(compressFlag);
167
- const level = compressFlag && compressFlag.includes('=') ? Number(compressFlag.split('=')[1]) : 9;
208
+ const compress = Boolean(brotliFlag || compressFlag);
209
+ const algorithm = brotliFlag ? 'brotli' : 'gzip';
210
+ const levelFlag = brotliFlag ?? compressFlag;
211
+ const level = levelFlag && levelFlag.includes('=') ? Number(levelFlag.split('=')[1]) : undefined;
168
212
  const splash = !args.includes('--no-splash');
169
213
  const outputIndex = args.indexOf('--output');
170
214
  const output = outputIndex !== -1 ? args[outputIndex + 1] : 'standalone.html';
171
215
 
172
- const result = await buildSingleFile({ dist: resolve(dist), compress, level, splash, output });
216
+ const result = await buildSingleFile({ dist: resolve(dist), compress, algorithm, level, splash, output });
173
217
  const kb = (n) => (n / 1024).toFixed(1) + ' KB';
174
218
  console.log(`\n ${result.path}`);
175
219
  console.log(
176
220
  ` ${result.scripts} script(s) inlined, ${kb(result.rawBytes)} raw -> ${kb(result.embeddedBytes)} embedded` +
177
- (compress ? ` (gzip level ${level})` : ' (uncompressed)'),
221
+ (compress ? ` (${algorithm} level ${level ?? (algorithm === 'brotli' ? 11 : 9)})` : ' (uncompressed)'),
178
222
  );
179
223
  console.log(' one file, no server, no sibling script - open it directly');
180
224
  }
@@ -0,0 +1,8 @@
1
+ export interface ToWebpOptions {
2
+ lossless?: boolean;
3
+ quality?: number;
4
+ }
5
+
6
+ export function toWebp(buffer: Buffer, options?: ToWebpOptions): Promise<Buffer>;
7
+
8
+ export const WEBP_CONVERTIBLE_EXTENSIONS: Set<string>;
@@ -0,0 +1,90 @@
1
+ import { readFile, writeFile, stat } from 'node:fs/promises';
2
+ import { extname, basename, resolve, dirname } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ /**
6
+ * Converts a raster image buffer to WebP - roadmap item 350's "convert pictures to WebP"
7
+ * half, kept as its own module so `tools/compile-resources.mjs` (a published, shipped tool -
8
+ * see `package.json`'s `files`/`exports`) never has to import an image codec itself. This file
9
+ * is *not* shipped: it dynamically imports `sharp`, a devDependency of this repo, so a game
10
+ * that never opts into WebP conversion carries no new dependency at all. A game that does want
11
+ * it installs `sharp` in its own project the same way this repo does.
12
+ *
13
+ * `lossless` (default true) uses WebP's real lossless mode (VP8L), which is what a pixel-art
14
+ * tileset's exact colours need - not "high quality lossy", which is still a DCT approximation
15
+ * at any quality setting. Lossy mode (`lossless: false`, with `quality`) is available for
16
+ * photographic source art where the extra size saving is worth it, but it is never the
17
+ * default: this project does not silently trade fidelity for size.
18
+ */
19
+
20
+ let sharpModule;
21
+ async function loadSharp() {
22
+ if (sharpModule) return sharpModule;
23
+ try {
24
+ sharpModule = (await import('sharp')).default;
25
+ } catch (err) {
26
+ throw new Error(
27
+ 'WebP conversion needs the optional `sharp` package, which is not installed. ' +
28
+ 'Run `npm install --save-dev sharp` in your project to use toWebp, or pass ' +
29
+ '`toWebp: false` (the default) to skip WebP conversion entirely.',
30
+ { cause: err },
31
+ );
32
+ }
33
+ return sharpModule;
34
+ }
35
+
36
+ /**
37
+ * @param {Buffer} buffer source image bytes (PNG or JPEG)
38
+ * @param {object} [options]
39
+ * @param {boolean} [options.lossless] use WebP's lossless mode (default true)
40
+ * @param {number} [options.quality] 0-100, used only when `lossless` is false (default 90)
41
+ * @returns {Promise<Buffer>} WebP-encoded image bytes
42
+ */
43
+ export async function toWebp(buffer, { lossless = true, quality = 90 } = {}) {
44
+ const sharp = await loadSharp();
45
+ return sharp(buffer)
46
+ .webp(lossless ? { lossless: true } : { quality })
47
+ .toBuffer();
48
+ }
49
+
50
+ /**
51
+ * The extensions `compileResources`'s `toWebp` option will attempt to convert. `.gif` (frame
52
+ * animation, which WebP would need to be re-authored to preserve) and `.svg` (already vector,
53
+ * so there is nothing raster to convert) are deliberately left out, along with `.webp` itself.
54
+ */
55
+ export const WEBP_CONVERTIBLE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg']);
56
+
57
+ // ---------------------------------------------------------------- CLI
58
+
59
+ const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
60
+
61
+ if (isMain) {
62
+ const args = process.argv.slice(2);
63
+ const input = args.find((a) => !a.startsWith('--'));
64
+ if (!input) {
65
+ console.error('usage: node tools/webp-convert.mjs <file.png|file.jpg> [--lossy[=quality]]');
66
+ process.exit(1);
67
+ }
68
+ const lossyFlag = args.find((a) => a === '--lossy' || a.startsWith('--lossy='));
69
+ const lossless = !lossyFlag;
70
+ const quality = lossyFlag && lossyFlag.includes('=') ? Number(lossyFlag.split('=')[1]) : 90;
71
+
72
+ const inPath = resolve(input);
73
+ if (!WEBP_CONVERTIBLE_EXTENSIONS.has(extname(inPath).toLowerCase())) {
74
+ console.error(`${input}: not a convertible image (expected .png, .jpg or .jpeg)`);
75
+ process.exit(1);
76
+ }
77
+ const source = await readFile(inPath);
78
+ const converted = await toWebp(source, { lossless, quality });
79
+ const outPath = resolve(dirname(inPath), basename(inPath, extname(inPath)) + '.webp');
80
+ await writeFile(outPath, converted);
81
+
82
+ const { size: sourceSize } = await stat(inPath);
83
+ const kb = (n) => (n / 1024).toFixed(1) + ' KB';
84
+ const pct = Math.round((100 * (sourceSize - converted.length)) / sourceSize);
85
+ console.log(` ${outPath}`);
86
+ console.log(
87
+ ` ${kb(sourceSize)} -> ${kb(converted.length)} (${pct >= 0 ? '-' : '+'}${Math.abs(pct)}%)` +
88
+ (lossless ? ' [lossless]' : ` [lossy, quality ${quality}]`),
89
+ );
90
+ }