pixeli 0.1.6 → 0.1.8
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 +110 -2
- package/commands/merge/collage.js +83 -0
- package/commands/merge/grid.js +6 -3
- package/commands/merge/helpers/utils.js +0 -9
- package/commands/merge/helpers/validations.js +55 -0
- package/commands/merge/index.js +2 -0
- package/commands/merge/masonry.js +6 -3
- package/lib/helpers/loadImages.js +15 -5
- package/lib/helpers/templateValidator.js +139 -0
- package/lib/merges/collage-merge/index.js +110 -0
- package/lib/merges/collage-merge/presets/artGallery.js +17 -0
- package/lib/merges/collage-merge/presets/dashboardShot.js +18 -0
- package/lib/merges/collage-merge/presets/horizontalBookSpread.js +15 -0
- package/lib/merges/collage-merge/presets/instagramGrid.js +18 -0
- package/lib/merges/collage-merge/presets/verticalBookSpread.js +15 -0
- package/lib/merges/collage-merge/presets.js +17 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -11,9 +11,15 @@ The tool currently supports two main layout modes: ***Grid*** and ***Masonry***
|
|
|
11
11
|
|
|
12
12
|
| Grid (1:1 images) | Contact Sheet Grid |
|
|
13
13
|
|---|---|
|
|
14
|
-
| <img src="samples/grid.
|
|
14
|
+
| <img src="samples/grid.jpg" width="400"> | <img src="samples/grid-with-captions.jpg" width="400"> |
|
|
15
15
|
| **Masonry (Horizontal)** | **Masonry (Vertical)** |
|
|
16
|
-
| <img src="samples/masonry-horizontal.
|
|
16
|
+
| <img src="samples/masonry-horizontal.jpg" width="400"> | <img src="samples/masonry-vertical.jpg" width="400"> |
|
|
17
|
+
| **Collag (Instagram Grid)** | **Collage (Vertical Book Spread)** |
|
|
18
|
+
| <img src="samples/instagram-grid.jpg" width="400"> | <img src="samples/vertical-book-spread.jpg" width="400"> |
|
|
19
|
+
| **Collage (Horizontal Book Spread)** | **Collage (Dashboard Shot)** |
|
|
20
|
+
| <img src="samples/horizontal-book-spread.jpg" width="400"> | <img src="samples/dashboard-shot.jpg" width="400"> |
|
|
21
|
+
| **Collage (Art Gallery)** |
|
|
22
|
+
| <img src="samples/art-gallery.jpg" width="400"> |
|
|
17
23
|
|
|
18
24
|
## Installation
|
|
19
25
|
Pixeli can be installed using npm. Simply run the following command to install it globally on your machine:
|
|
@@ -73,6 +79,19 @@ By default, the masonry merge command uses a horizontal flow, but a vertical one
|
|
|
73
79
|
pixeli merge masonry -rd ./samples/images -f vertical --cvh 4000
|
|
74
80
|
```
|
|
75
81
|
|
|
82
|
+
### Collage Layout
|
|
83
|
+
Collage layouts require a JSON template, or an inline JSON string, which describe your specific layout. The `-t` flag is used to specify the path to a JSON template, whereas the `-m` flag is used to provide inline JSON:
|
|
84
|
+
```bash
|
|
85
|
+
pixeli merge collage -rd ./samples/images -t ./template.json
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
You could also use a preset, and also round all the image corners in your collage:
|
|
89
|
+
```bash
|
|
90
|
+
pixeli merge collage -rd ./samples/images -t ./template.json --cr 100
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
To learn about the JSON template, see [collage templates](#collage-templates).
|
|
94
|
+
|
|
76
95
|
## Full Documentation
|
|
77
96
|
|
|
78
97
|
### pixeli merge
|
|
@@ -125,6 +144,95 @@ The masonry mode preserves each image’s natural shape, creating an organic bri
|
|
|
125
144
|
| `--ha`, `--h-align <left\|center\|right\|justified>` | `justified` | Controls **horizontal alignment** of rows when in `horizontal` flow. `justified` overfills each row and crops the final image to fill up the canvas. |
|
|
126
145
|
| `--va`, `--v-align <top\|middle\|bottom\|justified>` | `justified` | Controls **vertical alignment** of columns when in `vertical` flow. `justified` overfills each column and crops the final image to fill up the canvas. |
|
|
127
146
|
|
|
147
|
+
### pixeli merge collage
|
|
148
|
+
Usage: `pixeli merge collage [options] [files...]`
|
|
149
|
+
|
|
150
|
+
The collage merge requires a specified JSON template file, or JSON string. Images will be placed as per the template. If a preset ID is provided, both `--template` and `--mapping` are ignored.
|
|
151
|
+
|
|
152
|
+
| Option/Flag | Default | Description |
|
|
153
|
+
| ---------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
154
|
+
| `-t`, `--template <path>` | `null` | Sets the **path to the JSON template file** which will be used to arrange the collage. Priority is given to this option if `--mapping` is not provided. |
|
|
155
|
+
| `-m`, `--mapping <string>` | `null` | Sets the **JSON string** which will be parsed to later arrange the collage. Priority is given to `--template` if both options are provided. |
|
|
156
|
+
| `-p`, `--preset <preset-id>` | `null` | Use a **predefined collage preset** instead of providing your own. Available preset IDs: `instagram-grid`, `dashboardShot`, `horizontal-book-spread`, `vertical-book-spread`, `art-gallery` |
|
|
157
|
+
|
|
158
|
+
### Collage Templates
|
|
159
|
+
The following javascript object thoroughly describes the shape of the JSON objects which are expected to be received. Logical checks are performed on the values after the template is validated. This is to ensure the collage can be created, for example, without any overlaps or 0 pixel-wide images:
|
|
160
|
+
```javascript
|
|
161
|
+
{
|
|
162
|
+
type: 'object',
|
|
163
|
+
required: ['canvas', 'slots'],
|
|
164
|
+
properties: {
|
|
165
|
+
canvas: {
|
|
166
|
+
type: 'object',
|
|
167
|
+
required: ['width', 'height', 'columns', 'rows'],
|
|
168
|
+
properties: {
|
|
169
|
+
width: { type: 'number', minimum: 1, multipleOf: 1 },
|
|
170
|
+
height: { type: 'number', minimum: 1, multipleOf: 1 },
|
|
171
|
+
columns: { type: 'number', minimum: 1, multipleOf: 1 },
|
|
172
|
+
rows: { type: 'number', minimum: 1, multipleOf: 1 },
|
|
173
|
+
gap: { type: 'number', minimum: 0, multipleOf: 1 },
|
|
174
|
+
background: { type: 'string' },
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
slots: {
|
|
179
|
+
type: 'array',
|
|
180
|
+
items: {
|
|
181
|
+
type: 'object',
|
|
182
|
+
required: ['col', 'row', 'colSpan', 'rowSpan'],
|
|
183
|
+
properties: {
|
|
184
|
+
col: { type: 'number', minimum: 1, multipleOf: 1 },
|
|
185
|
+
row: { type: 'number', minimum: 1, multipleOf: 1 },
|
|
186
|
+
colSpan: { type: 'number', minimum: 1, multipleOf: 1 },
|
|
187
|
+
rowSpan: { type: 'number', minimum: 1, multipleOf: 1 },
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
The `template.canvas` object defines key properties of the canvas, and the `template.slots` array lists all the slots in which images are to be placed.
|
|
196
|
+
|
|
197
|
+
A slot object takes 4 properties: `col`, `row`, `colSpan`, and `rowSpan`. `col` and `row` specify which column and row to place the image in, while `colSpan` and `rowSpan` define how many units the image should take up horizontally and vertically.
|
|
198
|
+
|
|
199
|
+
The width of a single unit is equal to canvas width divided by the number of columns, and the height of a single unit is equal to the canvas height divided by the number of rows.
|
|
200
|
+
|
|
201
|
+
For example:
|
|
202
|
+
```json
|
|
203
|
+
{
|
|
204
|
+
"col": 1,
|
|
205
|
+
"row": 5,
|
|
206
|
+
"colSpan": 3,
|
|
207
|
+
"rowSpan": 2
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
This slot will be placed at the 1st column from the left, at the 5th row from the top. It will span 3 columns, including the one it has been placed in, meaning it will take up column 1, 2, and 3. The same goes for the rows; the slot will take up row 5 and 6.
|
|
211
|
+
|
|
212
|
+
This is an example of a full JSON template:
|
|
213
|
+
```json
|
|
214
|
+
{
|
|
215
|
+
"canvas": {
|
|
216
|
+
"width": 1200,
|
|
217
|
+
"height": 1600,
|
|
218
|
+
"columns": 3,
|
|
219
|
+
"rows": 6,
|
|
220
|
+
"gap": 12,
|
|
221
|
+
"background": "#000"
|
|
222
|
+
},
|
|
223
|
+
"slots": [
|
|
224
|
+
{ "col": 1, "row": 1, "colSpan": 2, "rowSpan": 2 },
|
|
225
|
+
{ "col": 3, "row": 1, "colSpan": 1, "rowSpan": 1 },
|
|
226
|
+
{ "col": 3, "row": 2, "colSpan": 1, "rowSpan": 1 },
|
|
227
|
+
{ "col": 1, "row": 3, "colSpan": 1, "rowSpan": 2 },
|
|
228
|
+
{ "col": 2, "row": 3, "colSpan": 2, "rowSpan": 2 },
|
|
229
|
+
{ "col": 1, "row": 5, "colSpan": 3, "rowSpan": 2 }
|
|
230
|
+
]
|
|
231
|
+
}
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Note that the `canvas.background` and `canvas.gap` properties are optional. If they are not provided, the defaults from the CLI options will be used. If both the CLI and template options exists, the template options take priority.
|
|
235
|
+
|
|
128
236
|
## License
|
|
129
237
|
This project is licensed under the [MIT License](./LICENSE).
|
|
130
238
|
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import {
|
|
4
|
+
cliConfirm,
|
|
5
|
+
displayInfoMessage,
|
|
6
|
+
displaySuccessMessage,
|
|
7
|
+
displayWarningMessage,
|
|
8
|
+
handleError,
|
|
9
|
+
writeImage,
|
|
10
|
+
} from '../../lib/helpers/utils.js';
|
|
11
|
+
import { validateCollageOptions, validateSharedOptions } from './helpers/validations.js';
|
|
12
|
+
import { loadImages } from '../../lib/helpers/loadImages.js';
|
|
13
|
+
import { addSharedOptions } from './helpers/utils.js';
|
|
14
|
+
import { validateTemplate } from '../../lib/helpers/templateValidator.js';
|
|
15
|
+
import { collageMerge } from '../../lib/merges/collage-merge/index.js';
|
|
16
|
+
|
|
17
|
+
const collageCommand = new Command('collage');
|
|
18
|
+
|
|
19
|
+
collageCommand
|
|
20
|
+
.description('Use JSON layouts to build custom collages.')
|
|
21
|
+
.option('-t, --template <path>', 'The path to the JSON file describing the collage template', null)
|
|
22
|
+
.option('-m, --mapping <json>', 'Inline JSON template override straight from the command line', null)
|
|
23
|
+
.option('-p, --preset <preset-id>', 'Collage preset ID to use. Available collage IDs: ', null)
|
|
24
|
+
.action(async (files, opts) => {
|
|
25
|
+
await main(files, opts);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const main = async (files, opts) => {
|
|
29
|
+
// Collect and validate parameters
|
|
30
|
+
try {
|
|
31
|
+
const params = { files, ...opts };
|
|
32
|
+
const sharedOptions = await validateSharedOptions(params);
|
|
33
|
+
const collageOptions = await validateCollageOptions(opts);
|
|
34
|
+
const validatedParams = { ...sharedOptions, ...collageOptions };
|
|
35
|
+
|
|
36
|
+
// Load images, create collage, and write on disk
|
|
37
|
+
await generateAndSaveCollage(validatedParams);
|
|
38
|
+
|
|
39
|
+
// Output success message
|
|
40
|
+
} catch (e) {
|
|
41
|
+
handleError(e);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const generateAndSaveCollage = async (validatedParams) => {
|
|
46
|
+
// Replace gap and background if not provided with command line values
|
|
47
|
+
if (validatedParams.template?.canvas?.gap === undefined) {
|
|
48
|
+
validatedParams.template.canvas.gap = validatedParams.gap;
|
|
49
|
+
}
|
|
50
|
+
if (validatedParams.template?.canvas?.background === undefined) {
|
|
51
|
+
validatedParams.template.canvas.background = validatedParams.canvasColor;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Validate the template and update it
|
|
55
|
+
const validatedTemplate = validateTemplate(validatedParams.template);
|
|
56
|
+
validatedParams.template = validatedTemplate;
|
|
57
|
+
|
|
58
|
+
// Load images from file
|
|
59
|
+
const { images, ignoredFiles } = await loadImages({ ...validatedParams, count: validatedParams.template.slots.length });
|
|
60
|
+
|
|
61
|
+
// Display warnings if needed
|
|
62
|
+
if (ignoredFiles.length) {
|
|
63
|
+
displayWarningMessage('\nThese files will be ignored due to unsupported formats:');
|
|
64
|
+
for (const file of ignoredFiles) {
|
|
65
|
+
displayInfoMessage(file);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const confirmation = await cliConfirm('\nAre you sure you want to continue?');
|
|
69
|
+
if (!confirmation) return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const collage = await collageMerge(images, validatedParams);
|
|
73
|
+
|
|
74
|
+
const success = await writeImage(collage, validatedParams.output);
|
|
75
|
+
|
|
76
|
+
// Display success message
|
|
77
|
+
if (success) {
|
|
78
|
+
displaySuccessMessage(`\nImage has been created successfully: ${chalk.bold(validatedParams.output)}\n`);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
addSharedOptions(collageCommand);
|
|
83
|
+
export default collageCommand;
|
package/commands/merge/grid.js
CHANGED
|
@@ -8,8 +8,8 @@ import {
|
|
|
8
8
|
handleError,
|
|
9
9
|
writeImage,
|
|
10
10
|
} from '../../lib/helpers/utils.js';
|
|
11
|
-
import { addSharedOptions
|
|
12
|
-
import { validateGridOptions } from './helpers/validations.js';
|
|
11
|
+
import { addSharedOptions } from './helpers/utils.js';
|
|
12
|
+
import { validateGridOptions, validateSharedOptions } from './helpers/validations.js';
|
|
13
13
|
import { loadImages } from '../../lib/helpers/loadImages.js';
|
|
14
14
|
import { gridMerge } from '../../lib/merges/grid-merge/index.js';
|
|
15
15
|
|
|
@@ -30,7 +30,10 @@ gridCommand
|
|
|
30
30
|
const main = async (files, opts) => {
|
|
31
31
|
// Collect and validate parameters
|
|
32
32
|
try {
|
|
33
|
-
const
|
|
33
|
+
const params = { files, ...opts };
|
|
34
|
+
const sharedOptions = await validateSharedOptions(params);
|
|
35
|
+
const gridOptions = validateGridOptions(sharedOptions, params);
|
|
36
|
+
const validatedParams = { ...sharedOptions, ...gridOptions };
|
|
34
37
|
|
|
35
38
|
// Load images, create grid, and write grid on disk
|
|
36
39
|
await generateAndSaveGrid(validatedParams);
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { validateSharedOptions } from './validations.js';
|
|
2
|
-
|
|
3
1
|
export const addSharedOptions = (cmd) => {
|
|
4
2
|
return cmd
|
|
5
3
|
.argument('[files...]', 'Image filepaths to merge (use --dir for directories)')
|
|
@@ -11,10 +9,3 @@ export const addSharedOptions = (cmd) => {
|
|
|
11
9
|
.option('--bg, --canvas-color <hex|transparent>', 'Background color for canvas', '#ffffff')
|
|
12
10
|
.option('-o, --output <file>', 'Output file path', './pixeli.png');
|
|
13
11
|
};
|
|
14
|
-
|
|
15
|
-
export const getValidatedParams = async (files, opts, validationFunc) => {
|
|
16
|
-
const params = { files, ...opts };
|
|
17
|
-
const sharedOptions = await validateSharedOptions(params);
|
|
18
|
-
const commandOptions = validationFunc(sharedOptions, params);
|
|
19
|
-
return { ...sharedOptions, ...commandOptions };
|
|
20
|
-
};
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
parseAspectRatio,
|
|
9
9
|
SUPPORTED_OUTPUT_FORMATS,
|
|
10
10
|
} from '../../../lib/helpers/utils.js';
|
|
11
|
+
import { isValidPreset, PRESETS } from '../../../lib/merges/collage-merge/presets.js';
|
|
11
12
|
|
|
12
13
|
export const validateSharedOptions = async (sharedOptions) => {
|
|
13
14
|
// Extract params
|
|
@@ -212,3 +213,57 @@ export const validateGridOptions = (sharedOptions, gridOptions) => {
|
|
|
212
213
|
|
|
213
214
|
return formattedParams;
|
|
214
215
|
};
|
|
216
|
+
|
|
217
|
+
export const validateCollageOptions = async (collageOptions) => {
|
|
218
|
+
const { template, mapping, preset } = collageOptions;
|
|
219
|
+
|
|
220
|
+
// If a valid preset is given, use it
|
|
221
|
+
if (preset && isValidPreset(preset)) {
|
|
222
|
+
return { template: PRESETS[preset] };
|
|
223
|
+
}
|
|
224
|
+
// If preset is invalid, throw error
|
|
225
|
+
else if (preset && !isValidPreset(preset)) {
|
|
226
|
+
const validPresets = Object.keys(PRESETS).join(', ');
|
|
227
|
+
throw new Error(`"${preset}" is not a valid preset ID. Choose one of the following: \n${validPresets}`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Ensure only one of the two are given
|
|
231
|
+
if (!template && !mapping) {
|
|
232
|
+
throw new Error('Either --template, --mapping, or --preset need to be provided.');
|
|
233
|
+
} else if (template && mapping) {
|
|
234
|
+
throw new Error('Either use --template or --mapping, not both.');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Ensure mapping is actually a json string
|
|
238
|
+
if (!template && mapping) {
|
|
239
|
+
try {
|
|
240
|
+
JSON.parse(mapping);
|
|
241
|
+
} catch {
|
|
242
|
+
throw new Error('--mapping should be a valid JSON string.');
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Ensure template is a valid dir path
|
|
247
|
+
if (template && !mapping) {
|
|
248
|
+
let stats;
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
stats = await fs.stat(template);
|
|
252
|
+
} catch (e) {
|
|
253
|
+
throw new Error('Template path does not exist.');
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Parse and return respective JSON data
|
|
258
|
+
try {
|
|
259
|
+
if (template) {
|
|
260
|
+
const jsonStr = await fs.readFile(template, 'utf8');
|
|
261
|
+
return { template: JSON.parse(jsonStr) };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// mapping is already a JSON string
|
|
265
|
+
return { template: JSON.parse(mapping) };
|
|
266
|
+
} catch (err) {
|
|
267
|
+
throw new Error('Could not read or parse the provided template or mapping JSON.');
|
|
268
|
+
}
|
|
269
|
+
};
|
package/commands/merge/index.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import masonryCommand from './masonry.js';
|
|
3
3
|
import gridCommand from './grid.js';
|
|
4
|
+
import collageCommand from './collage.js';
|
|
4
5
|
|
|
5
6
|
const mergeCommand = new Command('merge').description('Merge images into a grid layout.');
|
|
6
7
|
|
|
7
8
|
mergeCommand.addCommand(masonryCommand);
|
|
8
9
|
mergeCommand.addCommand(gridCommand);
|
|
10
|
+
mergeCommand.addCommand(collageCommand);
|
|
9
11
|
|
|
10
12
|
export default mergeCommand;
|
|
@@ -8,8 +8,8 @@ import {
|
|
|
8
8
|
handleError,
|
|
9
9
|
writeImage,
|
|
10
10
|
} from '../../lib/helpers/utils.js';
|
|
11
|
-
import { addSharedOptions
|
|
12
|
-
import { validateMasonryOptions } from './helpers/validations.js';
|
|
11
|
+
import { addSharedOptions } from './helpers/utils.js';
|
|
12
|
+
import { validateMasonryOptions, validateSharedOptions } from './helpers/validations.js';
|
|
13
13
|
import { loadImages } from '../../lib/helpers/loadImages.js';
|
|
14
14
|
import { masonryMerge } from '../../lib/merges/masonry-merge/index.js';
|
|
15
15
|
|
|
@@ -31,7 +31,10 @@ masonryCommand
|
|
|
31
31
|
const main = async (files, opts) => {
|
|
32
32
|
try {
|
|
33
33
|
// Collect and validate parameters
|
|
34
|
-
const
|
|
34
|
+
const params = { files, ...opts };
|
|
35
|
+
const sharedOptions = await validateSharedOptions(params);
|
|
36
|
+
const masonryOptions = validateMasonryOptions(sharedOptions, opts);
|
|
37
|
+
const validatedParams = { ...sharedOptions, ...masonryOptions };
|
|
35
38
|
|
|
36
39
|
// Load images, create grid, and write grid on disk
|
|
37
40
|
generateAndSaveGrid(validatedParams);
|
|
@@ -5,20 +5,25 @@ import { isSupportedInputImage, shuffleTogether } from './utils.js';
|
|
|
5
5
|
|
|
6
6
|
const MAX_RECURSION_DEPTH = 10;
|
|
7
7
|
|
|
8
|
-
export const loadImages = async ({ files, dir, recursive, shuffle }) => {
|
|
8
|
+
export const loadImages = async ({ files, dir, recursive, shuffle, count }) => {
|
|
9
9
|
let ignoredFiles = [];
|
|
10
10
|
let filepaths = files;
|
|
11
11
|
let images = [];
|
|
12
12
|
|
|
13
13
|
if (files && files.length) {
|
|
14
14
|
// Load directly from provided file list
|
|
15
|
-
images = await loadFromFiles(files);
|
|
15
|
+
images = await loadFromFiles(files, count);
|
|
16
16
|
} else {
|
|
17
17
|
// Get all files from directory
|
|
18
18
|
const { skippedFiles, paths } = await getFilesFromDirectory(dir, recursive);
|
|
19
19
|
filepaths = paths;
|
|
20
20
|
ignoredFiles = skippedFiles;
|
|
21
|
-
images = await loadFromFiles(filepaths);
|
|
21
|
+
images = await loadFromFiles(filepaths, count);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Ensure filepaths and images match
|
|
25
|
+
if (images.length !== filepaths.length) {
|
|
26
|
+
filepaths = filepaths.slice(0, images.length);
|
|
22
27
|
}
|
|
23
28
|
|
|
24
29
|
// Optional: shuffle filepaths and images together
|
|
@@ -29,10 +34,15 @@ export const loadImages = async ({ files, dir, recursive, shuffle }) => {
|
|
|
29
34
|
return { images, files: filepaths, ignoredFiles };
|
|
30
35
|
};
|
|
31
36
|
|
|
32
|
-
const loadFromFiles = async (files) => {
|
|
37
|
+
const loadFromFiles = async (files, count) => {
|
|
33
38
|
const images = [];
|
|
39
|
+
const total = count || files.length;
|
|
40
|
+
for (let i = 0; i < total; i++) {
|
|
41
|
+
// End the loop if count is higher than number of available files
|
|
42
|
+
if (i >= files.length) break;
|
|
34
43
|
|
|
35
|
-
|
|
44
|
+
// Load images
|
|
45
|
+
const filepath = files[i];
|
|
36
46
|
let image;
|
|
37
47
|
|
|
38
48
|
if (filepath.endsWith('.svg')) {
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import Ajv from 'ajv';
|
|
2
|
+
import { isValidHexadecimal } from './utils.js';
|
|
3
|
+
|
|
4
|
+
// Define entire template schema
|
|
5
|
+
const TEMPLATE_SCHEMA = {
|
|
6
|
+
type: 'object',
|
|
7
|
+
additionalProperties: false,
|
|
8
|
+
required: ['canvas', 'slots'],
|
|
9
|
+
properties: {
|
|
10
|
+
canvas: {
|
|
11
|
+
type: 'object',
|
|
12
|
+
additionalProperties: false,
|
|
13
|
+
required: ['width', 'height', 'columns', 'rows'],
|
|
14
|
+
properties: {
|
|
15
|
+
width: { type: 'number', minimum: 1, multipleOf: 1 },
|
|
16
|
+
height: { type: 'number', minimum: 1, multipleOf: 1 },
|
|
17
|
+
columns: { type: 'number', minimum: 1, multipleOf: 1 },
|
|
18
|
+
rows: { type: 'number', minimum: 1, multipleOf: 1 },
|
|
19
|
+
gap: { type: 'number', minimum: 0, multipleOf: 1 },
|
|
20
|
+
background: { type: 'string' },
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
slots: {
|
|
25
|
+
type: 'array',
|
|
26
|
+
items: {
|
|
27
|
+
type: 'object',
|
|
28
|
+
additionalProperties: false,
|
|
29
|
+
required: ['col', 'row', 'colSpan', 'rowSpan'],
|
|
30
|
+
properties: {
|
|
31
|
+
col: { type: 'number', minimum: 1, multipleOf: 1 },
|
|
32
|
+
row: { type: 'number', minimum: 1, multipleOf: 1 },
|
|
33
|
+
colSpan: { type: 'number', minimum: 1, multipleOf: 1 },
|
|
34
|
+
rowSpan: { type: 'number', minimum: 1, multipleOf: 1 },
|
|
35
|
+
borderRadius: { type: 'number', minimum: 0, multipleOf: 1 },
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const ajv = new Ajv({ allErrors: true });
|
|
43
|
+
|
|
44
|
+
const validate = ajv.compile(TEMPLATE_SCHEMA);
|
|
45
|
+
|
|
46
|
+
export const validateTemplate = (json) => {
|
|
47
|
+
const valid = validate(json);
|
|
48
|
+
|
|
49
|
+
// Handle schema validation
|
|
50
|
+
if (!valid) {
|
|
51
|
+
const path = validate.errors[0].instancePath.slice(1).replaceAll('/', '.');
|
|
52
|
+
const message = validate.errors[0].message;
|
|
53
|
+
throw new Error(`${path} ${message}.`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Handle canvas background color
|
|
57
|
+
if (json.canvas.background !== 'transparent' && !isValidHexadecimal(json.canvas.background)) {
|
|
58
|
+
throw new Error(`Canvas color must be a valid hex value or "transparent".`);
|
|
59
|
+
}
|
|
60
|
+
json.canvas.background = json.canvas.background === 'transparent' ? { r: 0, g: 0, b: 0, alpha: 0 } : json.canvas.background;
|
|
61
|
+
|
|
62
|
+
// Ensure canvas is wide enough for at least a single 1px column
|
|
63
|
+
if (json.canvas.width <= json.canvas.gap * 2) {
|
|
64
|
+
throw new Error(`Canvas width must be greater than ${json.canvas.gap * 2}.`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Ensure canvas is long enough for at least a single 1px row
|
|
68
|
+
if (json.canvas.height <= json.canvas.gap * 2) {
|
|
69
|
+
throw new Error(`Canvas height must be greater than ${json.canvas.gap * 2}.`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Calculate column width and row height
|
|
73
|
+
const workableCanvasWidth = json.canvas.width - json.canvas.gap * (json.canvas.columns + 1);
|
|
74
|
+
const workableCanvasHeight = json.canvas.height - json.canvas.gap * (json.canvas.rows + 1);
|
|
75
|
+
const columnWidth = Math.floor(workableCanvasWidth / json.canvas.columns);
|
|
76
|
+
const rowHeight = Math.floor(workableCanvasHeight / json.canvas.rows);
|
|
77
|
+
|
|
78
|
+
// Ensure columns are thick enough
|
|
79
|
+
if (columnWidth <= 0) {
|
|
80
|
+
throw new Error(`Columns are too thin. Increase canvas width, reduce gap, or reduce number of columns.`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Ensure rows are thick enough
|
|
84
|
+
if (rowHeight <= 0) {
|
|
85
|
+
throw new Error(`Rows are too thin. Increase canvas height or reduce number of rows.`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// For each slot...
|
|
89
|
+
for (let i = 0; i < json.slots.length; i++) {
|
|
90
|
+
const slot = json.slots[i];
|
|
91
|
+
|
|
92
|
+
// Ensure slot is placed inside given canvas columns
|
|
93
|
+
if (slot.col > json.canvas.columns) {
|
|
94
|
+
throw new Error(`json.slots[${i}].col must be between 1 and ${json.canvas.columns}.`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Ensure slot is placed inside given canvas rows
|
|
98
|
+
if (slot.row > json.canvas.rows) {
|
|
99
|
+
throw new Error(`json.slots[${i}].row must be between 1 and ${json.canvas.rows}.`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Ensure slot spans within given canvas columns
|
|
103
|
+
if (slot.col + slot.colSpan - 1 > json.canvas.columns) {
|
|
104
|
+
throw new Error(`json.slots[${i}] spans past the right edge of the grid (col + colSpan exceeds columns).`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Ensure slot spans within given canvas rows
|
|
108
|
+
if (slot.row + slot.rowSpan - 1 > json.canvas.rows) {
|
|
109
|
+
throw new Error(`json.slots[${i}] spans past the bottom edge of the grid (row + rowSpan exceeds rows).`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Ensure no slots overlap
|
|
114
|
+
validateSlotOverlaps(json.slots);
|
|
115
|
+
|
|
116
|
+
return json;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const validateSlotOverlaps = (slots) => {
|
|
120
|
+
for (let i = 0; i < slots.length; i++) {
|
|
121
|
+
const A = slots[i];
|
|
122
|
+
|
|
123
|
+
const A_right = A.col + A.colSpan - 1;
|
|
124
|
+
const A_bottom = A.row + A.rowSpan - 1;
|
|
125
|
+
|
|
126
|
+
for (let j = i + 1; j < slots.length; j++) {
|
|
127
|
+
const B = slots[j];
|
|
128
|
+
|
|
129
|
+
const B_right = B.col + B.colSpan - 1;
|
|
130
|
+
const B_bottom = B.row + B.rowSpan - 1;
|
|
131
|
+
|
|
132
|
+
const overlap = A.col <= B_right && A_right >= B.col && A.row <= B_bottom && A_bottom >= B.row;
|
|
133
|
+
|
|
134
|
+
if (overlap) {
|
|
135
|
+
throw new Error(`Slot ${i} overlaps with slot ${j}.`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import sharp from 'sharp';
|
|
2
|
+
import { progressBar, WRITING_TO_FILE_PERCENTAGE } from '../../helpers/progressBar.js';
|
|
3
|
+
import { roundImages } from '../merge-utils.js';
|
|
4
|
+
|
|
5
|
+
export const collageMerge = async (images, validatedParams) => {
|
|
6
|
+
const { template, cornerRadius } = validatedParams;
|
|
7
|
+
|
|
8
|
+
// Set up progress bar
|
|
9
|
+
const total = Math.min(images.length, template.slots.length) * 2;
|
|
10
|
+
const totalFileWrite = Math.ceil(total * WRITING_TO_FILE_PERCENTAGE);
|
|
11
|
+
progressBar.start(total + totalFileWrite, 0, {
|
|
12
|
+
stage: 'Calculating dimensions',
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// Calculate each column's width and height
|
|
16
|
+
const workableCanvasWidth = template.canvas.width - template.canvas.gap * (template.canvas.columns + 1);
|
|
17
|
+
const workableCanvasHeight = template.canvas.height - template.canvas.gap * (template.canvas.rows + 1);
|
|
18
|
+
const columnWidth = workableCanvasWidth / template.canvas.columns;
|
|
19
|
+
const rowHeight = workableCanvasHeight / template.canvas.rows;
|
|
20
|
+
|
|
21
|
+
// Each block has its resized image with its respective slot coordinates
|
|
22
|
+
progressBar.update({ stage: 'Resizing images' });
|
|
23
|
+
const blocks = await getBlocks({
|
|
24
|
+
slots: template.slots,
|
|
25
|
+
images,
|
|
26
|
+
gap: template.canvas.gap,
|
|
27
|
+
columnWidth,
|
|
28
|
+
rowHeight,
|
|
29
|
+
cornerRadius,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Lay blocks
|
|
33
|
+
progressBar.update({ stage: 'Merging images' });
|
|
34
|
+
const collage = layBlocks({
|
|
35
|
+
canvasOptions: template.canvas,
|
|
36
|
+
blocks,
|
|
37
|
+
columnWidth,
|
|
38
|
+
rowHeight,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
return collage;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const getBlocks = async ({ slots, images, gap, columnWidth, rowHeight, cornerRadius }) => {
|
|
45
|
+
const blocks = [];
|
|
46
|
+
|
|
47
|
+
for (let i = 0; i < slots.length && i < images.length; i++) {
|
|
48
|
+
const slot = slots[i];
|
|
49
|
+
const image = images[i];
|
|
50
|
+
let imageBuffer;
|
|
51
|
+
|
|
52
|
+
// Calculate image width and height
|
|
53
|
+
const width = slot.colSpan * columnWidth + (slot.colSpan - 1) * gap;
|
|
54
|
+
const height = slot.rowSpan * rowHeight + (slot.rowSpan - 1) * gap;
|
|
55
|
+
|
|
56
|
+
// Resize image respectively
|
|
57
|
+
const resizedImage = image.resize({ width: Math.floor(width), height: Math.floor(height) });
|
|
58
|
+
|
|
59
|
+
// Round corners of images if needed
|
|
60
|
+
if (cornerRadius > 0) {
|
|
61
|
+
const roundingOptions = {
|
|
62
|
+
width: Math.floor(width),
|
|
63
|
+
height: Math.floor(height),
|
|
64
|
+
cornerRadius,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const roundedImage = (await roundImages([resizedImage], roundingOptions))[0];
|
|
68
|
+
imageBuffer = await roundedImage.toBuffer();
|
|
69
|
+
} else {
|
|
70
|
+
imageBuffer = await resizedImage.toBuffer();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
blocks.push({ imageBuffer, col: slot.col, row: slot.row });
|
|
74
|
+
progressBar.increment();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return blocks;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const layBlocks = ({ canvasOptions, blocks, columnWidth, rowHeight }) => {
|
|
81
|
+
// Create canvas
|
|
82
|
+
const canvas = sharp({
|
|
83
|
+
limitInputPixels: false,
|
|
84
|
+
create: {
|
|
85
|
+
background: canvasOptions.background,
|
|
86
|
+
channels: 4,
|
|
87
|
+
width: canvasOptions.width,
|
|
88
|
+
height: canvasOptions.height,
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const composites = [];
|
|
93
|
+
|
|
94
|
+
// Collect composites
|
|
95
|
+
for (const block of blocks) {
|
|
96
|
+
const x = (block.col - 1) * columnWidth + block.col * canvasOptions.gap;
|
|
97
|
+
const y = (block.row - 1) * rowHeight + block.row * canvasOptions.gap;
|
|
98
|
+
|
|
99
|
+
composites.push({
|
|
100
|
+
input: block.imageBuffer,
|
|
101
|
+
left: Math.floor(x),
|
|
102
|
+
top: Math.floor(y),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
progressBar.increment();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
canvas.composite(composites);
|
|
109
|
+
return canvas;
|
|
110
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
canvas: {
|
|
3
|
+
width: 2000,
|
|
4
|
+
height: 1500,
|
|
5
|
+
columns: 5,
|
|
6
|
+
rows: 5,
|
|
7
|
+
gap: 20,
|
|
8
|
+
background: '#000',
|
|
9
|
+
},
|
|
10
|
+
slots: [
|
|
11
|
+
{ col: 1, row: 1, colSpan: 3, rowSpan: 3 },
|
|
12
|
+
{ col: 4, row: 1, colSpan: 2, rowSpan: 2 },
|
|
13
|
+
{ col: 4, row: 3, colSpan: 2, rowSpan: 1 },
|
|
14
|
+
{ col: 1, row: 4, colSpan: 2, rowSpan: 2 },
|
|
15
|
+
{ col: 3, row: 4, colSpan: 3, rowSpan: 2 },
|
|
16
|
+
],
|
|
17
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
canvas: {
|
|
3
|
+
width: 1800,
|
|
4
|
+
height: 1200,
|
|
5
|
+
columns: 6,
|
|
6
|
+
rows: 4,
|
|
7
|
+
gap: 10,
|
|
8
|
+
background: '#000',
|
|
9
|
+
},
|
|
10
|
+
slots: [
|
|
11
|
+
{ col: 1, row: 1, colSpan: 3, rowSpan: 2 },
|
|
12
|
+
{ col: 4, row: 1, colSpan: 3, rowSpan: 1 },
|
|
13
|
+
{ col: 4, row: 2, colSpan: 3, rowSpan: 1 },
|
|
14
|
+
{ col: 1, row: 3, colSpan: 2, rowSpan: 2 },
|
|
15
|
+
{ col: 3, row: 3, colSpan: 2, rowSpan: 2 },
|
|
16
|
+
{ col: 5, row: 3, colSpan: 2, rowSpan: 2 },
|
|
17
|
+
],
|
|
18
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
canvas: {
|
|
3
|
+
width: 2400,
|
|
4
|
+
height: 1400,
|
|
5
|
+
columns: 8,
|
|
6
|
+
rows: 3,
|
|
7
|
+
gap: 16,
|
|
8
|
+
background: '#000',
|
|
9
|
+
},
|
|
10
|
+
slots: [
|
|
11
|
+
{ col: 1, row: 1, colSpan: 3, rowSpan: 3 },
|
|
12
|
+
{ col: 4, row: 1, colSpan: 5, rowSpan: 1 },
|
|
13
|
+
{ col: 4, row: 2, colSpan: 5, rowSpan: 2 },
|
|
14
|
+
],
|
|
15
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
canvas: {
|
|
3
|
+
width: 1200,
|
|
4
|
+
height: 1600,
|
|
5
|
+
columns: 3,
|
|
6
|
+
rows: 6,
|
|
7
|
+
gap: 12,
|
|
8
|
+
background: '#000',
|
|
9
|
+
},
|
|
10
|
+
slots: [
|
|
11
|
+
{ col: 1, row: 1, colSpan: 2, rowSpan: 2 },
|
|
12
|
+
{ col: 3, row: 1, colSpan: 1, rowSpan: 1 },
|
|
13
|
+
{ col: 3, row: 2, colSpan: 1, rowSpan: 1 },
|
|
14
|
+
{ col: 1, row: 3, colSpan: 1, rowSpan: 2 },
|
|
15
|
+
{ col: 2, row: 3, colSpan: 2, rowSpan: 2 },
|
|
16
|
+
{ col: 1, row: 5, colSpan: 3, rowSpan: 2 },
|
|
17
|
+
],
|
|
18
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
canvas: {
|
|
3
|
+
width: 1400,
|
|
4
|
+
height: 2100,
|
|
5
|
+
columns: 2,
|
|
6
|
+
rows: 3,
|
|
7
|
+
gap: 20,
|
|
8
|
+
background: '#000',
|
|
9
|
+
},
|
|
10
|
+
slots: [
|
|
11
|
+
{ col: 1, row: 1, colSpan: 2, rowSpan: 1 },
|
|
12
|
+
{ col: 1, row: 2, colSpan: 1, rowSpan: 2 },
|
|
13
|
+
{ col: 2, row: 2, colSpan: 1, rowSpan: 2 },
|
|
14
|
+
],
|
|
15
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import instagramGrid from './presets/instagramGrid.js';
|
|
2
|
+
import dashboardShot from './presets/dashboardShot.js';
|
|
3
|
+
import horizontalBookSpread from './presets/horizontalBookSpread.js';
|
|
4
|
+
import verticalBookSpread from './presets/verticalBookSpread.js';
|
|
5
|
+
import artGallery from './presets/artGallery.js';
|
|
6
|
+
|
|
7
|
+
export const PRESETS = {
|
|
8
|
+
'instagram-grid': instagramGrid,
|
|
9
|
+
'dashboard-shot': dashboardShot,
|
|
10
|
+
'horizontal-book-spread': horizontalBookSpread,
|
|
11
|
+
'vertical-book-spread': verticalBookSpread,
|
|
12
|
+
'art-gallery': artGallery,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const isValidPreset = (presetId) => {
|
|
16
|
+
return presetId in PRESETS;
|
|
17
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pixeli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "A lightweight command-line tool for merging multiple images into customizable grid layouts.",
|
|
5
5
|
"homepage": "https://github.com/pakdad-mousavi/pixeli#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"test": "echo \"Error: no test specified\" && exit 1"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
+
"ajv": "^8.17.1",
|
|
36
37
|
"chalk": "^5.6.2",
|
|
37
38
|
"cli-progress": "^3.12.0",
|
|
38
39
|
"commander": "^14.0.2",
|