@visualizevalue/img-grid 0.1.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/README.md +53 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +129 -0
- package/package.json +28 -0
package/README.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# img-grid
|
|
2
|
+
|
|
3
|
+
Generate image grids from URLs (and highlight images).
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @visualizevalue/img-grid
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```javascript
|
|
14
|
+
import { grid } from '@visualizevalue/img-grid'
|
|
15
|
+
import { writeFileSync } from 'fs'
|
|
16
|
+
|
|
17
|
+
// Define image URLs with optional IDs
|
|
18
|
+
const images = [
|
|
19
|
+
{ url: 'https://example.com/image1.jpg', id: 'img1' },
|
|
20
|
+
{ url: 'https://example.com/image2.jpg', id: 'img2' },
|
|
21
|
+
{ url: 'https://example.com/image3.jpg', id: 'img3' },
|
|
22
|
+
{ url: 'https://example.com/image4.jpg', id: 'img4' },
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
// Create a grid with default options
|
|
26
|
+
const buffer = await grid(images)
|
|
27
|
+
|
|
28
|
+
// Or highlight specific images (makes them 2×2)
|
|
29
|
+
const highlightedBuffer = await grid(images, {
|
|
30
|
+
highlight: ['img2', 'img3'], // Images with these IDs will be 2×2
|
|
31
|
+
maxWidth: 1920, // Maximum width of output image
|
|
32
|
+
concurrency: 10, // Maximum concurrent downloads
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
// Save to file
|
|
36
|
+
writeFileSync('grid.png', buffer)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Features
|
|
40
|
+
|
|
41
|
+
- Arranges images in an optimal grid layout
|
|
42
|
+
- Supports highlighting specific images (makes them 2×2)
|
|
43
|
+
- Handles failed image downloads with placeholders
|
|
44
|
+
- Limits concurrent downloads
|
|
45
|
+
- Resizes all images to fit grid cells
|
|
46
|
+
|
|
47
|
+
## Options
|
|
48
|
+
|
|
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 |
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.grid = grid;
|
|
7
|
+
const sharp_1 = __importDefault(require("sharp"));
|
|
8
|
+
async function grid(images, opts = {}) {
|
|
9
|
+
const highlightIds = new Set(opts.highlight ?? []);
|
|
10
|
+
const concurrency = opts.concurrency ?? 10;
|
|
11
|
+
const highlightScale = 2;
|
|
12
|
+
const maxWidth = opts.maxWidth ?? 1920;
|
|
13
|
+
const highlightedImages = images.filter((img) => img.id && highlightIds.has(img.id));
|
|
14
|
+
const normalImages = images.filter((img) => !highlightIds.has(img.id ?? ''));
|
|
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 = Math.max(gridSize, Math.ceil(Math.sqrt(totalCells * 1.5)));
|
|
20
|
+
const gridHeight = gridSize * highlightScale;
|
|
21
|
+
const grid = Array(gridHeight)
|
|
22
|
+
.fill(0)
|
|
23
|
+
.map(() => Array(gridWidth).fill(false));
|
|
24
|
+
function isFree(x, y, width, height) {
|
|
25
|
+
for (let dy = 0; dy < height; dy++) {
|
|
26
|
+
for (let dx = 0; dx < width; dx++) {
|
|
27
|
+
if (y + dy >= gridHeight || x + dx >= gridWidth || grid[y + dy][x + dx]) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
function markOccupied(x, y, width, height) {
|
|
35
|
+
for (let dy = 0; dy < height; dy++) {
|
|
36
|
+
for (let dx = 0; dx < width; dx++) {
|
|
37
|
+
grid[y + dy][x + dx] = true;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function findSpot(width, height) {
|
|
42
|
+
for (let y = 0; y <= gridHeight - height; y++) {
|
|
43
|
+
for (let x = 0; x <= gridWidth - width; x++) {
|
|
44
|
+
if (isFree(x, y, width, height)) {
|
|
45
|
+
return [x, y];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
let inFlight = 0;
|
|
52
|
+
async function fetchImage(url, size) {
|
|
53
|
+
while (inFlight >= concurrency) {
|
|
54
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
55
|
+
}
|
|
56
|
+
inFlight++;
|
|
57
|
+
try {
|
|
58
|
+
const response = await fetch(url, {
|
|
59
|
+
signal: AbortSignal.timeout(15000),
|
|
60
|
+
headers: { Accept: 'image/*' },
|
|
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,
|
|
99
|
+
});
|
|
100
|
+
maxY = Math.max(maxY, y + highlightScale);
|
|
101
|
+
}
|
|
102
|
+
for (const img of normalImages) {
|
|
103
|
+
const spot = findSpot(1, 1);
|
|
104
|
+
if (!spot)
|
|
105
|
+
break;
|
|
106
|
+
const [x, y] = spot;
|
|
107
|
+
markOccupied(x, y, 1, 1);
|
|
108
|
+
const imageBuffer = await fetchImage(img.url, cellSize);
|
|
109
|
+
compositeLayers.push({
|
|
110
|
+
input: imageBuffer,
|
|
111
|
+
left: x * cellSize,
|
|
112
|
+
top: y * cellSize,
|
|
113
|
+
});
|
|
114
|
+
maxY = Math.max(maxY, y + 1);
|
|
115
|
+
}
|
|
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
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@visualizevalue/img-grid",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Generate a PNG grid from image URLs (and highlight images).",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"packageManager": "npm@11.4.0",
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc",
|
|
16
|
+
"format": "prettier --write",
|
|
17
|
+
"prepublishOnly": "npm run build"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"sharp": "^0.33"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/node": "^20",
|
|
24
|
+
"prettier": "^3.5.3",
|
|
25
|
+
"tsx": "^4",
|
|
26
|
+
"typescript": "^5"
|
|
27
|
+
}
|
|
28
|
+
}
|