sogni-gen 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mauvis Ledford
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 ADDED
@@ -0,0 +1,92 @@
1
+ <p align="center">
2
+ <img src="screenshot.jpg" alt="Telegram image render workflow" width="320" />
3
+ </p>
4
+
5
+ # Image & Video gen for 🦞 Moltbot
6
+
7
+ 🎨 Generate **images and videos** using [Sogni AI](https://sogni.ai)'s decentralized GPU network.
8
+
9
+ A [Clawdbot](https://github.com/clawdbot/clawdbot) skill for AI image + video generation.
10
+
11
+ ## Installation
12
+
13
+ ### As a Clawdbot Skill
14
+
15
+ ```bash
16
+ # Clone to your skills directory
17
+ git clone https://github.com/mauvis/sogni-gen ~/.clawdbot/skills/sogni-gen
18
+ cd ~/.clawdbot/skills/sogni-gen
19
+ npm install
20
+ ```
21
+
22
+ ### Standalone
23
+
24
+ ```bash
25
+ git clone https://github.com/mauvis/sogni-gen
26
+ cd sogni-gen
27
+ npm install
28
+ ```
29
+
30
+ ## Setup
31
+
32
+ 1. Create a Sogni account at https://sogni.ai
33
+ 2. Create credentials file:
34
+
35
+ ```bash
36
+ mkdir -p ~/.config/sogni
37
+ cat > ~/.config/sogni/credentials << 'EOF'
38
+ SOGNI_USERNAME=your_username
39
+ SOGNI_PASSWORD=your_password
40
+ EOF
41
+ chmod 600 ~/.config/sogni/credentials
42
+ ```
43
+
44
+ ## Usage
45
+
46
+ ```bash
47
+ # Generate image, get URL
48
+ node sogni-gen.mjs "a dragon eating tacos"
49
+
50
+ # Save to file
51
+ node sogni-gen.mjs -o dragon.png "a dragon eating tacos"
52
+
53
+ # JSON output
54
+ node sogni-gen.mjs --json "a dragon eating tacos"
55
+
56
+ # Different model
57
+ node sogni-gen.mjs -m flux1-schnell-fp8 "a dragon eating tacos"
58
+ ```
59
+
60
+ ## Options
61
+
62
+ ```
63
+ -o, --output <path> Save image to file
64
+ -m, --model <id> Model (default: z_image_turbo_bf16)
65
+ -w, --width <px> Width (default: 512)
66
+ -h, --height <px> Height (default: 512)
67
+ -n, --count <num> Number of images (default: 1)
68
+ -t, --timeout <sec> Timeout (default: 30)
69
+ --json JSON output
70
+ -q, --quiet Suppress progress
71
+ ```
72
+
73
+ ## Models
74
+
75
+ | Model | Speed | Notes |
76
+ |-------|-------|-------|
77
+ | `z_image_turbo_bf16` | ~5-10s | Default, general purpose |
78
+ | `flux1-schnell-fp8` | ~3-5s | Fast iterations |
79
+ | `flux2_dev_fp8` | ~2min | High quality |
80
+ | `chroma-v.46-flash_fp8` | ~30s | Balanced |
81
+
82
+ ## With Clawdbot
83
+
84
+ Once installed, just ask your agent:
85
+
86
+ > "Draw me a picture of a slothicorn eating a banana"
87
+
88
+ The agent will generate the image and send it to your chat.
89
+
90
+ ## License
91
+
92
+ MIT
package/SKILL.md ADDED
@@ -0,0 +1,194 @@
1
+ ---
2
+ name: sogni-gen
3
+ description: Generate images **and videos** using Sogni AI's decentralized network. Ask the agent to "draw", "generate", "create an image", or "make a video/animate" from a prompt or reference image.
4
+ homepage: https://sogni.ai
5
+ metadata:
6
+ clawdbot:
7
+ emoji: "🎨"
8
+ os: ["darwin", "linux", "win32"]
9
+ requires:
10
+ bins: ["node"]
11
+ install:
12
+ - id: npm
13
+ kind: exec
14
+ command: "cd {{skillDir}} && npm install"
15
+ label: "Install dependencies"
16
+ ---
17
+
18
+ # Sogni Image & Video Generation
19
+
20
+ Generate **images and videos** using Sogni AI's decentralized GPU network.
21
+
22
+ ## Setup
23
+
24
+ 1. **Get Sogni credentials** at https://sogni.ai
25
+ 2. **Create credentials file:**
26
+ ```bash
27
+ mkdir -p ~/.config/sogni
28
+ cat > ~/.config/sogni/credentials << 'EOF'
29
+ SOGNI_USERNAME=your_username
30
+ SOGNI_PASSWORD=your_password
31
+ EOF
32
+ chmod 600 ~/.config/sogni/credentials
33
+ ```
34
+
35
+ 3. **Install dependencies:**
36
+ ```bash
37
+ cd /path/to/sogni-gen
38
+ npm install
39
+ ```
40
+
41
+ ## Usage (Images & Video)
42
+
43
+ ```bash
44
+ # Generate and get URL
45
+ node sogni-gen.mjs "a cat wearing a hat"
46
+
47
+ # Save to file
48
+ node sogni-gen.mjs -o /tmp/cat.png "a cat wearing a hat"
49
+
50
+ # JSON output (for scripting)
51
+ node sogni-gen.mjs --json "a cat wearing a hat"
52
+
53
+ # Quiet mode (suppress progress)
54
+ node sogni-gen.mjs -q -o /tmp/cat.png "a cat wearing a hat"
55
+ ```
56
+
57
+ ## Options
58
+
59
+ | Flag | Description | Default |
60
+ |------|-------------|---------|
61
+ | `-o, --output <path>` | Save to file | prints URL |
62
+ | `-m, --model <id>` | Model ID | z_image_turbo_bf16 |
63
+ | `-w, --width <px>` | Width | 512 |
64
+ | `-h, --height <px>` | Height | 512 |
65
+ | `-n, --count <num>` | Number of images | 1 |
66
+ | `-t, --timeout <sec>` | Timeout seconds | 30 (300 for video) |
67
+ | `-s, --seed <num>` | Specific seed | random |
68
+ | `--last-seed` | Reuse seed from last render | - |
69
+ | `-c, --context <path>` | Context image for editing | - |
70
+ | `--last-image` | Use last generated image as context/ref | - |
71
+ | `--video, -v` | Generate video instead of image | - |
72
+ | `--fps <num>` | Frames per second (video) | 16 |
73
+ | `--duration <sec>` | Duration in seconds (video) | 5 |
74
+ | `--ref <path>` | Reference image for video | required for video |
75
+ | `--last` | Show last render info | - |
76
+ | `--json` | JSON output | false |
77
+ | `-q, --quiet` | No progress output | false |
78
+
79
+ ## Image Models
80
+
81
+ | Model | Speed | Use Case |
82
+ |-------|-------|----------|
83
+ | `z_image_turbo_bf16` | Fast (~5-10s) | General purpose, default |
84
+ | `flux1-schnell-fp8` | Very fast | Quick iterations |
85
+ | `flux2_dev_fp8` | Slow (~2min) | High quality |
86
+ | `chroma-v.46-flash_fp8` | Medium | Balanced |
87
+ | `qwen_image_edit_2511_fp8` | Medium | Image editing with context (up to 3) |
88
+ | `qwen_image_edit_2511_fp8_lightning` | Fast | Quick image editing |
89
+
90
+ ## Video Models
91
+
92
+ | Model | Speed | Use Case |
93
+ |-------|-------|----------|
94
+ | `wan_v2.2-14b-fp8_i2v_lightx2v` | Fast | Default video generation |
95
+ | `wan_v2.2-14b-fp8_i2v` | Slow | Higher quality video |
96
+
97
+ ## Image Editing with Context
98
+
99
+ Edit images using reference images (Qwen models support up to 3):
100
+
101
+ ```bash
102
+ # Single context image
103
+ node sogni-gen.mjs -c photo.jpg "make the background a beach"
104
+
105
+ # Multiple context images (subject + style)
106
+ node sogni-gen.mjs -c subject.jpg -c style.jpg "apply the style to the subject"
107
+
108
+ # Use last generated image as context
109
+ node sogni-gen.mjs --last-image "make it more vibrant"
110
+ ```
111
+
112
+ When context images are provided without `-m`, defaults to `qwen_image_edit_2511_fp8_lightning`.
113
+
114
+ ## Video Generation
115
+
116
+ Generate videos from a reference image:
117
+
118
+ ```bash
119
+ # Basic video from image
120
+ node sogni-gen.mjs --video --ref cat.jpg -o cat.mp4 "cat walks around"
121
+
122
+ # Use last generated image as reference
123
+ node sogni-gen.mjs --last-image --video "gentle camera pan"
124
+
125
+ # Custom duration and FPS
126
+ node sogni-gen.mjs --video --ref scene.png --duration 10 --fps 24 "zoom out slowly"
127
+ ```
128
+
129
+ ## Photo Restoration
130
+
131
+ Restore damaged vintage photos using Qwen image editing:
132
+
133
+ ```bash
134
+ # Basic restoration
135
+ sogni-gen -c damaged_photo.jpg -o restored.png \
136
+ "professionally restore this vintage photograph, remove damage and scratches"
137
+
138
+ # Detailed restoration with preservation hints
139
+ sogni-gen -c old_photo.jpg -o restored.png -w 1024 -h 1280 \
140
+ "restore this vintage photo, remove peeling, tears and wear marks, \
141
+ preserve natural features and expression, maintain warm nostalgic color tones"
142
+ ```
143
+
144
+ **Tips for good restorations:**
145
+ - Describe the damage: "peeling", "scratches", "tears", "fading"
146
+ - Specify what to preserve: "natural features", "eye color", "hair", "expression"
147
+ - Mention the era for color tones: "1970s warm tones", "vintage sepia"
148
+
149
+ **Finding received images (Telegram/etc):**
150
+ ```bash
151
+ ls -la ~/.clawdbot/media/inbound/*.jpg | tail -3
152
+ cp ~/.clawdbot/media/inbound/<latest>.jpg /tmp/to_restore.jpg
153
+ ```
154
+
155
+ ## Agent Usage
156
+
157
+ When user asks to generate/draw/create an image:
158
+
159
+ ```bash
160
+ # Generate and save locally
161
+ node {{skillDir}}/sogni-gen.mjs -q -o /tmp/generated.png "user's prompt"
162
+
163
+ # Edit an existing image
164
+ node {{skillDir}}/sogni-gen.mjs -q -c /path/to/input.jpg -o /tmp/edited.png "make it pop art style"
165
+
166
+ # Generate video from image
167
+ node {{skillDir}}/sogni-gen.mjs -q --video --ref /path/to/image.png -o /tmp/video.mp4 "camera slowly zooms in"
168
+
169
+ # Then send via message tool with filePath
170
+ ```
171
+
172
+ ## JSON Output
173
+
174
+ ```json
175
+ {
176
+ "success": true,
177
+ "prompt": "a cat wearing a hat",
178
+ "model": "z_image_turbo_bf16",
179
+ "width": 512,
180
+ "height": 512,
181
+ "urls": ["https://..."],
182
+ "localPath": "/tmp/cat.png"
183
+ }
184
+ ```
185
+
186
+ ## Cost
187
+
188
+ Uses Spark tokens from your Sogni account. 512x512 images are most cost-efficient.
189
+
190
+ ## Troubleshooting
191
+
192
+ - **Auth errors**: Check credentials in `~/.config/sogni/credentials`
193
+ - **Timeouts**: Try a faster model or increase `-t` timeout
194
+ - **No workers**: Check https://sogni.ai for network status
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "sogni-gen",
3
+ "version": "1.0.0",
4
+ "description": "Sogni AI image generation skill for Clawdbot",
5
+ "type": "module",
6
+ "main": "sogni-gen.mjs",
7
+ "bin": {
8
+ "sogni-gen": "./sogni-gen.mjs"
9
+ },
10
+ "scripts": {
11
+ "test": "node sogni-gen.mjs --help"
12
+ },
13
+ "keywords": [
14
+ "sogni",
15
+ "ai",
16
+ "image-generation",
17
+ "clawdbot",
18
+ "skill"
19
+ ],
20
+ "author": "Mauvis Ledford",
21
+ "license": "MIT",
22
+ "dependencies": {
23
+ "@sogni-ai/sogni-client-wrapper": "^1.3.2"
24
+ }
25
+ }
package/screenshot.jpg ADDED
Binary file
package/sogni-gen.mjs ADDED
@@ -0,0 +1,501 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * sogni-gen - Generate images and videos using Sogni AI
4
+ * Usage: sogni-gen [options] "prompt"
5
+ */
6
+
7
+ import { SogniClientWrapper, ClientEvent, supportsContextImages, getMaxContextImages } from '@sogni-ai/sogni-client-wrapper';
8
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
9
+ import { join, dirname } from 'path';
10
+ import { homedir } from 'os';
11
+
12
+ const LAST_RENDER_PATH = join(homedir(), '.config', 'sogni', 'last-render.json');
13
+
14
+ // Parse arguments
15
+ const args = process.argv.slice(2);
16
+ const options = {
17
+ prompt: null,
18
+ output: null,
19
+ model: null, // Will be set based on type
20
+ width: 512,
21
+ height: 512,
22
+ count: 1,
23
+ json: false,
24
+ quiet: false,
25
+ timeout: 30000,
26
+ seed: null,
27
+ lastSeed: false,
28
+ video: false,
29
+ fps: 16,
30
+ duration: 5,
31
+ refImage: null, // Reference image for video (start frame)
32
+ refImageEnd: null, // End frame for video interpolation
33
+ contextImages: [] // Context images for image editing
34
+ };
35
+
36
+ // Parse CLI args
37
+ for (let i = 0; i < args.length; i++) {
38
+ const arg = args[i];
39
+ if (arg === '-o' || arg === '--output') {
40
+ options.output = args[++i];
41
+ } else if (arg === '-m' || arg === '--model') {
42
+ options.model = args[++i];
43
+ } else if (arg === '-w' || arg === '--width') {
44
+ options.width = parseInt(args[++i]);
45
+ } else if (arg === '-h' || arg === '--height') {
46
+ options.height = parseInt(args[++i]);
47
+ } else if (arg === '-n' || arg === '--count') {
48
+ options.count = parseInt(args[++i]);
49
+ } else if (arg === '-t' || arg === '--timeout') {
50
+ options.timeout = parseInt(args[++i]) * 1000;
51
+ } else if (arg === '-s' || arg === '--seed') {
52
+ options.seed = parseInt(args[++i]);
53
+ } else if (arg === '--last-seed' || arg === '--reseed') {
54
+ options.lastSeed = true;
55
+ } else if (arg === '--video' || arg === '-v') {
56
+ options.video = true;
57
+ } else if (arg === '--fps') {
58
+ options.fps = parseInt(args[++i]);
59
+ } else if (arg === '--duration') {
60
+ options.duration = parseInt(args[++i]);
61
+ } else if (arg === '--ref' || arg === '--reference') {
62
+ options.refImage = args[++i];
63
+ } else if (arg === '--ref-end' || arg === '--end') {
64
+ options.refImageEnd = args[++i];
65
+ } else if (arg === '-c' || arg === '--context') {
66
+ options.contextImages.push(args[++i]);
67
+ } else if (arg === '--last-image') {
68
+ // Use image from last render as reference/context
69
+ if (existsSync(LAST_RENDER_PATH)) {
70
+ const lastRender = JSON.parse(readFileSync(LAST_RENDER_PATH, 'utf8'));
71
+ let lastImagePath = null;
72
+ if (lastRender.localPath && existsSync(lastRender.localPath)) {
73
+ lastImagePath = lastRender.localPath;
74
+ } else if (lastRender.urls?.[0]) {
75
+ lastImagePath = lastRender.urls[0];
76
+ }
77
+ if (lastImagePath) {
78
+ // Will be resolved later: video uses refImage, image editing uses contextImages
79
+ options._lastImagePath = lastImagePath;
80
+ }
81
+ }
82
+ } else if (arg === '--last') {
83
+ // Show last render info
84
+ if (existsSync(LAST_RENDER_PATH)) {
85
+ console.log(readFileSync(LAST_RENDER_PATH, 'utf8'));
86
+ } else {
87
+ console.error('No previous render found.');
88
+ }
89
+ process.exit(0);
90
+ } else if (arg === '--json') {
91
+ options.json = true;
92
+ } else if (arg === '-q' || arg === '--quiet') {
93
+ options.quiet = true;
94
+ } else if (arg === '--help') {
95
+ console.log(`
96
+ sogni-gen - Generate images and videos using Sogni AI
97
+
98
+ Usage: sogni-gen [options] "prompt"
99
+
100
+ Image Options:
101
+ -o, --output <path> Save to file (otherwise prints URL)
102
+ -m, --model <id> Model (default: z_image_turbo_bf16)
103
+ -w, --width <px> Width (default: 512)
104
+ -h, --height <px> Height (default: 512)
105
+ -n, --count <num> Number of images (default: 1)
106
+ -s, --seed <num> Use specific seed
107
+ --last-seed Reuse seed from previous render
108
+ -c, --context <path> Context image for editing (can use multiple)
109
+ --last-image Use last generated image as context
110
+
111
+ Video Options:
112
+ --video, -v Generate video instead of image
113
+ --fps <num> Frames per second (default: 16)
114
+ --duration <sec> Duration in seconds (default: 5)
115
+ --ref <path|url> Reference image for video (start frame)
116
+ --ref-end <path|url> End frame for interpolation/morphing
117
+ --last-image Use last generated image as reference
118
+
119
+ General:
120
+ -t, --timeout <sec> Timeout in seconds (default: 30, video: 300)
121
+ --last Show last render info (JSON)
122
+ --json Output JSON with all details
123
+ -q, --quiet Suppress progress output
124
+
125
+ Image Models:
126
+ z_image_turbo_bf16 Fast, general purpose (default)
127
+ flux1-schnell-fp8 Very fast
128
+ flux2_dev_fp8 High quality (slow)
129
+ qwen_image_edit_2511_fp8 Image editing with context (up to 3 images)
130
+ qwen_image_edit_2511_fp8_lightning Fast image editing
131
+
132
+ Video Models:
133
+ wan_v2.2-14b-fp8_i2v_lightx2v Fast (default)
134
+ wan_v2.2-14b-fp8_i2v Higher quality
135
+
136
+ Examples:
137
+ sogni-gen "a cat wearing a hat"
138
+ sogni-gen -o cat.jpg "a cat"
139
+ sogni-gen --video --ref cat.jpg -o cat.mp4 "cat walks around"
140
+ sogni-gen --video --last-image "gentle camera pan"
141
+ sogni-gen -c photo.jpg "make the background a beach" -m qwen_image_edit_2511_fp8
142
+ sogni-gen -c subject.jpg -c style.jpg "apply the style to the subject"
143
+ `);
144
+ process.exit(0);
145
+ } else if (!arg.startsWith('-') && !options.prompt) {
146
+ options.prompt = arg;
147
+ }
148
+ }
149
+
150
+ // Resolve --last-image: video uses refImage, image uses contextImages
151
+ if (options._lastImagePath) {
152
+ if (options.video) {
153
+ options.refImage = options._lastImagePath;
154
+ } else {
155
+ options.contextImages.push(options._lastImagePath);
156
+ }
157
+ delete options._lastImagePath;
158
+ }
159
+
160
+ // Set defaults based on type and context
161
+ if (options.video) {
162
+ options.model = options.model || 'wan_v2.2-14b-fp8_i2v_lightx2v';
163
+ options.timeout = options.timeout === 30000 ? 300000 : options.timeout; // 5 min for video
164
+ } else if (options.contextImages.length > 0) {
165
+ // Use qwen edit model when context images provided (unless model explicitly set)
166
+ options.model = options.model || 'qwen_image_edit_2511_fp8_lightning';
167
+ options.timeout = options.timeout === 30000 ? 60000 : options.timeout; // 1 min for editing
168
+ } else {
169
+ options.model = options.model || 'z_image_turbo_bf16';
170
+ }
171
+
172
+ if (!options.prompt) {
173
+ console.error('Error: No prompt provided. Use --help for usage.');
174
+ process.exit(1);
175
+ }
176
+
177
+ if (options.video && !options.refImage) {
178
+ console.error('Error: Video generation requires a reference image (--ref or --last-image)');
179
+ process.exit(1);
180
+ }
181
+
182
+ // Validate context images against model limits
183
+ if (options.contextImages.length > 0 && !options.video) {
184
+ const maxImages = getMaxContextImages(options.model);
185
+ if (maxImages === 0) {
186
+ console.error(`Error: Model ${options.model} does not support context images.`);
187
+ console.error('Try: qwen_image_edit_2511_fp8 or qwen_image_edit_2511_fp8_lightning');
188
+ process.exit(1);
189
+ }
190
+ if (options.contextImages.length > maxImages) {
191
+ console.error(`Error: Model ${options.model} supports max ${maxImages} context images, got ${options.contextImages.length}`);
192
+ process.exit(1);
193
+ }
194
+ }
195
+
196
+ // Load last render seed if requested
197
+ if (options.lastSeed) {
198
+ if (existsSync(LAST_RENDER_PATH)) {
199
+ try {
200
+ const lastRender = JSON.parse(readFileSync(LAST_RENDER_PATH, 'utf8'));
201
+ if (lastRender.seed) {
202
+ options.seed = lastRender.seed;
203
+ if (!options.quiet) console.error(`Using seed from last render: ${options.seed}`);
204
+ }
205
+ } catch (e) {
206
+ console.error('Warning: Could not load last render seed');
207
+ }
208
+ } else {
209
+ console.error('Warning: No previous render found, using random seed');
210
+ }
211
+ }
212
+
213
+ // Load credentials
214
+ function loadCredentials() {
215
+ const credPath = join(homedir(), '.config', 'sogni', 'credentials');
216
+
217
+ if (existsSync(credPath)) {
218
+ const content = readFileSync(credPath, 'utf8');
219
+ const creds = {};
220
+ for (const line of content.split('\n')) {
221
+ const [key, val] = line.split('=');
222
+ if (key && val) creds[key.trim()] = val.trim();
223
+ }
224
+ if (creds.SOGNI_USERNAME && creds.SOGNI_PASSWORD) {
225
+ return creds;
226
+ }
227
+ }
228
+
229
+ if (process.env.SOGNI_USERNAME && process.env.SOGNI_PASSWORD) {
230
+ return {
231
+ SOGNI_USERNAME: process.env.SOGNI_USERNAME,
232
+ SOGNI_PASSWORD: process.env.SOGNI_PASSWORD
233
+ };
234
+ }
235
+
236
+ console.error('Error: No Sogni credentials found.');
237
+ console.error('Create ~/.config/sogni/credentials with:');
238
+ console.error(' SOGNI_USERNAME=your_username');
239
+ console.error(' SOGNI_PASSWORD=your_password');
240
+ process.exit(1);
241
+ }
242
+
243
+ // Save last render info
244
+ function saveLastRender(info) {
245
+ try {
246
+ const dir = dirname(LAST_RENDER_PATH);
247
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
248
+ writeFileSync(LAST_RENDER_PATH, JSON.stringify(info, null, 2));
249
+ } catch (e) {
250
+ // Ignore save errors
251
+ }
252
+ }
253
+
254
+ // Fetch image as buffer
255
+ async function fetchImageBuffer(pathOrUrl) {
256
+ if (pathOrUrl.startsWith('http://') || pathOrUrl.startsWith('https://')) {
257
+ const response = await fetch(pathOrUrl);
258
+ return Buffer.from(await response.arrayBuffer());
259
+ } else {
260
+ return readFileSync(pathOrUrl);
261
+ }
262
+ }
263
+
264
+ async function main() {
265
+ const creds = loadCredentials();
266
+ const log = options.quiet ? () => {} : console.error.bind(console);
267
+
268
+ log('Connecting to Sogni...');
269
+
270
+ const client = new SogniClientWrapper({
271
+ username: creds.SOGNI_USERNAME,
272
+ password: creds.SOGNI_PASSWORD,
273
+ network: 'fast',
274
+ autoConnect: false,
275
+ authType: 'token'
276
+ });
277
+
278
+ try {
279
+ await client.connect();
280
+ log('Connected.');
281
+
282
+ const results = [];
283
+ let completedJobs = 0;
284
+
285
+ const completionPromise = new Promise((resolve, reject) => {
286
+ const timeout = setTimeout(() => {
287
+ reject(new Error(`Timeout after ${options.timeout / 1000}s`));
288
+ }, options.timeout);
289
+
290
+ client.on(ClientEvent.JOB_COMPLETED, (data) => {
291
+ const jobData = data.job?.data || {};
292
+ results.push({
293
+ imageUrl: data.imageUrl,
294
+ videoUrl: data.videoUrl,
295
+ seed: jobData.seed,
296
+ jobIndex: data.jobIndex,
297
+ projectId: data.projectId
298
+ });
299
+ completedJobs++;
300
+ log(`${options.video ? 'Video' : 'Image'} ${completedJobs}/${options.count} completed`);
301
+
302
+ if (completedJobs >= options.count) {
303
+ clearTimeout(timeout);
304
+ resolve();
305
+ }
306
+ });
307
+
308
+ client.on(ClientEvent.JOB_FAILED, (data) => {
309
+ clearTimeout(timeout);
310
+ reject(new Error(data.error || 'Job failed'));
311
+ });
312
+
313
+ // Progress for video
314
+ if (options.video) {
315
+ client.on(ClientEvent.PROJECT_PROGRESS, (data) => {
316
+ if (data.percentage && data.percentage > 0) {
317
+ log(`Progress: ${Math.round(data.percentage)}%`);
318
+ }
319
+ });
320
+ }
321
+ });
322
+
323
+ if (options.video) {
324
+ // Video generation
325
+ log(`Generating video with ${options.model}...`);
326
+ log(`Reference: ${options.refImage}`);
327
+ if (options.refImageEnd) log(`End frame: ${options.refImageEnd}`);
328
+
329
+ const imageBuffer = await fetchImageBuffer(options.refImage);
330
+ const endImageBuffer = options.refImageEnd ? await fetchImageBuffer(options.refImageEnd) : undefined;
331
+ const frames = options.fps * options.duration;
332
+
333
+ const projectConfig = {
334
+ type: 'video',
335
+ modelId: options.model,
336
+ positivePrompt: options.prompt,
337
+ negativePrompt: '',
338
+ stylePrompt: '',
339
+ numberOfMedia: options.count,
340
+ referenceImage: imageBuffer,
341
+ frames: frames,
342
+ fps: options.fps,
343
+ width: options.width,
344
+ height: options.height,
345
+ tokenType: 'spark',
346
+ waitForCompletion: false
347
+ };
348
+
349
+ // Add end frame for interpolation if provided
350
+ if (endImageBuffer) {
351
+ projectConfig.referenceImageEnd = endImageBuffer;
352
+ }
353
+
354
+ await client.createProject(projectConfig);
355
+ } else if (options.contextImages.length > 0) {
356
+ // Image editing with context images
357
+ log(`Editing with ${options.model}...`);
358
+ log(`Context images: ${options.contextImages.length}`);
359
+ if (options.seed) log(`Using seed: ${options.seed}`);
360
+
361
+ // Load all context images as buffers
362
+ const contextBuffers = await Promise.all(
363
+ options.contextImages.map(img => fetchImageBuffer(img))
364
+ );
365
+
366
+ const editConfig = {
367
+ modelId: options.model,
368
+ positivePrompt: options.prompt,
369
+ contextImages: contextBuffers,
370
+ numberOfMedia: options.count,
371
+ width: options.width,
372
+ height: options.height,
373
+ steps: options.model.includes('lightning') ? 4 : 20,
374
+ guidance: options.model.includes('lightning') ? 3.5 : 7.5
375
+ };
376
+
377
+ if (options.seed) {
378
+ editConfig.seed = options.seed;
379
+ }
380
+
381
+ await client.createImageEditProject(editConfig);
382
+ } else {
383
+ // Standard image generation
384
+ log(`Generating with ${options.model}...`);
385
+ if (options.seed) log(`Using seed: ${options.seed}`);
386
+
387
+ const projectConfig = {
388
+ type: 'image',
389
+ modelId: options.model,
390
+ positivePrompt: options.prompt,
391
+ negativePrompt: '',
392
+ stylePrompt: '',
393
+ numberOfImages: options.count,
394
+ tokenType: 'spark',
395
+ waitForCompletion: false,
396
+ sizePreset: 'custom',
397
+ width: options.width,
398
+ height: options.height,
399
+ guidance: 1.0
400
+ };
401
+
402
+ if (options.seed) {
403
+ projectConfig.seed = options.seed;
404
+ }
405
+
406
+ await client.createProject(projectConfig);
407
+ }
408
+
409
+ // Wait for completion via events
410
+ await completionPromise;
411
+
412
+ if (results.length > 0) {
413
+ const urls = results.map(r => options.video ? r.videoUrl : r.imageUrl).filter(Boolean);
414
+ const firstResult = results[0];
415
+
416
+ // Save last render info
417
+ const renderInfo = {
418
+ timestamp: new Date().toISOString(),
419
+ type: options.video ? 'video' : 'image',
420
+ prompt: options.prompt,
421
+ model: options.model,
422
+ width: options.width,
423
+ height: options.height,
424
+ seed: firstResult.seed,
425
+ seeds: results.map(r => r.seed),
426
+ projectId: firstResult.projectId,
427
+ urls: urls,
428
+ localPath: options.output || null
429
+ };
430
+ if (options.video) {
431
+ renderInfo.fps = options.fps;
432
+ renderInfo.duration = options.duration;
433
+ renderInfo.refImage = options.refImage;
434
+ }
435
+ if (options.contextImages.length > 0) {
436
+ renderInfo.contextImages = options.contextImages;
437
+ }
438
+ saveLastRender(renderInfo);
439
+
440
+ // Save to file if requested
441
+ if (options.output && urls[0]) {
442
+ const response = await fetch(urls[0]);
443
+ const buffer = Buffer.from(await response.arrayBuffer());
444
+
445
+ const dir = dirname(options.output);
446
+ if (dir && dir !== '.' && !existsSync(dir)) mkdirSync(dir, { recursive: true });
447
+
448
+ writeFileSync(options.output, buffer);
449
+ log(`Saved to ${options.output}`);
450
+ }
451
+
452
+ // Output result
453
+ if (options.json) {
454
+ const output = {
455
+ success: true,
456
+ type: options.video ? 'video' : 'image',
457
+ prompt: options.prompt,
458
+ model: options.model,
459
+ width: options.width,
460
+ height: options.height,
461
+ seed: firstResult.seed,
462
+ seeds: results.map(r => r.seed),
463
+ urls: urls,
464
+ localPath: options.output || null
465
+ };
466
+ if (options.video) {
467
+ output.fps = options.fps;
468
+ output.duration = options.duration;
469
+ }
470
+ if (options.contextImages.length > 0) {
471
+ output.contextImages = options.contextImages;
472
+ }
473
+ console.log(JSON.stringify(output));
474
+ } else {
475
+ urls.forEach(url => console.log(url));
476
+ }
477
+ } else {
478
+ throw new Error('No output generated - may have been filtered');
479
+ }
480
+
481
+ } catch (error) {
482
+ if (options.json) {
483
+ console.log(JSON.stringify({
484
+ success: false,
485
+ error: error.message,
486
+ prompt: options.prompt
487
+ }));
488
+ } else {
489
+ console.error(`Error: ${error.message}`);
490
+ }
491
+ process.exit(1);
492
+
493
+ } finally {
494
+ try {
495
+ if (client.isConnected?.()) await client.disconnect();
496
+ } catch (e) {}
497
+ process.exit(0);
498
+ }
499
+ }
500
+
501
+ main();