@visualizevalue/img-grid 0.1.1 → 0.1.3

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Visualize Value
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -30,6 +30,11 @@ const highlightedBuffer = await grid(images, {
30
30
  highlight: ['img2', 'img3'], // Images with these IDs will be 2×2
31
31
  maxWidth: 1920, // Maximum width of output image
32
32
  concurrency: 10, // Maximum concurrent downloads
33
+ background: '#000', // Background and letterbox color
34
+ pixelated: true, // Nearest-neighbour resize — keeps pixel art crisp
35
+ format: 'webp', // Output as png (default), jpeg, or webp
36
+ quality: 80, // Quality for jpeg/webp
37
+ onError: (img, err) => console.warn(`failed: ${img.url}`, err),
33
38
  })
34
39
 
35
40
  // Save to file
@@ -38,16 +43,23 @@ writeFileSync('grid.png', buffer)
38
43
 
39
44
  ## Features
40
45
 
41
- - Arranges images in an optimal grid layout
46
+ - Packs images into a compact, near-square grid
42
47
  - Supports highlighting specific images (makes them 2×2)
43
- - Handles failed image downloads with placeholders
44
- - Limits concurrent downloads
48
+ - Optional nearest-neighbour scaling to keep pixel art crisp (`pixelated`)
49
+ - Downloads images concurrently (with a configurable limit)
50
+ - Leaves a blank cell for failed downloads instead of failing the grid
51
+ - Output as PNG, JPEG, or WebP
45
52
  - Resizes all images to fit grid cells
46
53
 
47
54
  ## Options
48
55
 
49
- | Option | Default | Description |
50
- | ------------- | ------- | ------------------------------------------ |
51
- | `highlight` | `[]` | Array of image IDs to highlight (make 2×2) |
52
- | `maxWidth` | `1920` | Maximum width of output image in pixels |
53
- | `concurrency` | `10` | Maximum concurrent image downloads |
56
+ | Option | Default | Description |
57
+ | ------------- | ----------- | -------------------------------------------------- |
58
+ | `highlight` | `[]` | Array of image IDs to highlight (make 2×2) |
59
+ | `maxWidth` | `1920` | Maximum width of output image in pixels |
60
+ | `concurrency` | `10` | Maximum concurrent image downloads |
61
+ | `background` | `'#000'` | Background and letterbox color (any sharp color) |
62
+ | `pixelated` | `false` | Nearest-neighbour resize; keeps pixel art crisp |
63
+ | `format` | `'png'` | Output format: `'png'`, `'jpeg'`, or `'webp'` |
64
+ | `quality` | sharp's | Quality (1–100) for `jpeg`/`webp`; ignored for png |
65
+ | `onError` | `undefined` | `(img, error) => void` called when an image fails |
package/dist/index.d.ts CHANGED
@@ -1,10 +1,35 @@
1
+ export type ImageFormat = 'png' | 'jpeg' | 'webp';
1
2
  export interface Img {
2
3
  url: string;
3
4
  id?: string;
4
5
  }
5
6
  export interface GridOptions {
7
+ /** IDs of images to enlarge into a 2×2 block. */
6
8
  highlight?: string[];
9
+ /** Maximum width of the output image, in pixels. */
7
10
  maxWidth?: number;
11
+ /** Maximum number of images downloaded at once. */
8
12
  concurrency?: number;
13
+ /** Background and letterbox color (any sharp-compatible color). */
14
+ background?: string;
15
+ /**
16
+ * Resize with nearest-neighbour instead of the default smooth (lanczos)
17
+ * kernel. Keeps pixel art (e.g. NFTs) crisp instead of blurring it when
18
+ * scaled up. Like CSS `image-rendering: pixelated`.
19
+ */
20
+ pixelated?: boolean;
21
+ /** Output image format. */
22
+ format?: ImageFormat;
23
+ /** Quality (1–100) for lossy formats (`jpeg`, `webp`); ignored for `png`. */
24
+ quality?: number;
25
+ /** Called when an image can't be fetched or decoded; its cell is left blank. */
26
+ onError?: (img: Img, error: unknown) => void;
9
27
  }
28
+ /**
29
+ * Generate a PNG (or JPEG/WebP) grid from a list of image URLs.
30
+ *
31
+ * Images are packed into a near-square grid; highlighted images occupy a 2×2
32
+ * block. Unreachable or undecodable images leave a blank cell (and trigger
33
+ * `onError`) instead of failing the whole grid.
34
+ */
10
35
  export declare function grid(images: Img[], opts?: GridOptions): Promise<Buffer>;
package/dist/index.js CHANGED
@@ -5,127 +5,163 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.grid = grid;
7
7
  const sharp_1 = __importDefault(require("sharp"));
8
+ const DEFAULT_MAX_WIDTH = 1920;
9
+ const DEFAULT_CONCURRENCY = 10;
10
+ const DEFAULT_BACKGROUND = '#000';
11
+ const DEFAULT_FORMAT = 'png';
12
+ const FETCH_TIMEOUT_MS = 15_000;
13
+ const HIGHLIGHT_SPAN = 2;
14
+ /**
15
+ * Generate a PNG (or JPEG/WebP) grid from a list of image URLs.
16
+ *
17
+ * Images are packed into a near-square grid; highlighted images occupy a 2×2
18
+ * block. Unreachable or undecodable images leave a blank cell (and trigger
19
+ * `onError`) instead of failing the whole grid.
20
+ */
8
21
  async function grid(images, opts = {}) {
9
- const highlightIds = new Set(opts.highlight ?? []);
10
- const concurrency = opts.concurrency ?? 10;
11
- const maxWidth = opts.maxWidth ?? 1920;
12
- const highlightedImages = images.filter((img) => img.id && highlightIds.has(img.id));
13
- const normalImages = images.filter((img) => !highlightIds.has(img.id ?? ''));
14
- const highlightScale = highlightedImages.length ? 2 : 1;
15
- const totalCells = normalImages.length + highlightedImages.length * highlightScale * highlightScale;
16
- const gridSize = Math.ceil(Math.sqrt(totalCells));
17
- const cellSize = Math.floor(maxWidth / gridSize);
18
- const highlightedCellSize = cellSize * highlightScale;
19
- const gridWidth = totalCells === 1 || Math.sqrt(totalCells) % 2 === 0
20
- ? gridSize
21
- : Math.ceil(Math.sqrt(totalCells * 1.5));
22
- const gridHeight = gridSize * highlightScale;
23
- const grid = Array(gridHeight)
24
- .fill(0)
25
- .map(() => Array(gridWidth).fill(false));
26
- function isFree(x, y, width, height) {
27
- for (let dy = 0; dy < height; dy++) {
28
- for (let dx = 0; dx < width; dx++) {
29
- if (y + dy >= gridHeight || x + dx >= gridWidth || grid[y + dy][x + dx]) {
30
- return false;
31
- }
32
- }
22
+ const { highlight = [], maxWidth = DEFAULT_MAX_WIDTH, concurrency = DEFAULT_CONCURRENCY, background = DEFAULT_BACKGROUND, pixelated = false, format = DEFAULT_FORMAT, quality, onError, } = opts;
23
+ const highlightIds = new Set(highlight);
24
+ const isHighlighted = (img) => img.id !== undefined && highlightIds.has(img.id);
25
+ const highlighted = images.filter(isHighlighted);
26
+ const normal = images.filter((img) => !isHighlighted(img));
27
+ const totalCells = normal.length + highlighted.length * HIGHLIGHT_SPAN ** 2;
28
+ if (totalCells === 0)
29
+ return encode(blankCanvas(1, 1, background), format, quality);
30
+ const { columns, rows, placements } = chooseLayout(highlighted, normal);
31
+ const cellSize = Math.max(1, Math.floor(maxWidth / columns));
32
+ const layers = await mapWithConcurrency(placements, concurrency, async (placement) => {
33
+ const size = cellSize * placement.span;
34
+ const cell = await renderCell(placement.img, size, background, pixelated, onError);
35
+ return {
36
+ input: cell,
37
+ left: placement.col * cellSize,
38
+ top: placement.row * cellSize,
39
+ };
40
+ });
41
+ const canvas = blankCanvas(columns * cellSize, rows * cellSize, background).composite(layers);
42
+ return encode(canvas, format, quality);
43
+ }
44
+ /**
45
+ * Search candidate column counts and keep the one that packs into the smallest
46
+ * bounding square (then least wasted space, then widest). A closed-form column
47
+ * count can't predict how 2×2 highlight blocks pack, so we measure each instead.
48
+ */
49
+ function chooseLayout(highlighted, normal) {
50
+ const totalCells = normal.length + highlighted.length * HIGHLIGHT_SPAN ** 2;
51
+ const minColumns = highlighted.length > 0 ? HIGHLIGHT_SPAN : 1;
52
+ const maxColumns = Math.max(minColumns, Math.ceil(Math.sqrt(totalCells)) + HIGHLIGHT_SPAN);
53
+ let best;
54
+ for (let columns = minColumns; columns <= maxColumns; columns++) {
55
+ const { placements, rows } = pack(highlighted, normal, columns);
56
+ const score = [
57
+ Math.max(columns, rows), // smallest bounding square
58
+ columns * rows - totalCells, // least wasted cells
59
+ -columns, // prefer wider (fills the width)
60
+ ];
61
+ if (best === undefined || lexLess(score, best.score)) {
62
+ best = { columns, rows, placements, score };
33
63
  }
34
- return true;
35
64
  }
36
- function markOccupied(x, y, width, height) {
37
- for (let dy = 0; dy < height; dy++) {
38
- for (let dx = 0; dx < width; dx++) {
39
- grid[y + dy][x + dx] = true;
65
+ // minColumns maxColumns guarantees at least one candidate was evaluated.
66
+ const { columns, rows, placements } = best;
67
+ return { columns, rows, placements };
68
+ }
69
+ /** Lexicographic "less than" over equal-length numeric tuples. */
70
+ function lexLess(a, b) {
71
+ for (let i = 0; i < a.length; i++) {
72
+ if (a[i] !== b[i])
73
+ return a[i] < b[i];
74
+ }
75
+ return false;
76
+ }
77
+ /**
78
+ * Greedily place images into a grid with a fixed number of columns, growing
79
+ * rows as needed so no image is ever dropped. Larger (highlighted) blocks are
80
+ * placed first, then single cells fill the gaps.
81
+ */
82
+ function pack(highlighted, normal, columns) {
83
+ const occupied = [];
84
+ const cellFree = (col, row) => {
85
+ while (occupied.length <= row)
86
+ occupied.push(new Array(columns).fill(false));
87
+ return !occupied[row][col];
88
+ };
89
+ const fits = (col, row, span) => {
90
+ if (col + span > columns)
91
+ return false;
92
+ for (let r = row; r < row + span; r++) {
93
+ for (let c = col; c < col + span; c++) {
94
+ if (!cellFree(c, r))
95
+ return false;
40
96
  }
41
97
  }
42
- }
43
- function findSpot(width, height) {
44
- for (let y = 0; y <= gridHeight - height; y++) {
45
- for (let x = 0; x <= gridWidth - width; x++) {
46
- if (isFree(x, y, width, height)) {
47
- return [x, y];
98
+ return true;
99
+ };
100
+ const place = (img, span) => {
101
+ for (let row = 0;; row++) {
102
+ for (let col = 0; col + span <= columns; col++) {
103
+ if (!fits(col, row, span))
104
+ continue;
105
+ for (let r = row; r < row + span; r++) {
106
+ for (let c = col; c < col + span; c++)
107
+ occupied[r][c] = true;
48
108
  }
109
+ return { img, col, row, span };
49
110
  }
50
111
  }
51
- return null;
52
- }
53
- let inFlight = 0;
54
- async function fetchImage(url, size) {
55
- while (inFlight >= concurrency) {
56
- await new Promise((resolve) => setTimeout(resolve, 100));
57
- }
58
- inFlight++;
59
- try {
60
- const response = await fetch(url, {
61
- signal: AbortSignal.timeout(15000),
62
- headers: { Accept: 'image/*' },
63
- });
64
- if (!response.ok)
65
- throw new Error(`HTTP error ${response.status}`);
66
- const data = await response.arrayBuffer();
67
- return (0, sharp_1.default)(Buffer.from(data))
68
- .resize(size, size, { fit: 'contain', background: '#000' })
69
- .toBuffer();
70
- }
71
- catch (error) {
72
- // return placeholder if error
73
- return (0, sharp_1.default)({
74
- create: {
75
- width: size,
76
- height: size,
77
- channels: 3,
78
- background: '#007F00',
79
- },
80
- })
81
- .png()
82
- .toBuffer();
83
- }
84
- finally {
85
- inFlight--;
86
- }
87
- }
88
- const compositeLayers = [];
89
- let maxY = 0;
90
- for (const img of highlightedImages) {
91
- const spot = findSpot(highlightScale, highlightScale);
92
- if (!spot)
93
- continue;
94
- const [x, y] = spot;
95
- markOccupied(x, y, highlightScale, highlightScale);
96
- const imageBuffer = await fetchImage(img.url, highlightedCellSize);
97
- compositeLayers.push({
98
- input: imageBuffer,
99
- left: x * cellSize,
100
- top: y * cellSize,
112
+ };
113
+ const placements = [
114
+ ...highlighted.map((img) => place(img, HIGHLIGHT_SPAN)),
115
+ ...normal.map((img) => place(img, 1)),
116
+ ];
117
+ return { placements, rows: occupied.length };
118
+ }
119
+ /** Fetch and resize a single image; fall back to a blank cell on any failure. */
120
+ async function renderCell(img, size, background, pixelated, onError) {
121
+ try {
122
+ const response = await fetch(img.url, {
123
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
124
+ headers: { Accept: 'image/*' },
101
125
  });
102
- maxY = Math.max(maxY, y + highlightScale);
126
+ if (!response.ok)
127
+ throw new Error(`HTTP ${response.status} for ${img.url}`);
128
+ const data = Buffer.from(await response.arrayBuffer());
129
+ return await (0, sharp_1.default)(data)
130
+ .resize(size, size, {
131
+ fit: 'contain',
132
+ background,
133
+ ...(pixelated ? { kernel: 'nearest' } : {}),
134
+ })
135
+ .png()
136
+ .toBuffer();
103
137
  }
104
- for (const img of normalImages) {
105
- const spot = findSpot(1, 1);
106
- if (!spot)
107
- break;
108
- const [x, y] = spot;
109
- markOccupied(x, y, 1, 1);
110
- const imageBuffer = await fetchImage(img.url, cellSize);
111
- compositeLayers.push({
112
- input: imageBuffer,
113
- left: x * cellSize,
114
- top: y * cellSize,
115
- });
116
- maxY = Math.max(maxY, y + 1);
138
+ catch (error) {
139
+ onError?.(img, error);
140
+ return blankCanvas(size, size, background).png().toBuffer();
141
+ }
142
+ }
143
+ /** Run `task` over `items` with at most `limit` in flight, preserving order. */
144
+ async function mapWithConcurrency(items, limit, task) {
145
+ const results = new Array(items.length);
146
+ let cursor = 0;
147
+ const worker = async () => {
148
+ while (cursor < items.length) {
149
+ const index = cursor++;
150
+ results[index] = await task(items[index]);
151
+ }
152
+ };
153
+ const workers = Array.from({ length: Math.min(limit, items.length) }, worker);
154
+ await Promise.all(workers);
155
+ return results;
156
+ }
157
+ const blankCanvas = (width, height, background) => (0, sharp_1.default)({ create: { width, height, channels: 4, background } });
158
+ function encode(image, format, quality) {
159
+ switch (format) {
160
+ case 'jpeg':
161
+ return image.jpeg({ quality }).toBuffer();
162
+ case 'webp':
163
+ return image.webp({ quality }).toBuffer();
164
+ case 'png':
165
+ return image.png().toBuffer();
117
166
  }
118
- const outputHeight = Math.max(1, maxY) * cellSize;
119
- const outputWidth = gridWidth * cellSize;
120
- return (0, sharp_1.default)({
121
- create: {
122
- width: outputWidth,
123
- height: outputHeight,
124
- channels: 3,
125
- background: '#000',
126
- },
127
- })
128
- .composite(compositeLayers)
129
- .png()
130
- .toBuffer();
131
167
  }
package/package.json CHANGED
@@ -1,7 +1,22 @@
1
1
  {
2
2
  "name": "@visualizevalue/img-grid",
3
- "version": "0.1.1",
4
- "description": "Generate a PNG grid from image URLs (and highlight images).",
3
+ "version": "0.1.3",
4
+ "description": "Generate an image grid (PNG, JPEG, or WebP) from image URLs (and highlight images).",
5
+ "license": "MIT",
6
+ "author": "Visualize Value",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/visualizevalue/img-grid.git"
10
+ },
11
+ "homepage": "https://github.com/visualizevalue/img-grid#readme",
12
+ "bugs": "https://github.com/visualizevalue/img-grid/issues",
13
+ "keywords": [
14
+ "image",
15
+ "grid",
16
+ "montage",
17
+ "thumbnail",
18
+ "sharp"
19
+ ],
5
20
  "main": "dist/index.js",
6
21
  "types": "dist/index.d.ts",
7
22
  "exports": {
@@ -10,9 +25,13 @@
10
25
  "files": [
11
26
  "dist"
12
27
  ],
28
+ "engines": {
29
+ "node": ">=18"
30
+ },
13
31
  "packageManager": "npm@11.4.0",
14
32
  "scripts": {
15
33
  "build": "tsc",
34
+ "test": "tsx --test src/*.test.ts",
16
35
  "format": "prettier . --write",
17
36
  "prepublishOnly": "npm run build"
18
37
  },