@visualizevalue/img-grid 0.1.0 → 0.1.2
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 +16 -8
- package/dist/index.d.ts +19 -0
- package/dist/index.js +144 -110
- package/package.json +22 -3
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,9 @@ 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
|
+
format: 'webp', // Output as png (default), jpeg, or webp
|
|
34
|
+
quality: 80, // Quality for jpeg/webp
|
|
35
|
+
onError: (img, err) => console.warn(`failed: ${img.url}`, err),
|
|
33
36
|
})
|
|
34
37
|
|
|
35
38
|
// Save to file
|
|
@@ -38,16 +41,21 @@ writeFileSync('grid.png', buffer)
|
|
|
38
41
|
|
|
39
42
|
## Features
|
|
40
43
|
|
|
41
|
-
-
|
|
44
|
+
- Packs images into a compact, near-square grid
|
|
42
45
|
- Supports highlighting specific images (makes them 2×2)
|
|
43
|
-
-
|
|
44
|
-
-
|
|
46
|
+
- Downloads images concurrently (with a configurable limit)
|
|
47
|
+
- Leaves a blank cell for failed downloads instead of failing the grid
|
|
48
|
+
- Output as PNG, JPEG, or WebP
|
|
45
49
|
- Resizes all images to fit grid cells
|
|
46
50
|
|
|
47
51
|
## Options
|
|
48
52
|
|
|
49
|
-
| Option | Default
|
|
50
|
-
| ------------- |
|
|
51
|
-
| `highlight` | `[]`
|
|
52
|
-
| `maxWidth` | `1920`
|
|
53
|
-
| `concurrency` | `10`
|
|
53
|
+
| Option | Default | Description |
|
|
54
|
+
| ------------- | ----------- | -------------------------------------------------- |
|
|
55
|
+
| `highlight` | `[]` | Array of image IDs to highlight (make 2×2) |
|
|
56
|
+
| `maxWidth` | `1920` | Maximum width of output image in pixels |
|
|
57
|
+
| `concurrency` | `10` | Maximum concurrent image downloads |
|
|
58
|
+
| `background` | `'#000'` | Background and letterbox color (any sharp color) |
|
|
59
|
+
| `format` | `'png'` | Output format: `'png'`, `'jpeg'`, or `'webp'` |
|
|
60
|
+
| `quality` | sharp's | Quality (1–100) for `jpeg`/`webp`; ignored for png |
|
|
61
|
+
| `onError` | `undefined` | `(img, error) => void` called when an image fails |
|
package/dist/index.d.ts
CHANGED
|
@@ -1,10 +1,29 @@
|
|
|
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
|
+
/** Output image format. */
|
|
16
|
+
format?: ImageFormat;
|
|
17
|
+
/** Quality (1–100) for lossy formats (`jpeg`, `webp`); ignored for `png`. */
|
|
18
|
+
quality?: number;
|
|
19
|
+
/** Called when an image can't be fetched or decoded; its cell is left blank. */
|
|
20
|
+
onError?: (img: Img, error: unknown) => void;
|
|
9
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Generate a PNG (or JPEG/WebP) grid from a list of image URLs.
|
|
24
|
+
*
|
|
25
|
+
* Images are packed into a near-square grid; highlighted images occupy a 2×2
|
|
26
|
+
* block. Unreachable or undecodable images leave a blank cell (and trigger
|
|
27
|
+
* `onError`) instead of failing the whole grid.
|
|
28
|
+
*/
|
|
10
29
|
export declare function grid(images: Img[], opts?: GridOptions): Promise<Buffer>;
|
package/dist/index.js
CHANGED
|
@@ -5,125 +5,159 @@ 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
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
22
|
+
const { highlight = [], maxWidth = DEFAULT_MAX_WIDTH, concurrency = DEFAULT_CONCURRENCY, background = DEFAULT_BACKGROUND, 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, 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 };
|
|
31
63
|
}
|
|
32
|
-
return true;
|
|
33
64
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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;
|
|
38
96
|
}
|
|
39
97
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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;
|
|
46
108
|
}
|
|
109
|
+
return { img, col, row, span };
|
|
47
110
|
}
|
|
48
111
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
62
|
-
if (!response.ok)
|
|
63
|
-
throw new Error(`HTTP error ${response.status}`);
|
|
64
|
-
const data = await response.arrayBuffer();
|
|
65
|
-
return (0, sharp_1.default)(Buffer.from(data))
|
|
66
|
-
.resize(size, size, { fit: 'contain', background: '#000' })
|
|
67
|
-
.toBuffer();
|
|
68
|
-
}
|
|
69
|
-
catch (error) {
|
|
70
|
-
// return placeholder if error
|
|
71
|
-
return (0, sharp_1.default)({
|
|
72
|
-
create: {
|
|
73
|
-
width: size,
|
|
74
|
-
height: size,
|
|
75
|
-
channels: 3,
|
|
76
|
-
background: '#007F00',
|
|
77
|
-
},
|
|
78
|
-
})
|
|
79
|
-
.png()
|
|
80
|
-
.toBuffer();
|
|
81
|
-
}
|
|
82
|
-
finally {
|
|
83
|
-
inFlight--;
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
const compositeLayers = [];
|
|
87
|
-
let maxY = 0;
|
|
88
|
-
for (const img of highlightedImages) {
|
|
89
|
-
const spot = findSpot(highlightScale, highlightScale);
|
|
90
|
-
if (!spot)
|
|
91
|
-
continue;
|
|
92
|
-
const [x, y] = spot;
|
|
93
|
-
markOccupied(x, y, highlightScale, highlightScale);
|
|
94
|
-
const imageBuffer = await fetchImage(img.url, highlightedCellSize);
|
|
95
|
-
compositeLayers.push({
|
|
96
|
-
input: imageBuffer,
|
|
97
|
-
left: x * cellSize,
|
|
98
|
-
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, onError) {
|
|
121
|
+
try {
|
|
122
|
+
const response = await fetch(img.url, {
|
|
123
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
124
|
+
headers: { Accept: 'image/*' },
|
|
99
125
|
});
|
|
100
|
-
|
|
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, { fit: 'contain', background })
|
|
131
|
+
.png()
|
|
132
|
+
.toBuffer();
|
|
101
133
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
134
|
+
catch (error) {
|
|
135
|
+
onError?.(img, error);
|
|
136
|
+
return blankCanvas(size, size, background).png().toBuffer();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/** Run `task` over `items` with at most `limit` in flight, preserving order. */
|
|
140
|
+
async function mapWithConcurrency(items, limit, task) {
|
|
141
|
+
const results = new Array(items.length);
|
|
142
|
+
let cursor = 0;
|
|
143
|
+
const worker = async () => {
|
|
144
|
+
while (cursor < items.length) {
|
|
145
|
+
const index = cursor++;
|
|
146
|
+
results[index] = await task(items[index]);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, worker);
|
|
150
|
+
await Promise.all(workers);
|
|
151
|
+
return results;
|
|
152
|
+
}
|
|
153
|
+
const blankCanvas = (width, height, background) => (0, sharp_1.default)({ create: { width, height, channels: 4, background } });
|
|
154
|
+
function encode(image, format, quality) {
|
|
155
|
+
switch (format) {
|
|
156
|
+
case 'jpeg':
|
|
157
|
+
return image.jpeg({ quality }).toBuffer();
|
|
158
|
+
case 'webp':
|
|
159
|
+
return image.webp({ quality }).toBuffer();
|
|
160
|
+
case 'png':
|
|
161
|
+
return image.png().toBuffer();
|
|
115
162
|
}
|
|
116
|
-
const outputHeight = Math.max(1, maxY) * cellSize;
|
|
117
|
-
const outputWidth = gridWidth * cellSize;
|
|
118
|
-
return (0, sharp_1.default)({
|
|
119
|
-
create: {
|
|
120
|
-
width: outputWidth,
|
|
121
|
-
height: outputHeight,
|
|
122
|
-
channels: 3,
|
|
123
|
-
background: '#000',
|
|
124
|
-
},
|
|
125
|
-
})
|
|
126
|
-
.composite(compositeLayers)
|
|
127
|
-
.png()
|
|
128
|
-
.toBuffer();
|
|
129
163
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@visualizevalue/img-grid",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Generate
|
|
3
|
+
"version": "0.1.2",
|
|
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,10 +25,14 @@
|
|
|
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",
|
|
16
|
-
"
|
|
34
|
+
"test": "tsx --test src/*.test.ts",
|
|
35
|
+
"format": "prettier . --write",
|
|
17
36
|
"prepublishOnly": "npm run build"
|
|
18
37
|
},
|
|
19
38
|
"dependencies": {
|