@arraypress/covers 0.0.0-stage → 1.0.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/LICENSE +29 -0
  3. package/README.md +561 -2
  4. package/fonts/OFL.txt +93 -0
  5. package/fonts/inter-latin-400.woff +0 -0
  6. package/fonts/inter-latin-500.woff +0 -0
  7. package/fonts/inter-latin-600.woff +0 -0
  8. package/fonts/inter-latin-700.woff +0 -0
  9. package/package.json +96 -4
  10. package/recipes/aurora.json +132 -0
  11. package/recipes/bauhaus.json +113 -0
  12. package/recipes/bigType.json +103 -0
  13. package/recipes/contour.json +109 -0
  14. package/recipes/cutout.json +191 -0
  15. package/recipes/diagram.json +92 -0
  16. package/recipes/dither.json +170 -0
  17. package/recipes/duotone.json +75 -0
  18. package/recipes/gridTitle.json +136 -0
  19. package/recipes/iconTile.json +214 -0
  20. package/recipes/issue.json +221 -0
  21. package/recipes/mark.json +170 -0
  22. package/recipes/photo.json +156 -0
  23. package/recipes/riso.json +387 -0
  24. package/recipes/stripes.json +206 -0
  25. package/recipes/waveform.json +154 -0
  26. package/schema/recipe.schema.json +2292 -0
  27. package/src/astro.d.ts +31 -0
  28. package/src/astro.js +86 -0
  29. package/src/color.js +246 -0
  30. package/src/diagram.js +248 -0
  31. package/src/extract.js +90 -0
  32. package/src/flatten.js +234 -0
  33. package/src/fonts.js +200 -0
  34. package/src/geometry.js +187 -0
  35. package/src/grid.js +41 -0
  36. package/src/handmade.js +253 -0
  37. package/src/icons.js +157 -0
  38. package/src/index.d.ts +897 -0
  39. package/src/index.js +19 -0
  40. package/src/json-recipe.js +458 -0
  41. package/src/layer-options.js +37 -0
  42. package/src/layers.js +972 -0
  43. package/src/palette.js +185 -0
  44. package/src/png.js +77 -0
  45. package/src/print.js +658 -0
  46. package/src/recipes.js +78 -0
  47. package/src/render.js +462 -0
  48. package/src/rng.js +95 -0
  49. package/src/svg.js +69 -0
  50. package/src/text.js +230 -0
package/src/recipes.js ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Recipes: named layer stacks.
3
+ *
4
+ * A recipe is a function from the cover's options (and the canvas) to a
5
+ * list of layers, each with an `id`. Call it through
6
+ * `cover({ recipe: 'gridTitle', title: '…' })`. Any option named after a
7
+ * layer id customises that layer:
8
+ *
9
+ * - a **string** is the layer's content: `title: 'Hello'`, `icon: 'lucide:feather'`
10
+ * - an **object** is merged into the layer: `mesh: { blur: '16vmax' }`,
11
+ * `title: { content: 'Hello', size: 96 }`
12
+ * - **`false`** removes it: `grain: false`
13
+ *
14
+ * Extra `layers` passed with a recipe are painted on top of its stack.
15
+ *
16
+ * The built-ins are JSON documents (`recipes/<name>.json`) defined with
17
+ * {@link import('./json-recipe.js').defineRecipe}, the same format a theme
18
+ * can write its own styles in.
19
+ *
20
+ * Sizes are written against `u`, the geometric mean of the canvas sides,
21
+ * so a 1200×630 card and a 1200×1200 square get proportionate type.
22
+ *
23
+ * @module recipes
24
+ */
25
+
26
+ import { readFileSync } from 'node:fs';
27
+ import { defineRecipe } from './json-recipe.js';
28
+
29
+ /**
30
+ * What a recipe receives besides the options.
31
+ *
32
+ * @typedef {object} RecipeContext
33
+ * @property {number} width - Canvas width.
34
+ * @property {number} height - Canvas height.
35
+ * @property {import('./index.js').Palette} palette - Resolved palette.
36
+ * @property {import('./rng.js').Rng} rng - The cover's random stream (for recipe-level choices).
37
+ * @property {number} u - The design unit: `sqrt(width × height)`.
38
+ * @property {string} seed - The cover's seed (JSON recipes seed each layer's draws from it).
39
+ */
40
+
41
+ /**
42
+ * The text content of a string-or-object option.
43
+ *
44
+ * @param {unknown} v
45
+ * @returns {string | undefined}
46
+ */
47
+ export function contentOf(v) {
48
+ if (typeof v === 'string') return v;
49
+ if (v && typeof v === 'object' && 'content' in v) return /** @type {any} */ (v).content;
50
+ return undefined;
51
+ }
52
+
53
+ /** The built-in recipe names (each is `recipes/<name>.json`). */
54
+ const BUILT_IN = ['aurora', 'gridTitle', 'iconTile', 'bigType', 'stripes', 'duotone', 'mark', 'diagram', 'cutout', 'riso', 'dither', 'contour', 'waveform', 'bauhaus', 'issue', 'photo'];
55
+
56
+ /** Every built-in recipe, by name, defined from its JSON document. */
57
+ export const recipes = Object.freeze(Object.fromEntries(BUILT_IN.map((name) => [
58
+ name,
59
+ defineRecipe(JSON.parse(readFileSync(new URL(`../recipes/${name}.json`, import.meta.url), 'utf8'))),
60
+ ])));
61
+
62
+ /**
63
+ * Apply per-layer options to a recipe's stack (see the module docs).
64
+ *
65
+ * @param {import('./index.js').Layer[]} layers - The recipe's layers.
66
+ * @param {Record<string, unknown>} options - The cover options.
67
+ * @returns {import('./index.js').Layer[]} The customised stack.
68
+ */
69
+ export function applyOverrides(layers, options) {
70
+ return layers.flatMap((layer) => {
71
+ if (!layer.id || !Object.hasOwn(options, layer.id)) return [layer];
72
+ const v = options[layer.id];
73
+ if (v === false || v === null) return [];
74
+ if (v && typeof v === 'object' && !Array.isArray(v) && !(v instanceof Uint8Array)) return [/** @type {any} */ ({ ...layer, ...v, type: layer.type })];
75
+ return [layer];
76
+ });
77
+ }
78
+
package/src/render.js ADDED
@@ -0,0 +1,462 @@
1
+ /**
2
+ * Building a cover and rendering it to SVG or a raster image.
3
+ *
4
+ * @module render
5
+ */
6
+
7
+ import { createHash } from 'node:crypto';
8
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
9
+ import { join, resolve } from 'node:path';
10
+ import { readFileSync } from 'node:fs';
11
+ import { createRng } from './rng.js';
12
+ import { resolveSize, unit, len } from './geometry.js';
13
+ import { resolvePalette } from './palette.js';
14
+ import { bundledFonts } from './fonts.js';
15
+ import { LAYERS, VARY_FLOOR, VARY_PEAK } from './layers.js';
16
+ import { extractPalette } from './extract.js';
17
+ import { recipes, applyOverrides } from './recipes.js';
18
+ import { defineRecipe } from './json-recipe.js';
19
+ import { attrs, esc, num } from './svg.js';
20
+
21
+ /** The package version, part of every cache key. */
22
+ export const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
23
+
24
+ /** Covers built by {@link cover}, so rendering one doesn't resolve it twice. */
25
+ const built = new WeakSet();
26
+
27
+ /** Options that belong to the cover itself, never to a recipe layer. */
28
+ const COVER_KEYS = new Set(['width', 'height', 'aspect', 'seed', 'palette', 'recipe', 'layers', 'fonts', 'alt', 'radius', 'output']);
29
+
30
+ /**
31
+ * Build a cover: resolve its size and palette and expand its recipe into a
32
+ * layer stack. Pure and synchronous; the result is plain data you can
33
+ * inspect, tweak and hand to {@link renderSvg} or {@link renderImage}.
34
+ *
35
+ * @param {import('./index.js').CoverOptions | import('./index.js').Cover} options - The cover.
36
+ * @returns {import('./index.js').Cover} The resolved cover.
37
+ * @throws {TypeError} On an unknown recipe or layer type.
38
+ *
39
+ * @example
40
+ * const c = cover({ recipe: 'gridTitle', seed: 'my-post', title: 'Shipping covers', palette: 'dusk' });
41
+ * c.layers.map((l) => l.id); // ['bg', 'mesh', 'grid', 'grain', 'title']
42
+ */
43
+ export function cover(options) {
44
+ if (built.has(/** @type {object} */ (options))) return /** @type {import('./index.js').Cover} */ (options);
45
+ const o = /** @type {import('./index.js').CoverOptions} */ (options ?? {});
46
+ const { width, height } = resolveSize(o);
47
+ const palette = resolvePalette(o.palette);
48
+ const seed = String(o.seed ?? (typeof o.title === 'string' ? o.title : 'cover'));
49
+
50
+ let layers;
51
+ /** @type {{ lossless?: boolean } | undefined} */
52
+ let output;
53
+ if (o.recipe) {
54
+ const recipe = typeof o.recipe === 'function'
55
+ ? o.recipe
56
+ : typeof o.recipe === 'object'
57
+ ? defineRecipe(o.recipe)
58
+ : Object.hasOwn(recipes, o.recipe) ? recipes[/** @type {keyof typeof recipes} */ (o.recipe)] : undefined;
59
+ if (!recipe) throw new TypeError(`Unknown recipe "${o.recipe}". Recipes: ${Object.keys(recipes).join(', ')}`);
60
+ const ctx = { width, height, palette, rng: createRng(`${seed}:recipe`), u: unit({ width, height }), seed };
61
+ const recipeOptions = Object.fromEntries(Object.entries(o).filter(([k]) => !COVER_KEYS.has(k)));
62
+ layers = [...applyOverrides(recipe(/** @type {any} */ (recipeOptions), ctx), recipeOptions), ...(o.layers ?? [])];
63
+ output = /** @type {any} */ (recipe).json?.output;
64
+ } else {
65
+ layers = o.layers ?? [{ type: 'mesh' }, { type: 'grain' }];
66
+ }
67
+
68
+ for (const layer of layers) {
69
+ if (!layer || !Object.hasOwn(LAYERS, layer.type)) {
70
+ throw new TypeError(`Unknown layer type "${layer?.type}". Types: ${Object.keys(LAYERS).join(', ')}`);
71
+ }
72
+ }
73
+
74
+ const result = {
75
+ width,
76
+ height,
77
+ seed,
78
+ palette,
79
+ layers,
80
+ fonts: o.fonts ?? [],
81
+ alt: o.alt ?? (typeof o.title === 'string' ? o.title : undefined),
82
+ radius: o.radius,
83
+ // A recipe's preferred encoding (e.g. dither: lossless). renderImage options win.
84
+ output: o.output ?? output,
85
+ };
86
+ built.add(result);
87
+ return result;
88
+ }
89
+
90
+ /**
91
+ * Render every layer to markup, each with its own seeded stream.
92
+ *
93
+ * A layer's stream is keyed by its `seed`, else its `id`, else its type
94
+ * and how many layers of that type came before it, so adding a layer
95
+ * leaves every other layer's random choices alone.
96
+ *
97
+ * @param {import('./index.js').Cover} c - The cover.
98
+ * @param {any} sharp - The sharp module, or undefined.
99
+ * @returns {Promise<{ layer: import('./index.js').Layer, defs: string, body: string, pad?: number, finest?: number, ctx: import('./layers.js').RenderContext }[]>}
100
+ */
101
+ async function renderLayers(c, sharp) {
102
+ const fonts = [...c.fonts, ...bundledFonts()];
103
+ /** @type {Record<string, number>} */
104
+ const seen = {};
105
+ const out = [];
106
+ for (let i = 0; i < c.layers.length; i++) {
107
+ const layer = c.layers[i];
108
+ const n = (seen[layer.type] = (seen[layer.type] ?? -1) + 1);
109
+ const key = layer.seed ?? layer.id ?? `${layer.type}#${n}`;
110
+ const ctx = {
111
+ width: c.width,
112
+ height: c.height,
113
+ canvas: { width: c.width, height: c.height },
114
+ palette: c.palette,
115
+ rng: createRng(`${c.seed}:${key}`),
116
+ id: `l${i}`,
117
+ fonts,
118
+ sharp,
119
+ };
120
+ // A mesh can take its colours from a photo (`from`), extracted at render.
121
+ const resolved = layer.type === 'mesh' && /** @type {any} */ (layer).from && !/** @type {any} */ (layer).colors
122
+ ? { ...layer, colors: await extractPalette(/** @type {any} */ (layer).from, { count: 4 }) }
123
+ : layer;
124
+ const markup = await LAYERS[layer.type](/** @type {any} */ (resolved), ctx);
125
+ out.push({ layer, ...markup, ctx });
126
+ }
127
+ return out;
128
+ }
129
+
130
+ /**
131
+ * Assemble an SVG document from rendered layers.
132
+ *
133
+ * @param {import('./index.js').Cover} c - The cover.
134
+ * @param {{ defs: string, body: string }[]} parts - Rendered layers.
135
+ * @returns {string} The SVG document.
136
+ */
137
+ function documentFor(c, parts) {
138
+ const { width: w, height: h } = c;
139
+ let defs = parts.map((p) => p.defs).join('');
140
+ let body = parts.map((p) => p.body).join('');
141
+ if (c.radius) {
142
+ defs += `<clipPath id="r"><rect${attrs({ width: w, height: h, rx: len(c.radius, c) })}/></clipPath>`;
143
+ body = `<g clip-path="url(#r)">${body}</g>`;
144
+ }
145
+ const title = c.alt ? `<title>${esc(c.alt)}</title>` : '';
146
+ return `<svg xmlns="http://www.w3.org/2000/svg"${attrs({ width: w, height: h, viewBox: `0 0 ${num(w)} ${num(h)}`, role: c.alt ? 'img' : undefined })}>${title}${defs ? `<defs>${defs}</defs>` : ''}${body}</svg>`;
147
+ }
148
+
149
+ /**
150
+ * Render a cover to an SVG string. Text and icons are outlines, so the SVG
151
+ * needs no fonts; images are embedded as data URIs; nothing is fetched.
152
+ * The same cover always gives byte-identical output.
153
+ *
154
+ * @param {import('./index.js').CoverOptions | import('./index.js').Cover} input - The cover.
155
+ * @returns {Promise<string>} The SVG document.
156
+ *
157
+ * @example
158
+ * const svg = await renderSvg({ recipe: 'aurora', seed: 'hello', palette: 'paper' });
159
+ */
160
+ export async function renderSvg(input) {
161
+ const c = cover(input);
162
+ const sharp = c.layers.some((l) => l.type === 'image') ? await loadSharp(false) : undefined;
163
+ return documentFor(c, await renderLayers(c, sharp));
164
+ }
165
+
166
+ /** @type {Promise<any> | undefined} */
167
+ let sharpPromise;
168
+
169
+ /**
170
+ * Import sharp (an optional peer dependency).
171
+ *
172
+ * @param {boolean} required - Throw when it's missing.
173
+ * @returns {Promise<any>} The module's default export, or undefined.
174
+ */
175
+ async function loadSharp(required) {
176
+ sharpPromise ??= import('sharp').then((m) => m.default ?? m, () => undefined);
177
+ const sharp = await sharpPromise;
178
+ if (!sharp && required) throw new Error('renderImage() needs sharp: npm install sharp');
179
+ return sharp;
180
+ }
181
+
182
+ /** Default encoder quality, by format (see the grain note in {@link renderImage}). */
183
+ export const DEFAULT_QUALITY = Object.freeze({ webp: 90, avif: 72, jpeg: 88, png: 100 });
184
+
185
+ /** Output MIME types, by format. */
186
+ export const MIME = Object.freeze({ webp: 'image/webp', png: 'image/png', avif: 'image/avif', jpeg: 'image/jpeg' });
187
+
188
+ /**
189
+ * Render a cover to image bytes with sharp.
190
+ *
191
+ * Grain is the one layer not rasterised through the SVG: librsvg's
192
+ * `feTurbulence` costs ~200ms at 1200×1200, so each grain layer becomes
193
+ * seeded pixel noise composited by sharp (~10ms) between the SVG renders
194
+ * of the layers below and above it. Everything else goes through librsvg.
195
+ *
196
+ * @param {import('./index.js').CoverOptions | import('./index.js').Cover} input - The cover.
197
+ * @param {import('./index.js').RenderImageOptions} [options]
198
+ * @returns {Promise<Buffer>} The encoded image.
199
+ *
200
+ * @example
201
+ * const webp = await renderImage({ recipe: 'bigType', seed: 'x', title: 'Hello' }, { format: 'webp' });
202
+ */
203
+ export async function renderImage(input, options = {}) {
204
+ const c = cover(input);
205
+ const format = options.format ?? 'webp';
206
+ if (!Object.hasOwn(MIME, format)) throw new TypeError(`Unknown format "${format}" (webp, png, avif, jpeg)`);
207
+
208
+ let cacheFile;
209
+ if (options.cache) {
210
+ const dir = options.cache === true ? join(process.cwd(), 'node_modules', '.cache', 'covers') : resolve(String(options.cache));
211
+ cacheFile = join(dir, `${await coverKey(c, options)}.${format}`);
212
+ try {
213
+ return await readFile(cacheFile);
214
+ } catch {
215
+ // Not cached yet.
216
+ }
217
+ }
218
+
219
+ const sharp = await loadSharp(true);
220
+ const parts = await renderLayers(c, sharp);
221
+ const { width, height } = c;
222
+
223
+ // Split the stack: [svg] grain [svg] …, with each heavily blurred layer
224
+ // (a mesh) in a step of its own, rendered padded (see `soft` below).
225
+ /** @type {{ svg?: typeof parts, grain?: typeof parts[number], soft?: typeof parts[number] }[]} */
226
+ const steps = [];
227
+ let run = [];
228
+ for (const part of parts) {
229
+ const kind = part.layer.type === 'grain' ? 'grain' : part.pad ? 'soft' : null;
230
+ if (kind) {
231
+ if (run.length) steps.push({ svg: run });
232
+ steps.push({ [kind]: part });
233
+ run = [];
234
+ } else {
235
+ run.push(part);
236
+ }
237
+ }
238
+ if (run.length) steps.push({ svg: run });
239
+
240
+ const raw = { width, height, channels: /** @type {const} */ (4) };
241
+ const svgOf = (/** @type {typeof parts} */ p) => Buffer.from(documentFor({ ...c, radius: undefined, alt: undefined }, p));
242
+ /** @type {Buffer | undefined} */
243
+ let pixels;
244
+ for (const step of steps) {
245
+ if (step.soft) {
246
+ // librsvg clips a filter's input to the canvas, so a blur near an edge
247
+ // fades to transparent there (a vignette). Render the layer on a canvas
248
+ // padded by 3σ (so the edge sees the colour beyond it), at the smallest
249
+ // scale that keeps its finest blur above ~1.6px, then crop and upscale.
250
+ const pad = /** @type {number} */ (step.soft.pad);
251
+ const scale = Math.min(1, Math.max(0.2, 1.6 / (step.soft.finest || 1)));
252
+ const pw = width + pad * 2;
253
+ const ph = height + pad * 2;
254
+ const doc = `<svg xmlns="http://www.w3.org/2000/svg" width="${Math.round(pw * scale)}" height="${Math.round(ph * scale)}" viewBox="${-pad} ${-pad} ${pw} ${ph}"><defs>${step.soft.defs}</defs>${step.soft.body}</svg>`;
255
+ const layerPx = await sharp(Buffer.from(doc))
256
+ .extract({ left: Math.round(pad * scale), top: Math.round(pad * scale), width: Math.max(1, Math.round(width * scale)), height: Math.max(1, Math.round(height * scale)) })
257
+ .resize(width, height, { kernel: 'cubic', fit: 'fill' })
258
+ .ensureAlpha()
259
+ .raw()
260
+ .toBuffer();
261
+ pixels = pixels
262
+ ? await sharp(pixels, { raw }).composite([{ input: layerPx, raw }]).raw().toBuffer()
263
+ : layerPx;
264
+ } else if (step.svg) {
265
+ const svg = svgOf(step.svg);
266
+ pixels = pixels
267
+ ? await sharp(pixels, { raw }).composite([{ input: svg }]).raw().toBuffer()
268
+ : await sharp(svg).ensureAlpha().raw().toBuffer();
269
+ } else if (step.grain) {
270
+ pixels ??= Buffer.alloc(width * height * 4);
271
+ const g = /** @type {import('./index.js').GrainLayer} */ (step.grain.layer);
272
+ applyGrain(pixels, { ...(await noise(sharp, width, height, g, step.grain.ctx.rng)), width, height }, g);
273
+ }
274
+ }
275
+ let image = sharp(pixels ?? Buffer.alloc(width * height * 4), { raw });
276
+ if (c.radius) {
277
+ const rx = len(c.radius, c);
278
+ image = image.composite([{ input: Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"><rect width="${width}" height="${height}" rx="${num(rx)}"/></svg>`), blend: 'dest-in' }]);
279
+ }
280
+
281
+ // Grain is the first thing a lossy encoder throws away. Measured on an
282
+ // aurora cover: WebP q82 keeps ~40% of the grain (11 KB), q90 ~60% (41 KB),
283
+ // q94 ~78% (125 KB). 90 is the default; raise it for crisper grain.
284
+ const quality = options.quality ?? DEFAULT_QUALITY[format];
285
+ const lossless = options.lossless ?? c.output?.lossless ?? false;
286
+ if (format === 'webp') image = image.webp(lossless ? { lossless: true, effort: options.effort ?? 4 } : { quality, effort: options.effort ?? 4 });
287
+ else if (format === 'avif') image = image.avif(lossless ? { lossless: true, effort: options.effort ?? 4 } : { quality, effort: options.effort ?? 4 });
288
+ else if (format === 'jpeg') image = image.flatten({ background: c.palette.bg }).jpeg({ quality, mozjpeg: true });
289
+ else image = image.png({ compressionLevel: options.effort ?? 6 });
290
+ const out = await image.toBuffer();
291
+
292
+ if (cacheFile) {
293
+ await mkdir(join(cacheFile, '..'), { recursive: true });
294
+ const tmp = `${cacheFile}.${process.pid}.tmp`;
295
+ await writeFile(tmp, out);
296
+ await rename(tmp, cacheFile);
297
+ }
298
+ return out;
299
+ }
300
+
301
+ /**
302
+ * Seeded film-grain values, one per pixel (three with `monochrome: false`),
303
+ * at `size` px per grain (upscaled smoothly). Each value is the mean of two
304
+ * uniform draws, a triangular distribution: fewer harsh extremes than flat
305
+ * noise, closer to film.
306
+ *
307
+ * @param {any} sharp - The sharp module.
308
+ * @param {number} width @param {number} height
309
+ * @param {import('./index.js').GrainLayer} layer
310
+ * @param {import('./rng.js').Rng} rng - The layer's stream.
311
+ * @returns {Promise<{ data: Buffer, channels: 1 | 3 }>} Full-size noise.
312
+ */
313
+ async function noise(sharp, width, height, layer, rng) {
314
+ const size = Math.max(1, layer.size ?? 1);
315
+ const channels = layer.monochrome === false ? 3 : 1;
316
+ const nw = Math.ceil(width / size);
317
+ const nh = Math.ceil(height / size);
318
+ const data = Buffer.alloc(nw * nh * channels);
319
+ // xorshift32 seeded from the layer's stream: one 32-bit draw gives two
320
+ // values, so 1.4M pixels cost a few milliseconds.
321
+ let x = (rng.next() * 4294967296) >>> 0 || 1;
322
+ for (let i = 0; i < data.length; i++) {
323
+ x ^= x << 13; x ^= x >>> 17; x ^= x << 5;
324
+ data[i] = ((x & 255) + ((x >>> 8) & 255)) >>> 1;
325
+ }
326
+ if (nw === width && nh === height) return { data, channels };
327
+ const out = await sharp(data, { raw: { width: nw, height: nh, channels } })
328
+ .resize(width, height, { kernel: 'cubic', fit: 'fill' })
329
+ .raw()
330
+ .toBuffer();
331
+ return { data: out, channels };
332
+ }
333
+
334
+ /** Separable blend modes (W3C Compositing Level 1), channel values 0–1. */
335
+ const BLENDS = /** @type {Record<string, (b: number, s: number) => number>} */ ({
336
+ normal: (_b, s) => s,
337
+ multiply: (b, s) => b * s,
338
+ screen: (b, s) => b + s - b * s,
339
+ overlay: (b, s) => (b <= 0.5 ? 2 * b * s : 1 - 2 * (1 - b) * (1 - s)),
340
+ 'soft-light': (b, s) => {
341
+ if (s <= 0.5) return b - (1 - 2 * s) * b * (1 - b);
342
+ const d = b <= 0.25 ? ((16 * b - 12) * b + 4) * b : Math.sqrt(b);
343
+ return b + (2 * s - 1) * (d - b);
344
+ },
345
+ });
346
+
347
+ /**
348
+ * A 256×256 lookup (backdrop × noise → result) for one blend and strength:
349
+ * cheaper than the blend maths per pixel.
350
+ *
351
+ * @param {(b: number, s: number) => number} blend
352
+ * @param {number} amount
353
+ * @returns {Uint8Array}
354
+ */
355
+ function grainTable(blend, amount) {
356
+ const table = new Uint8Array(65536);
357
+ for (let b = 0; b < 256; b++) {
358
+ for (let s = 0; s < 256; s++) {
359
+ const cb = b / 255;
360
+ table[(b << 8) | s] = Math.round((cb + amount * (blend(cb, s / 255) - cb)) * 255);
361
+ }
362
+ }
363
+ return table;
364
+ }
365
+
366
+ /**
367
+ * Blend grain into RGBA pixels in place, at the layer's `amount`.
368
+ * Done in JS rather than with sharp's `composite` blend modes, whose
369
+ * overlay with a partly transparent layer flattens the noise to almost
370
+ * nothing (measured σ 0.5 against the expected 5.9 on mid grey).
371
+ *
372
+ * @param {Buffer} pixels - RGBA pixels (modified).
373
+ * @param {{ data: Buffer, channels: 1 | 3, width: number, height: number }} grain - Noise from {@link noise}, and the canvas size.
374
+ * @param {import('./index.js').GrainLayer} layer - The grain layer.
375
+ * @returns {void}
376
+ */
377
+ function applyGrain(pixels, grain, layer) {
378
+ const amount = Math.max(0, Math.min(1, (layer.amount ?? 0.08) * (layer.opacity ?? 1)));
379
+ const blend = BLENDS[layer.blend ?? 'overlay'];
380
+ if (!blend) throw new TypeError(`Unknown grain blend "${layer.blend}" (${Object.keys(BLENDS).join(', ')})`);
381
+ const table = grainTable(blend, amount);
382
+ const { data, channels } = grain;
383
+ if (layer.vary === 'edges') {
384
+ // Heavier toward the edges: per-pixel strength from the centre out, as in the SVG mask.
385
+ const { width, height } = grain;
386
+ const peak = Math.min(1, amount * VARY_PEAK);
387
+ const tables = Array.from({ length: 17 }, (_, k) => grainTable(blend, peak * (VARY_FLOOR + (1 - VARY_FLOOR) * (k / 16))));
388
+ const half = Math.hypot(width, height) / 2;
389
+ for (let y = 0, i = 0, j = 0; y < height; y++) {
390
+ for (let x = 0; x < width; x++, i += 4, j += channels) {
391
+ const t = tables[Math.min(16, Math.round((Math.hypot(x - width / 2, y - height / 2) / half) * 16))];
392
+ pixels[i] = t[(pixels[i] << 8) | data[j]];
393
+ pixels[i + 1] = t[(pixels[i + 1] << 8) | data[j + (channels === 3 ? 1 : 0)]];
394
+ pixels[i + 2] = t[(pixels[i + 2] << 8) | data[j + (channels === 3 ? 2 : 0)]];
395
+ }
396
+ }
397
+ return;
398
+ }
399
+ for (let i = 0, j = 0; i < pixels.length; i += 4, j += channels) {
400
+ pixels[i] = table[(pixels[i] << 8) | data[j]];
401
+ pixels[i + 1] = table[(pixels[i + 1] << 8) | data[j + (channels === 3 ? 1 : 0)]];
402
+ pixels[i + 2] = table[(pixels[i + 2] << 8) | data[j + (channels === 3 ? 2 : 0)]];
403
+ }
404
+ }
405
+
406
+ /**
407
+ * A stable cache key for a cover and its output options: a SHA-256 of the
408
+ * resolved cover (font and image bytes hashed by content), the output
409
+ * format and quality, and this package's version.
410
+ *
411
+ * @param {import('./index.js').CoverOptions | import('./index.js').Cover} input - The cover.
412
+ * @param {import('./index.js').RenderImageOptions} [options]
413
+ * @returns {Promise<string>} 40 hex characters.
414
+ *
415
+ * @example
416
+ * await coverKey({ recipe: 'aurora', seed: 'a' }, { format: 'webp' }); // 'f3a9…'
417
+ */
418
+ export async function coverKey(input, options = {}) {
419
+ const c = cover(input);
420
+ const h = createHash('sha256');
421
+ h.update(`covers@${VERSION}\n${options.format ?? 'webp'}:${options.quality ?? ''}:${options.effort ?? ''}:${options.lossless ?? c.output?.lossless ?? ''}\n`);
422
+ // Files a layer reads (an image's src, a mesh's photo, a waveform's audio) are hashed by content.
423
+ const file = async (/** @type {any} */ v) => (typeof v === 'string' && !v.startsWith('data:') ? digest(await readFile(v)) : v instanceof Uint8Array ? digest(v) : v);
424
+ const layers = await Promise.all(c.layers.map(async (l) => {
425
+ const x = /** @type {any} */ (l);
426
+ if (l.type === 'image') return { ...l, src: await file(x.src) };
427
+ if (l.type === 'mesh' && x.from) return { ...l, from: await file(x.from) };
428
+ if (l.type === 'waveform' && x.audio) return { ...l, audio: await file(x.audio) };
429
+ return l;
430
+ }));
431
+ h.update(stable({
432
+ width: c.width,
433
+ height: c.height,
434
+ seed: c.seed,
435
+ palette: c.palette,
436
+ layers,
437
+ radius: c.radius,
438
+ fonts: c.fonts.map((f) => ({ ...f, data: digest(f.data) })),
439
+ }));
440
+ return h.digest('hex').slice(0, 40);
441
+ }
442
+
443
+ /** @param {string | Uint8Array | ArrayBuffer} data @returns {string} */
444
+ function digest(data) {
445
+ return createHash('sha1').update(data instanceof ArrayBuffer ? new Uint8Array(data) : data).digest('hex');
446
+ }
447
+
448
+ /**
449
+ * JSON with sorted keys and bytes hashed, for hashing.
450
+ *
451
+ * @param {unknown} v
452
+ * @returns {string}
453
+ */
454
+ function stable(v) {
455
+ if (v instanceof Uint8Array || v instanceof ArrayBuffer) return JSON.stringify(digest(v));
456
+ if (Array.isArray(v)) return `[${v.map(stable).join(',')}]`;
457
+ if (v && typeof v === 'object') {
458
+ return `{${Object.keys(v).sort().filter((k) => /** @type {any} */ (v)[k] !== undefined).map((k) => `${JSON.stringify(k)}:${stable(/** @type {any} */ (v)[k])}`).join(',')}}`;
459
+ }
460
+ if (typeof v === 'function') return JSON.stringify(String(v));
461
+ return JSON.stringify(v) ?? 'null';
462
+ }
package/src/rng.js ADDED
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Seeded randomness.
3
+ *
4
+ * Every random choice a cover makes comes from a generator seeded by the
5
+ * cover's `seed` string, so the same input always draws the same image and
6
+ * two posts with different slugs draw different ones. Each layer gets its
7
+ * own stream (seed + layer index + layer type), so adding or removing one
8
+ * layer never reshuffles the others.
9
+ *
10
+ * @module rng
11
+ */
12
+
13
+ /**
14
+ * Hash a string to a 32-bit unsigned integer (cyrb53, folded to 32 bits).
15
+ * Fast, well distributed, and stable across platforms and Node versions.
16
+ *
17
+ * @param {string} str - Any string.
18
+ * @param {number} [salt=0] - Optional salt, to get several independent hashes of one string.
19
+ * @returns {number} An integer in `[0, 2^32)`.
20
+ *
21
+ * @example
22
+ * hashString('my-post'); // 1838215339 (always)
23
+ */
24
+ export function hashString(str, salt = 0) {
25
+ let h1 = 0xdeadbeef ^ salt;
26
+ let h2 = 0x41c6ce57 ^ salt;
27
+ for (let i = 0; i < str.length; i++) {
28
+ const ch = str.charCodeAt(i);
29
+ h1 = Math.imul(h1 ^ ch, 2654435761);
30
+ h2 = Math.imul(h2 ^ ch, 1597334677);
31
+ }
32
+ h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
33
+ h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
34
+ return (h1 ^ h2) >>> 0;
35
+ }
36
+
37
+ /**
38
+ * A small seeded pseudo-random generator (sfc32) with helpers.
39
+ *
40
+ * @typedef {object} Rng
41
+ * @property {() => number} next - A float in `[0, 1)`.
42
+ * @property {(min: number, max: number) => number} range - A float in `[min, max)`.
43
+ * @property {(min: number, max: number) => number} int - An integer in `[min, max]`.
44
+ * @property {<T>(items: readonly T[]) => T} pick - One item.
45
+ * @property {<T>(items: readonly T[]) => T[]} shuffle - A shuffled copy.
46
+ * @property {(chance?: number) => boolean} chance - `true` with the given probability (default 0.5).
47
+ */
48
+
49
+ /**
50
+ * Create a seeded generator. Strings are hashed; numbers are used as-is.
51
+ *
52
+ * @param {string | number} seed - The seed.
53
+ * @returns {Rng} The generator.
54
+ *
55
+ * @example
56
+ * const rng = createRng('my-post');
57
+ * rng.range(0, 360); // same angle every run
58
+ */
59
+ export function createRng(seed) {
60
+ const s = String(seed);
61
+ let a = hashString(s, 1);
62
+ let b = hashString(s, 2);
63
+ let c = hashString(s, 3);
64
+ let d = hashString(s, 4);
65
+
66
+ const next = () => {
67
+ a >>>= 0; b >>>= 0; c >>>= 0; d >>>= 0;
68
+ let t = (a + b) | 0;
69
+ a = b ^ (b >>> 9);
70
+ b = (c + (c << 3)) | 0;
71
+ c = (c << 21) | (c >>> 11);
72
+ d = (d + 1) | 0;
73
+ t = (t + d) | 0;
74
+ c = (c + t) | 0;
75
+ return (t >>> 0) / 4294967296;
76
+ };
77
+ // Warm up: the first few sfc32 outputs are correlated with the seed.
78
+ for (let i = 0; i < 12; i++) next();
79
+
80
+ return {
81
+ next,
82
+ range: (min, max) => min + next() * (max - min),
83
+ int: (min, max) => Math.floor(min + next() * (max - min + 1)),
84
+ pick: (items) => items[Math.floor(next() * items.length)],
85
+ shuffle: (items) => {
86
+ const out = items.slice();
87
+ for (let i = out.length - 1; i > 0; i--) {
88
+ const j = Math.floor(next() * (i + 1));
89
+ [out[i], out[j]] = [out[j], out[i]];
90
+ }
91
+ return out;
92
+ },
93
+ chance: (p = 0.5) => next() < p,
94
+ };
95
+ }