genmix 1.0.0 → 1.0.4
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 +44 -4
- package/demo/example-multiple.js +49 -0
- package/demo/example-translate.js +21 -25
- package/demo/example.js +1 -1
- package/generators/BaseGenerator.js +95 -12
- package/generators/GeminiGenerator.js +315 -234
- package/package.json +20 -5
- package/demo/.env +0 -1
- package/demo/genmix/generators/BaseGenerator.js +0 -58
- package/demo/mijb3y7u_transl_this_image_to_5bvpx7.png +0 -0
- package/demo/mijb4azh_add_a_vintag_film_1p04hum.png +0 -0
- package/demo/mijb4azj_add_a_vintag_film_igoidb.png +0 -0
- package/demo/mijb4azk_add_a_vintag_film_11ltrj0.png +0 -0
package/README.md
CHANGED
|
@@ -45,7 +45,7 @@ const result = await generator.generate(
|
|
|
45
45
|
);
|
|
46
46
|
|
|
47
47
|
// Save images
|
|
48
|
-
const savedPaths = generator.save('./output');
|
|
48
|
+
const savedPaths = await generator.save({ directory: './output' });
|
|
49
49
|
console.log('Images saved:', savedPaths);
|
|
50
50
|
```
|
|
51
51
|
|
|
@@ -62,7 +62,7 @@ const result = await generator.generate(
|
|
|
62
62
|
}
|
|
63
63
|
);
|
|
64
64
|
|
|
65
|
-
const savedPaths = generator.save('./output');
|
|
65
|
+
const savedPaths = await generator.save({ directory: './output' });
|
|
66
66
|
```
|
|
67
67
|
|
|
68
68
|
### Using URLs as Reference
|
|
@@ -76,7 +76,7 @@ const result = await generator.generate(
|
|
|
76
76
|
}
|
|
77
77
|
);
|
|
78
78
|
|
|
79
|
-
generator.save('./output');
|
|
79
|
+
generator.save({ directory: './output' });
|
|
80
80
|
```
|
|
81
81
|
|
|
82
82
|
## Configuration Options
|
|
@@ -106,6 +106,46 @@ await generator.generate(prompt, options)
|
|
|
106
106
|
| `options.quality` | string | Quality: '1K', '2K', '4K' | - |
|
|
107
107
|
| `options.aspectRatio` | string | Aspect ratio: '1:1', '16:9', '4:3', etc. | - |
|
|
108
108
|
|
|
109
|
+
### save() Method
|
|
110
|
+
|
|
111
|
+
```javascript
|
|
112
|
+
await generator.save(options)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
**Parameters:**
|
|
116
|
+
|
|
117
|
+
| Option | Type | Description | Default |
|
|
118
|
+
| ------------------- | ------ | ------------------------------------------------ | ------- |
|
|
119
|
+
| `options.directory` | string | Target directory path to save images | `'.'` |
|
|
120
|
+
| `options.filename` | string | Custom filename (without extension). If not provided, uses hash-based filename | - |
|
|
121
|
+
| `options.extension` | string | File format: 'jpg', 'png', 'webp', 'avif', 'tiff' | `'jpg'` |
|
|
122
|
+
|
|
123
|
+
**Examples:**
|
|
124
|
+
|
|
125
|
+
```javascript
|
|
126
|
+
// Save to specific directory with auto-generated filename (jpg by default)
|
|
127
|
+
await generator.save({ directory: './output' });
|
|
128
|
+
|
|
129
|
+
// Save as PNG
|
|
130
|
+
await generator.save({ directory: './output', extension: 'png' });
|
|
131
|
+
|
|
132
|
+
// Save to specific directory with custom filename
|
|
133
|
+
await generator.save({ directory: './output', filename: 'my-image' });
|
|
134
|
+
|
|
135
|
+
// Save as WebP with custom filename
|
|
136
|
+
await generator.save({ directory: './output', filename: 'my-image', extension: 'webp' });
|
|
137
|
+
|
|
138
|
+
// Save to current directory with custom filename
|
|
139
|
+
await generator.save({ filename: 'my-image' });
|
|
140
|
+
|
|
141
|
+
// Save to current directory with auto-generated filename (jpg)
|
|
142
|
+
await generator.save();
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
**Note:**
|
|
146
|
+
- When multiple images are generated and a custom filename is provided, they will be saved as `filename_0.jpg`, `filename_1.jpg`, etc.
|
|
147
|
+
- The method uses Sharp for image conversion, supporting high-quality format conversion
|
|
148
|
+
|
|
109
149
|
## Advanced Examples
|
|
110
150
|
|
|
111
151
|
### Style Transfer
|
|
@@ -179,7 +219,7 @@ try {
|
|
|
179
219
|
const result = await generator.generate(prompt, options);
|
|
180
220
|
|
|
181
221
|
if (result.images && result.images.length > 0) {
|
|
182
|
-
const paths = generator.save('./output');
|
|
222
|
+
const paths = generator.save({ directory: './output' });
|
|
183
223
|
console.log('Success!', paths);
|
|
184
224
|
} else {
|
|
185
225
|
console.log('No images generated');
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
process.loadEnvFile(); // Native Node.js .env loading (Node 20.12+)
|
|
2
|
+
const { GeminiGenerator } = require('../index');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
|
|
6
|
+
async function main() {
|
|
7
|
+
try {
|
|
8
|
+
const generator = new GeminiGenerator();
|
|
9
|
+
|
|
10
|
+
// === Multiple variations of the same image ===
|
|
11
|
+
const localImagePath = path.join(__dirname, 'camera_4126.jpg');
|
|
12
|
+
|
|
13
|
+
if (fs.existsSync(localImagePath)) {
|
|
14
|
+
console.log('🎨 Generating multiple variations...\n');
|
|
15
|
+
|
|
16
|
+
const result = await generator.generate(
|
|
17
|
+
'Add a vintage film look with grain and vignette effect',
|
|
18
|
+
{
|
|
19
|
+
referenceImage: localImagePath,
|
|
20
|
+
quality: '1K',
|
|
21
|
+
numberOfImages: 3
|
|
22
|
+
}
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
if (result.images && result.images.length > 0) {
|
|
26
|
+
const saved = await generator.save({ directory: __dirname });
|
|
27
|
+
console.log(`✅ ${saved.length} variations saved:`);
|
|
28
|
+
saved.forEach(p => console.log(` - ${p}`));
|
|
29
|
+
console.log();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
console.log('🎉 Example completed successfully!\n');
|
|
34
|
+
|
|
35
|
+
} catch (error) {
|
|
36
|
+
console.error('❌ Error:', error.message);
|
|
37
|
+
|
|
38
|
+
if (error.message.includes('API Key')) {
|
|
39
|
+
console.error('\n💡 Tip: Make sure you have GEMINI_API_KEY in your .env file');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (error.message.includes('reference image')) {
|
|
43
|
+
console.error('\n💡 Tip: Verify that the image path is correct');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
main();
|
|
49
|
+
|
|
@@ -17,42 +17,38 @@ async function main() {
|
|
|
17
17
|
'Translate this image to Portuguese',
|
|
18
18
|
{
|
|
19
19
|
referenceImage: localImagePath,
|
|
20
|
-
quality: '
|
|
20
|
+
quality: '1K',
|
|
21
21
|
numberOfImages: 1
|
|
22
22
|
}
|
|
23
23
|
);
|
|
24
24
|
|
|
25
25
|
if (result1.images && result1.images.length > 0) {
|
|
26
|
-
|
|
26
|
+
// Create pt directory if it doesn't exist
|
|
27
|
+
const ptDir = path.join(__dirname, 'pt');
|
|
28
|
+
if (!fs.existsSync(ptDir)) {
|
|
29
|
+
fs.mkdirSync(ptDir, { recursive: true });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Use the same filename as the original
|
|
33
|
+
const originalName = path.basename(localImagePath, path.extname(localImagePath));
|
|
34
|
+
|
|
35
|
+
// Get reference metadata to match format
|
|
36
|
+
const refMetadata = generator.getReferenceMetadata();
|
|
37
|
+
console.log('📊 Reference format:', refMetadata);
|
|
38
|
+
|
|
39
|
+
// Save with same format as reference
|
|
40
|
+
const saved = await generator.save({
|
|
41
|
+
directory: ptDir,
|
|
42
|
+
filename: originalName,
|
|
43
|
+
formatOptions: refMetadata
|
|
44
|
+
});
|
|
27
45
|
console.log('✅ Modified image saved at:', saved[0], '\n');
|
|
28
46
|
} else if (result1.text) {
|
|
29
47
|
console.log('📝 Result:', result1.text, '\n');
|
|
30
48
|
}
|
|
31
49
|
}
|
|
32
50
|
|
|
33
|
-
|
|
34
|
-
// === Option 3: Multiple variations of the same image ===
|
|
35
|
-
if (fs.existsSync(localImagePath)) {
|
|
36
|
-
console.log('🎨 Generating multiple variations...\n');
|
|
37
|
-
|
|
38
|
-
const result3 = await generator.generate(
|
|
39
|
-
'Add a vintage film look with grain and vignette effect',
|
|
40
|
-
{
|
|
41
|
-
referenceImage: localImagePath,
|
|
42
|
-
quality: '1K',
|
|
43
|
-
numberOfImages: 3
|
|
44
|
-
}
|
|
45
|
-
);
|
|
46
|
-
|
|
47
|
-
if (result3.images && result3.images.length > 0) {
|
|
48
|
-
const saved = generator.save(__dirname);
|
|
49
|
-
console.log(`✅ ${saved.length} variations saved:`);
|
|
50
|
-
saved.forEach(p => console.log(` - ${p}`));
|
|
51
|
-
console.log();
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
console.log('🎉 All examples completed successfully!\n');
|
|
51
|
+
console.log('🎉 Example completed successfully!\n');
|
|
56
52
|
|
|
57
53
|
} catch (error) {
|
|
58
54
|
console.error('❌ Error:', error.message);
|
package/demo/example.js
CHANGED
|
@@ -25,7 +25,7 @@ async function exampleBasicGeneration() {
|
|
|
25
25
|
if (result.images && result.images.length > 0) {
|
|
26
26
|
console.log(`Found ${result.images.length} images.`);
|
|
27
27
|
|
|
28
|
-
const savedPaths = generator.save(__dirname);
|
|
28
|
+
const savedPaths = await generator.save({ directory: __dirname });
|
|
29
29
|
savedPaths.forEach(p => console.log(`Saved image to ${p}`));
|
|
30
30
|
} else {
|
|
31
31
|
console.log('No images generated.');
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const hashFactory = require('hash-factory');
|
|
4
|
+
const sharp = require('sharp');
|
|
4
5
|
|
|
5
6
|
class BaseGenerator {
|
|
6
7
|
constructor(config = {}) {
|
|
@@ -20,12 +21,15 @@ class BaseGenerator {
|
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
|
-
* Saves generated images to the specified directory using hashed filenames.
|
|
24
|
-
* @param {
|
|
25
|
-
* @
|
|
24
|
+
* Saves generated images to the specified directory using hashed filenames or custom filename.
|
|
25
|
+
* @param {Object} [options={}] - Save options
|
|
26
|
+
* @param {string} [options.directory='.'] - Target directory path. Defaults to current directory.
|
|
27
|
+
* @param {string} [options.filename] - Optional custom filename (without extension). If not provided, uses hashed filenames.
|
|
28
|
+
* @param {string} [options.extension='jpg'] - File extension: 'jpg', 'png', 'webp', 'avif', 'tiff'. Defaults to 'jpg'.
|
|
29
|
+
* @param {Object} [options.formatOptions] - Format-specific options (quality, compressionLevel, palette, colours, etc.)
|
|
30
|
+
* @returns {Promise<string[]>} Promise that resolves to array of saved file paths.
|
|
26
31
|
*/
|
|
27
|
-
save(directory) {
|
|
28
|
-
const extension = 'png';
|
|
32
|
+
async save({directory = '.', filename = null, extension = 'jpg', formatOptions = null} = {}) {
|
|
29
33
|
const targetImages = this.lastGeneration?.images;
|
|
30
34
|
const targetPrompt = this.lastGeneration?.prompt;
|
|
31
35
|
|
|
@@ -34,7 +38,7 @@ class BaseGenerator {
|
|
|
34
38
|
return [];
|
|
35
39
|
}
|
|
36
40
|
|
|
37
|
-
if (!targetPrompt) {
|
|
41
|
+
if (!targetPrompt && !filename) {
|
|
38
42
|
console.warn('No prompt available for hash generation.');
|
|
39
43
|
}
|
|
40
44
|
|
|
@@ -43,18 +47,97 @@ class BaseGenerator {
|
|
|
43
47
|
fs.mkdirSync(directory, { recursive: true });
|
|
44
48
|
}
|
|
45
49
|
|
|
50
|
+
// Use format from formatOptions if provided, otherwise use extension
|
|
51
|
+
if (formatOptions && formatOptions.format) {
|
|
52
|
+
extension = formatOptions.format;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Normalize extension
|
|
56
|
+
extension = extension.toLowerCase().replace(/^\./, '');
|
|
57
|
+
|
|
58
|
+
// Validate extension
|
|
59
|
+
const supportedFormats = ['jpg', 'jpeg', 'png', 'webp', 'avif', 'tiff', 'tif'];
|
|
60
|
+
if (!supportedFormats.includes(extension)) {
|
|
61
|
+
throw new Error(`Unsupported extension: ${extension}. Supported formats: ${supportedFormats.join(', ')}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Normalize jpeg/jpg
|
|
65
|
+
const sharpFormat = extension === 'jpeg' ? 'jpg' : extension;
|
|
66
|
+
const fileExtension = extension === 'jpeg' ? 'jpg' : extension;
|
|
67
|
+
|
|
46
68
|
const savedPaths = [];
|
|
47
|
-
|
|
69
|
+
|
|
70
|
+
for (let index = 0; index < targetImages.length; index++) {
|
|
71
|
+
const imgData = targetImages[index];
|
|
48
72
|
const base64Data = imgData.replace(/^data:image\/\w+;base64,/, "");
|
|
49
73
|
const buffer = Buffer.from(base64Data, 'base64');
|
|
50
74
|
|
|
51
|
-
|
|
52
|
-
|
|
75
|
+
let fileName;
|
|
76
|
+
if (filename) {
|
|
77
|
+
// Use custom filename, add index if multiple images
|
|
78
|
+
if (targetImages.length > 1) {
|
|
79
|
+
fileName = `${filename}_${index}.${fileExtension}`;
|
|
80
|
+
} else {
|
|
81
|
+
fileName = `${filename}.${fileExtension}`;
|
|
82
|
+
}
|
|
83
|
+
} else {
|
|
84
|
+
// Use hash-based filename
|
|
85
|
+
const hash = this.generateHash(targetPrompt + '_' + index);
|
|
86
|
+
fileName = `${hash}.${fileExtension}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
53
89
|
const outputPath = path.join(directory, fileName);
|
|
54
90
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
91
|
+
// Convert image format using sharp
|
|
92
|
+
try {
|
|
93
|
+
let sharpInstance = sharp(buffer);
|
|
94
|
+
|
|
95
|
+
// Resize if dimensions are specified in formatOptions
|
|
96
|
+
if (formatOptions && formatOptions.width && formatOptions.height) {
|
|
97
|
+
sharpInstance = sharpInstance.resize(formatOptions.width, formatOptions.height, {
|
|
98
|
+
fit: 'fill'
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Build format-specific options
|
|
103
|
+
const sharpFormatOptions = {};
|
|
104
|
+
|
|
105
|
+
if (formatOptions) {
|
|
106
|
+
if (sharpFormat === 'jpg' && formatOptions.quality) {
|
|
107
|
+
sharpFormatOptions.quality = formatOptions.quality;
|
|
108
|
+
} else if (sharpFormat === 'png') {
|
|
109
|
+
if (formatOptions.compressionLevel !== undefined) {
|
|
110
|
+
sharpFormatOptions.compressionLevel = formatOptions.compressionLevel;
|
|
111
|
+
}
|
|
112
|
+
if (formatOptions.quality !== undefined) {
|
|
113
|
+
sharpFormatOptions.quality = formatOptions.quality;
|
|
114
|
+
}
|
|
115
|
+
if (formatOptions.effort !== undefined) {
|
|
116
|
+
sharpFormatOptions.effort = formatOptions.effort;
|
|
117
|
+
}
|
|
118
|
+
if (formatOptions.palette) {
|
|
119
|
+
sharpFormatOptions.palette = true;
|
|
120
|
+
// Add dithering for better quality with palette
|
|
121
|
+
sharpFormatOptions.dither = 1.0;
|
|
122
|
+
}
|
|
123
|
+
if (formatOptions.colours) {
|
|
124
|
+
sharpFormatOptions.colours = formatOptions.colours;
|
|
125
|
+
}
|
|
126
|
+
} else if (sharpFormat === 'webp' && formatOptions.quality) {
|
|
127
|
+
sharpFormatOptions.quality = formatOptions.quality;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
await sharpInstance
|
|
132
|
+
.toFormat(sharpFormat, sharpFormatOptions)
|
|
133
|
+
.toFile(outputPath);
|
|
134
|
+
|
|
135
|
+
savedPaths.push(outputPath);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
console.error(`Error saving image ${fileName}:`, error.message);
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
58
141
|
|
|
59
142
|
return savedPaths;
|
|
60
143
|
}
|
|
@@ -4,261 +4,342 @@ const fs = require('fs');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
|
|
6
6
|
class GeminiGenerator extends BaseGenerator {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
7
|
+
/**
|
|
8
|
+
* @param {Object} [config]
|
|
9
|
+
* @param {string} [config.apiKey]
|
|
10
|
+
* @param {string} [config.modelId]
|
|
11
|
+
*/
|
|
12
|
+
constructor(config = {}) {
|
|
13
|
+
super(config);
|
|
14
|
+
this.apiKey = config.apiKey || process.env.GEMINI_API_KEY;
|
|
15
|
+
|
|
16
|
+
if (!this.apiKey) {
|
|
17
|
+
throw new Error('API Key is required. Provide it in the constructor or set GEMINI_API_KEY environment variable.');
|
|
18
|
+
}
|
|
19
|
+
// Use the specific model for image generation or default to pro-vision if needed
|
|
20
|
+
// However, for standard image generation via prompt, specific models like 'gemini-1.5-pro' or 'imagen-3.0-generate-001' are often used.
|
|
21
|
+
// Keeping the user's modelId preference.
|
|
22
|
+
this.modelId = config.modelId || 'gemini-3-pro-image-preview';
|
|
23
|
+
// NOTE: For simple generation, we might not need streamGenerateContent unless we want text streaming.
|
|
24
|
+
// But let's stick to what worked for text, and adjust the body.
|
|
25
|
+
this.apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${this.modelId}:streamGenerateContent`;
|
|
26
|
+
this.referenceMetadata = null;
|
|
18
27
|
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {string} prompt
|
|
31
|
+
* @param {Object} [options]
|
|
32
|
+
* @param {string|Buffer} [options.referenceImage] - Path to image file, base64 data URI, or Buffer
|
|
33
|
+
* @param {string} [options.numberOfImages] - Number of images to generate
|
|
34
|
+
* @param {string} [options.quality] - Image quality: '1K', '2K', '4K'
|
|
35
|
+
* @param {string} [options.aspectRatio] - Aspect ratio like '1:1', '16:9', etc.
|
|
36
|
+
* @returns {Promise<{images: string[], text: string, raw: any}>}
|
|
37
|
+
*/
|
|
38
|
+
async generate(prompt, options = {}) {
|
|
39
|
+
// If the user asks for multiple images, we might need to make parallel requests
|
|
40
|
+
// if the API doesn't support candidateCount > 1 for images.
|
|
41
|
+
// Based on search results, candidateCount > 1 can cause 400 errors.
|
|
42
|
+
const numberOfImages = options.numberOfImages || 1;
|
|
43
|
+
|
|
44
|
+
let result;
|
|
45
|
+
if (numberOfImages > 1) {
|
|
46
|
+
result = await this.generateMultiple(prompt, numberOfImages, options);
|
|
47
|
+
} else {
|
|
48
|
+
result = await this._generateSingleRequest(prompt, options);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Store result in state for saveImages()
|
|
52
|
+
this.lastGeneration = {
|
|
53
|
+
prompt: prompt,
|
|
54
|
+
images: result.images,
|
|
55
|
+
text: result.text,
|
|
56
|
+
raw: result.raw
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
return result;
|
|
48
60
|
}
|
|
49
61
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
62
|
+
/**
|
|
63
|
+
* Processes a reference image and returns base64 data and mime type
|
|
64
|
+
* @param {string|Buffer} imageInput - File path, base64 data URI, or Buffer
|
|
65
|
+
* @returns {Promise<{data: string, mimeType: string}>}
|
|
66
|
+
* @private
|
|
67
|
+
*/
|
|
68
|
+
async _processReferenceImage(imageInput) {
|
|
69
|
+
let base64Data;
|
|
70
|
+
let mimeType = 'image/png'; // default
|
|
71
|
+
|
|
72
|
+
if (Buffer.isBuffer(imageInput)) {
|
|
73
|
+
// If it's already a buffer
|
|
74
|
+
base64Data = imageInput.toString('base64');
|
|
75
|
+
} else if (typeof imageInput === 'string') {
|
|
76
|
+
if (imageInput.startsWith('data:image/')) {
|
|
77
|
+
// It's a data URI
|
|
78
|
+
const match = imageInput.match(/^data:(image\/[^;]+);base64,(.+)$/);
|
|
79
|
+
if (match) {
|
|
80
|
+
mimeType = match[1];
|
|
81
|
+
base64Data = match[2];
|
|
82
|
+
} else {
|
|
83
|
+
throw new Error('Invalid data URI format for reference image');
|
|
84
|
+
}
|
|
85
|
+
} else if (imageInput.startsWith('http://') || imageInput.startsWith('https://')) {
|
|
86
|
+
// It's a URL - download it
|
|
87
|
+
try {
|
|
88
|
+
const response = await axios.get(imageInput, { responseType: 'arraybuffer' });
|
|
89
|
+
base64Data = Buffer.from(response.data).toString('base64');
|
|
90
|
+
mimeType = response.headers['content-type'] || 'image/png';
|
|
91
|
+
} catch (error) {
|
|
92
|
+
throw new Error(`Failed to download reference image from URL: ${error.message}`);
|
|
93
|
+
}
|
|
94
|
+
} else {
|
|
95
|
+
// Assume it's a file path
|
|
96
|
+
try {
|
|
97
|
+
const fileBuffer = fs.readFileSync(imageInput);
|
|
98
|
+
base64Data = fileBuffer.toString('base64');
|
|
99
|
+
|
|
100
|
+
// Determine mime type from extension
|
|
101
|
+
const ext = path.extname(imageInput).toLowerCase();
|
|
102
|
+
const mimeTypes = {
|
|
103
|
+
'.png': 'image/png',
|
|
104
|
+
'.jpg': 'image/jpeg',
|
|
105
|
+
'.jpeg': 'image/jpeg',
|
|
106
|
+
'.gif': 'image/gif',
|
|
107
|
+
'.webp': 'image/webp'
|
|
108
|
+
};
|
|
109
|
+
mimeType = mimeTypes[ext] || 'image/png';
|
|
110
|
+
|
|
111
|
+
// Extract metadata from reference image using sharp
|
|
112
|
+
await this._extractReferenceMetadata(imageInput);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
throw new Error(`Failed to read reference image file: ${error.message}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
81
117
|
} else {
|
|
82
|
-
|
|
118
|
+
throw new Error('Reference image must be a file path, URL, data URI, or Buffer');
|
|
83
119
|
}
|
|
84
|
-
|
|
85
|
-
|
|
120
|
+
|
|
121
|
+
return { data: base64Data, mimeType };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Extract metadata from reference image
|
|
126
|
+
* @param {string} imagePath - Path to the reference image
|
|
127
|
+
* @private
|
|
128
|
+
*/
|
|
129
|
+
async _extractReferenceMetadata(imagePath) {
|
|
130
|
+
const sharp = require('sharp');
|
|
86
131
|
try {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
132
|
+
const image = sharp(imagePath);
|
|
133
|
+
const metadata = await image.metadata();
|
|
134
|
+
const stats = fs.statSync(imagePath);
|
|
135
|
+
|
|
136
|
+
// Build format options based on image format
|
|
137
|
+
const formatOptions = {
|
|
138
|
+
format: metadata.format,
|
|
139
|
+
width: metadata.width,
|
|
140
|
+
height: metadata.height
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// Format-specific options
|
|
144
|
+
if (metadata.format === 'jpeg' || metadata.format === 'jpg') {
|
|
145
|
+
// Estimate quality from file size (heuristic)
|
|
146
|
+
const pixelCount = metadata.width * metadata.height;
|
|
147
|
+
const bytesPerPixel = stats.size / pixelCount;
|
|
148
|
+
|
|
149
|
+
// Quality estimation based on bytes per pixel
|
|
150
|
+
if (bytesPerPixel < 0.5) formatOptions.quality = 70;
|
|
151
|
+
else if (bytesPerPixel < 1) formatOptions.quality = 80;
|
|
152
|
+
else if (bytesPerPixel < 1.5) formatOptions.quality = 90;
|
|
153
|
+
else formatOptions.quality = 90;
|
|
154
|
+
} else if (metadata.format === 'png') {
|
|
155
|
+
formatOptions.compressionLevel = 9;
|
|
156
|
+
|
|
157
|
+
// Check if it's a palette PNG using paletteBitDepth
|
|
158
|
+
if (metadata.paletteBitDepth) {
|
|
159
|
+
formatOptions.palette = true;
|
|
160
|
+
formatOptions.quality = 90; // Max quality for palette PNG
|
|
161
|
+
formatOptions.effort = 9; // Maximum compression effort
|
|
162
|
+
|
|
163
|
+
// Count actual unique colors in the image
|
|
164
|
+
try {
|
|
165
|
+
const { data, info } = await sharp(imagePath).raw().toBuffer({ resolveWithObject: true });
|
|
166
|
+
const colors = new Set();
|
|
167
|
+
const pixelSize = info.channels;
|
|
168
|
+
|
|
169
|
+
for (let i = 0; i < data.length; i += pixelSize) {
|
|
170
|
+
const color = [];
|
|
171
|
+
for (let j = 0; j < pixelSize; j++) {
|
|
172
|
+
color.push(data[i + j]);
|
|
173
|
+
}
|
|
174
|
+
colors.add(color.join(','));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
formatOptions.colours = colors.size;
|
|
178
|
+
} catch (error) {
|
|
179
|
+
// Fallback to paletteBitDepth calculation if color counting fails
|
|
180
|
+
formatOptions.colours = Math.pow(2, metadata.paletteBitDepth);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
} else if (metadata.format === 'webp') {
|
|
184
|
+
formatOptions.quality = 80;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
this.referenceMetadata = formatOptions;
|
|
90
188
|
} catch (error) {
|
|
91
|
-
|
|
189
|
+
console.warn('Could not extract reference metadata:', error.message);
|
|
92
190
|
}
|
|
93
|
-
|
|
94
|
-
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Get metadata from the last processed reference image
|
|
195
|
+
* @returns {Object|null} Metadata object or null if no reference processed
|
|
196
|
+
*/
|
|
197
|
+
getReferenceMetadata() {
|
|
198
|
+
return this.referenceMetadata;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async generateMultiple(prompt, count, options) {
|
|
202
|
+
const promises = [];
|
|
203
|
+
for (let i = 0; i < count; i++) {
|
|
204
|
+
promises.push(this._generateSingleRequest(prompt, options));
|
|
205
|
+
}
|
|
206
|
+
|
|
95
207
|
try {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
208
|
+
const results = await Promise.all(promises);
|
|
209
|
+
|
|
210
|
+
const merged = {
|
|
211
|
+
images: [],
|
|
212
|
+
text: '',
|
|
213
|
+
raw: results.map(r => r.raw),
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
for (const res of results) {
|
|
217
|
+
merged.images.push(...res.images);
|
|
218
|
+
if (res.text && !merged.text.includes(res.text)) {
|
|
219
|
+
merged.text += res.text + '\n';
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return merged;
|
|
109
223
|
} catch (error) {
|
|
110
|
-
|
|
224
|
+
throw error;
|
|
111
225
|
}
|
|
112
|
-
}
|
|
113
|
-
} else {
|
|
114
|
-
throw new Error('Reference image must be a file path, URL, data URI, or Buffer');
|
|
115
226
|
}
|
|
116
227
|
|
|
117
|
-
|
|
118
|
-
|
|
228
|
+
async _generateSingleRequest(prompt, options) {
|
|
229
|
+
const url = `${this.apiUrl}?key=${this.apiKey}`;
|
|
119
230
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
for (let i = 0; i < count; i++) {
|
|
123
|
-
promises.push(this._generateSingleRequest(prompt, options));
|
|
124
|
-
}
|
|
231
|
+
// Build the parts array
|
|
232
|
+
const parts = [];
|
|
125
233
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
for (const res of results) {
|
|
136
|
-
merged.images.push(...res.images);
|
|
137
|
-
if (res.text && !merged.text.includes(res.text)) {
|
|
138
|
-
merged.text += res.text + '\n';
|
|
234
|
+
// Add reference image if provided
|
|
235
|
+
if (options.referenceImage) {
|
|
236
|
+
const imageData = await this._processReferenceImage(options.referenceImage);
|
|
237
|
+
parts.push({
|
|
238
|
+
inlineData: {
|
|
239
|
+
mimeType: imageData.mimeType,
|
|
240
|
+
data: imageData.data
|
|
241
|
+
}
|
|
242
|
+
});
|
|
139
243
|
}
|
|
140
|
-
}
|
|
141
|
-
return merged;
|
|
142
|
-
} catch (error) {
|
|
143
|
-
throw error;
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
async _generateSingleRequest(prompt, options) {
|
|
148
|
-
const url = `${this.apiUrl}?key=${this.apiKey}`;
|
|
149
|
-
|
|
150
|
-
// Build the parts array
|
|
151
|
-
const parts = [];
|
|
152
|
-
|
|
153
|
-
// Add reference image if provided
|
|
154
|
-
if (options.referenceImage) {
|
|
155
|
-
const imageData = await this._processReferenceImage(options.referenceImage);
|
|
156
|
-
parts.push({
|
|
157
|
-
inlineData: {
|
|
158
|
-
mimeType: imageData.mimeType,
|
|
159
|
-
data: imageData.data
|
|
160
|
-
}
|
|
161
|
-
});
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// Add text prompt
|
|
165
|
-
parts.push({
|
|
166
|
-
text: prompt,
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
// Simplified data structure to minimize conflicts
|
|
170
|
-
const data = {
|
|
171
|
-
contents: [
|
|
172
|
-
{
|
|
173
|
-
role: 'user', // Role is good practice
|
|
174
|
-
parts: parts,
|
|
175
|
-
},
|
|
176
|
-
],
|
|
177
|
-
// Only include generationConfig if there are specific options to set
|
|
178
|
-
generationConfig: {
|
|
179
|
-
// 'responseModalities' is often implied or specific to the model.
|
|
180
|
-
// Removing explicit 'responseModalities' might help if the model detects intent from prompt.
|
|
181
|
-
// But let's keep basic structure.
|
|
182
|
-
// candidateCount MUST be 1 for many image models per request.
|
|
183
|
-
candidateCount: 1,
|
|
184
|
-
},
|
|
185
|
-
};
|
|
186
|
-
|
|
187
|
-
// Only add imageConfig if it's actually supported/needed.
|
|
188
|
-
// Some models might reject 'imageConfig' inside 'generationConfig' if they expect text.
|
|
189
|
-
// However, if we are targeting an image model, we need to be careful.
|
|
190
|
-
// Let's try sending a cleaner request first.
|
|
191
|
-
|
|
192
|
-
// If specific image options are passed, add them carefully
|
|
193
|
-
if (options.imageSize || options.aspectRatio || options.quality) {
|
|
194
|
-
// Note: Not all models support 'imageConfig' this way.
|
|
195
|
-
// But if the user is using a model that supports it...
|
|
196
|
-
data.generationConfig.imageConfig = {};
|
|
197
|
-
|
|
198
|
-
// User mentioned quality is 1k, 2k, 4k. Mapping quality or imageSize to this.
|
|
199
|
-
const size = options.quality || options.imageSize;
|
|
200
|
-
if (size) {
|
|
201
|
-
data.generationConfig.imageConfig.image_size = size; // Expects '1K', '2K', '4K'
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
if (options.aspectRatio) {
|
|
205
|
-
data.generationConfig.imageConfig.aspect_ratio = options.aspectRatio;
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
244
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
//
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
245
|
+
// Add text prompt
|
|
246
|
+
parts.push({
|
|
247
|
+
text: prompt,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// Simplified data structure to minimize conflicts
|
|
251
|
+
const data = {
|
|
252
|
+
contents: [
|
|
253
|
+
{
|
|
254
|
+
role: 'user', // Role is good practice
|
|
255
|
+
parts: parts,
|
|
256
|
+
},
|
|
257
|
+
],
|
|
258
|
+
// Only include generationConfig if there are specific options to set
|
|
259
|
+
generationConfig: {
|
|
260
|
+
// 'responseModalities' is often implied or specific to the model.
|
|
261
|
+
// Removing explicit 'responseModalities' might help if the model detects intent from prompt.
|
|
262
|
+
// But let's keep basic structure.
|
|
263
|
+
// candidateCount MUST be 1 for many image models per request.
|
|
264
|
+
candidateCount: 1,
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
// Only add imageConfig if it's actually supported/needed.
|
|
269
|
+
// Some models might reject 'imageConfig' inside 'generationConfig' if they expect text.
|
|
270
|
+
// However, if we are targeting an image model, we need to be careful.
|
|
271
|
+
// Let's try sending a cleaner request first.
|
|
272
|
+
|
|
273
|
+
// If specific image options are passed, add them carefully
|
|
274
|
+
if (options.imageSize || options.aspectRatio || options.quality) {
|
|
275
|
+
// Note: Not all models support 'imageConfig' this way.
|
|
276
|
+
// But if the user is using a model that supports it...
|
|
277
|
+
data.generationConfig.imageConfig = {};
|
|
278
|
+
|
|
279
|
+
// User mentioned quality is 1k, 2k, 4k. Mapping quality or imageSize to this.
|
|
280
|
+
const size = options.quality || options.imageSize;
|
|
281
|
+
if (size) {
|
|
282
|
+
data.generationConfig.imageConfig.image_size = size; // Expects '1K', '2K', '4K'
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (options.aspectRatio) {
|
|
286
|
+
data.generationConfig.imageConfig.aspect_ratio = options.aspectRatio;
|
|
250
287
|
}
|
|
251
|
-
}
|
|
252
288
|
}
|
|
253
|
-
|
|
289
|
+
|
|
290
|
+
// Remove 'tools' unless explicitly requested or needed for search grounding.
|
|
291
|
+
// 'googleSearch' tool might conflict with pure image generation intent on some endpoints.
|
|
292
|
+
// data.tools = ... (removed)
|
|
293
|
+
|
|
294
|
+
try {
|
|
295
|
+
const response = await axios.post(url, data, {
|
|
296
|
+
headers: {
|
|
297
|
+
'Content-Type': 'application/json',
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
return this.processResponse(response.data);
|
|
302
|
+
} catch (error) {
|
|
303
|
+
const errorMessage = error.response?.data?.error?.message || error.message;
|
|
304
|
+
// Log detailed error for debugging
|
|
305
|
+
if (error.response?.data) {
|
|
306
|
+
console.error("API Error Details:", JSON.stringify(error.response.data, null, 2));
|
|
307
|
+
}
|
|
308
|
+
throw new Error(`Gemini API Error: ${errorMessage}`);
|
|
309
|
+
}
|
|
254
310
|
}
|
|
255
311
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
312
|
+
processResponse(data) {
|
|
313
|
+
// Handle stream response which might be an array of chunks
|
|
314
|
+
const chunks = Array.isArray(data) ? data : [data];
|
|
315
|
+
const images = [];
|
|
316
|
+
let fullText = '';
|
|
317
|
+
|
|
318
|
+
for (const chunk of chunks) {
|
|
319
|
+
if (chunk.candidates) {
|
|
320
|
+
for (const candidate of chunk.candidates) {
|
|
321
|
+
if (candidate.content && candidate.content.parts) {
|
|
322
|
+
for (const part of candidate.content.parts) {
|
|
323
|
+
if (part.text) {
|
|
324
|
+
fullText += part.text;
|
|
325
|
+
}
|
|
326
|
+
if (part.inlineData && part.inlineData.mimeType.startsWith('image/')) {
|
|
327
|
+
// Base64 image data
|
|
328
|
+
images.push(`data:${part.inlineData.mimeType};base64,${part.inlineData.data}`);
|
|
329
|
+
}
|
|
330
|
+
// Handle executable code or other parts if necessary
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
images,
|
|
339
|
+
text: fullText,
|
|
340
|
+
raw: data,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
262
343
|
}
|
|
263
344
|
|
|
264
345
|
module.exports = GeminiGenerator;
|
package/package.json
CHANGED
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "genmix",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "",
|
|
5
|
-
"license": "
|
|
6
|
-
"author": "",
|
|
3
|
+
"version": "1.0.4",
|
|
4
|
+
"description": "AI-powered image generator using Google Gemini API. Supports image generation from text prompts and image modification with reference images.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Martin Clasen",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"nano",
|
|
9
|
+
"banana",
|
|
10
|
+
"pro",
|
|
11
|
+
"image-generator",
|
|
12
|
+
"google-gemini",
|
|
13
|
+
"gemini-api",
|
|
14
|
+
"text-to-image",
|
|
15
|
+
"image-modification",
|
|
16
|
+
"style-transfer",
|
|
17
|
+
"generative-ai",
|
|
18
|
+
"genmix",
|
|
19
|
+
"clasen"
|
|
20
|
+
],
|
|
7
21
|
"type": "commonjs",
|
|
8
22
|
"main": "index.js",
|
|
9
23
|
"scripts": {
|
|
@@ -11,6 +25,7 @@
|
|
|
11
25
|
},
|
|
12
26
|
"dependencies": {
|
|
13
27
|
"axios": "^1.13.2",
|
|
14
|
-
"hash-factory": "^1.1.2"
|
|
28
|
+
"hash-factory": "^1.1.2",
|
|
29
|
+
"sharp": "^0.33.5"
|
|
15
30
|
}
|
|
16
31
|
}
|
package/demo/.env
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
GEMINI_API_KEY="AIzaSyDMy_iCPVzMHF3sSrbCYr2K9PO9lHkCPsg"
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
const fs = require('fs');
|
|
2
|
-
const path = require('path');
|
|
3
|
-
const hashFactory = require('hash-factory');
|
|
4
|
-
|
|
5
|
-
class BaseGenerator {
|
|
6
|
-
constructor(config = {}) {
|
|
7
|
-
this.config = config;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Generates a hash from the text prompt.
|
|
12
|
-
* @param {string} prompt
|
|
13
|
-
* @param {Object} options
|
|
14
|
-
* @returns {string}
|
|
15
|
-
*/
|
|
16
|
-
generateHash(prompt, options = { length: 10 }) {
|
|
17
|
-
return hashFactory(prompt, options);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Saves generated images to the specified directory using hashed filenames.
|
|
22
|
-
* @param {string[]} images - Array of base64 image strings.
|
|
23
|
-
* @param {string} directory - Target directory path.
|
|
24
|
-
* @param {string} prompt - The prompt used for generation (to create hash).
|
|
25
|
-
* @param {string} [extension='png'] - File extension.
|
|
26
|
-
* @returns {string[]} Array of saved file paths.
|
|
27
|
-
*/
|
|
28
|
-
saveImages(images, directory, prompt, extension = 'png') {
|
|
29
|
-
if (!images || !Array.isArray(images) || images.length === 0) {
|
|
30
|
-
return [];
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// Ensure directory exists
|
|
34
|
-
if (!fs.existsSync(directory)) {
|
|
35
|
-
fs.mkdirSync(directory, { recursive: true });
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const savedPaths = [];
|
|
39
|
-
const hash = this.generateHash(prompt);
|
|
40
|
-
|
|
41
|
-
images.forEach((imgData, index) => {
|
|
42
|
-
const base64Data = imgData.replace(/^data:image\/\w+;base64,/, "");
|
|
43
|
-
const buffer = Buffer.from(base64Data, 'base64');
|
|
44
|
-
|
|
45
|
-
// Using format: hash_index.ext
|
|
46
|
-
const fileName = `${hash}_${index + 1}.${extension}`;
|
|
47
|
-
const outputPath = path.join(directory, fileName);
|
|
48
|
-
|
|
49
|
-
fs.writeFileSync(outputPath, buffer);
|
|
50
|
-
savedPaths.push(outputPath);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
return savedPaths;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
module.exports = BaseGenerator;
|
|
58
|
-
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|